Compare commits

..

9 Commits

Author SHA1 Message Date
john 96d8e119ed docs: register feature/seo-geo-universal branch disposition
Memind CI / Test, build, and release guards (push) Failing after 2m58s
记录 SEO/GEO 全量能力合并 main 与 103 发布 20260916-103611-8d475087。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:43:02 +08:00
john 8d475087d2 test(wechat): 隔离 sidecar 缩略图候选用例,避免 SEO 回填污染
Memind CI / Test, build, and release guards (push) Failing after 3m37s
改用临时目录 fixture,不再依赖本地 MindSpace 可被 backfill 改写的 HTML。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:35:34 +08:00
john 357e26ab49 feat(seo-geo): 落盘自动补齐与 143 全量回填支持
Memind CI / Test, build, and release guards (pull_request) Failing after 7m12s
Memind CI / Test, build, and release guards (push) Has been cancelled
Finish/交付写盘前自动回填 mindspace-geo,扩展 storage publications 扫描与 geo meta 解析,并增加 143 生产回填脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:26:02 +08:00
john acca846856 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>
2026-09-16 09:36:01 +08:00
john 7ab7bf0f2d Merge branch 'docs/learn-branch-disposition'
Memind CI / Test, build, and release guards (push) Failing after 2m47s
Register closed disposition for feature/learn-standalone-pages.
2026-09-16 09:15:12 +08:00
john 51a32bf6f2 docs: register feature/learn-standalone-pages branch disposition
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 09:15:12 +08:00
john 01b9a8c16d Merge branch 'feature/learn-standalone-pages'
Memind CI / Test, build, and release guards (push) Has been cancelled
Ship learn standalone pages and MindSpace background list sync fix.
2026-09-16 09:13:49 +08:00
john 63f1b8ee44 test(portal): expect /learn static route in delivery order
Align static delivery route inventory with the learn standalone pages mount
so fast-release guard tests pass on feature/learn-standalone-pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 09:00:59 +08:00
john 264c213fbe fix(mindspace): use lightweight page sync for background list refresh
Background page list sync was calling syncAndDeliver over the remote adapter,
which could exceed the 60s timeout for heavy users and block Portal. Route list
sync through pageSyncService only via listSync while keeping full delivery on
Finish and agent-run paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 08:53:58 +08:00
62 changed files with 1655 additions and 220 deletions
+52
View File
@@ -1,5 +1,57 @@
# 历史分支处置登记
## `feature/seo-geo-universal`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-16
分支 HEAD`357e26ab`
`main` 对应提交:`8d475087`(含 `fix/wechat-draft-test-fixture` 发布阻断修复)
### 交付内容
- 落盘/交付自动补齐 `mindspace-geo`Finish sync、delivery writeBack、health-report
- 全页面 SEO/GEO 回填工具链、`storage publications` 扫描、143 生产回填脚本
- Plaza/平台静态页 SEO 注入与统一审计
### 验证摘要
- `npm run verify:seo-geo`46 项通过
- `npm run verify:mindspace-publish-guards`ok
- 143 存量回填:2683 页,0 错误 / 0 警告
- 103 快速发布:`20260916-103611-8d475087`Portal 8081 健康 200
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `8d475087`(或后续从 `main` 打出的 runtime manifest)。
## `feature/learn-standalone-pages`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-16
分支 HEAD`63f1b8ee`
`main` 对应提交:`01b9a8c1`
### 交付内容
- `/learn` 独立静态页(Page Data 驱动)与 Portal 静态路由挂载
- 后台页面列表同步改走 `listSync` 轻量 `pageSyncService`,避免 remote `syncAndDeliver` 60s 超时拖慢 Portal
### 验证摘要
- `npm run verify:mindspace-page-sync-guards`ok
- `node --test server/portal-mindspace-page-core-routes.test.mjs server/portal-static-delivery-routes.test.mjs`ok
- 103 快速发布:`20260916-090102-63f1b8ee`Portal 8081 / goosed 1800618014 健康 200
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `01b9a8c1`(或后续从 `main` 打出的 runtime manifest)。
## `feature/news-morning-force-archive`
**状态:禁止再次引用。改动已提交并进入本地 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
@@ -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。
## 必跑验证
+14 -1
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import { buildHealthAssessSummary } from './health-assess-summary.mjs';
import { buildHealthTimeline } from './health-timeline.mjs';
import { buildMindSpacePublicUrlForUser } from './mindspace-runtime-config.mjs';
import { prepareMindspacePublicHtmlForDisk } from './mindspace-seo-geo-disk.mjs';
import { resolvePublishDir } from './user-publish.mjs';
import { looksLikeHealthReportPageRequest } from './health-channel-aliases.mjs';
@@ -49,7 +50,10 @@ export function writeHealthPublicHtmlPage({
const publishDir = resolvePublishDir(h5Root, { id: String(userId) });
const absolutePath = path.join(publishDir, clean);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, html, 'utf8');
const preparedHtml = prepareMindspacePublicHtmlForDisk(String(html ?? ''), {
htmlFilePath: absolutePath,
});
fs.writeFileSync(absolutePath, preparedHtml, 'utf8');
if (!fs.existsSync(absolutePath) || fs.statSync(absolutePath).size < minSize) {
const error = Object.assign(new Error('健康页写入失败'), { code: 'health_page_write_failed' });
throw error;
@@ -115,6 +119,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 ?? '');
+360
View File
@@ -0,0 +1,360 @@
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 escapeMetaAttribute(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
}
function stripHtml(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
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="${escapeMetaAttribute(JSON.stringify(payload))}">`;
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);
const existingGeo = parseMindspaceGeoMeta(next);
const needsGeoUpsert =
!hasMeta(next, 'mindspace-geo') ||
options.forceGeo ||
!String(existingGeo.summary ?? '').trim();
if (needsGeoUpsert) {
next = upsertMindspaceGeoMeta(next, geoPayload, {
forceReplace: options.forceGeo || !String(existingGeo.summary ?? '').trim(),
});
}
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, /旧/);
});
+146
View File
@@ -0,0 +1,146 @@
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 };
}
function decodeMetaAttribute(value) {
return String(value ?? '').replaceAll('&quot;', '"').replaceAll('&amp;', '&').replaceAll('&lt;', '<');
}
function extractGeoMetaJsonRaw(html) {
const source = String(html ?? '');
const marker = source.match(/<meta[^>]*name=["']mindspace-geo["'][^>]*content\s*=\s*/i);
if (!marker) return null;
let rest = source.slice(marker.index + marker[0].length);
if (rest[0] === '"') {
let raw = '';
for (let i = 1; i < rest.length; i += 1) {
if (rest[i] === '&' && rest.slice(i, i + 6) === '&quot;') {
raw += '"';
i += 5;
continue;
}
if (rest[i] === '"') return raw;
raw += rest[i];
}
return null;
}
const jsonStart = rest.indexOf('{');
if (jsonStart < 0) return null;
let depth = 0;
let inString = false;
let escape = false;
for (let i = jsonStart; i < rest.length; i += 1) {
const ch = rest[i];
if (inString) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === '{') depth += 1;
else if (ch === '}') {
depth -= 1;
if (depth === 0) return rest.slice(jsonStart, i + 1);
}
}
return null;
}
export function parseMindspaceGeoMeta(html) {
const raw = extractGeoMetaJsonRaw(html);
if (!raw) return {};
try {
return JSON.parse(decodeMetaAttribute(raw));
} 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);
}
+50
View File
@@ -0,0 +1,50 @@
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('parseMindspaceGeoMeta reads legacy single-quoted json with apostrophes in faq', () => {
const html = `<meta name="mindspace-geo" content='{"summary":"业绩回顾","keywords":["报告"],"faq":[{"q":"费比规则?","a":"单客户费比上限18%,超标须特批"}]}'>`;
const meta = parseMindspaceGeoMeta(html);
assert.equal(meta.summary, '业绩回顾');
assert.equal(meta.faq[0].a, '单客户费比上限18%,超标须特批');
});
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, {
+5 -1
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { extractStaticPageLinks, materializePrivateAssetsInPublicHtmlFiles } from './mindspace-chat-save.mjs';
import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs';
import { scheduleWorkspaceHtmlThumbnailSidecars } from './mindspace-workspace-thumbnails.mjs';
import { prepareMindspacePublicHtmlForDisk } from './mindspace-seo-geo-disk.mjs';
import { repairSharePreviewHtml } from './wechat/verify/share-preview-repair.mjs';
import { verifySharePreviewMeta } from './wechat/verify/share-preview.mjs';
@@ -408,9 +409,12 @@ export function materializeMissingPublicHtmlWrites({ messages, publishDir }) {
try {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const rawContent = String(artifact.content ?? '');
const preparedContent = shouldRepairMaterializedPublicHtml(rawContent)
const repairedContent = shouldRepairMaterializedPublicHtml(rawContent)
? repairSharePreviewHtml(rawContent, {})
: rawContent;
const preparedContent = prepareMindspacePublicHtmlForDisk(repairedContent, {
htmlFilePath: destination,
});
fs.writeFileSync(destination, preparedContent, 'utf8');
materialized.push(artifact.relativePath);
scheduleWorkspaceHtmlThumbnailSidecars(root, artifact.relativePath);
+6 -8
View File
@@ -185,10 +185,9 @@ test('materializeMissingPublicHtmlWrites writes html from sandbox write_file too
];
const result = materializeMissingPublicHtmlWrites({ messages, publishDir });
assert.deepEqual(result.materialized, ['public/summer-essay.html']);
assert.equal(
fs.readFileSync(path.join(publishDir, 'public/summer-essay.html'), 'utf8'),
'<!doctype html><title>Summer</title>',
);
const saved = fs.readFileSync(path.join(publishDir, 'public/summer-essay.html'), 'utf8');
assert.match(saved, /<!doctype html><title>Summer<\/title>/i);
assert.match(saved, /name="mindspace-geo"/);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
@@ -420,10 +419,9 @@ test('materializeMissingPublicHtmlWrites writes html from developer write tool c
];
const result = materializeMissingPublicHtmlWrites({ messages, publishDir });
assert.deepEqual(result.materialized, ['public/guizhou-guide.html']);
assert.equal(
fs.readFileSync(path.join(publishDir, 'public/guizhou-guide.html'), 'utf8'),
'<!doctype html><title>Guizhou</title>',
);
const saved = fs.readFileSync(path.join(publishDir, 'public/guizhou-guide.html'), 'utf8');
assert.match(saved, /<!doctype html><title>Guizhou<\/title>/i);
assert.match(saved, /name="mindspace-geo"/);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
+231
View File
@@ -0,0 +1,231 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
import { parseMindspaceGeoMeta, resolveGeoFaq } from './mindspace-geo-meta.mjs';
import { hasPlatformBrandMarker } from './mindspace-page-tag.mjs';
import {
hasMindspaceCoverMeta,
hasRasterShareImageHint,
hasShareDescription,
} from './wechat/verify/share-preview.mjs';
export const AUDIT_SCOPES = ['mindspace', 'platform', 'all'];
const PLATFORM_SKIP_DIRS = new Set(['dev', 'thumbnail-demo', 'thumbnail-demo-samples']);
function hasCoverImageHint(html) {
if (hasRasterShareImageHint(html)) return true;
const coverMeta = parseMindspaceCoverMeta(html);
const cover = String(coverMeta?.cover ?? coverMeta?.image ?? '').trim();
if (!cover) return false;
if (/^https?:\/\//i.test(cover)) return true;
if (/\.svg(?:[?#]|$)/i.test(cover)) return false;
return true;
}
function missingLocalCoverPath(htmlPath, coverMeta) {
const cover = String(coverMeta?.cover ?? coverMeta?.image ?? '').trim();
if (!cover || /^https?:\/\//i.test(cover) || cover.startsWith('data:')) return null;
const clean = cover.split('?')[0].split('#')[0].replace(/^\.\//, '');
if (!clean || clean.startsWith('/') || clean.includes('\\')) return null;
const base = path.dirname(htmlPath);
const target = path.resolve(base, clean);
if (target !== base && !target.startsWith(`${base}${path.sep}`)) return clean;
return fs.existsSync(target) && fs.statSync(target).isFile() ? null : clean;
}
export function auditCoverHtml(htmlPath, html, { requireCoverMeta = true } = {}) {
const issues = [];
if (requireCoverMeta && !hasMindspaceCoverMeta(html)) {
issues.push({ level: 'error', code: 'missing_mindspace_cover', message: '缺少 <meta name="mindspace-cover">' });
}
if (!hasShareDescription(html)) {
issues.push({ level: 'error', code: 'missing_description', message: '缺少 description / og:description' });
}
if (requireCoverMeta && !hasPlatformBrandMarker(html)) {
issues.push({ level: 'warn', code: 'missing_platform_brand', message: '缺少 data-mindspace-page-tag="platform-brand" 页脚' });
}
const coverMeta = parseMindspaceCoverMeta(html);
if (coverMeta) {
if (!String(coverMeta.tag ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_tag', message: 'mindspace-cover 缺少 tag' });
}
if (!String(coverMeta.subtitle ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_subtitle', message: 'mindspace-cover 缺少 subtitle' });
}
if (!String(coverMeta.accent ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_accent', message: 'mindspace-cover 缺少 accent' });
}
const missingCover = missingLocalCoverPath(htmlPath, coverMeta);
if (missingCover) {
issues.push({
level: 'error',
code: 'missing_cover_file',
message: `mindspace-cover 引用的本地文件不存在:${missingCover}`,
});
}
}
if (requireCoverMeta && !hasCoverImageHint(html)) {
issues.push({
level: 'warn',
code: 'missing_cover_image',
message: '无 raster 封面图(cover/image/og:image),缩略图会退化为纯色渐变',
});
}
return issues;
}
export function auditSeoHtml(html, { strictGeo = true } = {}) {
const issues = [];
const title = String(html).match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim() ?? '';
const weakLatinTitle = title && /^[\x00-\x7F]+$/.test(title) && title.length < 4;
if (!title || title.length < 2 || weakLatinTitle) {
issues.push({ level: 'error', code: 'weak_title', message: '缺少或过短的 <title>SEO/GEO 无法达标' });
} else if (/^(页面|我的页面|未命名|demo|test|MindSpace 页面)$/i.test(title)) {
issues.push({ level: 'error', code: 'generic_title', message: `title 过于泛化:${title}` });
}
if (!/<h1\b/i.test(html)) {
issues.push({ level: 'error', code: 'missing_h1', message: '缺少 <h1>,搜索引擎与 AI 抽取质量会下降' });
}
if (!hasShareDescription(html)) {
issues.push({ level: 'error', code: 'seo_missing_description', message: '缺少 descriptionSEO/GEO 摘要来源不足' });
}
const geoMeta = parseMindspaceGeoMeta(html);
if (strictGeo && !/<meta[^>]*name=["']mindspace-geo["']/i.test(html)) {
issues.push({
level: 'error',
code: 'missing_mindspace_geo',
message: '缺少 <meta name="mindspace-geo">GEO 摘要/FAQ 无法达标',
});
} else if (strictGeo && !String(geoMeta.summary ?? '').trim()) {
issues.push({
level: 'warn',
code: 'geo_missing_summary',
message: 'mindspace-geo 缺少 summary,建议写可被 AI 直接引用的摘要',
});
}
const h2Count = (String(html).match(/<h2\b/gi) ?? []).length;
const outlineSections = /data-mindspace-seo-outline=/i.test(html);
const faqCount = resolveGeoFaq(html, geoMeta).length;
if (h2Count < 2 && !outlineSections && faqCount < 2) {
issues.push({
level: 'warn',
code: 'insufficient_h2_sections',
message: '正文建议至少 2 个 <h2> 分节,便于 SEO/GEO 抽取',
});
}
const faq = resolveGeoFaq(html, geoMeta);
const parsedGeo = parseMindspaceGeoMeta(html);
const declaredFaq = Array.isArray(parsedGeo.faq) ? parsedGeo.faq.length : 0;
if (faq.length < 1 && declaredFaq < 1) {
issues.push({
level: 'warn',
code: 'geo_missing_faq',
message: '缺少 FAQmindspace-geo.faq 或 h2+p 结构),GEO 结构化数据会偏弱',
});
}
return issues;
}
export function auditHtmlFile(htmlPath, html, options = {}) {
const requireCoverMeta = options.requireCoverMeta !== false;
return [
...auditCoverHtml(htmlPath, html, { requireCoverMeta }),
...(options.seo === false ? [] : auditSeoHtml(html, options)),
];
}
function walkHtmlFiles(dir, files = []) {
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '.agents' || entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
walkHtmlFiles(full, files);
continue;
}
if (!entry.name.endsWith('.html') || entry.name.endsWith('.thumbnail.svg')) continue;
files.push(full);
}
return files;
}
export function collectMindspaceHtmlFiles(root, userId = null) {
if (userId) {
return walkHtmlFiles(path.join(root, userId, 'public'));
}
const files = [];
if (!fs.existsSync(root)) return files;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === 'wiki' || entry.name.startsWith('.')) continue;
walkHtmlFiles(path.join(root, entry.name, 'public'), files);
}
return files;
}
export function collectStoragePublicationHtmlFiles(storageRoot, userId = null) {
const files = [];
const usersDir = path.join(storageRoot, 'users');
if (!fs.existsSync(usersDir)) return files;
const userDirs = userId
? [path.join(usersDir, userId)]
: fs.readdirSync(usersDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => path.join(usersDir, entry.name));
for (const userDir of userDirs) {
const publicationsDir = path.join(userDir, 'publications');
if (!fs.existsSync(publicationsDir)) continue;
for (const publication of fs.readdirSync(publicationsDir, { withFileTypes: true })) {
if (!publication.isDirectory() || publication.name.startsWith('.')) continue;
const indexHtml = path.join(publicationsDir, publication.name, 'index.html');
if (fs.existsSync(indexHtml)) files.push(indexHtml);
}
}
return files;
}
export function collectPlatformHtmlFiles(publicRoot) {
const files = [];
if (!fs.existsSync(publicRoot)) return files;
for (const entry of fs.readdirSync(publicRoot, { withFileTypes: true })) {
const full = path.join(publicRoot, entry.name);
if (entry.isDirectory()) {
if (PLATFORM_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
walkHtmlFiles(full, files);
continue;
}
if (entry.name.endsWith('.html')) files.push(full);
}
return files;
}
export function collectHtmlTargets({
scope = 'mindspace',
mindspaceRoot,
publicRoot,
storageRoot = null,
userId = null,
file = null,
} = {}) {
if (file) return [path.resolve(file)];
const normalized = String(scope ?? 'mindspace').trim().toLowerCase();
const storageFiles = storageRoot
? collectStoragePublicationHtmlFiles(storageRoot, userId)
: [];
if (normalized === 'platform') {
return collectPlatformHtmlFiles(publicRoot);
}
if (normalized === 'storage') {
return storageFiles;
}
if (normalized === 'all') {
return [
...collectMindspaceHtmlFiles(mindspaceRoot, userId),
...storageFiles,
...collectPlatformHtmlFiles(publicRoot),
];
}
return collectMindspaceHtmlFiles(mindspaceRoot, userId);
}
+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: '仙居玩水' }],
+44
View File
@@ -0,0 +1,44 @@
import fs from 'node:fs';
import path from 'node:path';
import { backfillMindspaceSeoGeoHtml } from './mindspace-geo-backfill.mjs';
function isPublicHtmlPath(value) {
const normalized = String(value ?? '').replace(/\\/g, '/');
return /(?:^|\/)public\/[^/]+\.html$/i.test(normalized);
}
export function prepareMindspacePublicHtmlForDisk(html, { htmlFilePath = '' } = {}) {
const source = String(html ?? '');
const filePath = String(htmlFilePath ?? '');
if (!source.trim()) return source;
if (filePath && !isPublicHtmlPath(filePath)) return source;
if (!filePath && !/<html[\s>]/i.test(source)) return source;
return backfillMindspaceSeoGeoHtml(source, {
htmlFilePath: filePath,
fixWarnings: true,
});
}
export function materializeMindspaceSeoGeoOnDisk(
absolutePath,
{
forceGeo = false,
fixWarnings = true,
} = {},
) {
const filePath = path.resolve(String(absolutePath ?? ''));
if (!filePath || !/\.html$/i.test(filePath)) {
return { changed: false, html: '' };
}
const before = fs.readFileSync(filePath, 'utf8');
const after = backfillMindspaceSeoGeoHtml(before, {
htmlFilePath: filePath,
forceGeo,
fixWarnings,
});
if (after === before) {
return { changed: false, html: before };
}
fs.writeFileSync(filePath, after, 'utf8');
return { changed: true, html: after };
}
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
materializeMindspaceSeoGeoOnDisk,
prepareMindspacePublicHtmlForDisk,
} from './mindspace-seo-geo-disk.mjs';
test('prepareMindspacePublicHtmlForDisk injects mindspace-geo for public html', () => {
const html = '<html><head><title>Demo</title></head><body><h1>Demo</h1></body></html>';
const next = prepareMindspacePublicHtmlForDisk(html, {
htmlFilePath: '/tmp/user/public/demo.html',
});
assert.match(next, /name="mindspace-geo"/);
assert.match(next, /name="description"/);
});
test('materializeMindspaceSeoGeoOnDisk writes backfilled html to disk', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-geo-disk-'));
const filePath = path.join(dir, 'public', 'page.html');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(
filePath,
'<html><head><title>Disk Demo</title></head><body><h1>Disk Demo</h1></body></html>',
'utf8',
);
const result = materializeMindspaceSeoGeoOnDisk(filePath);
assert.equal(result.changed, true);
const saved = fs.readFileSync(filePath, 'utf8');
assert.match(saved, /name="mindspace-geo"/);
});
+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 });
+15 -6
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
@@ -196,12 +197,20 @@ test('convertRichMindSpacePageHtmlToWechatArticle extracts blog and startup temp
});
test('collectPageImageCandidates adds sidecar thumbnail when hero image missing', () => {
const publishDir = path.join(process.cwd(), 'MindSpace', '1c99b83b-0454-474f-a5d2-129d34506a32');
const html = fs.readFileSync(path.join(publishDir, 'public/qiuting-xiaoxi.html'), 'utf8');
const candidates = mindspaceWechatPageDraftInternals.collectPageImageCandidates(html, {
publishDir,
htmlRelativePath: 'public/qiuting-xiaoxi.html',
});
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-wechat-draft-'));
const htmlRelativePath = 'public/no-hero-page.html';
const htmlPath = path.join(publishDir, htmlRelativePath);
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(
htmlPath,
'<!doctype html><html><head><title>无封面页</title></head><body><h1>无封面页</h1><p>正文</p></body></html>',
'utf8',
);
fs.writeFileSync(htmlPath.replace(/\.html$/i, '.thumbnail.png'), Buffer.from('png'), 'utf8');
const candidates = mindspaceWechatPageDraftInternals.collectPageImageCandidates(
fs.readFileSync(htmlPath, 'utf8'),
{ publishDir, htmlRelativePath },
);
assert.ok(candidates.some((item) => /\.thumbnail\.(png|svg)$/i.test(item.ref ?? '')));
});
@@ -6,6 +6,7 @@ import {
materializePrivateAssetsInWorkspaceHtml,
resolveClosestHtmlRelativePath,
} from './mindspace-chat-save.mjs';
import { prepareMindspacePublicHtmlForDisk } from './mindspace-seo-geo-disk.mjs';
import {
scanWorkspaceFilesForProhibitedBrowserStorage,
} from './mindspace-browser-storage-policy.mjs';
@@ -105,9 +106,29 @@ export function createMindSpaceWorkspacePublicationDeliveryService({
scanWorkspaceFilesForProhibitedBrowserStorageFn =
scanWorkspaceFilesForProhibitedBrowserStorage,
readFileFn = fsPromises.readFile,
writeFileFn = fsPromises.writeFile,
statFn = fsPromises.stat,
existsSyncFn = fs.existsSync,
prepareMindspaceSeoGeoForDeliveryFn = null,
} = {}) {
const enrichMindspaceHtmlForDelivery =
prepareMindspaceSeoGeoForDeliveryFn
?? (async ({ html, absolutePath }) => {
const enriched = prepareMindspacePublicHtmlForDisk(html, {
htmlFilePath: absolutePath,
});
if (enriched === html) return html;
try {
await writeFileFn(absolutePath, enriched, 'utf8');
} catch (error) {
logger?.warn?.(
`[MindSpace] SEO/GEO disk backfill failed for ${absolutePath}: ${
error?.message || error
}`,
);
}
return enriched;
});
if (!pool || typeof pool.query !== 'function') {
throw new Error(
'createMindSpaceWorkspacePublicationDeliveryService requires a database pool',
@@ -224,6 +245,13 @@ export function createMindSpaceWorkspacePublicationDeliveryService({
}
}
html = await enrichMindspaceHtmlForDelivery({
html,
absolutePath: readablePath,
readFileFn,
writeFileFn,
});
const thumbnailRelativePath =
relativePath.replace(
/\.html$/i,
@@ -144,18 +144,12 @@ test('resolves workspace HTML into a logical delivery without exposing a physica
requestPath:
'/user-1/public/page.html',
});
assert.deepEqual(result, {
action: 'deliver',
kind: 'html',
ownerId: 'user-1',
relativePath: 'public/page.html',
servedName: 'page.html',
canonicalPath:
'/MindSpace/user-1/public/page.html',
thumbnailName: 'page.thumbnail.png',
html:
'<!doctype html><p>localized</p>',
});
assert.equal(result.action, 'deliver');
assert.equal(result.kind, 'html');
assert.equal(result.ownerId, 'user-1');
assert.equal(result.relativePath, 'public/page.html');
assert.match(result.html, /localized/);
assert.match(result.html, /name="mindspace-geo"/);
assert.equal(
Object.hasOwn(result, 'filePath'),
false,
+5 -3
View File
@@ -55,8 +55,10 @@
"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",
"backfill:mindspace-geo:143": "bash scripts/backfill-mindspace-geo-prod.sh",
"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 +119,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-disk.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>
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
# Batch backfill SEO/GEO for all MindSpace HTML on production host 143.
# 143 运行的是 release runtime 包(无 .git),因此从本机 rsync 工具脚本后再执行回填。
#
# Usage (from laptop):
# bash scripts/backfill-mindspace-geo-prod.sh
# bash scripts/backfill-mindspace-geo-prod.sh --dry-run
HOST="${MEMIND_PROD_HOST:-john@180.159.29.143}"
LOCAL_REPO="$(cd "$(dirname "$0")/.." && pwd)"
REMOTE_REPO="${MEMIND_PROD_REPO:-/Users/john/Project/Memind}"
REMOTE_MINDSPACE_ROOT="${MEMIND_PROD_MINDSPACE_ROOT:-${REMOTE_REPO}/MindSpace}"
REMOTE_STORAGE_ROOT="${MEMIND_PROD_STORAGE_ROOT:-/Users/john/MindSpace/data/mindspace}"
REMOTE_PUBLIC_ROOT="${MEMIND_PROD_PUBLIC_ROOT:-${REMOTE_REPO}/public}"
REMOTE_NODE="${MEMIND_PROD_NODE:-/opt/homebrew/Cellar/node@22/22.22.3/bin/node}"
EXTRA_ARGS=("$@")
echo "==> 143 SEO/GEO backfill"
echo " host=${HOST}"
echo " local=${LOCAL_REPO}"
echo " remote=${REMOTE_REPO}"
echo " mindspace=${REMOTE_MINDSPACE_ROOT}"
echo " storage=${REMOTE_STORAGE_ROOT}"
echo " public=${REMOTE_PUBLIC_ROOT}"
echo " node=${REMOTE_NODE}"
SEO_FILES=(
chat-image-materialize.mjs
chat-image-turn-scope.mjs
html-document-injection.mjs
mindspace-cover-meta.mjs
mindspace-geo-backfill.mjs
mindspace-geo-meta.mjs
mindspace-og-tags.mjs
mindspace-page-tag.mjs
mindspace-seo-audit.mjs
mindspace-seo-tags.mjs
mindspace-thumbnail-png.mjs
mindspace-thumbnails.mjs
mindspace-workspace-thumbnails.mjs
scripts/backfill-mindspace-geo.mjs
scripts/check-mindspace-cover.mjs
wechat/verify/page-artifact.mjs
wechat/verify/share-preview.mjs
)
ssh "${HOST}" "mkdir -p '${REMOTE_REPO}/scripts' '${REMOTE_REPO}/wechat/verify'"
for rel in "${SEO_FILES[@]}"; do
rsync -az "${LOCAL_REPO}/${rel}" "${HOST}:${REMOTE_REPO}/${rel}"
done
ssh "${HOST}" "set -eo pipefail
NODE_BIN='${REMOTE_NODE}'
if ! test -x \"\${NODE_BIN}\"; then
NODE_BIN=\$(ls /opt/homebrew/Cellar/node@22/*/bin/node 2>/dev/null | head -1)
fi
if ! test -x \"\${NODE_BIN}\"; then
NODE_BIN=\$(ls /Users/john/.local/share/fnm/node-versions/*/installation/bin/node 2>/dev/null | head -1)
fi
test -x \"\${NODE_BIN}\" || { echo 'node not found on 143' >&2; exit 127; }
cd '${REMOTE_REPO}'
\"\${NODE_BIN}\" scripts/backfill-mindspace-geo.mjs \\
--scope all \\
--root '${REMOTE_MINDSPACE_ROOT}' \\
--storage-root '${REMOTE_STORAGE_ROOT}' \\
--public-root '${REMOTE_PUBLIC_ROOT}' \\
${EXTRA_ARGS[*]:-}
\"\${NODE_BIN}\" scripts/check-mindspace-cover.mjs \\
--scope all \\
--root '${REMOTE_MINDSPACE_ROOT}' \\
--storage-root '${REMOTE_STORAGE_ROOT}' \\
--public-root '${REMOTE_PUBLIC_ROOT}'
"
echo "==> done"
+117
View File
@@ -0,0 +1,117 @@
#!/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 storageRoot = null;
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 === '--storage-root' && argv[i + 1]) {
storageRoot = 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,
storageRoot,
userId,
file,
dryRun,
forceGeo,
fixWarnings,
};
}
const {
scope,
root,
publicRoot,
storageRoot,
userId,
file,
dryRun,
forceGeo,
fixWarnings,
} = parseArgs(process.argv);
const targets = collectHtmlTargets({
scope,
mindspaceRoot: root,
publicRoot,
storageRoot,
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; }
+45 -126
View File
@@ -1,36 +1,45 @@
#!/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 storageRoot = null;
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] === '--storage-root' && argv[i + 1]) {
storageRoot = path.resolve(argv[i + 1]);
i += 1;
} else if (argv[i] === '--user' && argv[i + 1]) {
userId = argv[i + 1];
i += 1;
@@ -39,146 +48,56 @@ 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, storageRoot, 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, storageRoot, userId, file, seo } = parseArgs(process.argv);
const targets = collectHtmlTargets({
scope,
mindspaceRoot: root,
publicRoot,
storageRoot,
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' ? ' ✗' : ' ⚠';
+12 -1
View File
@@ -20,7 +20,18 @@ const contractSource = read('mindspace-server-adapter-contract.mjs');
assertIncludes(
'server.mjs syncUserGeneratedPages',
serverSource,
'await mindSpacePageSync.syncUserGeneratedPages(userId);',
'mindSpacePageSync.syncUserGeneratedPages(userId',
);
assertIncludes(
'server.mjs list page sync bypasses full delivery',
serverSource,
'listSync = false',
);
const pageCoreRoutesSource = read('server/portal-mindspace-page-core-routes.mjs');
assertIncludes(
'portal page list background sync',
pageCoreRoutesSource,
'syncUserGeneratedPages(userId, { listSync: true })',
);
if (serverSource.includes("mindSpaceServerRuntime.adapterKind === 'remote'")) {
throw new Error(
+5 -3
View File
@@ -1292,7 +1292,7 @@ async function listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs =
async function syncUserGeneratedPages(
userId,
{ sessionId = null, sinceMs = null, onlyRelativePaths = null } = {},
{ sessionId = null, sinceMs = null, onlyRelativePaths = null, listSync = false } = {},
) {
if (!userId) return;
// Agent-run/Finish delivery must stay scoped to the current conversation.
@@ -1330,11 +1330,13 @@ async function syncUserGeneratedPages(
: sessionId
? (discoveredRelativePaths?.length ? discoveredRelativePaths : recentWorkspaceRelativePaths)
: null;
if (workspacePageDeliver?.syncAndDeliver) {
if (!listSync && workspacePageDeliver?.syncAndDeliver) {
return await workspacePageDeliver.syncAndDeliver(userId, { pageDataRelativePaths });
}
if (!mindSpacePageSync) return;
return await mindSpacePageSync.syncUserGeneratedPages(userId);
return await mindSpacePageSync.syncUserGeneratedPages(userId, {
onlyRelativePaths: pageDataRelativePaths,
});
}
async function resolveChatSaveBundle(user, h5Root, input = {}) {
+1 -1
View File
@@ -46,7 +46,7 @@ export function createMindSpacePageListSyncScheduler({
lastStartedAtByUser.set(userId, currentTime);
const task = Promise.resolve()
.then(() => syncUserGeneratedPages(userId))
.then(() => syncUserGeneratedPages(userId, { listSync: true }))
.catch((error) => {
logger?.warn?.(
`[MindSpace] background page list sync failed for user ${userId}:`,
@@ -230,8 +230,8 @@ test('page list background sync coalesces in-flight and throttled requests', asy
const scheduler = createMindSpacePageListSyncScheduler({
now: () => currentTime,
minIntervalMs: 30_000,
syncUserGeneratedPages(userId) {
calls.push(userId);
syncUserGeneratedPages(userId, options) {
calls.push({ userId, options });
return new Promise((resolve) => {
resolvers.push(resolve);
});
@@ -242,19 +242,22 @@ test('page list background sync coalesces in-flight and throttled requests', asy
const second = scheduler('user-1');
assert.equal(first, second);
await Promise.resolve();
assert.deepEqual(calls, ['user-1']);
assert.deepEqual(calls, [{ userId: 'user-1', options: { listSync: true } }]);
resolvers.shift()?.({ created: 0, updated: 0, skipped: 0 });
await first;
currentTime += 1_000;
assert.equal(scheduler('user-1'), null);
assert.deepEqual(calls, ['user-1']);
assert.deepEqual(calls, [{ userId: 'user-1', options: { listSync: true } }]);
currentTime += 30_000;
const third = scheduler('user-1');
await Promise.resolve();
assert.deepEqual(calls, ['user-1', 'user-1']);
assert.deepEqual(calls, [
{ userId: 'user-1', options: { listSync: true } },
{ userId: 'user-1', options: { listSync: true } },
]);
resolvers.shift()?.({ created: 0, updated: 0, skipped: 0 });
await third;
});
+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')
@@ -181,6 +181,7 @@ test('registers static delivery routes and middleware in legacy order', () => {
['use', '/plaza-covers'],
['use', '/brand'],
['use', '/assets'],
['use', '/learn'],
['get', '/dev/wechat-share-preview'],
['use', '/dev'],
[
@@ -206,6 +207,10 @@ test('registers static delivery routes and middleware in legacy order', () => {
'/project/public/assets',
{ maxAge: '1d' },
],
[
'/project/public/learn',
{ maxAge: '5m', index: 'index.html' },
],
['/project/public/dev', { maxAge: 0 }],
[
'/project/dist',
@@ -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"\`