Files
memind/mindspace-seo-audit.mjs
T
john 357e26ab49
Memind CI / Test, build, and release guards (pull_request) Failing after 7m12s
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(seo-geo): 落盘自动补齐与 143 全量回填支持
Finish/交付写盘前自动回填 mindspace-geo,扩展 storage publications 扫描与 geo meta 解析,并增加 143 生产回填脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:26:02 +08:00

232 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '缺少 <meta name="mindspace-cover">' });
}
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[^>]*>([^<]+)<\/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: '缺少或过短的 <title>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: '缺少 descriptionSEO/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: '缺少 FAQmindspace-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);
}