Compare commits

..

16 Commits

Author SHA1 Message Date
john 607e0d758b docs: register feature/baidu-seo-push branch disposition
Memind CI / Test, build, and release guards (pull_request) Failing after 2m58s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 19:25:46 +08:00
tkmind 61a594f10a Merge pull request 'fix(test): 微信草稿缩略图测试改用自包含 fixture' (#49) from feature/baidu-seo-push into main
Memind CI / Test, build, and release guards (push) Successful in 4m41s
2026-09-16 11:17:34 +00:00
john 856096674a fix(test): 微信草稿缩略图测试改用自包含 fixture
Memind CI / Test, build, and release guards (pull_request) Successful in 3m41s
CI 环境无 gitignore 的 MindSpace/august-afternoon.html,导致 resolveWechatDraftThumbPath 用例 ENOENT。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 16:49:54 +08:00
tkmind 1e4f6433ff feat(seo): 固化 m.tkmind.cn 百度验证、推送与 sitemap 去重
Memind CI / Test, build, and release guards (push) Failing after 3m54s
2026-09-16 08:45:14 +00:00
john f281f15788 feat(seo): 固化 m.tkmind.cn 百度验证、推送与 sitemap 去重
Memind CI / Test, build, and release guards (pull_request) Failing after 3m21s
让 Portal 首页稳定注入站长验证 meta,并提供 sitemap 批量推送脚本与 LaunchAgent 安装入口;sitemap/llms 对同页优先保留 /u/ canonical URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 16:28:46 +08:00
john 0f692ab68f docs: register feature/wechat-news-morning-inline-v2 branch disposition
Memind CI / Test, build, and release guards (push) Failing after 4m38s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 15:21:55 +08:00
john b8bcbcb764 feat(wechat): 早报草稿改用内联模板并去掉正文大封面
让定时任务按新版式推送草稿:热门旅游城市天气、无 Hero logo,并约束生成页必须写城市天气卡。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 15:20:44 +08:00
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
76 changed files with 3355 additions and 740 deletions
+103
View File
@@ -1,5 +1,108 @@
# 历史分支处置登记
## `feature/baidu-seo-push`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-16
分支 HEAD`85609667`
`main` 对应提交:`61a594f1`(含 PR #48 SEO + PR #49 CI fixture 修复)
### 交付内容
- m.tkmind.cn 百度站长 HTML/文件验证(`codeva-32NHQfJ8qL`
- 服务端首页与 SPA fallback 注入验证 meta`/baidu_verify_*.html` 路由
- sitemap 同 `page_id` 去重,优先 `/u/`,降权 `_archived`
- 百度批量推送脚本、103 每日 01:00 LaunchAgenttoken 已配 103 `.env`
### 验证摘要
- `node scripts/run-memind-tests.mjs --mode quick`101 项通过
- Gitea CIPR #48/#49):通过
- 103 热修:验证 meta、推送 token、定时任务已生效(发版前)
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `61a594f1`(或后续从 `main` 打出的 runtime manifest)。
## `feature/wechat-news-morning-inline-v2`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-16
分支 HEAD`b8bcbcb7`
### 交付内容
- 微信早报草稿改用内联模板(去 Hero logo、热门旅游城市天气、新卡片版式)
- 自动生成任务要求天气卡写热门旅游城市,禁止用区域预报顶替
- 模板版本 `2026-09-16`
### 验证摘要
- `node scripts/run-memind-tests.mjs --mode changed`ok
- 143 管理后台手动推送已验收(去 logo、旅游城市天气栏)
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` 上包含本分支提交的 commit。
## `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; }
+1
View File
@@ -1,6 +1,7 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta name="baidu-site-verification" content="codeva-32NHQfJ8qL" />
<meta charset="UTF-8" />
<meta name="theme-color" content="#f4f1ea" />
<script>
+19
View File
@@ -0,0 +1,19 @@
export const BAIDU_SITE_VERIFICATION_CODE = 'codeva-32NHQfJ8qL';
export const BAIDU_SITE_VERIFICATION_META =
`<meta name="baidu-site-verification" content="${BAIDU_SITE_VERIFICATION_CODE}" />`;
export function injectBaiduSiteVerification(html) {
const source = String(html ?? '');
const tag = BAIDU_SITE_VERIFICATION_META;
if (/baidu-site-verification/i.test(source)) {
return source.replace(
/<meta[^>]+name=["']baidu-site-verification["'][^>]*>/i,
tag,
);
}
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n ${tag}`);
}
return `${tag}\n${source}`;
}
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
BAIDU_SITE_VERIFICATION_CODE,
injectBaiduSiteVerification,
} from './mindspace-baidu-site-verification.mjs';
test('injectBaiduSiteVerification inserts meta as first head child', () => {
const html = '<!doctype html><html><head><title>TKMind</title></head><body></body></html>';
const next = injectBaiduSiteVerification(html);
assert.match(next, /<head>\s*<meta name="baidu-site-verification" content="codeva-32NHQfJ8qL" \/>/);
});
test('injectBaiduSiteVerification normalizes existing meta', () => {
const html =
'<html><head><meta name="baidu-site-verification" content="old" /><title>x</title></head></html>';
const next = injectBaiduSiteVerification(html);
assert.match(next, /content="codeva-32NHQfJ8qL"/);
assert.doesNotMatch(next, /content="old"/);
});
test('verification code stays stable', () => {
assert.equal(BAIDU_SITE_VERIFICATION_CODE, 'codeva-32NHQfJ8qL');
});
+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);
}
+46
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,48 @@ export async function resolvePublicationIndexSnapshot(
});
}
function scoreDiscoveryEntry(entry) {
const url = String(entry?.publicUrl ?? entry?.public_url ?? '').trim();
if (!url) return -100;
if (/\/_archived/i.test(url)) return -50;
if (url.includes('/u/')) return 100;
if (url.includes('/MindSpace/')) return 10;
return 20;
}
export function dedupeDiscoveryEntriesByPage(publications = []) {
const byPage = new Map();
for (const entry of publications) {
const snapshot = normalizePublicationSnapshot(entry);
if (!snapshot) continue;
const pageKey =
String(snapshot.pageId ?? '').trim() ||
String(snapshot.publicUrl ?? '').trim();
if (!pageKey) continue;
const existing = byPage.get(pageKey);
if (
!existing ||
scoreDiscoveryEntry(snapshot) > scoreDiscoveryEntry(existing)
) {
byPage.set(pageKey, snapshot);
}
}
return [...byPage.values()];
}
export function mergeDiscoveryEntries(publications = [], staticEntries = PLATFORM_STATIC_DISCOVERY_ENTRIES) {
const merged = [];
const seen = new Set();
const dedupedPublications = dedupeDiscoveryEntriesByPage(publications);
for (const entry of [...dedupedPublications, ...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 +178,7 @@ export function renderRobotsTxt({
const lines = [
'User-agent: *',
'Allow: /u/',
'Allow: /learn/',
'Disallow: /api/',
'Disallow: /admin-api/',
'Disallow: /mindspace/',
@@ -171,6 +215,8 @@ export function createMindspaceSeoDiscoveryService(pool) {
listIndexablePublications: (options) => listIndexablePublications(pool, options),
resolvePublicationIndexSnapshot: (options) =>
resolvePublicationIndexSnapshot(pool, options),
dedupeDiscoveryEntriesByPage,
mergeDiscoveryEntries,
renderSitemapXml,
renderRobotsTxt,
renderLlmsTxt,
+47
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
dedupeDiscoveryEntriesByPage,
mergeDiscoveryEntries,
renderLlmsTxt,
renderRobotsTxt,
renderSitemapXml,
@@ -24,6 +26,51 @@ test('renderRobotsTxt references sitemap when enabled', () => {
assert.match(body, /llms\.txt/);
});
test('dedupeDiscoveryEntriesByPage prefers /u/ urls for the same page', () => {
const merged = dedupeDiscoveryEntriesByPage([
{
pageId: 'page-1',
publicUrl: '/MindSpace/user/public/demo.html',
accessMode: 'public',
status: 'online',
},
{
pageId: 'page-1',
publicUrl: '/u/john/pages/demo',
accessMode: 'public',
status: 'online',
},
]);
assert.equal(merged.length, 1);
assert.equal(merged[0].publicUrl, '/u/john/pages/demo');
});
test('dedupeDiscoveryEntriesByPage drops archived duplicates', () => {
const merged = dedupeDiscoveryEntriesByPage([
{
pageId: 'page-2',
publicUrl: '/MindSpace/user/public/_archived-demo.html',
accessMode: 'public',
status: 'online',
},
{
pageId: 'page-2',
publicUrl: '/u/john/pages/demo',
accessMode: 'public',
status: 'online',
},
]);
assert.equal(merged.length, 1);
assert.equal(merged[0].publicUrl, '/u/john/pages/demo');
});
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 });
+39 -13
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 ?? '')));
});
@@ -221,26 +230,43 @@ test('convertGenericPageHtmlToWechatArticle leaves length clipping to layout', (
});
test('resolveWechatDraftThumbPath prefers hero image over thumbnail sidecar', () => {
const userId = '1c99b83b-0454-474f-a5d2-129d34506a32';
const html = fs.readFileSync(`MindSpace/${userId}/public/august-afternoon.html`, 'utf8');
const userId = 'test-user-hero-pref';
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-thumb-hero-'));
const publishDir = path.join(root, 'MindSpace', userId);
const publicDir = path.join(publishDir, 'public');
fs.mkdirSync(publicDir, { recursive: true });
const heroPath = path.join(publicDir, 'images', 'hero-ca5a45b835d8.webp');
fs.mkdirSync(path.dirname(heroPath), { recursive: true });
fs.writeFileSync(heroPath, Buffer.from('webp'));
fs.writeFileSync(path.join(publicDir, 'august-afternoon.thumbnail.png'), Buffer.from('png'));
const html = `<!DOCTYPE html><html><head><title>八月午后</title>
<meta name="mindspace-cover" content='{"cover":"images/hero-ca5a45b835d8.webp"}'></head>
<body><h1>标题</h1></body></html>`;
fs.writeFileSync(path.join(publicDir, 'august-afternoon.html'), html, 'utf8');
const resolved = resolveWechatDraftThumbPath(html, {
h5Root: process.cwd(),
h5Root: root,
userId,
workspaceRelativePath: 'public/august-afternoon.html',
publishDir: path.join(process.cwd(), 'MindSpace', userId),
publishDir,
});
assert.equal(resolved.source, 'hero');
assert.match(resolved.path ?? '', /hero-ca5a45b835d8\.webp$/);
});
test('resolveWechatDraftThumbPath falls back to raster sidecar when hero missing', () => {
const userId = '1c99b83b-0454-474f-a5d2-129d34506a32';
const userId = 'test-user-sidecar-fallback';
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-thumb-sidecar-'));
const publishDir = path.join(root, 'MindSpace', userId);
const publicDir = path.join(publishDir, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'august-afternoon.thumbnail.png'), Buffer.from('png'));
const html = `<!DOCTYPE html><html><head><title>无 hero</title></head><body><h1>标题</h1></body></html>`;
fs.writeFileSync(path.join(publicDir, 'august-afternoon.html'), html, 'utf8');
const resolved = resolveWechatDraftThumbPath(html, {
h5Root: process.cwd(),
h5Root: root,
userId,
workspaceRelativePath: 'public/august-afternoon.html',
publishDir: path.join(process.cwd(), 'MindSpace', userId),
publishDir,
});
assert.equal(resolved.source, 'thumbnail-sidecar');
assert.match(resolved.path ?? '', /august-afternoon\.thumbnail\.png$/);
@@ -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"/);
});
@@ -0,0 +1 @@
codeva-32NHQfJ8qL
+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' ? ' ✗' : ' ⚠';
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WRAPPER="${MEMIND_BAIDU_PUSH_WRAPPER:-$ROOT/scripts/run-baidu-sitemap-push-prod.sh}"
LABEL="${MEMIND_BAIDU_PUSH_LABEL:-cn.tkmind.memind-baidu-sitemap-push}"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
LOG="$HOME/Library/Logs/memind-baidu-sitemap-push.log"
GUI="gui/$(id -u)"
HOUR="${MEMIND_BAIDU_PUSH_HOUR:-1}"
MINUTE="${MEMIND_BAIDU_PUSH_MINUTE:-0}"
STATE_FILE="${BAIDU_PUSH_STATE_FILE:-$ROOT/data/baidu-push-state.json}"
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" "$(dirname "$STATE_FILE")"
if [[ ! -x "$WRAPPER" ]]; then
chmod +x "$WRAPPER"
fi
if [[ ! -f "$WRAPPER" ]]; then
echo "baidu push wrapper not found: $WRAPPER" >&2
exit 1
fi
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>$WRAPPER</string>
</array>
<key>WorkingDirectory</key>
<string>$ROOT</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>$HOUR</integer>
<key>Minute</key>
<integer>$MINUTE</integer>
</dict>
<key>StandardOutPath</key>
<string>$LOG</string>
<key>StandardErrorPath</key>
<string>$LOG</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>BAIDU_PUSH_STATE_FILE</key>
<string>$STATE_FILE</string>
</dict>
</dict>
</plist>
EOF
plutil -lint "$PLIST"
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
launchctl bootstrap "$GUI" "$PLIST"
launchctl enable "$GUI/$LABEL"
echo "installed $PLIST"
echo "wrapper: $WRAPPER"
echo "schedule: daily ${HOUR}:$(printf '%02d' "$MINUTE") (local time)"
echo "state_file: $STATE_FILE"
echo "log: $LOG"
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* sitemap 批量推送 URL 到百度受每日配额限制
*
* 用法:
* node scripts/push-baidu-sitemap.mjs
* node scripts/push-baidu-sitemap.mjs --dry-run
* node scripts/push-baidu-sitemap.mjs --sitemap https://m.tkmind.cn/sitemap.xml
*
* 环境变量:
* MINDSPACE_BAIDU_SITE 默认 m.tkmind.cn
* MINDSPACE_BAIDU_PUSH_TOKEN 必填
* BAIDU_PUSH_STATE_FILE 默认 data/baidu-push-state.json
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function parseArgs(argv) {
const args = {
dryRun: false,
sitemap: process.env.BAIDU_PUSH_SITEMAP ?? 'https://m.tkmind.cn/sitemap.xml',
batchSize: Number(process.env.BAIDU_PUSH_BATCH_SIZE ?? 10),
stateFile:
process.env.BAIDU_PUSH_STATE_FILE ??
path.join(ROOT, 'data', 'baidu-push-state.json'),
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dry-run') args.dryRun = true;
else if (arg === '--sitemap') args.sitemap = argv[++i] ?? args.sitemap;
else if (arg === '--batch-size') args.batchSize = Number(argv[++i] ?? args.batchSize);
else if (arg === '--state-file') args.stateFile = argv[++i] ?? args.stateFile;
}
return args;
}
function loadState(stateFile) {
try {
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
} catch {
return { pushed: {}, lastRunAt: null, history: [] };
}
}
function saveState(stateFile, state) {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
function extractSitemapUrls(xml) {
return [...String(xml ?? '').matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim());
}
function isArchivedDiscoveryUrl(url) {
return /\/_archived/i.test(String(url ?? ''));
}
function prioritizeUrls(urls) {
const filtered = urls.filter((url) => url && !isArchivedDiscoveryUrl(url));
const uUrls = filtered.filter((url) => url.includes('/u/'));
const mindspaceUrls = filtered.filter((url) => url.includes('/MindSpace/'));
const otherUrls = filtered.filter(
(url) => !url.includes('/u/') && !url.includes('/MindSpace/'),
);
const seen = new Set();
const ordered = [];
for (const url of [...uUrls, ...otherUrls, ...mindspaceUrls]) {
if (!url || seen.has(url)) continue;
seen.add(url);
ordered.push(url);
}
return ordered;
}
async function fetchSitemap(url) {
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!response.ok) {
throw new Error(`sitemap fetch failed: ${response.status}`);
}
return response.text();
}
async function pingBaidu({ site, token, urls }) {
const endpoint = `http://data.zz.baidu.com/urls?site=${encodeURIComponent(site)}&token=${encodeURIComponent(token)}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: urls.join('\n'),
signal: AbortSignal.timeout(60_000),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload?.message || 'baidu_push_failed');
error.code = payload?.error ?? response.status;
error.details = payload;
throw error;
}
return payload;
}
async function main() {
const args = parseArgs(process.argv);
const site = String(process.env.MINDSPACE_BAIDU_SITE ?? 'm.tkmind.cn').trim();
const token = String(
process.env.MINDSPACE_BAIDU_PUSH_TOKEN ??
process.env.PLAZA_BAIDU_PUSH_TOKEN ??
'',
).trim();
if (!token) {
console.error('缺少 MINDSPACE_BAIDU_PUSH_TOKEN');
process.exit(1);
}
const xml = await fetchSitemap(args.sitemap);
const allUrls = prioritizeUrls(extractSitemapUrls(xml));
const state = loadState(args.stateFile);
const pending = allUrls.filter((url) => !state.pushed[url]);
console.log(
JSON.stringify(
{
site,
sitemap: args.sitemap,
totalInSitemap: allUrls.length,
alreadyPushed: allUrls.length - pending.length,
pending: pending.length,
dryRun: args.dryRun,
},
null,
2,
),
);
if (args.dryRun || pending.length === 0) {
return;
}
let pushedThisRun = 0;
const runHistory = {
at: new Date().toISOString(),
attempted: 0,
success: 0,
batches: [],
};
for (let i = 0; i < pending.length; i += args.batchSize) {
const batch = pending.slice(i, i + args.batchSize);
runHistory.attempted += batch.length;
try {
const result = await pingBaidu({ site, token, urls: batch });
const successCount = Number(result.success ?? 0);
runHistory.success += successCount;
runHistory.batches.push({ size: batch.length, result });
for (const url of batch.slice(0, successCount)) {
state.pushed[url] = runHistory.at;
pushedThisRun += 1;
}
console.log(
JSON.stringify(
{
batch: batch.length,
success: successCount,
remain: result.remain ?? null,
notSameSite: result.not_same_site ?? null,
notValid: result.not_valid ?? null,
},
null,
2,
),
);
if (successCount < batch.length) break;
if (Number(result.remain ?? 0) <= 0) break;
} catch (error) {
runHistory.batches.push({
size: batch.length,
error: error.message,
details: error.details ?? null,
});
console.error(JSON.stringify({ batch: batch.length, error: error.message, details: error.details ?? null }, null, 2));
break;
}
}
state.lastRunAt = runHistory.at;
state.history = [...(state.history ?? []), runHistory].slice(-30);
saveState(args.stateFile, state);
console.log(
JSON.stringify(
{
pushedThisRun,
totalTracked: Object.keys(state.pushed).length,
pendingRemaining: allUrls.filter((url) => !state.pushed[url]).length,
},
null,
2,
),
);
}
main().catch((error) => {
console.error(error?.stack || error?.message || String(error));
process.exit(1);
});
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ -f "${ROOT}/.env" ]]; then
set -a
# shellcheck disable=SC1091
source "${ROOT}/.env"
set +a
fi
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
if [[ ! -x "${NODE_BIN}" ]]; then
NODE_BIN="$(command -v node)"
fi
exec "${NODE_BIN}" "${ROOT}/scripts/push-baidu-sitemap.mjs" "$@"
+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')
+33 -7
View File
@@ -6,6 +6,7 @@ import {
injectOgTags,
renderWechatSharePreviewHtml,
} from '../mindspace-og-tags.mjs';
import { injectBaiduSiteVerification } from '../mindspace-baidu-site-verification.mjs';
export function attachPortalStaticDeliveryRoutes({
app,
@@ -285,6 +286,37 @@ export function attachPortalStaticDeliveryRoutes({
},
);
const sendPortalIndexHtml = (res) => {
const indexPath = pathApi.join(h5Root, 'dist', 'index.html');
const html = fsApi.readFileSync(indexPath, 'utf8');
res
.type('text/html; charset=utf-8')
.set('Cache-Control', 'public, max-age=0')
.send(injectBaiduSiteVerification(html));
};
app.get(
/^\/baidu_verify_[A-Za-z0-9-]+\.html$/,
(req, res) => {
const fileName = pathApi.basename(req.path);
const filePath = pathApi.join(
h5Root,
'public',
fileName,
);
if (!fsApi.existsSync(filePath)) {
return res.status(404).end();
}
return res
.type('text/html; charset=utf-8')
.sendFile(filePath);
},
);
app.get('/', (_req, res) => {
sendPortalIndexHtml(res);
});
app.use('/auth', (_req, res) => {
res.status(404).json({
message:
@@ -305,12 +337,6 @@ export function attachPortalStaticDeliveryRoutes({
),
);
app.get('*', (_req, res) => {
res.sendFile(
pathApi.join(
h5Root,
'dist',
'index.html',
),
);
sendPortalIndexHtml(res);
});
}
+19 -2
View File
@@ -181,12 +181,18 @@ 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'],
[
'get',
'/^\\/MP_verify_[A-Za-z0-9]+\\.txt$/',
],
[
'get',
'/^\\/baidu_verify_[A-Za-z0-9-]+\\.html$/',
],
['get', '/'],
['use', '/auth'],
['use', '/admin-api'],
['use', null],
@@ -206,6 +212,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',
@@ -389,6 +399,9 @@ test('preserves verification, API fallbacks, and SPA fallback', async () => {
existsSync() {
return false;
},
readFileSync() {
return '<!doctype html><html><head><title>TKMind</title></head><body></body></html>';
},
},
});
const verification = missingSetup.routes.find(
@@ -427,8 +440,12 @@ test('preserves verification, API fallbacks, and SPA fallback', async () => {
'get',
'*',
);
assert.match(
spa.response.body,
/baidu-site-verification/,
);
assert.equal(
spa.response.sentFile,
'/project/dist/index.html',
spa.response.contentType,
'text/html; charset=utf-8',
);
});
@@ -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"\`
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
convertDailyNewsHtmlToWechatInlineArticle,
wechatDailyNewsInlineInternals,
} from './wechat-daily-news-inline.mjs';
const WEATHER_HTML = `<!DOCTYPE html><html><body><div class="container">
<div class="hero"><h1>📰 每日新闻早报</h1></div>
<div class="quick-links"><a href="#weather">🌤 天气</a><a href="#world">🌍 </a></div>
<div class="section" id="headlines"><div class="section-title">要闻</div><div class="card"><h3></h3><p></p></div></div>
<div class="section" id="weather">
<div class="section-title">🌤 今日天气</div>
<div class="weather-today">
<div class="wt-row"><div><div class="wt-city">📍 北京</div><div class="wt-detail"> · </div></div><div><div class="wt-temp-big">30°</div></div></div>
<div style="display:flex;gap:12px;"><span>🌧 华南有雨</span><span> </span></div>
<div style="background:rgba(255,255,255,0.5);padding:8px;"><strong>中央气象台</strong></div>
</div>
<div class="weather-grid">
<div class="weather-card w-hot"><div class="w-left"><span class="w-city">北京</span><span class="w-cond"> </span></div><div class="w-right"><span class="w-temp">30°</span></div></div>
<div class="weather-card w-rain"><div class="w-left"><span class="w-city">上海</span><span class="w-cond">🌧 </span></div><div class="w-right"><span class="w-temp">26°</span></div></div>
</div>
<p style="font-size:12px;color:#a0aec0;">数据来源中央气象台</p>
</div>
</div></body></html>`;
test('splitSections preserves section ids after opening tag split', () => {
const sections = wechatDailyNewsInlineInternals.splitSections(WEATHER_HTML);
assert.deepEqual(sections.map((item) => item.id), ['headlines', 'weather']);
});
test('renderWeatherSection uses tourist city title and two-column city cards', () => {
const sections = wechatDailyNewsInlineInternals.splitSections(WEATHER_HTML);
const weather = sections.find((item) => item.id === 'weather');
const rendered = wechatDailyNewsInlineInternals.renderWeatherSection(weather.html);
assert.match(rendered, /热门旅游城市天气/u);
assert.match(rendered, /📍 北京/u);
assert.match(rendered, /☀️ 晴 · 温差大/u);
assert.match(rendered, /width:50%;box-sizing:border-box/u);
assert.match(rendered, /display:inline-block/u);
assert.doesNotMatch(rendered, /display:flex/u);
assert.match(rendered, /30°/u);
assert.match(rendered, /26°/u);
assert.doesNotMatch(rendered, /中央气象台/u);
assert.doesNotMatch(rendered, /今日天气/u);
});
test('renderWeatherSection skips regional fallback cards and national summary', () => {
const regionalWeatherHtml = `<div class="section" id="weather">
<div class="section-title">🌤 今日天气 · 9月16日</div>
<div class="weather-today">
<div class="wt-row"><div><div class="wt-city">📍 全国概况 · 江汉西南阴雨开场</div><div class="wt-detail">🌧 西</div></div></div>
</div>
<div class="weather-grid">
<div class="weather-card"><div class="w-left"><span class="w-city">湖北西部</span><span class="w-cond">🌧 </span></div><div class="w-right"><span class="w-temp"></span></div></div>
<div class="weather-card"><div class="w-left"><span class="w-city">北京</span><span class="w-cond"> </span></div><div class="w-right"><span class="w-temp">30°</span></div></div>
<div class="weather-card"><div class="w-left"><span class="w-city">上海</span><span class="w-cond">🌧 </span></div><div class="w-right"><span class="w-temp">26°</span></div></div>
</div>
</div>`;
const rendered = wechatDailyNewsInlineInternals.renderWeatherSection(regionalWeatherHtml);
assert.match(rendered, /热门旅游城市天气 · 9月16日/u);
assert.match(rendered, /北京/u);
assert.match(rendered, /上海/u);
assert.doesNotMatch(rendered, /湖北西部/u);
assert.doesNotMatch(rendered, /全国概况/u);
});
test('buildWechatInlineContent renders weather and optional quick links', () => {
const built = wechatDailyNewsInlineInternals.buildWechatInlineContent(WEATHER_HTML, {
includeFixedLayout: false,
trimProfile: { skipHistory: true, skipQuickLinks: false },
});
assert.match(built.content, /🌤️ 天气/u);
assert.match(built.content, /📍 北京/u);
assert.match(built.content, /30°/u);
});
test('convertDailyNewsHtmlToWechatInlineArticle keeps weather when trimming', () => {
const article = convertDailyNewsHtmlToWechatInlineArticle(WEATHER_HTML, {
includeFixedLayout: false,
fixedLayoutLength: 1200,
});
assert.match(article.content, /📍 北京/u);
assert.match(article.content, /30°/u);
});
test('trimWechatInlineBodyByRemovingCards drops tail cards instead of hard slicing', () => {
const html = `${WEATHER_HTML.slice(0, -24)}<div class="section" id="world"><div class="section-title">国际</div><div class="card"><h3>国际一</h3><p>正文一</p></div><div class="card"><h3>国际二</h3><p>正文二</p></div></div></div></body></html>`;
const built = wechatDailyNewsInlineInternals.buildWechatInlineContent(html, {
includeFixedLayout: false,
});
const beforeCards = wechatDailyNewsInlineInternals.countInlineCards(built.content);
const trimmed = wechatDailyNewsInlineInternals.trimWechatInlineBodyByRemovingCards(
built.content,
built.content.length - 260,
{ minCards: 1 },
);
const afterCards = wechatDailyNewsInlineInternals.countInlineCards(trimmed);
assert.ok(beforeCards >= 3);
assert.ok(afterCards < beforeCards);
assert.ok(trimmed.length < built.content.length);
assert.match(trimmed, /<\/section>\s*$/u);
});
test('section list numbering starts at 01 after feature cards', () => {
const html = `<!doctype html><html><body>
<div class="hero"><h1>每日新闻早报</h1><div class="subtitle"> · </div></div>
<div class="section" id="headlines">
<div class="section-title">今日要闻</div>
<div class="card highlight-box"><h3>头条卡</h3><p></p></div>
<div class="card"><h3>普通要闻</h3><p></p></div>
</div>
<div class="section" id="world">
<div class="section-title">国际热点</div>
<div class="card"><h3>国际一</h3><p></p></div>
</div>
</body></html>`;
const built = wechatDailyNewsInlineInternals.buildWechatInlineContent(html, {
includeFixedLayout: false,
});
assert.match(built.content, /头条卡[\s\S]*?01<\/span> <strong>普通要闻/u);
assert.match(built.content, /WORLD[\s\S]*?01<\/span> <strong>国际一/u);
assert.doesNotMatch(built.content, /02<\/span> <strong>普通要闻/u);
});
test('hero highlights render as a separate readable digest', () => {
const digest = wechatDailyNewsInlineInternals.renderTodayDigest(
'<div class="subtitle">平陆运河通航 · 海上火箭发射 · 全球市场聚焦议息</div>',
);
assert.match(digest, /今日导读/u);
assert.match(digest, /平陆运河通航<br>/u);
assert.match(digest, /海上火箭发射<br>/u);
});
test('renderHero does not insert the cover image as a large logo', () => {
const html = `<div class="hero"><h1>每日新闻早报</h1><div class="date-badge">9月16日</div></div>
<meta name="mindspace-cover" content='{"cover":"https://mmbiz.qpic.cn/logo.png"}'>`;
const rendered = wechatDailyNewsInlineInternals.renderHero(html, new Map([
['https://mmbiz.qpic.cn/logo.png', 'https://mmbiz.qpic.cn/logo.png'],
]));
assert.match(rendered, /每日新闻早报/u);
assert.doesNotMatch(rendered, /<img\b/u);
assert.doesNotMatch(rendered, /TKMIND · DAILY BRIEF/u);
});
+13 -13
View File
@@ -42,15 +42,14 @@ export function clipWechatDraftBodyToFitFooter(
export function renderReadOriginalLink(publicUrl) {
if (!publicUrl) return '';
return [
'<section style="text-align:center;margin:26px 0 14px;padding:20px 16px 18px;background:linear-gradient(180deg,#eef4ff 0%,#fff 100%);border:2px dashed #576b95;border-radius:16px;box-shadow:0 6px 18px rgba(87,107,149,.12);">',
'<p style="margin:0;font-size:14px;color:#4a5568;line-height:1.6;font-weight:600;">✨ 完整网页版更精彩</p>',
'<p style="margin:6px 0 0;font-size:12px;color:#718096;line-height:1.5;">动画 · 互动 · 高清大图 · 完整内容</p>',
'<p style="margin:14px 0 0;font-size:26px;line-height:1;color:#576b95;letter-spacing:6px;font-weight:700;">⬇ ⬇ ⬇</p>',
'<p style="margin:12px 0 0;">',
`<a href="${publicUrl}" style="display:inline-block;padding:12px 32px;border-radius:999px;background:linear-gradient(135deg,#576b95,#3d5a80);color:#fff;font-size:16px;font-weight:700;text-decoration:none;box-shadow:0 6px 16px rgba(87,107,149,.35);">📖 阅读原文</a>`,
'<section style="text-align:center;margin:28px 0 14px;padding:24px 18px;background:#f4f8ff;border:1px dashed #1a4a8a;border-radius:16px">',
'<p style="margin:0;font-size:15px;font-weight:700;color:#0d2b5c;line-height:1.6">完整网页版更精彩</p>',
'<p style="margin:8px 0 0;font-size:12px;color:#3a5f92;line-height:1.6">全部资讯 · 高清配图 · 来源直达 · 历史早报</p>',
'<p style="margin:15px 0 0;font-size:19px;line-height:1;color:#1a4a8a;letter-spacing:8px">⬇ ⬇ ⬇</p>',
'<p style="margin:15px 0 0">',
`<a href="${publicUrl}" style="display:inline-block;padding:13px 36px;border-radius:999px;background:linear-gradient(135deg,#0d2b5c,#1a4a8a);color:#fff;font-size:16px;font-weight:700;text-decoration:none">📖 阅读原文</a>`,
'</p>',
'<p style="margin:12px 0 0;font-size:13px;color:#576b95;font-weight:700;line-height:1.5;">👆 点击上方按钮,查看完整精彩页面</p>',
'<p style="margin:8px 0 0;font-size:22px;line-height:1;color:#576b95;letter-spacing:4px;">➡️ ➡️ ➡️</p>',
'<p style="margin:14px 0 0;font-size:12px;color:#3a5f92;line-height:1.5">点击上方按钮,查看完整早报</p>',
'</section>',
].join('');
}
@@ -62,15 +61,16 @@ export function renderFollowTkMindSection({
qrcodeImageUrl = '',
} = {}) {
const qrcodeBlock = qrcodeImageUrl
? `<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:${FOLLOW_QRCODE_SIZE_PX}px;height:${FOLLOW_QRCODE_SIZE_PX}px;display:block;margin:12px auto 0;border-radius:10px;border:1px solid #eee;" />`
? `<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:${FOLLOW_QRCODE_SIZE_PX}px;height:${FOLLOW_QRCODE_SIZE_PX}px;display:block;margin:14px auto 0;border-radius:10px" />`
: '';
return [
'<section style="text-align:center;padding:22px 16px 18px;margin:18px 0 8px;background:linear-gradient(180deg,#fff8f8,#fff);border:1px solid #ffcdd2;border-radius:12px;">',
'<p style="margin:0 0 6px;font-size:16px;font-weight:700;color:#b71c1c;line-height:1.5;">欢迎关注 TKMind 智趣</p>',
'<section style="text-align:center;padding:24px 16px;margin:18px 0 8px;background:#fff5f6;border-radius:14px">',
'<p style="margin:0;font-size:16px;font-weight:700;color:#c8102e;line-height:1.5">欢迎关注 TKMind 智趣</p>',
'<p style="margin:8px 0 0;font-size:12px;color:#9b9b9b;line-height:1.6">每日早报 · 智能创作 · 一键成页</p>',
qrcodeBlock,
qrcodeImageUrl
? '<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">长按扫描关注</p>'
: '<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">关注 TKMind 服务号</p>',
? '<p style="margin:12px 0 0;font-size:12px;color:#9b9b9b">长按扫描关注</p>'
: '<p style="margin:12px 0 0;font-size:12px;color:#9b9b9b">关注 TKMind 服务号</p>',
'</section>',
].filter(Boolean).join('');
}
+1 -1
View File
@@ -37,7 +37,7 @@ test('applyWechatDraftArticleLayout renders guided read-original section and sma
});
assert.match(content, /完整网页版更精彩/);
assert.match(content, /⬇ ⬇ ⬇/);
assert.match(content, /点击上方按钮,查看完整精彩页面/);
assert.match(content, /点击上方按钮,查看完整早报/);
assert.match(content, /width:120px;height:120px/);
assert.match(content, /📖 阅读原文/);
const check = verifyWechatDraftPublicationContent(content, { publicUrl: PUBLIC_URL });
+117 -15
View File
@@ -10,8 +10,16 @@ import {
} from './user-publish.mjs';
import { extractMindspaceCoverPath } from './wechat/verify/generated-thumbnail.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { convertDailyNewsHtmlToWechatInlineArticle } from './wechat-daily-news-inline.mjs';
import { uploadWechatDraftBrandAssets } from './wechat-draft-article-layout.mjs';
import {
convertDailyNewsHtmlToWechatInlineArticle,
renderCreatePortalCTA,
trimWechatInlineBodyByRemovingCards,
} from './wechat-daily-news-inline.mjs';
import {
applyWechatDraftArticleLayout,
clipWechatDraftBodyToFitFooter,
uploadWechatDraftBrandAssets,
} from './wechat-draft-article-layout.mjs';
import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs';
const CONFIG_TABLE = 'h5_wechat_admin_config';
@@ -22,7 +30,7 @@ const DEFAULT_DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add';
const DEFAULT_MATERIAL_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/material/add_material';
const DEFAULT_UPLOAD_IMG_URL = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg';
const MAX_THUMB_BYTES = 64 * 1024;
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-15';
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-16';
/** 0910 新闻早报页面结构说明,供定时任务 taskSpec 与草稿转换共用。 */
export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
@@ -33,7 +41,7 @@ export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
'4. 可选 weather、knowledge、history 等区块;',
'5. footer 品牌;',
'6. 文件名 daily-news-MMDD.html(如 daily-news-0910.html),并生成同名 .thumbnail.png',
'7. 推送微信草稿时转为内联 style HTML(参考公众号排版),禁止整页长图;正文需控制在 2 万字符内。',
'7. 推送微信草稿时转为内联 style 版式(与 daily-news-0904 公众号已发版一致,微信不支持 <style> 标签),禁止整页长图;底部追加统一「阅读原文 + 二维码」layout。',
].join('\n');
/** 每日正文必须新搜,禁止把历史早报当新闻来源。 */
@@ -45,7 +53,7 @@ export const NEWS_MORNING_FRESH_CONTENT_SPEC = [
'4. 禁止复制、改写、微调昨日或任何历史 daily-news-*.html 的新闻标题与正文。',
'5. 最近一版 daily-news 只允许当作版式参考(hero / section / card 结构);读完后必须丢弃其中的条目内容。',
'6. 每条卡片必须是当日可核验事件,并写明来源;title、date-badge、导语必须写明当日日期。要闻栏至少 6 条,其中国内、国际、热门事件都要有代表条目,并用 highlight-box 突出头条。',
'7. 必须包含天气区块(section id=weatherweather-today + weather-card)。若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
'7. 必须包含天气区块(section id=weatherweather-today + weather-card)。天气卡片必须写热门旅游城市当日天气(北京、上海、广州、深圳、成都、杭州、西安、重庆、武汉、南京、厦门、三亚等),禁止用区域预报(西部/大部/平原/秋收区)顶替城市卡。若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
].join('\n');
function normalizeBoolean(value, fallback = false) {
@@ -276,18 +284,85 @@ export function rewriteDailyNewsHtmlLinks(html, { publicBaseUrl, userId }) {
});
}
export function buildDailyNewsWechatHtmlContent(html, { publicUrl = '', publicBaseUrl = '', userId = '' } = {}) {
const source = String(html ?? '');
const styleBlock = source.match(/<style>([\s\S]*?)<\/style>/i)?.[0] ?? '';
const bodyBlock = source.match(/<body>([\s\S]*?)<\/body>/i)?.[0] ?? '';
let content = `${styleBlock}${bodyBlock}`;
function stripMindspaceChromeFromDailyNewsBody(bodyHtml) {
let body = String(bodyHtml ?? '');
body = body.replace(/<div[^>]*data-mindspace-public-share[\s\S]*$/i, '');
body = body.replace(/<section[^>]*data-mindspace-seo-outline[\s\S]*$/i, '');
body = body.replace(/<p[^>]*>\s*✨\s*一起创作[\s\S]*?<\/p>/gi, '');
return body.trim();
}
function extractDailyNewsStyleBlocks(html) {
const blocks = [];
for (const match of String(html ?? '').matchAll(/<style([^>]*)>([\s\S]*?)<\/style>/gi)) {
const attrs = match[1] ?? '';
const block = match[0];
if (/mindspace-public-share/i.test(`${attrs}${block}`)) continue;
blocks.push(block);
}
return blocks.join('\n');
}
function replaceDailyNewsHtmlImageRefs(html, imageUrlMap = new Map()) {
if (!imageUrlMap || imageUrlMap.size === 0) return String(html ?? '');
return String(html).replace(/(<img[^>]+src=["'])([^"']+)(["'])/gi, (match, prefix, src, suffix) => {
const mapped =
imageUrlMap.get(src)
?? imageUrlMap.get(src.replace(/^\.\//, ''))
?? imageUrlMap.get(src.replace(/^\//, ''));
return mapped ? `${prefix}${mapped}${suffix}` : match;
});
}
export function buildDailyNewsWechatHtmlContent(
html,
{
publicUrl = '',
publicBaseUrl = '',
userId = '',
imageUrlMap = new Map(),
appendLegacyReadOriginalLink = false,
} = {},
) {
const source = String(html ?? '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const styleBlock = extractDailyNewsStyleBlocks(source);
const bodyInner = stripMindspaceChromeFromDailyNewsBody(
source.match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] ?? source,
);
let content = `${styleBlock}<body>${bodyInner}</body>`;
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
if (publicUrl) {
content = replaceDailyNewsHtmlImageRefs(content, imageUrlMap);
if (appendLegacyReadOriginalLink && publicUrl) {
content += `<p style="margin:16px 0 0;font-size:13px;color:#718096;text-align:center;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`;
}
return content;
}
function estimateDailyNewsDraftFooterLength({
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = 'https://m.tkmind.cn',
} = {}) {
const footer = applyWechatDraftArticleLayout('', { publicUrl, qrcodeImageUrl });
const cta = renderCreatePortalCTA(portalUrl);
return footer.length + (cta ? cta.length + 1 : 0);
}
export function applyDailyNewsWechatDraftFooter(
body,
{ publicUrl = '', qrcodeImageUrl = '', portalUrl = 'https://m.tkmind.cn' } = {},
) {
const footer = applyWechatDraftArticleLayout('', { publicUrl, qrcodeImageUrl });
const cta = renderCreatePortalCTA(portalUrl);
const footerWithCta = [footer, cta].filter(Boolean).join('\n');
const separator = body && footerWithCta ? '\n' : '';
const maxBody = Math.max(0, 20000 - footerWithCta.length - separator.length);
let fittedBody = trimWechatInlineBodyByRemovingCards(String(body ?? '').trim(), maxBody);
fittedBody = clipWechatDraftBodyToFitFooter(fittedBody, footerWithCta);
return [fittedBody, footerWithCta].filter(Boolean).join('\n');
}
export function extractDailyNewsArticleMeta(html) {
const title = extractPageTitle(html) || '每日新闻早报';
const digest = (extractMetaDescription(html) || title).slice(0, 120);
@@ -419,17 +494,38 @@ export function buildDailyNewsWechatDraftArticle({
publicUrl,
qrcodeImageUrl = '',
imageUrlMap = new Map(),
publicBaseUrl = '',
userId = '',
} = {}) {
const article = convertDailyNewsHtmlToWechatInlineArticle(html, {
const meta = extractDailyNewsArticleMeta(html);
const footerLength = estimateDailyNewsDraftFooterLength({ publicUrl, qrcodeImageUrl });
const inline = convertDailyNewsHtmlToWechatInlineArticle(html, {
publicUrl,
qrcodeImageUrl,
imageUrlMap,
includeFixedLayout: false,
fixedLayoutLength: footerLength,
});
let content = applyDailyNewsWechatDraftFooter(inline.content, {
publicUrl,
qrcodeImageUrl,
});
if (publicBaseUrl && userId) {
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
}
return {
...article,
cardCount: extractCardArticles(html).length,
title: meta.title.slice(0, 64),
digest: meta.digest,
content,
contentSourceUrl: publicUrl || undefined,
contentMode: 'inline_html',
contentLength: content.length,
cardCount: inline.cardCount ?? extractCardArticles(html).length,
statsCount: extractStats(html).length,
uploadedImageCount: imageUrlMap.size,
trimLevel: inline.trimLevel,
trimmed: inline.trimmed,
usedFallbackTrim: inline.usedFallbackTrim,
};
}
@@ -831,7 +927,12 @@ export function createWechatNewsMorningDraftService(
}
const html = fs.readFileSync(page.localPath, 'utf8');
const article = isDailyNewsFormat(html)
? buildDailyNewsWechatDraftArticle({ html, publicUrl: pageSummary.publicUrl })
? buildDailyNewsWechatDraftArticle({
html,
publicUrl: pageSummary.publicUrl,
publicBaseUrl: resolveDailyNewsPublicBaseUrl(env),
userId: config.sourceUserId,
})
: convertNewsPageHtmlToWechatArticle(html, { publicUrl: pageSummary.publicUrl });
return {
config,
@@ -1194,6 +1295,7 @@ export const wechatNewsMorningDraftInternals = {
slugPatternToRegExp,
convertNewsPageHtmlToWechatArticle,
buildDailyNewsWechatHtmlContent,
applyDailyNewsWechatDraftFooter,
buildDailyNewsWechatDraftArticle,
findLatestNewsMorningPage,
isNewsMorningPageForToday,
+5 -3
View File
@@ -82,14 +82,15 @@ function createPool(seedRow = null) {
};
}
test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html', () => {
test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html with footer', () => {
const article = convertNewsPageHtmlToWechatArticle(SAMPLE_HTML, {
publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html',
qrcodeImageUrl: 'https://m.tkmind.cn/assets/mp-follow-qrcode.png',
});
assert.match(article.title, /2026年9月10日/u);
assert.equal(article.contentMode, 'inline_html');
assert.match(article.content, /每日新闻早报/u);
assert.match(article.content, /linear-gradient\(135deg,#c8102e/u);
assert.doesNotMatch(article.content, /<style>/u);
assert.match(article.content, /示例热点/u);
assert.match(article.content, /阅读原文/u);
assert.match(article.content, /完整网页版更精彩/u);
@@ -98,7 +99,7 @@ test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html', ()
assert.match(article.content, /mp-follow-qrcode\.png/u);
assert.match(article.content, /一起创作/u);
assert.match(article.content, /点我/u);
assert.doesNotMatch(article.content, /近7天新闻早报/u);
assert.doesNotMatch(article.content, /data-mindspace-public-share/u);
});
test('buildDailyNewsWechatHtmlContent preserves style and body markup', () => {
@@ -214,6 +215,7 @@ test('buildNewsMorningAutoGenerationTask requires fresh daily search', () => {
assert.match(task.taskSpec, /国内热点/u);
assert.match(task.taskSpec, /国际热点/u);
assert.match(task.taskSpec, /热门事件/u);
assert.match(task.taskSpec, /热门旅游城市/u);
assert.match(task.taskSpec, /2026-09-15/u);
assert.match(task.taskSpec, /daily-news-0915\.html/u);
assert.doesNotMatch(task.taskSpec, /可以用昨日正文/u);