357e26ab49
Finish/交付写盘前自动回填 mindspace-geo,扩展 storage publications 扫描与 geo meta 解析,并增加 143 生产回填脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
232 lines
8.9 KiB
JavaScript
232 lines
8.9 KiB
JavaScript
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: '缺少 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);
|
||
}
|