Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 607e0d758b | |||
| 61a594f10a | |||
| 856096674a | |||
| 1e4f6433ff | |||
| f281f15788 | |||
| 0f692ab68f | |||
| b8bcbcb764 | |||
| 96d8e119ed | |||
| 8d475087d2 | |||
| 357e26ab49 | |||
| acca846856 | |||
| 7ab7bf0f2d | |||
| 51a32bf6f2 | |||
| 01b9a8c16d | |||
| 63f1b8ee44 | |||
| 264c213fbe |
@@ -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 LaunchAgent(token 已配 103 `.env`)
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node scripts/run-memind-tests.mjs --mode quick`:101 项通过
|
||||
- Gitea CI(PR #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 18006–18014 健康 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
@@ -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, ''')}' />
|
||||
<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,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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 ?? '');
|
||||
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
}
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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, /旧/);
|
||||
});
|
||||
@@ -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('"', '"').replaceAll('&', '&').replaceAll('<', '<');
|
||||
}
|
||||
|
||||
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) === '"') {
|
||||
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);
|
||||
}
|
||||
@@ -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
@@ -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('"', '"'));
|
||||
} 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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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: '缺少 description,SEO/GEO 摘要来源不足' });
|
||||
}
|
||||
const geoMeta = parseMindspaceGeoMeta(html);
|
||||
if (strictGeo && !/<meta[^>]*name=["']mindspace-geo["']/i.test(html)) {
|
||||
issues.push({
|
||||
level: 'error',
|
||||
code: 'missing_mindspace_geo',
|
||||
message: '缺少 <meta name="mindspace-geo">,GEO 摘要/FAQ 无法达标',
|
||||
});
|
||||
} else if (strictGeo && !String(geoMeta.summary ?? '').trim()) {
|
||||
issues.push({
|
||||
level: 'warn',
|
||||
code: 'geo_missing_summary',
|
||||
message: 'mindspace-geo 缺少 summary,建议写可被 AI 直接引用的摘要',
|
||||
});
|
||||
}
|
||||
const h2Count = (String(html).match(/<h2\b/gi) ?? []).length;
|
||||
const outlineSections = /data-mindspace-seo-outline=/i.test(html);
|
||||
const faqCount = resolveGeoFaq(html, geoMeta).length;
|
||||
if (h2Count < 2 && !outlineSections && faqCount < 2) {
|
||||
issues.push({
|
||||
level: 'warn',
|
||||
code: 'insufficient_h2_sections',
|
||||
message: '正文建议至少 2 个 <h2> 分节,便于 SEO/GEO 抽取',
|
||||
});
|
||||
}
|
||||
const faq = resolveGeoFaq(html, geoMeta);
|
||||
const parsedGeo = parseMindspaceGeoMeta(html);
|
||||
const declaredFaq = Array.isArray(parsedGeo.faq) ? parsedGeo.faq.length : 0;
|
||||
if (faq.length < 1 && declaredFaq < 1) {
|
||||
issues.push({
|
||||
level: 'warn',
|
||||
code: 'geo_missing_faq',
|
||||
message: '缺少 FAQ(mindspace-geo.faq 或 h2+p 结构),GEO 结构化数据会偏弱',
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function auditHtmlFile(htmlPath, html, options = {}) {
|
||||
const requireCoverMeta = options.requireCoverMeta !== false;
|
||||
return [
|
||||
...auditCoverHtml(htmlPath, html, { requireCoverMeta }),
|
||||
...(options.seo === false ? [] : auditSeoHtml(html, options)),
|
||||
];
|
||||
}
|
||||
|
||||
function walkHtmlFiles(dir, files = []) {
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === '.agents' || entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
walkHtmlFiles(full, files);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith('.html') || entry.name.endsWith('.thumbnail.svg')) continue;
|
||||
files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function collectMindspaceHtmlFiles(root, userId = null) {
|
||||
if (userId) {
|
||||
return walkHtmlFiles(path.join(root, userId, 'public'));
|
||||
}
|
||||
const files = [];
|
||||
if (!fs.existsSync(root)) return files;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === 'wiki' || entry.name.startsWith('.')) continue;
|
||||
walkHtmlFiles(path.join(root, entry.name, 'public'), files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function collectStoragePublicationHtmlFiles(storageRoot, userId = null) {
|
||||
const files = [];
|
||||
const usersDir = path.join(storageRoot, 'users');
|
||||
if (!fs.existsSync(usersDir)) return files;
|
||||
|
||||
const userDirs = userId
|
||||
? [path.join(usersDir, userId)]
|
||||
: fs.readdirSync(usersDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
||||
.map((entry) => path.join(usersDir, entry.name));
|
||||
|
||||
for (const userDir of userDirs) {
|
||||
const publicationsDir = path.join(userDir, 'publications');
|
||||
if (!fs.existsSync(publicationsDir)) continue;
|
||||
for (const publication of fs.readdirSync(publicationsDir, { withFileTypes: true })) {
|
||||
if (!publication.isDirectory() || publication.name.startsWith('.')) continue;
|
||||
const indexHtml = path.join(publicationsDir, publication.name, 'index.html');
|
||||
if (fs.existsSync(indexHtml)) files.push(indexHtml);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function collectPlatformHtmlFiles(publicRoot) {
|
||||
const files = [];
|
||||
if (!fs.existsSync(publicRoot)) return files;
|
||||
for (const entry of fs.readdirSync(publicRoot, { withFileTypes: true })) {
|
||||
const full = path.join(publicRoot, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (PLATFORM_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
||||
walkHtmlFiles(full, files);
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith('.html')) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function collectHtmlTargets({
|
||||
scope = 'mindspace',
|
||||
mindspaceRoot,
|
||||
publicRoot,
|
||||
storageRoot = null,
|
||||
userId = null,
|
||||
file = null,
|
||||
} = {}) {
|
||||
if (file) return [path.resolve(file)];
|
||||
const normalized = String(scope ?? 'mindspace').trim().toLowerCase();
|
||||
const storageFiles = storageRoot
|
||||
? collectStoragePublicationHtmlFiles(storageRoot, userId)
|
||||
: [];
|
||||
if (normalized === 'platform') {
|
||||
return collectPlatformHtmlFiles(publicRoot);
|
||||
}
|
||||
if (normalized === 'storage') {
|
||||
return storageFiles;
|
||||
}
|
||||
if (normalized === 'all') {
|
||||
return [
|
||||
...collectMindspaceHtmlFiles(mindspaceRoot, userId),
|
||||
...storageFiles,
|
||||
...collectPlatformHtmlFiles(publicRoot),
|
||||
];
|
||||
}
|
||||
return collectMindspaceHtmlFiles(mindspaceRoot, userId);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '仙居玩水' }],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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, ''')}'> `;
|
||||
}
|
||||
|
||||
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
@@ -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('广场首页加载失败');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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="{"tag":"报告","emoji":"👋","accent":"#f093fb","accent2":"#24243e","subtitle":"Hello John","cover":"https://m.tkmind.cn/brand/tkmind-icon.png"}">
|
||||
<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
@@ -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="{"tag":"学习","emoji":"📚","accent":"#3b5bfd","accent2":"#1c2333","subtitle":"每日任务与成长记录","cover":"https://m.tkmind.cn/brand/tkmind-icon.png"}">
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta name="mindspace-cover" content="{"tag":"报告","emoji":"📄","accent":"#f2f4fa","accent2":"#1c2333","subtitle":"TKMind 学习助手家长后台:配置学员、任务模板与查看学习统计。","cover":"https://m.tkmind.cn/brand/tkmind-icon.png"}">
|
||||
<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>
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta name="mindspace-cover" content="{"tag":"精选页面","emoji":"📄","accent":"#2f6f57","accent2":"#285e4a","subtitle":"以下文件已存在,请选择处理方式:","cover":"https://m.tkmind.cn/brand/tkmind-icon.png"}">
|
||||
<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>
|
||||
|
||||
Executable
+77
@@ -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"
|
||||
@@ -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; }
|
||||
|
||||
@@ -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: '缺少 description,SEO/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' ? ' ✗' : ' ⚠';
|
||||
|
||||
Executable
+69
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+19
@@ -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" "$@"
|
||||
@@ -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
@@ -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 = {}) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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":"30–120 字的直接答案"},
|
||||
{"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
@@ -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;不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件
|
||||
@@ -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":"30–120 字直接答案"}]
|
||||
}'>
|
||||
\`\`\`
|
||||
|
||||
硬性要求:
|
||||
|
||||
- \`<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"\`
|
||||
|
||||
+886
-472
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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=weather,weather-today + weather-card)。若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
|
||||
'7. 必须包含天气区块(section id=weather,weather-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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user