import fs from 'node:fs'; import path from 'node:path'; import { parseMindspaceCoverMeta } from './mindspace-cover-meta.mjs'; import { parseMindspaceGeoMeta, resolveGeoFaq } from './mindspace-geo-meta.mjs'; import { hasPlatformBrandMarker } from './mindspace-page-tag.mjs'; import { hasMindspaceCoverMeta, hasRasterShareImageHint, hasShareDescription, } from './wechat/verify/share-preview.mjs'; export const AUDIT_SCOPES = ['mindspace', 'platform', 'all']; const PLATFORM_SKIP_DIRS = new Set(['dev', 'thumbnail-demo', 'thumbnail-demo-samples']); function hasCoverImageHint(html) { if (hasRasterShareImageHint(html)) return true; const coverMeta = parseMindspaceCoverMeta(html); const cover = String(coverMeta?.cover ?? coverMeta?.image ?? '').trim(); if (!cover) return false; if (/^https?:\/\//i.test(cover)) return true; if (/\.svg(?:[?#]|$)/i.test(cover)) return false; return true; } function missingLocalCoverPath(htmlPath, coverMeta) { const cover = String(coverMeta?.cover ?? coverMeta?.image ?? '').trim(); if (!cover || /^https?:\/\//i.test(cover) || cover.startsWith('data:')) return null; const clean = cover.split('?')[0].split('#')[0].replace(/^\.\//, ''); if (!clean || clean.startsWith('/') || clean.includes('\\')) return null; const base = path.dirname(htmlPath); const target = path.resolve(base, clean); if (target !== base && !target.startsWith(`${base}${path.sep}`)) return clean; return fs.existsSync(target) && fs.statSync(target).isFile() ? null : clean; } export function auditCoverHtml(htmlPath, html, { requireCoverMeta = true } = {}) { const issues = []; if (requireCoverMeta && !hasMindspaceCoverMeta(html)) { issues.push({ level: 'error', code: 'missing_mindspace_cover', message: '缺少 ' }); } if (!hasShareDescription(html)) { issues.push({ level: 'error', code: 'missing_description', message: '缺少 description / og:description' }); } if (requireCoverMeta && !hasPlatformBrandMarker(html)) { issues.push({ level: 'warn', code: 'missing_platform_brand', message: '缺少 data-mindspace-page-tag="platform-brand" 页脚' }); } const coverMeta = parseMindspaceCoverMeta(html); if (coverMeta) { if (!String(coverMeta.tag ?? '').trim()) { issues.push({ level: 'warn', code: 'missing_tag', message: 'mindspace-cover 缺少 tag' }); } if (!String(coverMeta.subtitle ?? '').trim()) { issues.push({ level: 'warn', code: 'missing_subtitle', message: 'mindspace-cover 缺少 subtitle' }); } if (!String(coverMeta.accent ?? '').trim()) { issues.push({ level: 'warn', code: 'missing_accent', message: 'mindspace-cover 缺少 accent' }); } const missingCover = missingLocalCoverPath(htmlPath, coverMeta); if (missingCover) { issues.push({ level: 'error', code: 'missing_cover_file', message: `mindspace-cover 引用的本地文件不存在:${missingCover}`, }); } } if (requireCoverMeta && !hasCoverImageHint(html)) { issues.push({ level: 'warn', code: 'missing_cover_image', message: '无 raster 封面图(cover/image/og:image),缩略图会退化为纯色渐变', }); } return issues; } export function auditSeoHtml(html, { strictGeo = true } = {}) { const issues = []; const title = String(html).match(/]*>([^<]+)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim() ?? ''; const weakLatinTitle = title && /^[\x00-\x7F]+$/.test(title) && title.length < 4; if (!title || title.length < 2 || weakLatinTitle) { issues.push({ level: 'error', code: 'weak_title', message: '缺少或过短的 ,SEO/GEO 无法达标' }); } else if (/^(页面|我的页面|未命名|demo|test|MindSpace 页面)$/i.test(title)) { issues.push({ level: 'error', code: 'generic_title', message: `title 过于泛化:${title}` }); } if (!/<h1\b/i.test(html)) { issues.push({ level: 'error', code: 'missing_h1', message: '缺少 <h1>,搜索引擎与 AI 抽取质量会下降' }); } if (!hasShareDescription(html)) { issues.push({ level: 'error', code: 'seo_missing_description', message: '缺少 description,SEO/GEO 摘要来源不足' }); } const geoMeta = parseMindspaceGeoMeta(html); if (strictGeo && !/<meta[^>]*name=["']mindspace-geo["']/i.test(html)) { issues.push({ level: 'error', code: 'missing_mindspace_geo', message: '缺少 <meta name="mindspace-geo">,GEO 摘要/FAQ 无法达标', }); } else if (strictGeo && !String(geoMeta.summary ?? '').trim()) { issues.push({ level: 'warn', code: 'geo_missing_summary', message: 'mindspace-geo 缺少 summary,建议写可被 AI 直接引用的摘要', }); } const h2Count = (String(html).match(/<h2\b/gi) ?? []).length; const outlineSections = /data-mindspace-seo-outline=/i.test(html); const faqCount = resolveGeoFaq(html, geoMeta).length; if (h2Count < 2 && !outlineSections && faqCount < 2) { issues.push({ level: 'warn', code: 'insufficient_h2_sections', message: '正文建议至少 2 个 <h2> 分节,便于 SEO/GEO 抽取', }); } const faq = resolveGeoFaq(html, geoMeta); const parsedGeo = parseMindspaceGeoMeta(html); const declaredFaq = Array.isArray(parsedGeo.faq) ? parsedGeo.faq.length : 0; if (faq.length < 1 && declaredFaq < 1) { issues.push({ level: 'warn', code: 'geo_missing_faq', message: '缺少 FAQ(mindspace-geo.faq 或 h2+p 结构),GEO 结构化数据会偏弱', }); } return issues; } export function auditHtmlFile(htmlPath, html, options = {}) { const requireCoverMeta = options.requireCoverMeta !== false; return [ ...auditCoverHtml(htmlPath, html, { requireCoverMeta }), ...(options.seo === false ? [] : auditSeoHtml(html, options)), ]; } function walkHtmlFiles(dir, files = []) { if (!fs.existsSync(dir)) return files; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === '.agents' || entry.name === 'node_modules' || entry.name.startsWith('.')) continue; walkHtmlFiles(full, files); continue; } if (!entry.name.endsWith('.html') || entry.name.endsWith('.thumbnail.svg')) continue; files.push(full); } return files; } export function collectMindspaceHtmlFiles(root, userId = null) { if (userId) { return walkHtmlFiles(path.join(root, userId, 'public')); } const files = []; if (!fs.existsSync(root)) return files; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === 'wiki' || entry.name.startsWith('.')) continue; walkHtmlFiles(path.join(root, entry.name, 'public'), files); } return files; } export function collectStoragePublicationHtmlFiles(storageRoot, userId = null) { const files = []; const usersDir = path.join(storageRoot, 'users'); if (!fs.existsSync(usersDir)) return files; const userDirs = userId ? [path.join(usersDir, userId)] : fs.readdirSync(usersDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) .map((entry) => path.join(usersDir, entry.name)); for (const userDir of userDirs) { const publicationsDir = path.join(userDir, 'publications'); if (!fs.existsSync(publicationsDir)) continue; for (const publication of fs.readdirSync(publicationsDir, { withFileTypes: true })) { if (!publication.isDirectory() || publication.name.startsWith('.')) continue; const indexHtml = path.join(publicationsDir, publication.name, 'index.html'); if (fs.existsSync(indexHtml)) files.push(indexHtml); } } return files; } export function collectPlatformHtmlFiles(publicRoot) { const files = []; if (!fs.existsSync(publicRoot)) return files; for (const entry of fs.readdirSync(publicRoot, { withFileTypes: true })) { const full = path.join(publicRoot, entry.name); if (entry.isDirectory()) { if (PLATFORM_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; walkHtmlFiles(full, files); continue; } if (entry.name.endsWith('.html')) files.push(full); } return files; } export function collectHtmlTargets({ scope = 'mindspace', mindspaceRoot, publicRoot, storageRoot = null, userId = null, file = null, } = {}) { if (file) return [path.resolve(file)]; const normalized = String(scope ?? 'mindspace').trim().toLowerCase(); const storageFiles = storageRoot ? collectStoragePublicationHtmlFiles(storageRoot, userId) : []; if (normalized === 'platform') { return collectPlatformHtmlFiles(publicRoot); } if (normalized === 'storage') { return storageFiles; } if (normalized === 'all') { return [ ...collectMindspaceHtmlFiles(mindspaceRoot, userId), ...storageFiles, ...collectPlatformHtmlFiles(publicRoot), ]; } return collectMindspaceHtmlFiles(mindspaceRoot, userId); }