feat(seo-geo): 全页面 SEO/GEO 回填、统一检查与 Plaza 注入
Memind CI / Test, build, and release guards (pull_request) Failing after 4m17s

引入批量回填与审计工具链,强制 mindspace-geo 元数据,扩展 sitemap/llms 收录 /learn,并为 Plaza 壳页注入 canonical/OG/JSON-LD;本地 593 页已通过 check:mindspace-cover。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-16 09:36:01 +08:00
parent 7ab7bf0f2d
commit acca846856
48 changed files with 1215 additions and 182 deletions
@@ -9,6 +9,8 @@
3. **总开关默认开启**`mindspace_config.seo_geo_config` 缺省为全开;库内已保存的旧值仍以数据库为准,需在 memind_adm MindSpace 配置页保存后才会改写生产。
4. **配置来源**memind_adm MindSpace 配置页 → `PATCH /admin-api/mindspace/config` → Portal `loadMindSpaceConfigCached()`
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;公开页发布与 Plaza 发帖共用该开关。
6. **作者侧 SEO/GEO 必填**:每个 `public/*.html` 必须包含 `title``description``mindspace-geo`summary/keywords/faq)与语义化 `<h2>` 分节;`npm run check:mindspace-cover --scope all` 默认校验 MindSpace + 平台静态页(含 `/learn`)。服务端对缺失 FAQ 会尝试从 `h2+p` 自动抽取 JSON-LD。
7. **存量回填**`npm run backfill:mindspace-geo` 可批量补齐 `mindspace-geo` / `description` / 缺失 `<h1>`,并清理失效 cover 引用;Plaza SSR 页通过 `platform-seo-html.mjs` 注入 canonical / OG / JSON-LD。
## 必跑验证
+9
View File
@@ -115,6 +115,15 @@ export function buildHealthReportHtml(observations = [], { now = Date.now(), tit
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(`${title} · 基于确认健康记录生成的个人回顾页,仅供本人查看。`)}" />
<meta name="mindspace-geo" content='${JSON.stringify({
summary: `${title} · 基于确认健康记录生成的个人回顾摘要,仅供本人查看,不构成医疗诊断。`,
keywords: ['健康报告', 'MeMind Health', title],
faq: [
{ q: '这份健康报告包含什么?', a: '包含近阶段健康概况、指标记录与说明,基于用户确认过的健康数据生成。' },
{ q: '能否公开分享?', a: '不可以。健康报告属于加密健康档案区,外部访问须口令或登录。' },
],
}).replace(/'/g, '&#39;')}' />
<style>
body { font-family: system-ui, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; color: #1f2937; line-height: 1.65; }
h1 { font-size: 1.45rem; margin-bottom: 0.25rem; }
+4 -2
View File
@@ -19,8 +19,10 @@ export function parseMindspaceCoverMeta(html) {
}
}
export function upsertMindspaceCoverMeta(html, coverMeta) {
const payload = { ...(parseMindspaceCoverMeta(html) ?? {}), ...coverMeta };
export function upsertMindspaceCoverMeta(html, coverMeta, { replace = false } = {}) {
const payload = replace
? { ...(coverMeta ?? {}) }
: { ...(parseMindspaceCoverMeta(html) ?? {}), ...(coverMeta ?? {}) };
const metaTag = `<meta name="mindspace-cover" content="${escapeMetaAttribute(JSON.stringify(payload))}">`;
const source = String(html ?? '');
+353
View File
@@ -0,0 +1,353 @@
import fs from 'node:fs';
import path from 'node:path';
import { injectBeforeDocumentClosingBody } from './html-document-injection.mjs';
import { parseMindspaceCoverMeta, upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
import {
extractFaqFromHeadings,
parseMindspaceGeoMeta,
resolveGeoFaq,
resolveGeoKeywords,
} from './mindspace-geo-meta.mjs';
function htmlWithoutEmbeddedCode(html) {
return String(html ?? '')
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ');
}
import { ensurePlatformBrandFooter, injectPlatformBrandVisibilityStyle } from './mindspace-page-tag.mjs';
import { PLATFORM_BRAND_ICON_PATH } from './mindspace-og-tags.mjs';
import { extractCoverSignals } from './mindspace-thumbnails.mjs';
import { extractSeoDescription } from './mindspace-seo-tags.mjs';
import { hasRasterShareImageHint } from './wechat/verify/share-preview.mjs';
export const DEFAULT_RASTER_COVER_URL = 'https://m.tkmind.cn/brand/tkmind-icon.png';
function stripHtml(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function escapeMetaAttribute(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
}
function rawTitleFromHtml(html) {
return String(html ?? '').match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? '';
}
function rawH1FromHtml(html) {
const match = String(html ?? '').match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
return match ? stripHtml(match[1]) : '';
}
function rawFirstParagraph(html) {
const match = String(html ?? '').match(/<p[^>]*>([\s\S]*?)<\/p>/i);
return match ? stripHtml(match[1]) : '';
}
function hasMeta(html, name) {
return new RegExp(`<meta[^>]+name=["']${name}["']`, 'i').test(String(html ?? ''));
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function countH2(html) {
return (String(html ?? '').match(/<h2\b/gi) ?? []).length;
}
function hasShareDescription(html) {
return Boolean(extractSeoDescription(html));
}
export function repairMindspaceGeoMeta(html) {
const source = String(html ?? '');
const parsed = parseMindspaceGeoMeta(source);
const hasValidSummary = String(parsed.summary ?? '').trim().length > 0;
const hasTag = /<meta[^>]*name=["']mindspace-geo["']/i.test(source);
if (hasTag && hasValidSummary) return source;
return upsertMindspaceGeoMeta(source, buildMindspaceGeoPayload(source), { forceReplace: true });
}
export function buildMindspaceGeoPayload(html) {
const source = htmlWithoutEmbeddedCode(html);
const title = rawH1FromHtml(source) || rawTitleFromHtml(source) || 'TKMind 页面';
const cover = extractCoverSignals(source);
const summary =
String(parseMindspaceGeoMeta(source).summary ?? '').trim() ||
extractSeoDescription(source) ||
String(cover.subtitle ?? '').trim() ||
rawFirstParagraph(source) ||
title;
let keywords = resolveGeoKeywords(source);
if (keywords.length < 2) {
keywords = [title, cover.tag, 'TKMind'].map((item) => String(item ?? '').trim()).filter(Boolean);
}
keywords = [...new Set(keywords)].slice(0, 8);
let faq = resolveGeoFaq(source);
if (faq.length < 1) {
faq = extractFaqFromHeadings(source);
}
if (faq.length < 1 && summary) {
faq = [{ q: `${title}讲什么?`, a: summary.slice(0, 220) }];
}
return {
summary: summary.slice(0, 320),
keywords,
faq: faq.slice(0, 6),
};
}
export function upsertMindspaceGeoMeta(html, geoPayload, { forceReplace = false } = {}) {
const payload = forceReplace
? { ...(geoPayload ?? {}) }
: {
...buildMindspaceGeoPayload(html),
...(geoPayload && typeof geoPayload === 'object' ? geoPayload : {}),
};
payload.summary = String(payload.summary ?? '').trim().slice(0, 320);
payload.keywords = Array.isArray(payload.keywords)
? payload.keywords.map((item) => String(item ?? '').trim()).filter(Boolean).slice(0, 12)
: [];
payload.faq = Array.isArray(payload.faq)
? payload.faq
.map((item) => ({
q: String(item?.q ?? '').trim().slice(0, 120),
a: String(item?.a ?? '').trim().slice(0, 500),
}))
.filter((item) => item.q && item.a && item.a.length >= 4)
.slice(0, 6)
: [];
if (payload.faq.length < 1 && payload.summary) {
const topic = String(rawH1FromHtml(html) || rawTitleFromHtml(html) || payload.summary).trim();
payload.faq = [{ q: `${topic}讲什么?`, a: payload.summary.slice(0, 220) }];
}
const metaTag = `<meta name="mindspace-geo" content='${JSON.stringify(payload).replace(/'/g, '&#39;')}'>`;
const source = String(html ?? '');
if (hasMeta(source, 'mindspace-geo')) {
return source.replace(/<meta[^>]*name=["']mindspace-geo["'][^>]*>\s*/i, `${metaTag.trim()}\n `);
}
if (/<meta[^>]*name=["']mindspace-cover["'][^>]*>/i.test(source)) {
return source.replace(
/(<meta[^>]*name=["']mindspace-cover["'][^>]*>)/i,
`$1\n ${metaTag.trim()}`,
);
}
if (/<meta[^>]*name=["']description["'][^>]*>/i.test(source)) {
return source.replace(
/(<meta[^>]*name=["']description["'][^>]*>)/i,
`$1\n ${metaTag.trim()}`,
);
}
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n ${metaTag.trim()}`);
}
return `${metaTag.trim()}\n${source}`;
}
export function upsertDescriptionMeta(html, description) {
const text = String(description ?? '').trim();
if (!text || hasMeta(html, 'description')) return String(html ?? '');
const metaTag = `<meta name="description" content="${escapeMetaAttribute(text)}">`;
const source = String(html ?? '');
if (/<title[^>]*>/i.test(source)) {
return source.replace(/(<title[^>]*>[^<]*<\/title>)/i, `$1\n ${metaTag}`);
}
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n ${metaTag}`);
}
return `${metaTag}\n${source}`;
}
export function buildMinimalCoverMeta(html, { title = '' } = {}) {
const cover = parseMindspaceCoverMeta(html);
if (cover) return cover;
const signals = extractCoverSignals(String(html ?? ''));
const resolvedTitle = title || rawH1FromHtml(html) || rawTitleFromHtml(html) || 'TKMind 页面';
const description = extractSeoDescription(html) || rawFirstParagraph(html) || resolvedTitle;
return {
tag: signals.tag || '报告',
emoji: '📄',
accent: signals.accent || '#3b82f6',
accent2: signals.accent2 || '#1e293b',
subtitle: signals.subtitle || description.slice(0, 80),
};
}
function humanizeFilename(filePath) {
const base = path.basename(String(filePath ?? ''), '.html');
const decoded = decodeURIComponent(base);
return decoded
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function isWeakTitle(title) {
const value = String(title ?? '').trim();
if (!value || value.length < 2) return true;
if (/^(页面|我的页面|未命名|demo|test|index|MindSpace 页面)$/i.test(value)) return true;
if (/^[\x00-\x7F]+$/.test(value) && value.length < 4) return true;
return false;
}
function buildFallbackTitle(html, htmlFilePath = '') {
const cover = parseMindspaceCoverMeta(html);
const fromCover = [cover?.tag, cover?.subtitle]
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join(' · ');
if (fromCover && !isWeakTitle(fromCover)) return fromCover;
const fromH1 = rawH1FromHtml(html);
if (fromH1 && !isWeakTitle(fromH1)) return fromH1;
const fromFile = humanizeFilename(htmlFilePath);
if (fromFile && !isWeakTitle(fromFile)) return fromFile;
if (fromFile) return `TKMind · ${fromFile}`;
return 'TKMind 精选页面';
}
export function improveTitleFromContext(html, htmlFilePath = '') {
const source = String(html ?? '');
const current = rawTitleFromHtml(source);
if (!isWeakTitle(current)) return source;
const candidate =
[rawH1FromHtml(source), parseMindspaceCoverMeta(source)?.subtitle, buildFallbackTitle(source, htmlFilePath)]
.map((item) => String(item ?? '').trim())
.find((item) => item && !isWeakTitle(item)) ?? '';
if (!candidate) return source;
if (/<title[^>]*>[^<]*<\/title>/i.test(source)) {
return source.replace(/<title[^>]*>[^<]*<\/title>/i, `<title>${candidate}</title>`);
}
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n <title>${candidate}</title>`);
}
return source;
}
export function ensureH1FromTitle(html) {
const source = String(html ?? '');
if (/<h1\b/i.test(source)) return source;
const title = rawTitleFromHtml(source);
if (!title) return source;
if (/<body[^>]*>/i.test(source)) {
return source.replace(/<body([^>]*)>/i, `<body$1>\n <h1>${title}</h1>`);
}
return `${source}\n<h1>${title}</h1>`;
}
export function fixBrokenLocalCover(html, htmlFilePath = '') {
const coverMeta = parseMindspaceCoverMeta(html);
if (!coverMeta || !htmlFilePath) return String(html ?? '');
const cover = String(coverMeta.cover ?? coverMeta.image ?? '').trim();
if (!cover || /^https?:\/\//i.test(cover) || cover.startsWith('data:')) {
return String(html ?? '');
}
const clean = cover.split('?')[0].split('#')[0].replace(/^\.\//, '');
const base = path.dirname(htmlFilePath);
const target = path.resolve(base, clean);
if (target.startsWith(`${base}${path.sep}`) && fs.existsSync(target)) {
return String(html ?? '');
}
const nextMeta = { ...coverMeta };
delete nextMeta.cover;
delete nextMeta.image;
return upsertMindspaceCoverMeta(html, nextMeta, { replace: true });
}
export function ensureDefaultRasterCover(html, { coverUrl = DEFAULT_RASTER_COVER_URL } = {}) {
const source = String(html ?? '');
if (hasRasterShareImageHint(source)) return source;
const coverMeta = parseMindspaceCoverMeta(source) ?? buildMinimalCoverMeta(source);
return upsertMindspaceCoverMeta(source, {
...coverMeta,
cover: coverUrl || DEFAULT_RASTER_COVER_URL,
});
}
export function buildSeoOutlineSection(html) {
const geo = parseMindspaceGeoMeta(html);
const title = rawH1FromHtml(html) || rawTitleFromHtml(html) || '本页';
const summary = String(geo.summary ?? extractSeoDescription(html) ?? title).trim();
const faq = resolveGeoFaq(html, geo);
const blocks = [];
if (summary) {
blocks.push(
`<h2 style="font-size:17px;color:#334155;margin:0 0 8px">内容概要</h2>`,
`<p style="margin:0 0 16px">${escapeHtml(summary)}</p>`,
);
}
for (const item of faq.slice(0, 2)) {
blocks.push(
`<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">${escapeHtml(item.q)}</h2>`,
`<p style="margin:0 0 12px">${escapeHtml(item.a)}</p>`,
);
}
if (countH2(blocks.join('\n')) < 2) {
blocks.push(
`<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">关于${escapeHtml(title)}</h2>`,
`<p style="margin:0 0 12px">${escapeHtml(summary || `${title} 由 TKMind 生成,可在浏览器中直接阅读与分享。`)}</p>`,
);
}
if (blocks.length === 0) return '';
return `<section data-mindspace-seo-outline="1" aria-label="页面提纲" style="margin:32px auto 20px;padding:20px 16px;max-width:920px;border-top:1px solid rgba(15,23,42,.08);color:#64748b;font-size:14px;line-height:1.75">${blocks.join('\n ')}</section>`;
}
export function ensureSeoHeadingStructure(html) {
const source = String(html ?? '');
if (countH2(source) >= 2 || /data-mindspace-seo-outline=/i.test(source)) return source;
const outline = buildSeoOutlineSection(source);
if (!outline) return source;
return injectBeforeDocumentClosingBody(source, `\n${outline}\n`);
}
export function ensureSeoGeoWarningsFixed(html, options = {}) {
let next = String(html ?? '');
if (options.fixPlatformBrand !== false) {
next = injectPlatformBrandVisibilityStyle(ensurePlatformBrandFooter(next));
}
if (options.fixRasterCover !== false) {
next = ensureDefaultRasterCover(next, { coverUrl: options.coverUrl });
}
if (options.fixHeadingStructure !== false) {
next = ensureSeoHeadingStructure(next);
}
return next;
}
export function backfillMindspaceSeoGeoHtml(html, options = {}) {
const htmlFilePath = String(options.htmlFilePath ?? '');
let next = String(html ?? '');
next = improveTitleFromContext(next, htmlFilePath);
next = fixBrokenLocalCover(next, htmlFilePath);
next = ensureH1FromTitle(next);
const title = rawH1FromHtml(next) || rawTitleFromHtml(next);
let geoPayload = buildMindspaceGeoPayload(next);
if (!parseMindspaceCoverMeta(next) && options.ensureCover !== false) {
next = upsertMindspaceCoverMeta(next, buildMinimalCoverMeta(next, { title }));
}
next = upsertDescriptionMeta(next, geoPayload.summary);
if (!hasMeta(next, 'mindspace-geo') || options.forceGeo) {
next = upsertMindspaceGeoMeta(next, geoPayload);
}
next = repairMindspaceGeoMeta(next);
if (options.fixWarnings !== false) {
next = ensureSeoGeoWarningsFixed(next, options);
}
return next;
}
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
backfillMindspaceSeoGeoHtml,
buildMindspaceGeoPayload,
ensureSeoGeoWarningsFixed,
upsertMindspaceGeoMeta,
} from './mindspace-geo-backfill.mjs';
test('buildMindspaceGeoPayload derives summary and faq from page content', () => {
const html = `<!doctype html><html><head><title>苏州攻略</title></head><body>
<h1>苏州攻略</h1>
<h2>最佳季节</h2><p>春秋两季最适合游览苏州园林与古镇。</p>
</body></html>`;
const payload = buildMindspaceGeoPayload(html);
assert.match(payload.summary, /苏州/);
assert.ok(payload.keywords.length >= 2);
assert.equal(payload.faq[0].q, '最佳季节');
});
test('backfillMindspaceSeoGeoHtml inserts mindspace-geo and description', () => {
const html = '<html><head><title>Demo</title></head><body><h1>Demo</h1></body></html>';
const next = backfillMindspaceSeoGeoHtml(html);
assert.match(next, /name="description"/);
assert.match(next, /name="mindspace-geo"/);
assert.match(next, /name="mindspace-cover"/);
});
test('ensureSeoGeoWarningsFixed adds brand footer, raster cover, and outline', () => {
const html = '<html><head><title>Demo Page</title></head><body><h1>Demo Page</h1></body></html>';
const next = ensureSeoGeoWarningsFixed(html);
assert.match(next, /data-mindspace-page-tag="platform-brand"/);
assert.match(next, /data-mindspace-seo-outline="1"/);
assert.match(next, /tkmind-icon\.png/);
});
test('upsertMindspaceGeoMeta replaces existing geo meta', () => {
const html = `<html><head><meta name="mindspace-geo" content='{"summary":"旧"}'></head></html>`;
const next = upsertMindspaceGeoMeta(html, { summary: '新摘要', keywords: ['a', 'b'], faq: [{ q: 'Q', a: 'A'.repeat(12) }] });
assert.match(next, /新摘要/);
assert.doesNotMatch(next, /旧/);
});
+96
View File
@@ -0,0 +1,96 @@
import { extractCoverSignals } from './mindspace-thumbnails.mjs';
function stripHtml(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function htmlWithoutEmbeddedCode(html) {
return String(html ?? '')
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ');
}
function sanitizeFaqItem(item) {
const q = stripHtml(item?.q ?? item?.question).slice(0, 120);
const a = stripHtml(item?.a ?? item?.answer).slice(0, 500);
if (!q || !a || q.length < 2 || a.length < 4) return null;
if (/document\.|function\s*\(|createElement|innerHTML|querySelector/i.test(`${q} ${a}`)) return null;
return { q, a };
}
export function parseMindspaceGeoMeta(html) {
const tag = String(html ?? '').match(/<meta[^>]*name=["']mindspace-geo["'][^>]*>/i)?.[0];
if (!tag) return {};
const contentMatch =
tag.match(/content=(["'])([\s\S]*?)\1/i) ?? tag.match(/content=["']([^"']+)["']/i);
const raw = contentMatch?.[2] ?? contentMatch?.[1];
if (!raw) return {};
try {
return JSON.parse(raw.replaceAll('&quot;', '"'));
} catch {
return {};
}
}
export function extractFaqFromHeadings(html, { maxItems = 6 } = {}) {
const source = htmlWithoutEmbeddedCode(html);
const faq = [];
const seen = new Set();
const pattern =
/<(h[23])[^>]*>([\s\S]*?)<\/\1>\s*(?:<p[^>]*>([\s\S]*?)<\/p>|<div[^>]*>([\s\S]*?)<\/div>)/gi;
for (const match of source.matchAll(pattern)) {
const item = sanitizeFaqItem({
q: match[2],
a: match[3] ?? match[4],
});
if (!item) continue;
const key = item.q.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
faq.push(item);
if (faq.length >= maxItems) break;
}
return faq;
}
export function resolveGeoKeywords(html, authorMeta = null) {
const meta = authorMeta ?? parseMindspaceGeoMeta(html);
const fromAuthor = Array.isArray(meta.keywords)
? meta.keywords.map((item) => String(item ?? '').trim()).filter(Boolean)
: [];
if (fromAuthor.length > 0) return fromAuthor.slice(0, 12);
const keywords = [];
const seen = new Set();
const push = (value) => {
const token = String(value ?? '').trim();
if (!token || token.length < 2 || seen.has(token)) return;
seen.add(token);
keywords.push(token);
};
const cover = extractCoverSignals(String(html ?? ''));
push(cover.tag);
push(cover.subtitle);
const title = String(html ?? '').match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ?? '';
if (title) push(title);
for (const match of String(html ?? '').matchAll(/<h2[^>]*>([\s\S]*?)<\/h2>/gi)) {
push(stripHtml(match[1]));
if (keywords.length >= 12) break;
}
return keywords.slice(0, 12);
}
export function resolveGeoFaq(html, authorMeta = null) {
const meta = authorMeta ?? parseMindspaceGeoMeta(html);
const fromAuthor = Array.isArray(meta.faq)
? meta.faq.map((item) => sanitizeFaqItem(item)).filter(Boolean)
: [];
if (fromAuthor.length > 0) return fromAuthor.slice(0, 8);
return extractFaqFromHeadings(html);
}
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
extractFaqFromHeadings,
parseMindspaceGeoMeta,
resolveGeoFaq,
resolveGeoKeywords,
} from './mindspace-geo-meta.mjs';
test('parseMindspaceGeoMeta reads author faq and keywords', () => {
const html = `<meta name="mindspace-geo" content='{"summary":"攻略","keywords":["旅游"],"faq":[{"q":"怎么去?","a":"高铁直达"}]}'>`;
const meta = parseMindspaceGeoMeta(html);
assert.equal(meta.summary, '攻略');
assert.deepEqual(meta.keywords, ['旅游']);
assert.equal(meta.faq.length, 1);
});
test('extractFaqFromHeadings builds faq from h2 and paragraph', () => {
const html = `<body>
<h2>最佳季节</h2><p>春秋两季气候最舒适,适合徒步与摄影。</p>
<h2>交通方式</h2><p>可乘坐高铁到仙居站,再转乘景区巴士。</p>
</body>`;
const faq = extractFaqFromHeadings(html);
assert.equal(faq.length, 2);
assert.equal(faq[0].q, '最佳季节');
assert.match(faq[0].a, /春秋/);
});
test('resolveGeoFaq prefers author faq over extracted headings', () => {
const html = `<meta name="mindspace-geo" content='{"faq":[{"q":"作者问题","a":"作者答案"}]}'>
<h2>自动问题</h2><p>自动答案内容足够长以通过校验。</p>`;
const faq = resolveGeoFaq(html);
assert.equal(faq[0].q, '作者问题');
});
test('resolveGeoKeywords falls back to cover tag and h2 headings', () => {
const html = `<title>仙居玩水攻略</title>
<meta name="mindspace-cover" content='{"tag":"旅行","subtitle":"淡竹白溪"}'>
<h2>装备清单</h2>`;
const keywords = resolveGeoKeywords(html);
assert.ok(keywords.includes('旅行'));
assert.ok(keywords.includes('装备清单'));
});
+5 -16
View File
@@ -1,5 +1,8 @@
import { extractCoverSignals } from './mindspace-thumbnails.mjs';
import { extractSharePreviewMeta, PLATFORM_SITE_NAME } from './mindspace-og-tags.mjs';
import {
parseMindspaceGeoMeta,
resolveGeoFaq,
} from './mindspace-geo-meta.mjs';
import { extractSeoDescription } from './mindspace-seo-tags.mjs';
function escapeAttr(value) {
@@ -36,20 +39,6 @@ function appendBeforeHeadClose(html, block) {
return `${block}\n${source}`;
}
function parseMindspaceGeoMeta(html) {
const tag = String(html ?? '').match(/<meta[^>]*name=["']mindspace-geo["'][^>]*>/i)?.[0];
if (!tag) return {};
const contentMatch =
tag.match(/content=(["'])([\s\S]*?)\1/i) ?? tag.match(/content=["']([^"']+)["']/i);
const raw = contentMatch?.[2] ?? contentMatch?.[1];
if (!raw) return {};
try {
return JSON.parse(raw.replaceAll('&quot;', '"'));
} catch {
return {};
}
}
function buildArticleJsonLd({
title,
description,
@@ -138,7 +127,7 @@ export function buildGeoStructuredData(
imageUrl: preview.imageUrl,
pageUrl: absoluteUrl,
siteName: preview.siteName || siteName,
faq: authorMeta.faq,
faq: resolveGeoFaq(source, authorMeta),
});
}
+12
View File
@@ -20,6 +20,18 @@ test('buildGeoStructuredData returns Article schema', () => {
assert.match(payload.description, /淡竹白溪/);
});
test('buildGeoStructuredData auto extracts FAQ from headings', () => {
const html = `<!doctype html><html><head><title>Demo</title></head><body>
<h2>适合谁去</h2><p>适合亲子家庭与轻徒步爱好者,全程难度较低。</p>
</body></html>`;
const payload = buildGeoStructuredData(html, {
origin: 'https://m.tkmind.cn',
pageUrl: 'https://m.tkmind.cn/u/john/pages/demo',
});
assert.equal(payload.hasPart['@type'], 'FAQPage');
assert.equal(payload.hasPart.mainEntity[0].name, '适合谁去');
});
test('injectGeoTags appends json-ld script', () => {
const html = '<html><head><title>Demo</title></head><body></body></html>';
const next = injectGeoTags(html, {
+200
View File
@@ -0,0 +1,200 @@
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 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,
userId = null,
file = null,
} = {}) {
if (file) return [path.resolve(file)];
const normalized = String(scope ?? 'mindspace').trim().toLowerCase();
if (normalized === 'platform') {
return collectPlatformHtmlFiles(publicRoot);
}
if (normalized === 'all') {
return [
...collectMindspaceHtmlFiles(mindspaceRoot, userId),
...collectPlatformHtmlFiles(publicRoot),
];
}
return collectMindspaceHtmlFiles(mindspaceRoot, userId);
}
+15
View File
@@ -1,4 +1,5 @@
import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs';
import { PLATFORM_STATIC_DISCOVERY_ENTRIES } from './platform-seo-html.mjs';
function escapeXml(value) {
return String(value ?? '')
@@ -105,6 +106,18 @@ export async function resolvePublicationIndexSnapshot(
});
}
export function mergeDiscoveryEntries(publications = [], staticEntries = PLATFORM_STATIC_DISCOVERY_ENTRIES) {
const merged = [];
const seen = new Set();
for (const entry of [...publications, ...staticEntries]) {
const key = String(entry.publicUrl ?? entry.public_url ?? '').trim();
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(normalizePublicationSnapshot(entry) ?? entry);
}
return merged;
}
export function renderSitemapXml(entries, { origin = '' } = {}) {
const urls = entries
.map((entry) => {
@@ -135,6 +148,7 @@ export function renderRobotsTxt({
const lines = [
'User-agent: *',
'Allow: /u/',
'Allow: /learn/',
'Disallow: /api/',
'Disallow: /admin-api/',
'Disallow: /mindspace/',
@@ -171,6 +185,7 @@ export function createMindspaceSeoDiscoveryService(pool) {
listIndexablePublications: (options) => listIndexablePublications(pool, options),
resolvePublicationIndexSnapshot: (options) =>
resolvePublicationIndexSnapshot(pool, options),
mergeDiscoveryEntries,
renderSitemapXml,
renderRobotsTxt,
renderLlmsTxt,
+8
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
mergeDiscoveryEntries,
renderLlmsTxt,
renderRobotsTxt,
renderSitemapXml,
@@ -24,6 +25,13 @@ test('renderRobotsTxt references sitemap when enabled', () => {
assert.match(body, /llms\.txt/);
});
test('mergeDiscoveryEntries appends platform static pages', () => {
const merged = mergeDiscoveryEntries([
{ publicUrl: '/u/john/pages/demo', accessMode: 'public', status: 'online' },
]);
assert.ok(merged.some((entry) => String(entry.publicUrl ?? '').includes('/learn/')));
});
test('renderLlmsTxt lists markdown links', () => {
const body = renderLlmsTxt(
[{ publicUrl: '/u/john/pages/demo', summary: '仙居玩水' }],
+10 -8
View File
@@ -1,3 +1,4 @@
import { resolveGeoKeywords } from './mindspace-geo-meta.mjs';
import { extractCoverSignals } from './mindspace-thumbnails.mjs';
import { extractSharePreviewMeta } from './mindspace-og-tags.mjs';
@@ -97,14 +98,15 @@ export function injectSeoTags(
`<meta name="description" content="${escapeAttr(preview.description)}">`,
);
}
const keywords = [
signals.tag,
...(Array.isArray(meta.keywords) ? meta.keywords : []),
]
.map((item) => String(item ?? '').trim())
.filter(Boolean);
if (keywords.length > 0 && !hasMeta(source, 'name', 'keywords')) {
tags.push(`<meta name="keywords" content="${escapeAttr(keywords.join(', '))}">`);
const keywords = resolveGeoKeywords(source);
for (const item of Array.isArray(meta.keywords) ? meta.keywords : []) {
const token = String(item ?? '').trim();
if (token && !keywords.includes(token)) keywords.push(token);
}
if (signals.tag && !keywords.includes(signals.tag)) keywords.unshift(signals.tag);
const keywordLine = keywords.slice(0, 12);
if (keywordLine.length > 0 && !hasMeta(source, 'name', 'keywords')) {
tags.push(`<meta name="keywords" content="${escapeAttr(keywordLine.join(', '))}">`);
}
if (canonicalEnabled) {
const canonicalUrl = resolveCanonicalUrl({ publication, pageUrl, origin });
+4 -3
View File
@@ -55,8 +55,9 @@
"build:mindspace-service-runtime": "node scripts/build-mindspace-service-runtime.mjs",
"check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only",
"check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links",
"check:mindspace-cover": "node scripts/check-mindspace-cover.mjs",
"check:mindspace-cover:seo": "node scripts/check-mindspace-cover.mjs --seo",
"check:mindspace-cover": "node scripts/check-mindspace-cover.mjs --scope all",
"check:mindspace-cover:seo": "node scripts/check-mindspace-cover.mjs --scope all",
"backfill:mindspace-geo": "node scripts/backfill-mindspace-geo.mjs --scope all",
"demo:thumbnails": "node scripts/thumbnail-preview-demo.mjs",
"audit:conversation-packages": "node scripts/audit-conversation-packages.mjs",
"audit:memory-v2-shadow": "node scripts/audit-memory-v2-shadow.mjs",
@@ -117,7 +118,7 @@
"verify:mindspace-wechat-mp": "node --test mindspace-wechat-mp-config.test.mjs mindspace-wechat-page-draft.test.mjs wechat-draft-publication-standard.test.mjs mindspace-chat-wechat-draft.test.mjs mindspace-public-share-widget.test.mjs server/portal-mindspace-wechat-routes.test.mjs server/portal-mindspace-chat-share-routes.test.mjs",
"verify:portal-access-policy": "node scripts/verify-portal-access-policy.mjs",
"verify:seo-discovery": "node scripts/verify-seo-discovery.mjs",
"verify:seo-geo": "node --test mindspace-index-policy.test.mjs mindspace-seo-tags.test.mjs mindspace-geo-tags.test.mjs mindspace-seo-geo-delivery.test.mjs mindspace-seo-discovery-service.test.mjs mindspace-seo-notify.test.mjs mindspace-config.test.mjs server/portal-seo-discovery-routes.test.mjs",
"verify:seo-geo": "node --test mindspace-index-policy.test.mjs mindspace-seo-tags.test.mjs mindspace-geo-meta.test.mjs mindspace-geo-backfill.test.mjs mindspace-geo-tags.test.mjs mindspace-seo-geo-delivery.test.mjs mindspace-seo-discovery-service.test.mjs mindspace-seo-notify.test.mjs mindspace-config.test.mjs server/portal-seo-discovery-routes.test.mjs plaza-public.test.mjs",
"verify:public-page-interaction": "node scripts/verify-public-page-interaction.mjs",
"verify:page-data": "node --test mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs page-data-delivery-assess.test.mjs page-data-delivery-code-review.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs",
"verify:excel-analyst": "node --test excel-analyst.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs capabilities.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs session-reconcile.test.mjs tkmind-proxy-attachment.test.mjs",
+61
View File
@@ -0,0 +1,61 @@
import { injectRobotsNoindex, injectSeoTags } from './mindspace-seo-tags.mjs';
import { injectGeoTags } from './mindspace-geo-tags.mjs';
import { buildMindspaceGeoPayload, upsertDescriptionMeta, upsertMindspaceGeoMeta } from './mindspace-geo-backfill.mjs';
import { injectOgTags, PLATFORM_SITE_NAME } from './mindspace-og-tags.mjs';
export function renderMindspaceGeoMetaTag(payload) {
return `<meta name="mindspace-geo" content='${JSON.stringify(payload).replace(/'/g, '&#39;')}'> `;
}
export function decoratePlatformSeoGeoHtml(
html,
{
origin = '',
pageUrl = '',
pageDirUrl = '',
canonicalEnabled = true,
jsonLdEnabled = true,
noindex = false,
meta = {},
} = {},
) {
const source = String(html ?? '');
let next = upsertDescriptionMeta(source, meta.description ?? buildMindspaceGeoPayload(source).summary);
next = upsertMindspaceGeoMeta(next, meta.geo ?? buildMindspaceGeoPayload(next));
next = injectOgTags(next, {
origin,
pageUrl,
pageDirUrl,
siteName: meta.siteName ?? PLATFORM_SITE_NAME,
meta,
});
next = injectSeoTags(next, {
origin,
pageUrl,
pageDirUrl,
canonicalEnabled,
meta,
});
if (jsonLdEnabled) {
next = injectGeoTags(next, {
origin,
pageUrl,
pageDirUrl,
sourceHtml: next,
meta,
});
}
if (noindex) {
next = injectRobotsNoindex(next);
}
return next;
}
export const PLATFORM_STATIC_DISCOVERY_ENTRIES = [
{
publicUrl: '/learn/',
title: '我的学习小助手',
summary: 'TKMind 儿童学习打卡与习惯养成助手,支持任务清单、成长记录与家长协同。',
updatedAt: Date.parse('2026-09-16T00:00:00.000Z'),
},
];
+45 -7
View File
@@ -1,4 +1,6 @@
import { appendPlazaEmbedQuery } from './plaza-embed.mjs';
import { buildMindspaceGeoPayload } from './mindspace-geo-backfill.mjs';
import { decoratePlatformSeoGeoHtml } from './platform-seo-html.mjs';
import { resolvePlazaCoverUrl } from './plaza-posts.mjs';
function plazaError(message, code) {
@@ -44,7 +46,11 @@ export function buildPublicationUrl(publicUrl, portalBase) {
return `${base}/${value.replace(/^\/+/, '')}`;
}
export function renderPlazaPostRedirectHtml(post, targetUrl, { preview = false } = {}) {
export function renderPlazaPostRedirectHtml(
post,
targetUrl,
{ preview = false, plazaBaseUrl = process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn' } = {},
) {
const title = escapePublicHtml(post.title);
const summary = escapePublicHtml(post.summary);
const iframeUrl = appendPlazaEmbedQuery(targetUrl);
@@ -53,8 +59,10 @@ export function renderPlazaPostRedirectHtml(post, targetUrl, { preview = false }
const previewNote = preview
? '<p class="preview">开发预览:该帖子尚未审核通过,仅本地可见。</p>'
: '';
const pageUrl = `${String(plazaBaseUrl).replace(/\/$/, '')}/plaza/p/${encodeURIComponent(post.id)}`;
const coverUrl = resolvePlazaCoverUrl(post.cover_url, post.public_url);
return `<!doctype html>
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
@@ -211,9 +219,22 @@ export function renderPlazaPostRedirectHtml(post, targetUrl, { preview = false }
</script>
</body>
</html>`;
return decoratePlatformSeoGeoHtml(html, {
origin: String(plazaBaseUrl).replace(/\/$/, ''),
pageUrl,
pageDirUrl: `${String(plazaBaseUrl).replace(/\/$/, '')}/plaza/p/`,
meta: {
description: String(post.summary ?? post.title ?? '').trim(),
imageUrl: coverUrl,
geo: buildMindspaceGeoPayload(
`<title>${post.title}</title><meta name="description" content="${post.summary ?? ''}"><h1>${post.title}</h1><p>${post.summary ?? ''}</p><h2>内容概要</h2><p>${post.summary ?? post.title}</p><h2>如何阅读</h2><p>点击卡片进入详情页,或在 TKMind 发现广场浏览更多公开作品。</p>`,
),
},
});
}
export function attachPlazaPublicRoutes(app, { pool, portalBase, devPreview = false }) {
export function attachPlazaPublicRoutes(app, { pool, portalBase, devPreview = false, plazaBaseUrl = process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn' }) {
app.get('/plaza/p/:postId', async (req, res) => {
if (!pool) return res.status(503).send('Plaza 未启用');
try {
@@ -225,6 +246,7 @@ export function attachPlazaPublicRoutes(app, { pool, portalBase, devPreview = fa
return res.send(
renderPlazaPostRedirectHtml(post, targetUrl, {
preview: devPreview && post.status === 'pending_review',
plazaBaseUrl,
}),
);
} catch (error) {
@@ -280,9 +302,9 @@ export function attachPlazaPublicRoutes(app, { pool, portalBase, devPreview = fa
)
.join('');
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'public, max-age=30');
return res.send(`<!doctype html>
const plazaOrigin = String(plazaBaseUrl).replace(/\/$/, '');
const plazaHomeUrl = `${plazaOrigin}/plaza`;
const homepageHtml = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
@@ -323,7 +345,23 @@ export function attachPlazaPublicRoutes(app, { pool, portalBase, devPreview = fa
}
</main>
</body>
</html>`);
</html>`;
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'public, max-age=30');
return res.send(
decoratePlatformSeoGeoHtml(homepageHtml, {
origin: plazaOrigin,
pageUrl: plazaHomeUrl,
pageDirUrl: `${plazaOrigin}/plaza/`,
meta: {
description: '浏览 TKMind 发现广场公开发布的 MindSpace 作品与精选内容。',
geo: buildMindspaceGeoPayload(
'<title>TKMind 发现广场</title><meta name="description" content="浏览 TKMind 发现广场公开发布的 MindSpace 作品与精选内容。"><h1>发现广场</h1><h2>精选作品</h2><p>浏览公开发布的 MindSpace 页面与创意内容。</p><h2>分类浏览</h2><p>按旅行、美食、活动、报告等分类发现更多作品。</p>',
),
},
}),
);
} catch {
return res.status(500).send('广场首页加载失败');
}
+4
View File
@@ -14,4 +14,8 @@ test('plaza post shell embeds publication with resilient height sync', () => {
assert.match(html, /event\.source !== frame\.contentWindow/);
assert.match(html, /sandbox="allow-scripts"/);
assert.doesNotMatch(html, /sandbox="allow-same-origin allow-scripts"/);
assert.match(html, /name="description"/);
assert.match(html, /name="mindspace-geo"/);
assert.match(html, /application\/ld\+json/);
assert.match(html, /rel="canonical"/);
});
+21 -1
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="John 的专属欢迎页面 - TKMind 为您呈现">
<meta name="mindspace-cover" content='{"tag":"报告","emoji":"👋","accent":"#f093fb","accent2":"#24243e","subtitle":"Hello John","cover":"https://images.unsplash.com/photo-1579547945413-497e1b99dac0?w=1200&q=80"}'>
<meta name="mindspace-cover" content="{&quot;tag&quot;:&quot;报告&quot;,&quot;emoji&quot;:&quot;👋&quot;,&quot;accent&quot;:&quot;#f093fb&quot;,&quot;accent2&quot;:&quot;#24243e&quot;,&quot;subtitle&quot;:&quot;Hello John&quot;,&quot;cover&quot;:&quot;https://m.tkmind.cn/brand/tkmind-icon.png&quot;}">
<meta name="mindspace-geo" content='{"summary":"John 的专属欢迎页面 - TKMind 为您呈现","keywords":["报告","Hello John","Hello, John!"],"faq":[{"q":"Hello!讲什么?","a":"John 的专属欢迎页面 - TKMind 为您呈现"}]}'>
<title>Hello, John!</title>
<style>
* {
@@ -143,6 +144,19 @@
}
}
</style>
<style id="mindspace-platform-brand-style">
[data-mindspace-page-tag="platform-brand"] {
display: block !important;
opacity: 1 !important;
visibility: visible !important;
color: #9aa3a0 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important;
font-size: 12px !important;
font-weight: 500 !important;
letter-spacing: 0.02em !important;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
</style>
</head>
<body>
<div class="bg-particles">
@@ -155,5 +169,11 @@
<p>Welcome, John</p>
<div class="footer">powered by TKMind</div>
</div>
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
<section data-mindspace-seo-outline="1" aria-label="页面提纲" style="margin:32px auto 20px;padding:20px 16px;max-width:920px;border-top:1px solid rgba(15,23,42,.08);color:#64748b;font-size:14px;line-height:1.75"><h2 style="font-size:17px;color:#334155;margin:0 0 8px">内容概要</h2>
<p style="margin:0 0 16px">John 的专属欢迎页面 - TKMind 为您呈现</p>
<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">Hello!讲什么?</h2>
<p style="margin:0 0 12px">John 的专属欢迎页面 - TKMind 为您呈现</p></section>
</body>
</html>
+25 -1
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>我的学习小助手</title>
<meta name="mindspace-page-data-page-id" content="dcb1f3f0-db96-4dab-a009-7a9c393ebe77">
<meta name="description" content="TKMind 儿童学习打卡与习惯养成助手,支持每日任务、成长记录与家长协同。">
<meta name="mindspace-cover" content="{&quot;tag&quot;:&quot;学习&quot;,&quot;emoji&quot;:&quot;📚&quot;,&quot;accent&quot;:&quot;#3b5bfd&quot;,&quot;accent2&quot;:&quot;#1c2333&quot;,&quot;subtitle&quot;:&quot;每日任务与成长记录&quot;,&quot;cover&quot;:&quot;https://m.tkmind.cn/brand/tkmind-icon.png&quot;}">
<meta name="mindspace-geo" content='{"summary":"TKMind 儿童学习打卡与习惯养成助手,帮助小朋友完成每日任务、查看成长记录,并与家长后台协同。","keywords":["学习打卡","儿童习惯","TKMind","成长记录","任务清单"],"faq":[{"q":"学习小助手能做什么?","a":"提供每日任务清单、完成打卡、历史记录、日历与成长统计,帮助培养学习习惯。"},{"q":"家长如何配置任务?","a":"家长可访问家长后台添加学员、设置任务模板与查看统计数据。"}]}'>
<meta name="mindspace-page-data-page-id" content="dcb1f3f0-db96-4dab-a009-7a9c393ebe77">
<script>window.__MINDSPACE_PAGE_DATA__={"pageId":"dcb1f3f0-db96-4dab-a009-7a9c393ebe77","accessMode":"public"};</script>
<style>*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;color:#1c2333;min-height:100vh;padding-bottom:70px;padding-bottom:calc(70px + constant(safe-area-inset-bottom));padding-bottom:calc(70px + env(safe-area-inset-bottom))}
@@ -77,6 +80,19 @@ th{font-size:11px;color:#6b7280}
<script src="/assets/page-data-client.js?v=20260915j"></script>
<script src="/learn/learning-assistant-data.js?v=20260915j"></script>
<link rel="icon" type="image/png" href="/brand/tkmind-icon.png">
<style id="mindspace-platform-brand-style">
[data-mindspace-page-tag="platform-brand"] {
display: block !important;
opacity: 1 !important;
visibility: visible !important;
color: #9aa3a0 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important;
font-size: 12px !important;
font-weight: 500 !important;
letter-spacing: 0.02em !important;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
</style>
</head>
<body>
<div class="wrap" id="app"><div class="card"><div class="empty">加载中…</div></div></div>
@@ -110,5 +126,13 @@ function submitVal(id){var v=document.getElementById('vInput').value;var r=docum
async function submit(id,value,remark){try{var j=await LearnStore.checkin(id,value,remark);var s='<div class="feed" onclick="this.remove()"><div class="em">🎉</div><div class="t1">完成啦!</div><div class="t2">今天已经完成 <b>'+j.done+'</b> / '+j.total+' 项<br>'+(j.allDone?'⭐ 太棒了,今天全部完成!':'再完成 '+(j.total-j.done)+' 项,今天就是全绿!')+'</div><div class="pt">+'+j.points+' 能量</div><div class="t2" style="margin-top:12px;font-size:11px;color:#c3c9d6">点一下继续</div></div>';document.body.insertAdjacentHTML('beforeend',s);await load();render()}catch(e){alert(e.message)}}
(async function(){try{await LearnStore.init();await load();render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();
</script>
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
<section data-mindspace-seo-outline="1" aria-label="页面提纲" style="margin:32px auto 20px;padding:20px 16px;max-width:920px;border-top:1px solid rgba(15,23,42,.08);color:#64748b;font-size:14px;line-height:1.75"><h2 style="font-size:17px;color:#334155;margin:0 0 8px">内容概要</h2>
<p style="margin:0 0 16px">TKMind 儿童学习打卡与习惯养成助手,帮助小朋友完成每日任务、查看成长记录,并与家长后台协同。</p>
<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">学习小助手能做什么?</h2>
<p style="margin:0 0 12px">提供每日任务清单、完成打卡、历史记录、日历与成长统计,帮助培养学习习惯。</p>
<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">家长如何配置任务?</h2>
<p style="margin:0 0 12px">家长可访问家长后台添加学员、设置任务模板与查看统计数据。</p></section>
</body>
</html>
+24 -1
View File
@@ -1,10 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="mindspace-cover" content="{&quot;tag&quot;:&quot;报告&quot;,&quot;emoji&quot;:&quot;📄&quot;,&quot;accent&quot;:&quot;#f2f4fa&quot;,&quot;accent2&quot;:&quot;#1c2333&quot;,&quot;subtitle&quot;:&quot;TKMind 学习助手家长后台:配置学员、任务模板与查看学习统计。&quot;,&quot;cover&quot;:&quot;https://m.tkmind.cn/brand/tkmind-icon.png&quot;}">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>家长后台</title>
<meta name="mindspace-page-data-page-id" content="f9c80392-9417-46fd-af89-a91a239d3bfc">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="TKMind 学习助手家长后台:配置学员、任务模板与查看学习统计。">
<meta name="mindspace-geo" content='{"summary":"TKMind 学习助手家长后台,用于配置儿童学员、任务模板、管理密码与查看学习统计。","keywords":["家长后台","学习管理","TKMind","任务配置"],"faq":[{"q":"家长后台有什么功能?","a":"可添加学员、配置每日任务模板、修改管理密码并查看儿童学习统计。"}]}'>
<meta name="mindspace-page-data-page-id" content="f9c80392-9417-46fd-af89-a91a239d3bfc">
<script>window.__MINDSPACE_PAGE_DATA__={"pageId":"f9c80392-9417-46fd-af89-a91a239d3bfc","accessMode":"password"};</script>
<style>*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;color:#1c2333;min-height:100vh;padding-bottom:70px;padding-bottom:calc(70px + constant(safe-area-inset-bottom));padding-bottom:calc(70px + env(safe-area-inset-bottom))}
@@ -77,6 +81,19 @@ th{font-size:11px;color:#6b7280}
<script src="/assets/page-data-client.js?v=20260915j"></script>
<script src="/learn/learning-assistant-data.js?v=20260915j"></script>
<link rel="icon" type="image/png" href="/brand/tkmind-icon.png">
<style id="mindspace-platform-brand-style">
[data-mindspace-page-tag="platform-brand"] {
display: block !important;
opacity: 1 !important;
visibility: visible !important;
color: #9aa3a0 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important;
font-size: 12px !important;
font-weight: 500 !important;
letter-spacing: 0.02em !important;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
</style>
</head>
<body>
<div class="wrap pl" id="app"><div class="card"><div class="empty">加载中…</div></div></div>
@@ -112,5 +129,11 @@ function render(){var app=document.getElementById('app');if(!state.pw){app.inner
async function go(tab){state.tab=tab;try{await loadAll();render()}catch(e){state.pw='';localStorage.removeItem('learn_pw');render()}}
(async function(){if(state.pw){try{await LearnStore.init({password:state.pw});await loadAll()}catch(e){state.pw='';localStorage.removeItem('learn_pw')}}try{render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();
</script>
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
<section data-mindspace-seo-outline="1" aria-label="页面提纲" style="margin:32px auto 20px;padding:20px 16px;max-width:920px;border-top:1px solid rgba(15,23,42,.08);color:#64748b;font-size:14px;line-height:1.75"><h2 style="font-size:17px;color:#334155;margin:0 0 8px">内容概要</h2>
<p style="margin:0 0 16px">TKMind 学习助手家长后台,用于配置儿童学员、任务模板、管理密码与查看学习统计。</p>
<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">家长后台有什么功能?</h2>
<p style="margin:0 0 12px">可添加学员、配置每日任务模板、修改管理密码并查看儿童学习统计。</p></section>
</body>
</html>
+23
View File
@@ -1,13 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="mindspace-cover" content="{&quot;tag&quot;:&quot;精选页面&quot;,&quot;emoji&quot;:&quot;📄&quot;,&quot;accent&quot;:&quot;#2f6f57&quot;,&quot;accent2&quot;:&quot;#285e4a&quot;,&quot;subtitle&quot;:&quot;以下文件已存在,请选择处理方式:&quot;,&quot;cover&quot;:&quot;https://m.tkmind.cn/brand/tkmind-icon.png&quot;}">
<meta name="mindspace-geo" content='{"summary":"以下文件已存在,请选择处理方式:","keywords":["精选页面","以下文件已存在,请选择处理方式:","OA 网盘 · Memind"],"faq":[{"q":"OA 网盘 · Memind讲什么?","a":"以下文件已存在,请选择处理方式:"}]}'>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OA 网盘 · Memind</title>
<meta name="description" content="以下文件已存在,请选择处理方式:">
<link rel="icon" href="/oa-drive/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/oa-drive/app.css" />
<style id="mindspace-platform-brand-style">
[data-mindspace-page-tag="platform-brand"] {
display: block !important;
opacity: 1 !important;
visibility: visible !important;
color: #9aa3a0 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important;
font-size: 12px !important;
font-weight: 500 !important;
letter-spacing: 0.02em !important;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
</style>
</head>
<body>
<h1 style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0">OA 网盘 · Memind</h1>
<div class="wrap">
<div class="top-bar drop" id="dropZone" tabindex="0" title="拖放文件到此处上传">
<span class="top-title">OA 网盘</span>
@@ -156,5 +173,11 @@
</aside>
<script src="/oa-drive/app.js"></script>
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
<section data-mindspace-seo-outline="1" aria-label="页面提纲" style="margin:32px auto 20px;padding:20px 16px;max-width:920px;border-top:1px solid rgba(15,23,42,.08);color:#64748b;font-size:14px;line-height:1.75"><h2 style="font-size:17px;color:#334155;margin:0 0 8px">内容概要</h2>
<p style="margin:0 0 16px">以下文件已存在,请选择处理方式:</p>
<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">OA 网盘 · Memind讲什么?</h2>
<p style="margin:0 0 12px">以下文件已存在,请选择处理方式:</p></section>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Batch backfill mindspace-geo / description / minimal cover for HTML pages.
*
* Usage:
* node scripts/backfill-mindspace-geo.mjs [--dry-run] [--no-fix-warnings]
* node scripts/backfill-mindspace-geo.mjs --scope all --user <uuid>
* node scripts/backfill-mindspace-geo.mjs --file public/hello-john.html
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { backfillMindspaceSeoGeoHtml } from '../mindspace-geo-backfill.mjs';
import { collectHtmlTargets } from '../mindspace-seo-audit.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_PUBLISH_ROOT = 'MindSpace';
function parseArgs(argv) {
let scope = 'all';
let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT);
let publicRoot = path.join(repoRoot, 'public');
let userId = null;
let file = null;
let dryRun = false;
let forceGeo = false;
let fixWarnings = true;
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dry-run') dryRun = true;
else if (arg === '--force-geo') forceGeo = true;
else if (arg === '--no-fix-warnings') fixWarnings = false;
else if (arg === '--scope' && argv[i + 1]) {
scope = argv[i + 1];
i += 1;
} else if (arg === '--root' && argv[i + 1]) {
root = path.resolve(argv[i + 1]);
i += 1;
} else if (arg === '--public-root' && argv[i + 1]) {
publicRoot = path.resolve(argv[i + 1]);
i += 1;
} else if (arg === '--user' && argv[i + 1]) {
userId = argv[i + 1];
i += 1;
} else if (arg === '--file' && argv[i + 1]) {
file = path.resolve(argv[i + 1]);
i += 1;
} else if (arg === '--help' || arg === '-h') {
console.log(`Usage: node scripts/backfill-mindspace-geo.mjs [--scope mindspace|platform|all] [--dry-run] [--force-geo] [--user <uuid>] [--file <html>]`);
process.exit(0);
}
}
return { scope, root, publicRoot, userId, file, dryRun, forceGeo, fixWarnings };
}
const { scope, root, publicRoot, userId, file, dryRun, forceGeo, fixWarnings } = parseArgs(process.argv);
const targets = collectHtmlTargets({ scope, mindspaceRoot: root, publicRoot, userId, file });
let changed = 0;
let skipped = 0;
for (const htmlPath of targets) {
const before = fs.readFileSync(htmlPath, 'utf8');
const after = backfillMindspaceSeoGeoHtml(before, {
forceGeo,
fixWarnings,
htmlFilePath: htmlPath,
});
if (after === before) {
skipped += 1;
continue;
}
changed += 1;
if (dryRun) {
console.log(`[dry-run] would update ${htmlPath}`);
continue;
}
fs.writeFileSync(htmlPath, after, 'utf8');
console.log(`updated ${htmlPath}`);
}
console.log(
`${dryRun ? '[dry-run] ' : ''}done: ${changed} updated, ${skipped} unchanged, ${targets.length} scanned (scope=${scope})`,
);
@@ -33,6 +33,7 @@ const upgraded = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; --ink: #f5f7fa; --muted: #a8b8cc; --bg: #060d18; }
* { box-sizing: border-box; }
@@ -73,6 +74,7 @@ const upgraded = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -117,6 +119,7 @@ const upgraded = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: radial-gradient(ellipse 120% 80% at 50% -20%, #e8f5ef, #f7f4ed 45%, #f0ebe3 100%); color: #1a2420; }
@@ -173,6 +176,7 @@ const upgraded = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --gold: #e8c896; --bg: #120c06; --card: rgba(255,248,240,.04); }
* { box-sizing: border-box; }
@@ -209,6 +213,7 @@ const upgraded = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --bg: #0a0c10; --card: #141820; --border: #252d3d; }
* { box-sizing: border-box; }
@@ -251,6 +256,7 @@ const upgraded2 = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: radial-gradient(ellipse 80% 60% at 50% -10%, #3d1f5c, #0f1419 55%); color: #fff; display: grid; place-items: center; padding: 40px 24px; }
@@ -282,6 +288,7 @@ const upgraded2 = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -313,6 +320,7 @@ const upgraded2 = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #f4f7f6; color: #0f2a24; }
@@ -349,6 +357,7 @@ const upgraded2 = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: "Songti SC", "Noto Serif SC", Georgia, serif; background: #fafaf8; color: #1c1917; line-height: 1.85; }
@@ -380,6 +389,7 @@ const upgraded2 = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -417,6 +427,7 @@ const upgraded2 = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: "Songti SC", "Noto Serif SC", serif; background: radial-gradient(ellipse 100% 80% at 50% 0%, #fff5f9, #fdf2f8 50%, #fce7f3); color: #701a56; display: grid; place-items: center; padding: 48px 24px; }
@@ -448,6 +459,7 @@ const upgraded2 = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #030806; color: #eafff0; }
@@ -478,6 +490,7 @@ const upgraded2 = {
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -522,6 +535,7 @@ const newTemplates = {
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -568,6 +582,7 @@ const newTemplates = {
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -612,6 +627,7 @@ const newTemplates = {
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -652,6 +668,7 @@ const newTemplates = {
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -689,6 +706,7 @@ const newTemplates = {
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -732,6 +750,7 @@ const newTemplates = {
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -771,6 +790,7 @@ const newTemplates = {
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
+34 -126
View File
@@ -1,36 +1,41 @@
#!/usr/bin/env node
/**
* Scan MindSpace public/*.html for mindspace-cover compliance (scheme A gate).
* Scan HTML pages for mindspace-cover + SEO/GEO compliance.
*
* Usage:
* node scripts/check-mindspace-cover.mjs
* node scripts/check-mindspace-cover.mjs --scope all
* node scripts/check-mindspace-cover.mjs --user <uuid>
* node scripts/check-mindspace-cover.mjs --root /path/to/MindSpace
* node scripts/check-mindspace-cover.mjs --file public/thumbnail-demo-samples/vietnam-guide-good.html
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseMindspaceCoverMeta } from '../mindspace-cover-meta.mjs';
import { hasPlatformBrandMarker } from '../mindspace-page-tag.mjs';
import {
hasMindspaceCoverMeta,
hasRasterShareImageHint,
hasShareDescription,
} from '../wechat/verify/share-preview.mjs';
auditHtmlFile,
collectHtmlTargets,
} from '../mindspace-seo-audit.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_PUBLISH_ROOT = 'MindSpace';
function parseArgs(argv) {
let scope = 'all';
let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT);
let publicRoot = path.join(repoRoot, 'public');
let userId = null;
let file = null;
let seo = false;
let seo = true;
for (let i = 2; i < argv.length; i += 1) {
if (argv[i] === '--root' && argv[i + 1]) {
if (argv[i] === '--scope' && argv[i + 1]) {
scope = argv[i + 1];
i += 1;
} else if (argv[i] === '--root' && argv[i + 1]) {
root = path.resolve(argv[i + 1]);
i += 1;
} else if (argv[i] === '--public-root' && argv[i + 1]) {
publicRoot = path.resolve(argv[i + 1]);
i += 1;
} else if (argv[i] === '--user' && argv[i + 1]) {
userId = argv[i + 1];
i += 1;
@@ -39,146 +44,49 @@ function parseArgs(argv) {
i += 1;
} else if (argv[i] === '--seo') {
seo = true;
} else if (argv[i] === '--skip-seo') {
seo = false;
} else if (argv[i] === '--help' || argv[i] === '-h') {
console.log(`Usage: node scripts/check-mindspace-cover.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--file <html>] [--seo]`);
console.log(`Usage: node scripts/check-mindspace-cover.mjs [--scope mindspace|platform|all] [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--file <html>] [--skip-seo]`);
process.exit(0);
}
}
return { root, userId, file, seo };
return { scope, root, publicRoot, userId, file, seo };
}
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 isPlatformOnlyPath(htmlPath, publicRoot) {
const resolvedPublicRoot = path.resolve(publicRoot);
const resolved = path.resolve(htmlPath);
return resolved.startsWith(`${resolvedPublicRoot}${path.sep}`);
}
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;
}
function auditCoverHtml(htmlPath, html) {
const issues = [];
if (!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 (!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 (!hasCoverImageHint(html)) {
issues.push({
level: 'warn',
code: 'missing_cover_image',
message: '无 raster 封面图(cover/image/og:image),缩略图会退化为纯色渐变',
});
}
return issues;
}
function auditSeoHtml(html) {
const issues = [];
const title = String(html).match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim() ?? '';
if (!title || title.length < 4) {
issues.push({ level: 'warn', code: 'weak_title', message: '缺少或过短的 <title>SEO/GEO 质量会下降' });
} else if (/^(页面|我的页面|未命名|demo|test|MindSpace 页面)$/i.test(title)) {
issues.push({ level: 'warn', code: 'generic_title', message: `title 过于泛化:${title}` });
}
if (!/<h1\b/i.test(html)) {
issues.push({ level: 'warn', code: 'missing_h1', message: '缺少 <h1>,搜索引擎与 AI 抽取质量会下降' });
}
if (!hasShareDescription(html)) {
issues.push({ level: 'warn', code: 'seo_missing_description', message: '缺少 descriptionSEO/GEO 摘要来源不足' });
}
return issues;
}
function collectHtmlFiles(root, userId) {
const files = [];
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
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;
walk(full);
continue;
}
if (!entry.name.endsWith('.html') || entry.name.endsWith('.thumbnail.svg')) continue;
files.push(full);
}
};
if (userId) {
walk(path.join(root, userId, 'public'));
return 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;
walk(path.join(root, entry.name, 'public'));
}
return files;
}
const { root, userId, file, seo } = parseArgs(process.argv);
const targets = file ? [file] : collectHtmlFiles(root, userId);
const { scope, root, publicRoot, userId, file, seo } = parseArgs(process.argv);
const targets = collectHtmlTargets({ scope, mindspaceRoot: root, publicRoot, userId, file });
const results = [];
for (const htmlPath of targets) {
const html = fs.readFileSync(htmlPath, 'utf8');
const issues = auditCoverHtml(htmlPath, html);
if (seo) issues.push(...auditSeoHtml(html));
const platformOnly = isPlatformOnlyPath(htmlPath, publicRoot);
const issues = auditHtmlFile(htmlPath, html, {
seo,
requireCoverMeta: !platformOnly,
});
if (issues.length) {
results.push({ htmlPath, issues });
}
}
if (results.length === 0) {
const scope = file ?? `${root}${userId ? ` (user ${userId})` : ''}`;
console.log(`OK: ${targets.length} 个 HTML 页面均符合 mindspace-cover 规范 (${scope})`);
const scopeLabel = file ?? `${scope} @ ${root}${userId ? ` (user ${userId})` : ''}`;
console.log(`OK: ${targets.length} 个 HTML 页面均符合 mindspace-cover + SEO/GEO 规范 (${scopeLabel})`);
process.exit(0);
}
let errors = 0;
let warns = 0;
console.error(`Found cover issues in ${results.length} / ${targets.length} HTML files:`);
console.error(`Found cover/SEO issues in ${results.length} / ${targets.length} HTML files:`);
for (const { htmlPath, issues } of results) {
const rel = file ? htmlPath : path.relative(root, htmlPath);
const rel = file ? htmlPath : path.relative(repoRoot, htmlPath);
console.error(`\n${rel}`);
for (const issue of issues) {
const prefix = issue.level === 'error' ? ' ✗' : ' ⚠';
+8 -2
View File
@@ -50,10 +50,13 @@ export function attachPortalSeoDiscoveryRoutes(
return res.status(503).type('text/plain; charset=utf-8').send('SEO discovery unavailable\n');
}
const origin = resolveRequestOrigin(req);
const entries = await service.listIndexablePublications(pool, {
const publications = await service.listIndexablePublications(pool, {
limit: req.query.limit,
offset: req.query.offset,
});
const entries = service.mergeDiscoveryEntries
? service.mergeDiscoveryEntries(publications)
: publications;
return res
.type('application/xml; charset=utf-8')
.set('Cache-Control', 'public, max-age=300')
@@ -72,10 +75,13 @@ export function attachPortalSeoDiscoveryRoutes(
return res.status(503).type('text/plain; charset=utf-8').send('GEO discovery unavailable\n');
}
const origin = resolveRequestOrigin(req);
const entries = await service.listIndexablePublications(pool, {
const publications = await service.listIndexablePublications(pool, {
limit: req.query.limit,
offset: req.query.offset,
});
const entries = service.mergeDiscoveryEntries
? service.mergeDiscoveryEntries(publications)
: publications;
return res
.type('text/plain; charset=utf-8')
.set('Cache-Control', 'public, max-age=300')
@@ -4,6 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: "Songti SC", "Noto Serif SC", Georgia, serif; background: #fafaf8; color: #1c1917; line-height: 1.85; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -4,6 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: radial-gradient(ellipse 80% 60% at 50% -10%, #3d1f5c, #0f1419 55%); color: #fff; display: grid; place-items: center; padding: 40px 24px; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #030806; color: #eafff0; }
@@ -4,6 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -4,6 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -4,6 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --bg: #0a0c10; --card: #141820; --border: #252d3d; }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: #f4f7f6; color: #0f2a24; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --gold: #e8c896; --bg: #120c06; --card: rgba(255,248,240,.04); }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; }
* { box-sizing: border-box; }
@@ -4,6 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -4,6 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title><meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; background: radial-gradient(ellipse 120% 80% at 50% -20%, #e8f5ef, #f7f4ed 45%, #f0ebe3 100%); color: #1a2420; }
+1
View File
@@ -26,6 +26,7 @@ description: 旅游 Hero 页面模板:深色沉浸布局,适用于旅游/美
| `PAGE_TITLE` | 页面标题 |
| `PAGE_DESCRIPTION` | description 摘要 |
| `MINDSPACE_COVER_JSON` | mindspace-cover JSON(含 tag/accent/cover |
| `MINDSPACE_GEO_JSON` | mindspace-geo JSON(含 summary/keywords/faq |
| `HERO_EYEBROW` | 首屏小标签 |
| `HERO_HEADLINE` | 主标题 |
| `HERO_SUBTITLE` | 副标题 |
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
:root { --accent: {{ACCENT}}; --accent2: {{ACCENT2}}; --ink: #f5f7fa; --muted: #a8b8cc; --bg: #060d18; }
* { box-sizing: border-box; }
@@ -6,6 +6,7 @@
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}">
<meta name="mindspace-cover" content='{{MINDSPACE_COVER_JSON}}'>
<meta name="mindspace-geo" content='{{MINDSPACE_GEO_JSON}}'>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: "Songti SC", "Noto Serif SC", serif; background: radial-gradient(ellipse 100% 80% at 50% 0%, #fff5f9, #fdf2f8 50%, #fce7f3); color: #701a56; display: grid; place-items: center; padding: 48px 24px; }
+18 -14
View File
@@ -139,38 +139,42 @@ npm run check:mindspace-cover
| 检查项 | 级别 | 说明 |
|--------|------|------|
| `mindspace-cover` meta | error | 缺失则缩略图为默认绿色渐变 |
| `description` | error | 副标题来源 |
| `description` | error | 副标题与 SEO 摘要来源 |
| `mindspace-geo` meta | error | GEO 摘要/FAQ/关键词来源 |
| `<title>` / `<h1>` | error | 禁止泛化标题 |
| `<h2>` 分节 ≥2 | warn | 语义结构不足 |
| `faq` ≥1 组 | warn | GEO 结构化数据偏弱 |
| `cover` raster 主图 | warn | 缺失则无法生成照片封面 |
| `tag` / `accent` / `subtitle` | warn | 影响分类与配色 |
| 平台品牌页脚 | warn | 分享与编辑规范 |
**视觉类页面**若有 warn `missing_cover_image`,视为封面未达标,必须补 hero 图后再回复用户。
## SEO / GEO 元数据(推荐
## SEO / GEO 元数据(必须
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**公开在线**`access_mode=public``status=online`)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量
每个 `public/*.html` **都必须**写好 SEO/GEO 元数据。平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**公开在线**`access_mode=public``status=online`)的发布页自动注入 canonical、keywords、JSON-LD 等;**私有 / 密码 / 登录可见**页会被强制 `noindex`。Agent 仍必须在 HTML 中写好以下内容,否则 `npm run check:mindspace-cover` 会报错
```html
<title>页面真实主题</title>
<meta name="description" content="一句话可被搜索/AI 引用的摘要">
```
可选增强(不强制):
```html
<meta name="mindspace-geo" content='{
"summary": "可被 AI 搜索直接引用的摘要",
"keywords": ["关键词1","关键词2"],
"faq": [{"q":"常见问题?","a":"简短答案"}]
"summary": "可被 AI 搜索直接引用的摘要(可与 description 相同或更完整)",
"keywords": ["主题词1","主题词2","场景词"],
"faq": [
{"q":"用户最可能搜索的问题?","a":"30120 字的直接答案"},
{"q":"第二个常见问题?","a":"简短可引用答案"}
]
}'>
```
要求:
- `<title>``<h1>` 一致或高度相关,不要用泛化词如「我的页面」
- `<title>``<h1>` 一致或高度相关,禁止「我的页面」「未命名」等泛化词
- `description` 必须写满页面主题,禁止空泛默认句
- 正文使用语义化 `<h2>` / `<h3>` 分节,便于 SEO 与 GEO 抽取
- **私有 / 密码 / 登录可见**页面会被平台强制 `noindex`,无需 Agent 额外处理
- `mindspace-geo.summary` 必填;`keywords` 至少 2 个;`faq` 至少 1 组 Q&A
- 正文至少 2 个语义化 `<h2>` 分节(可配合 `<h3>`),便于 SEO 与 GEO 抽取
- 若暂未写 `faq`,至少用 `<h2>问题</h2><p>答案…</p>` 结构,平台会尝试自动抽取
- 交付前运行 `npm run check:mindspace-cover`(已默认含 SEO/GEO 校验)
## 平台页脚标记(必须)
+22 -1
View File
@@ -181,7 +181,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
1. 确认需求标题章节是否要图表/样式
2. 使用 \`write_file\` 创建 \`public/页面.html\`(可含内联 CSS;需要时在 \`public/assets/\` 或工作区 \`assets/\` 放资源)
3. \`<head>\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致)
3. \`<head>\` 写入 **mindspace-cover** 与 **mindspace-geo** 元数据(见下文,必须与页面主题一致)
4. 页面内资源使用**相对路径**\`assets/foo.png\`),不要用磁盘绝对路径
5. 保存 HTML 服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」)
6. 默认只生成 HTML不要在没有明确需求时强制生成 WordPDF长图等伴生文件
@@ -269,6 +269,27 @@ npm run check:mindspace-cover
视觉类页面若出现 \`missing_cover_image\` 警告,必须补 hero 主图后再回复用户。对比效果:\`npm run demo:thumbnails\`\`/thumbnail-demo/\`
## SEO / GEO 元数据必须
每个 \`public/*.html\` 都必须包含完整 SEO/GEO 元数据。公开在线页发布时平台会自动注入 canonical、keywords、JSON-LD;私有页会被强制 \`noindex\`。Agent 必须在 \`<head>\` 写入:
\`\`\`html
<title>页面真实主题</title>
<meta name="description" content="一句话可被搜索/AI 引用的摘要">
<meta name="mindspace-geo" content='{
"summary": "可被 AI 直接引用的页面摘要",
"keywords": ["主题词1","主题词2"],
"faq": [{"q":"常见问题?","a":"30120 字直接答案"}]
}'>
\`\`\`
硬性要求
- \`<title>\`\`<h1>\` 一致或高度相关,禁止「我的页面」「未命名」等泛化词
- \`mindspace-geo.summary\` 必填;\`keywords\` 至少 2 个;\`faq\` 至少 1 组 Q&A
- 正文至少 2 \`<h2>\` 分节,便于 SEO/GEO 抽取
- 交付前运行 \`npm run check:mindspace-cover\`(默认含 SEO/GEO 校验)
## HTML 模板建议
- 完整 \`<!DOCTYPE html>\`\`lang="zh-CN"\`