Compare commits

..

10 Commits

Author SHA1 Message Date
john 745e9b691d feat(seo): 工作区 public/*.html 默认纳入 SEO 收录
Memind CI / Test, build, and release guards (pull_request) Successful in 3m35s
未点公开发布但已落盘到 public/ 的页面,在无在线非 public 发布阻挡时
合成 implicit public snapshot,注入 canonical/JSON-LD 并扫描进 sitemap。

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 15:20:44 +08:00
john 96d8e119ed docs: register feature/seo-geo-universal branch disposition
Memind CI / Test, build, and release guards (push) Failing after 2m58s
记录 SEO/GEO 全量能力合并 main 与 103 发布 20260916-103611-8d475087。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:43:02 +08:00
24 changed files with 2029 additions and 558 deletions
+78
View File
@@ -1,5 +1,83 @@
# 历史分支处置登记
## `feature/baidu-seo-push`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-16
分支 HEAD`85609667`
`main` 对应提交:`61a594f1`(含 PR #48 SEO + PR #49 CI fixture 修复)
### 交付内容
- m.tkmind.cn 百度站长 HTML/文件验证(`codeva-32NHQfJ8qL`
- 服务端首页与 SPA fallback 注入验证 meta`/baidu_verify_*.html` 路由
- sitemap 同 `page_id` 去重,优先 `/u/`,降权 `_archived`
- 百度批量推送脚本、103 每日 01:00 LaunchAgenttoken 已配 103 `.env`
### 验证摘要
- `node scripts/run-memind-tests.mjs --mode quick`101 项通过
- Gitea CIPR #48/#49):通过
- 103 热修:验证 meta、推送 token、定时任务已生效(发版前)
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `61a594f1`(或后续从 `main` 打出的 runtime manifest)。
## `feature/wechat-news-morning-inline-v2`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-16
分支 HEAD`b8bcbcb7`
### 交付内容
- 微信早报草稿改用内联模板(去 Hero logo、热门旅游城市天气、新卡片版式)
- 自动生成任务要求天气卡写热门旅游城市,禁止用区域预报顶替
- 模板版本 `2026-09-16`
### 验证摘要
- `node scripts/run-memind-tests.mjs --mode changed`ok
- 143 管理后台手动推送已验收(去 logo、旅游城市天气栏)
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` 上包含本分支提交的 commit。
## `feature/seo-geo-universal`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-16
分支 HEAD`357e26ab`
`main` 对应提交:`8d475087`(含 `fix/wechat-draft-test-fixture` 发布阻断修复)
### 交付内容
- 落盘/交付自动补齐 `mindspace-geo`Finish sync、delivery writeBack、health-report
- 全页面 SEO/GEO 回填工具链、`storage publications` 扫描、143 生产回填脚本
- Plaza/平台静态页 SEO 注入与统一审计
### 验证摘要
- `npm run verify:seo-geo`46 项通过
- `npm run verify:mindspace-publish-guards`ok
- 143 存量回填:2683 页,0 错误 / 0 警告
- 103 快速发布:`20260916-103611-8d475087`Portal 8081 健康 200
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `8d475087`(或后续从 `main` 打出的 runtime manifest)。
## `feature/learn-standalone-pages`
**状态:禁止再次引用。改动已提交并进入 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
+2 -2
View File
@@ -4,8 +4,8 @@
## 保护内容
1. **收录策略硬规则** `access_mode=public``status=online` 且未过期的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。不再要求 `user_confirmed_at`
2. **私有页强制 noindex**密码、登录可见、owner_only、已过期、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`
1. **收录策略硬规则**`h5_publish_records` `access_mode=public``status=online` 且未过期的发布页允许 SEO/GEO 注入与 sitemap/llms 收录**此外**,工作区 `public/*.html` 直链在无在线非 public 发布记录阻挡时,默认视为可收录(implicit public。不再要求 `user_confirmed_at`
2. **私有页强制 noindex**在线 `password` / `private_link` / `login_required` / `owner_only` 发布、已过期、embed 模式等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`
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 发帖共用该开关。
+1
View File
@@ -1,6 +1,7 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta name="baidu-site-verification" content="codeva-32NHQfJ8qL" />
<meta charset="UTF-8" />
<meta name="theme-color" content="#f4f1ea" />
<script>
+19
View File
@@ -0,0 +1,19 @@
export const BAIDU_SITE_VERIFICATION_CODE = 'codeva-32NHQfJ8qL';
export const BAIDU_SITE_VERIFICATION_META =
`<meta name="baidu-site-verification" content="${BAIDU_SITE_VERIFICATION_CODE}" />`;
export function injectBaiduSiteVerification(html) {
const source = String(html ?? '');
const tag = BAIDU_SITE_VERIFICATION_META;
if (/baidu-site-verification/i.test(source)) {
return source.replace(
/<meta[^>]+name=["']baidu-site-verification["'][^>]*>/i,
tag,
);
}
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n ${tag}`);
}
return `${tag}\n${source}`;
}
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
BAIDU_SITE_VERIFICATION_CODE,
injectBaiduSiteVerification,
} from './mindspace-baidu-site-verification.mjs';
test('injectBaiduSiteVerification inserts meta as first head child', () => {
const html = '<!doctype html><html><head><title>TKMind</title></head><body></body></html>';
const next = injectBaiduSiteVerification(html);
assert.match(next, /<head>\s*<meta name="baidu-site-verification" content="codeva-32NHQfJ8qL" \/>/);
});
test('injectBaiduSiteVerification normalizes existing meta', () => {
const html =
'<html><head><meta name="baidu-site-verification" content="old" /><title>x</title></head></html>';
const next = injectBaiduSiteVerification(html);
assert.match(next, /content="codeva-32NHQfJ8qL"/);
assert.doesNotMatch(next, /content="old"/);
});
test('verification code stays stable', () => {
assert.equal(BAIDU_SITE_VERIFICATION_CODE, 'codeva-32NHQfJ8qL');
});
+32
View File
@@ -1,5 +1,36 @@
const INDEXABLE_ACCESS_MODE = 'public';
export function isWorkspacePublicHtmlRelativePath(value) {
const normalized = String(value ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/, '');
if (!/^public\/[^/]+\.html$/i.test(normalized)) return false;
if (/\/_archived[^/]*\.html$/i.test(normalized)) return false;
return true;
}
export function buildImplicitWorkspacePublicPublication({
publicUrl = '',
pageId = null,
updatedAt = null,
title = null,
summary = null,
} = {}) {
const url = String(publicUrl ?? '').trim();
if (!url) return null;
return normalizePublicationSnapshot({
accessMode: INDEXABLE_ACCESS_MODE,
status: 'online',
publicUrl: url,
pageId,
userConfirmedAt: null,
expiresAt: null,
updatedAt: updatedAt ?? Date.now(),
title,
summary,
});
}
export function normalizePublicationSnapshot(input = {}) {
if (!input || typeof input !== 'object') return null;
const accessMode = String(
@@ -83,4 +114,5 @@ export function resolveIndexPolicy({
export const indexPolicyInternals = {
INDEXABLE_ACCESS_MODE,
isWorkspacePublicHtmlRelativePath,
};
+24
View File
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildImplicitWorkspacePublicPublication,
isPublicationIndexable,
isWorkspacePublicHtmlRelativePath,
resolveIndexPolicy,
} from './mindspace-index-policy.mjs';
@@ -73,3 +75,25 @@ test('resolveIndexPolicy skips embed delivery', () => {
});
assert.equal(policy.mode, 'off');
});
test('isWorkspacePublicHtmlRelativePath accepts public html and rejects archived', () => {
assert.equal(isWorkspacePublicHtmlRelativePath('public/demo.html'), true);
assert.equal(isWorkspacePublicHtmlRelativePath('public/_archived-demo.html'), false);
assert.equal(isWorkspacePublicHtmlRelativePath('private/demo.html'), false);
});
test('buildImplicitWorkspacePublicPublication marks workspace public html indexable', () => {
const snapshot = buildImplicitWorkspacePublicPublication({
publicUrl: '/MindSpace/user/public/demo.html',
});
assert.equal(isPublicationIndexable(snapshot), true);
const policy = resolveIndexPolicy({
seoGeoConfig: {
enabled: true,
seo: { enabled: true, canonical: true },
geo: { enabled: true, jsonLd: true },
},
publication: snapshot,
});
assert.equal(policy.mode, 'indexable');
});
+207 -33
View File
@@ -1,5 +1,30 @@
import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs';
import fs from 'node:fs';
import path from 'node:path';
import {
buildImplicitWorkspacePublicPublication,
isPublicationIndexable,
isWorkspacePublicHtmlRelativePath,
normalizePublicationSnapshot,
} from './mindspace-index-policy.mjs';
import { buildMindSpacePublicRoutePath } from './mindspace-runtime-config.mjs';
import { PLATFORM_STATIC_DISCOVERY_ENTRIES } from './platform-seo-html.mjs';
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
function mapPublicationRow(row) {
if (!row) return null;
return normalizePublicationSnapshot({
id: row.id,
pageId: row.page_id,
publicUrl: row.public_url,
accessMode: row.access_mode,
status: row.status,
userConfirmedAt: row.user_confirmed_at,
expiresAt: row.expires_at,
updatedAt: row.updated_at ?? row.published_at,
title: row.title,
summary: row.summary,
});
}
function escapeXml(value) {
return String(value ?? '')
@@ -18,9 +43,88 @@ function toAbsoluteUrl(origin, value) {
return raw.split('#')[0];
}
async function loadOnlineNonPublicWorkspacePaths(pool) {
if (!pool) return new Set();
const [rows] = await pool.query(
`SELECT LOWER(pr.user_id) AS user_id, p.workspace_relative_path
FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id
WHERE pr.status = 'online'
AND pr.access_mode <> 'public'
AND p.workspace_relative_path LIKE 'public/%.html'`,
);
return new Set(
rows.map((row) => `${String(row.user_id ?? '').toLowerCase()}\0${row.workspace_relative_path}`),
);
}
async function loadWorkspacePageMetaByPath(pool) {
if (!pool) return new Map();
const [rows] = await pool.query(
`SELECT id, LOWER(user_id) AS user_id, workspace_relative_path, title, summary, updated_at
FROM h5_page_records
WHERE status <> 'deleted'
AND workspace_relative_path LIKE 'public/%.html'`,
);
const map = new Map();
for (const row of rows) {
map.set(`${String(row.user_id ?? '').toLowerCase()}\0${row.workspace_relative_path}`, row);
}
return map;
}
export async function listWorkspacePublicHtmlIndexEntries(
pool,
{ h5Root = null, now = Date.now() } = {},
) {
if (!h5Root) return [];
const root = path.join(path.resolve(String(h5Root)), PUBLISH_ROOT_DIR);
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return [];
const blocked = await loadOnlineNonPublicWorkspacePaths(pool);
const pageMeta = await loadWorkspacePageMetaByPath(pool);
const entries = [];
for (const userId of fs.readdirSync(root)) {
if (!userId || userId.startsWith('.')) continue;
const publicDir = path.join(root, userId, 'public');
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) continue;
for (const name of fs.readdirSync(publicDir)) {
if (!name.toLowerCase().endsWith('.html')) continue;
const relativePath = `public/${name}`;
if (!isWorkspacePublicHtmlRelativePath(relativePath)) continue;
const blockKey = `${userId.toLowerCase()}\0${relativePath}`;
if (blocked.has(blockKey)) continue;
const meta = pageMeta.get(blockKey);
const filePath = path.join(publicDir, name);
let updatedAt = now;
try {
updatedAt = fs.statSync(filePath).mtimeMs;
} catch {
updatedAt = now;
}
const snapshot = buildImplicitWorkspacePublicPublication({
publicUrl: buildMindSpacePublicRoutePath(userId, relativePath.split('/')),
pageId: meta?.id ?? null,
updatedAt: meta?.updated_at ?? updatedAt,
title: meta?.title ?? null,
summary: meta?.summary ?? null,
});
if (snapshot && isPublicationIndexable(snapshot, { now })) {
entries.push(snapshot);
}
}
}
return entries;
}
export async function listIndexablePublications(
pool,
{ limit = 5000, offset = 0 } = {},
{ limit = 5000, offset = 0, h5Root = null } = {},
) {
if (!pool) return [];
const safeLimit = Math.min(Math.max(Number(limit) || 5000, 1), 10000);
@@ -38,22 +142,14 @@ export async function listIndexablePublications(
LIMIT ? OFFSET ?`,
[Date.now(), safeLimit, safeOffset],
);
return rows
.map((row) =>
normalizePublicationSnapshot({
id: row.id,
pageId: row.page_id,
publicUrl: row.public_url,
accessMode: row.access_mode,
status: row.status,
userConfirmedAt: row.user_confirmed_at,
expiresAt: row.expires_at,
updatedAt: row.updated_at ?? row.published_at,
title: row.title,
summary: row.summary,
}),
)
const fromDb = rows
.map((row) => mapPublicationRow(row))
.filter((entry) => isPublicationIndexable(entry));
if (!h5Root || safeOffset > 0) {
return dedupeDiscoveryEntriesByPage(fromDb);
}
const fromWorkspace = await listWorkspacePublicHtmlIndexEntries(pool, { h5Root });
return dedupeDiscoveryEntriesByPage([...fromDb, ...fromWorkspace]);
}
export async function resolvePublicationIndexSnapshot(
@@ -90,26 +186,98 @@ export async function resolvePublicationIndexSnapshot(
}
sql += ' ORDER BY pr.published_at DESC LIMIT 1';
const [rows] = await pool.query(sql, params);
const row = rows[0];
if (!row) return null;
return normalizePublicationSnapshot({
id: row.id,
pageId: row.page_id,
publicUrl: row.public_url,
accessMode: row.access_mode,
status: row.status,
userConfirmedAt: row.user_confirmed_at,
expiresAt: row.expires_at,
updatedAt: row.updated_at ?? row.published_at,
title: row.title,
summary: row.summary,
return mapPublicationRow(rows[0]);
}
export async function resolveWorkspacePublicationIndexSnapshot(
pool,
{
publicationId = null,
pageId = null,
userId = null,
workspaceRelativePath = '',
publicUrl = '',
} = {},
) {
const online = await resolvePublicationIndexSnapshot(pool, {
publicationId,
pageId,
userId,
});
if (online) {
return online;
}
const relativePath = String(workspaceRelativePath ?? '').trim();
const owner = String(userId ?? '').trim();
if (!isWorkspacePublicHtmlRelativePath(relativePath) || !owner) {
return null;
}
if (pool) {
const [rows] = await pool.query(
`SELECT pr.id, pr.page_id, pr.public_url, pr.access_mode, pr.status,
pr.user_confirmed_at, pr.expires_at, pr.updated_at, pr.published_at,
p.title, p.summary
FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id
WHERE pr.status = 'online'
AND pr.user_id = ?
AND p.workspace_relative_path = ?
AND pr.access_mode <> 'public'
ORDER BY pr.published_at DESC
LIMIT 1`,
[owner, relativePath],
);
const blocking = mapPublicationRow(rows[0]);
if (blocking) {
return blocking;
}
}
const resolvedPublicUrl =
String(publicUrl ?? '').trim() ||
buildMindSpacePublicRoutePath(owner, relativePath.split('/'));
return buildImplicitWorkspacePublicPublication({
publicUrl: resolvedPublicUrl,
pageId: pageId ?? null,
});
}
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();
for (const entry of [...publications, ...staticEntries]) {
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);
@@ -180,11 +348,17 @@ export function renderLlmsTxt(entries, { origin = '' } = {}) {
return `${header.concat(body).join('\n')}\n`;
}
export function createMindspaceSeoDiscoveryService(pool) {
export function createMindspaceSeoDiscoveryService(pool, { h5Root = null } = {}) {
return {
listIndexablePublications: (options) => listIndexablePublications(pool, options),
listIndexablePublications: (options) =>
listIndexablePublications(pool, { h5Root, ...options }),
resolvePublicationIndexSnapshot: (options) =>
resolvePublicationIndexSnapshot(pool, options),
resolveWorkspacePublicationIndexSnapshot: (options) =>
resolveWorkspacePublicationIndexSnapshot(pool, options),
listWorkspacePublicHtmlIndexEntries: (options) =>
listWorkspacePublicHtmlIndexEntries(pool, { h5Root, ...options }),
dedupeDiscoveryEntriesByPage,
mergeDiscoveryEntries,
renderSitemapXml,
renderRobotsTxt,
+100
View File
@@ -1,10 +1,16 @@
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 {
dedupeDiscoveryEntriesByPage,
listWorkspacePublicHtmlIndexEntries,
mergeDiscoveryEntries,
renderLlmsTxt,
renderRobotsTxt,
renderSitemapXml,
resolveWorkspacePublicationIndexSnapshot,
} from './mindspace-seo-discovery-service.mjs';
test('renderSitemapXml emits only provided urls', () => {
@@ -25,6 +31,44 @@ 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' },
@@ -39,3 +83,59 @@ test('renderLlmsTxt lists markdown links', () => {
);
assert.match(body, /\[仙居玩水\]\(https:\/\/m\.tkmind\.cn\/u\/john\/pages\/demo\)/);
});
test('listWorkspacePublicHtmlIndexEntries includes unpublished public html on disk', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-seo-disk-'));
const userId = 'user-implicit-seo';
const publicDir = path.join(root, 'MindSpace', userId, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(
path.join(publicDir, 'weather-broadcast-20260916.html'),
'<!doctype html><html><head><title>Weather</title></head><body></body></html>',
'utf8',
);
const entries = await listWorkspacePublicHtmlIndexEntries(null, { h5Root: root });
assert.equal(entries.length, 1);
assert.match(entries[0].publicUrl ?? '', /weather-broadcast-20260916\.html/);
});
test('resolveWorkspacePublicationIndexSnapshot falls back to implicit public html', async () => {
const snapshot = await resolveWorkspacePublicationIndexSnapshot(null, {
userId: 'user-1',
workspaceRelativePath: 'public/demo.html',
publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/demo.html',
});
assert.equal(snapshot?.accessMode, 'public');
assert.equal(snapshot?.status, 'online');
});
test('resolveWorkspacePublicationIndexSnapshot respects online non-public publication', async () => {
const pool = {
async query(sql, params) {
if (/access_mode <> 'public'/i.test(sql)) {
assert.equal(params[1], 'public/demo.html');
return [[{
id: 'pub-private',
page_id: 'page-1',
public_url: '/u/user/pages/demo',
access_mode: 'password',
status: 'online',
user_confirmed_at: null,
expires_at: null,
updated_at: Date.now(),
published_at: Date.now(),
title: 'Demo',
summary: '私有',
}]];
}
return [[], []];
},
};
const snapshot = await resolveWorkspacePublicationIndexSnapshot(pool, {
userId: 'user-1',
workspaceRelativePath: 'public/demo.html',
publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/demo.html',
});
assert.equal(snapshot?.accessMode, 'password');
});
+24 -7
View File
@@ -230,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$/);
@@ -0,0 +1 @@
codeva-32NHQfJ8qL
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WRAPPER="${MEMIND_BAIDU_PUSH_WRAPPER:-$ROOT/scripts/run-baidu-sitemap-push-prod.sh}"
LABEL="${MEMIND_BAIDU_PUSH_LABEL:-cn.tkmind.memind-baidu-sitemap-push}"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
LOG="$HOME/Library/Logs/memind-baidu-sitemap-push.log"
GUI="gui/$(id -u)"
HOUR="${MEMIND_BAIDU_PUSH_HOUR:-1}"
MINUTE="${MEMIND_BAIDU_PUSH_MINUTE:-0}"
STATE_FILE="${BAIDU_PUSH_STATE_FILE:-$ROOT/data/baidu-push-state.json}"
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" "$(dirname "$STATE_FILE")"
if [[ ! -x "$WRAPPER" ]]; then
chmod +x "$WRAPPER"
fi
if [[ ! -f "$WRAPPER" ]]; then
echo "baidu push wrapper not found: $WRAPPER" >&2
exit 1
fi
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>$WRAPPER</string>
</array>
<key>WorkingDirectory</key>
<string>$ROOT</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>$HOUR</integer>
<key>Minute</key>
<integer>$MINUTE</integer>
</dict>
<key>StandardOutPath</key>
<string>$LOG</string>
<key>StandardErrorPath</key>
<string>$LOG</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>BAIDU_PUSH_STATE_FILE</key>
<string>$STATE_FILE</string>
</dict>
</dict>
</plist>
EOF
plutil -lint "$PLIST"
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
launchctl bootstrap "$GUI" "$PLIST"
launchctl enable "$GUI/$LABEL"
echo "installed $PLIST"
echo "wrapper: $WRAPPER"
echo "schedule: daily ${HOUR}:$(printf '%02d' "$MINUTE") (local time)"
echo "state_file: $STATE_FILE"
echo "log: $LOG"
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* 从 sitemap 批量推送 URL 到百度(受每日配额限制)。
*
* 用法:
* node scripts/push-baidu-sitemap.mjs
* node scripts/push-baidu-sitemap.mjs --dry-run
* node scripts/push-baidu-sitemap.mjs --sitemap https://m.tkmind.cn/sitemap.xml
*
* 环境变量:
* MINDSPACE_BAIDU_SITE 默认 m.tkmind.cn
* MINDSPACE_BAIDU_PUSH_TOKEN 必填
* BAIDU_PUSH_STATE_FILE 默认 data/baidu-push-state.json
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function parseArgs(argv) {
const args = {
dryRun: false,
sitemap: process.env.BAIDU_PUSH_SITEMAP ?? 'https://m.tkmind.cn/sitemap.xml',
batchSize: Number(process.env.BAIDU_PUSH_BATCH_SIZE ?? 10),
stateFile:
process.env.BAIDU_PUSH_STATE_FILE ??
path.join(ROOT, 'data', 'baidu-push-state.json'),
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dry-run') args.dryRun = true;
else if (arg === '--sitemap') args.sitemap = argv[++i] ?? args.sitemap;
else if (arg === '--batch-size') args.batchSize = Number(argv[++i] ?? args.batchSize);
else if (arg === '--state-file') args.stateFile = argv[++i] ?? args.stateFile;
}
return args;
}
function loadState(stateFile) {
try {
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
} catch {
return { pushed: {}, lastRunAt: null, history: [] };
}
}
function saveState(stateFile, state) {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
function extractSitemapUrls(xml) {
return [...String(xml ?? '').matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim());
}
function isArchivedDiscoveryUrl(url) {
return /\/_archived/i.test(String(url ?? ''));
}
function prioritizeUrls(urls) {
const filtered = urls.filter((url) => url && !isArchivedDiscoveryUrl(url));
const uUrls = filtered.filter((url) => url.includes('/u/'));
const mindspaceUrls = filtered.filter((url) => url.includes('/MindSpace/'));
const otherUrls = filtered.filter(
(url) => !url.includes('/u/') && !url.includes('/MindSpace/'),
);
const seen = new Set();
const ordered = [];
for (const url of [...uUrls, ...otherUrls, ...mindspaceUrls]) {
if (!url || seen.has(url)) continue;
seen.add(url);
ordered.push(url);
}
return ordered;
}
async function fetchSitemap(url) {
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!response.ok) {
throw new Error(`sitemap fetch failed: ${response.status}`);
}
return response.text();
}
async function pingBaidu({ site, token, urls }) {
const endpoint = `http://data.zz.baidu.com/urls?site=${encodeURIComponent(site)}&token=${encodeURIComponent(token)}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: urls.join('\n'),
signal: AbortSignal.timeout(60_000),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload?.message || 'baidu_push_failed');
error.code = payload?.error ?? response.status;
error.details = payload;
throw error;
}
return payload;
}
async function main() {
const args = parseArgs(process.argv);
const site = String(process.env.MINDSPACE_BAIDU_SITE ?? 'm.tkmind.cn').trim();
const token = String(
process.env.MINDSPACE_BAIDU_PUSH_TOKEN ??
process.env.PLAZA_BAIDU_PUSH_TOKEN ??
'',
).trim();
if (!token) {
console.error('缺少 MINDSPACE_BAIDU_PUSH_TOKEN');
process.exit(1);
}
const xml = await fetchSitemap(args.sitemap);
const allUrls = prioritizeUrls(extractSitemapUrls(xml));
const state = loadState(args.stateFile);
const pending = allUrls.filter((url) => !state.pushed[url]);
console.log(
JSON.stringify(
{
site,
sitemap: args.sitemap,
totalInSitemap: allUrls.length,
alreadyPushed: allUrls.length - pending.length,
pending: pending.length,
dryRun: args.dryRun,
},
null,
2,
),
);
if (args.dryRun || pending.length === 0) {
return;
}
let pushedThisRun = 0;
const runHistory = {
at: new Date().toISOString(),
attempted: 0,
success: 0,
batches: [],
};
for (let i = 0; i < pending.length; i += args.batchSize) {
const batch = pending.slice(i, i + args.batchSize);
runHistory.attempted += batch.length;
try {
const result = await pingBaidu({ site, token, urls: batch });
const successCount = Number(result.success ?? 0);
runHistory.success += successCount;
runHistory.batches.push({ size: batch.length, result });
for (const url of batch.slice(0, successCount)) {
state.pushed[url] = runHistory.at;
pushedThisRun += 1;
}
console.log(
JSON.stringify(
{
batch: batch.length,
success: successCount,
remain: result.remain ?? null,
notSameSite: result.not_same_site ?? null,
notValid: result.not_valid ?? null,
},
null,
2,
),
);
if (successCount < batch.length) break;
if (Number(result.remain ?? 0) <= 0) break;
} catch (error) {
runHistory.batches.push({
size: batch.length,
error: error.message,
details: error.details ?? null,
});
console.error(JSON.stringify({ batch: batch.length, error: error.message, details: error.details ?? null }, null, 2));
break;
}
}
state.lastRunAt = runHistory.at;
state.history = [...(state.history ?? []), runHistory].slice(-30);
saveState(args.stateFile, state);
console.log(
JSON.stringify(
{
pushedThisRun,
totalTracked: Object.keys(state.pushed).length,
pendingRemaining: allUrls.filter((url) => !state.pushed[url]).length,
},
null,
2,
),
);
}
main().catch((error) => {
console.error(error?.stack || error?.message || String(error));
process.exit(1);
});
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ -f "${ROOT}/.env" ]]; then
set -a
# shellcheck disable=SC1091
source "${ROOT}/.env"
set +a
fi
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
if [[ ! -x "${NODE_BIN}" ]]; then
NODE_BIN="$(command -v node)"
fi
exec "${NODE_BIN}" "${ROOT}/scripts/push-baidu-sitemap.mjs" "$@"
+3 -1
View File
@@ -1703,7 +1703,9 @@ let mindspaceSeoDiscoveryService = null;
function getMindspaceSeoDiscoveryService() {
if (!authPool) return null;
if (!mindspaceSeoDiscoveryService) {
mindspaceSeoDiscoveryService = createMindspaceSeoDiscoveryService(authPool);
mindspaceSeoDiscoveryService = createMindspaceSeoDiscoveryService(authPool, {
h5Root: H5_ROOT,
});
}
return mindspaceSeoDiscoveryService;
}
+33 -7
View File
@@ -6,6 +6,7 @@ import {
injectOgTags,
renderWechatSharePreviewHtml,
} from '../mindspace-og-tags.mjs';
import { injectBaiduSiteVerification } from '../mindspace-baidu-site-verification.mjs';
export function attachPortalStaticDeliveryRoutes({
app,
@@ -285,6 +286,37 @@ export function attachPortalStaticDeliveryRoutes({
},
);
const sendPortalIndexHtml = (res) => {
const indexPath = pathApi.join(h5Root, 'dist', 'index.html');
const html = fsApi.readFileSync(indexPath, 'utf8');
res
.type('text/html; charset=utf-8')
.set('Cache-Control', 'public, max-age=0')
.send(injectBaiduSiteVerification(html));
};
app.get(
/^\/baidu_verify_[A-Za-z0-9-]+\.html$/,
(req, res) => {
const fileName = pathApi.basename(req.path);
const filePath = pathApi.join(
h5Root,
'public',
fileName,
);
if (!fsApi.existsSync(filePath)) {
return res.status(404).end();
}
return res
.type('text/html; charset=utf-8')
.sendFile(filePath);
},
);
app.get('/', (_req, res) => {
sendPortalIndexHtml(res);
});
app.use('/auth', (_req, res) => {
res.status(404).json({
message:
@@ -305,12 +337,6 @@ export function attachPortalStaticDeliveryRoutes({
),
);
app.get('*', (_req, res) => {
res.sendFile(
pathApi.join(
h5Root,
'dist',
'index.html',
),
);
sendPortalIndexHtml(res);
});
}
+14 -2
View File
@@ -188,6 +188,11 @@ test('registers static delivery routes and middleware in legacy order', () => {
'get',
'/^\\/MP_verify_[A-Za-z0-9]+\\.txt$/',
],
[
'get',
'/^\\/baidu_verify_[A-Za-z0-9-]+\\.html$/',
],
['get', '/'],
['use', '/auth'],
['use', '/admin-api'],
['use', null],
@@ -394,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(
@@ -432,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',
);
});
@@ -44,7 +44,7 @@ import {
loadMindSpaceConfigCached,
} from '../mindspace-config.mjs';
import {
resolvePublicationIndexSnapshot,
resolveWorkspacePublicationIndexSnapshot,
} from '../mindspace-seo-discovery-service.mjs';
function deliveryNotFoundMessage(reason) {
@@ -71,7 +71,7 @@ export function createPortalWorkspacePublicationDelivery({
resolvePageDataContext =
resolveMindSpacePageDataContext,
getMindSpaceConfig = loadMindSpaceConfigCached,
resolvePublicationSnapshot = resolvePublicationIndexSnapshot,
resolvePublicationSnapshot = resolveWorkspacePublicationIndexSnapshot,
} = {}) {
if (
typeof resolveRequestOrigin !==
@@ -279,6 +279,8 @@ export function createPortalWorkspacePublicationDelivery({
null,
pageId: pageDataContext?.pageId ?? null,
userId: delivery.ownerId ?? null,
workspaceRelativePath: delivery.relativePath ?? null,
publicUrl: context.pageUrl ?? null,
}).catch(() => null);
}
const decorated =
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
convertDailyNewsHtmlToWechatInlineArticle,
wechatDailyNewsInlineInternals,
} from './wechat-daily-news-inline.mjs';
const WEATHER_HTML = `<!DOCTYPE html><html><body><div class="container">
<div class="hero"><h1>📰 每日新闻早报</h1></div>
<div class="quick-links"><a href="#weather">🌤️ 天气</a><a href="#world">🌍 国际</a></div>
<div class="section" id="headlines"><div class="section-title">要闻</div><div class="card"><h3>头条</h3><p>正文</p></div></div>
<div class="section" id="weather">
<div class="section-title">🌤️ 今日天气</div>
<div class="weather-today">
<div class="wt-row"><div><div class="wt-city">📍 北京</div><div class="wt-detail">☀️ 晴 · 温差大</div></div><div><div class="wt-temp-big">30°</div></div></div>
<div style="display:flex;gap:12px;"><span>🌧️ 华南有雨</span><span>☀️ 华北晴好</span></div>
<div style="background:rgba(255,255,255,0.5);padding:8px;"><strong>中央气象台:</strong>未来三天降水减弱。</div>
</div>
<div class="weather-grid">
<div class="weather-card w-hot"><div class="w-left"><span class="w-city">北京</span><span class="w-cond">☀️ 晴</span></div><div class="w-right"><span class="w-temp">30°</span></div></div>
<div class="weather-card w-rain"><div class="w-left"><span class="w-city">上海</span><span class="w-cond">🌧️ 小雨</span></div><div class="w-right"><span class="w-temp">26°</span></div></div>
</div>
<p style="font-size:12px;color:#a0aec0;">数据来源:中央气象台</p>
</div>
</div></body></html>`;
test('splitSections preserves section ids after opening tag split', () => {
const sections = wechatDailyNewsInlineInternals.splitSections(WEATHER_HTML);
assert.deepEqual(sections.map((item) => item.id), ['headlines', 'weather']);
});
test('renderWeatherSection uses tourist city title and two-column city cards', () => {
const sections = wechatDailyNewsInlineInternals.splitSections(WEATHER_HTML);
const weather = sections.find((item) => item.id === 'weather');
const rendered = wechatDailyNewsInlineInternals.renderWeatherSection(weather.html);
assert.match(rendered, /热门旅游城市天气/u);
assert.match(rendered, /📍 北京/u);
assert.match(rendered, /☀️ 晴 · 温差大/u);
assert.match(rendered, /width:50%;box-sizing:border-box/u);
assert.match(rendered, /display:inline-block/u);
assert.doesNotMatch(rendered, /display:flex/u);
assert.match(rendered, /30°/u);
assert.match(rendered, /26°/u);
assert.doesNotMatch(rendered, /中央气象台/u);
assert.doesNotMatch(rendered, /今日天气/u);
});
test('renderWeatherSection skips regional fallback cards and national summary', () => {
const regionalWeatherHtml = `<div class="section" id="weather">
<div class="section-title">🌤️ 今日天气 · 9月16日</div>
<div class="weather-today">
<div class="wt-row"><div><div class="wt-city">📍 全国概况 · 江汉西南阴雨开场</div><div class="wt-detail">🌧️ 湖北西部局地暴雨</div></div></div>
</div>
<div class="weather-grid">
<div class="weather-card"><div class="w-left"><span class="w-city">湖北西部</span><span class="w-cond">🌧️ 暴雨</span></div><div class="w-right"><span class="w-temp">雨</span></div></div>
<div class="weather-card"><div class="w-left"><span class="w-city">北京</span><span class="w-cond">☀️ 晴</span></div><div class="w-right"><span class="w-temp">30°</span></div></div>
<div class="weather-card"><div class="w-left"><span class="w-city">上海</span><span class="w-cond">🌧️ 小雨</span></div><div class="w-right"><span class="w-temp">26°</span></div></div>
</div>
</div>`;
const rendered = wechatDailyNewsInlineInternals.renderWeatherSection(regionalWeatherHtml);
assert.match(rendered, /热门旅游城市天气 · 9月16日/u);
assert.match(rendered, /北京/u);
assert.match(rendered, /上海/u);
assert.doesNotMatch(rendered, /湖北西部/u);
assert.doesNotMatch(rendered, /全国概况/u);
});
test('buildWechatInlineContent renders weather and optional quick links', () => {
const built = wechatDailyNewsInlineInternals.buildWechatInlineContent(WEATHER_HTML, {
includeFixedLayout: false,
trimProfile: { skipHistory: true, skipQuickLinks: false },
});
assert.match(built.content, /🌤️ 天气/u);
assert.match(built.content, /📍 北京/u);
assert.match(built.content, /30°/u);
});
test('convertDailyNewsHtmlToWechatInlineArticle keeps weather when trimming', () => {
const article = convertDailyNewsHtmlToWechatInlineArticle(WEATHER_HTML, {
includeFixedLayout: false,
fixedLayoutLength: 1200,
});
assert.match(article.content, /📍 北京/u);
assert.match(article.content, /30°/u);
});
test('trimWechatInlineBodyByRemovingCards drops tail cards instead of hard slicing', () => {
const html = `${WEATHER_HTML.slice(0, -24)}<div class="section" id="world"><div class="section-title">国际</div><div class="card"><h3>国际一</h3><p>正文一</p></div><div class="card"><h3>国际二</h3><p>正文二</p></div></div></div></body></html>`;
const built = wechatDailyNewsInlineInternals.buildWechatInlineContent(html, {
includeFixedLayout: false,
});
const beforeCards = wechatDailyNewsInlineInternals.countInlineCards(built.content);
const trimmed = wechatDailyNewsInlineInternals.trimWechatInlineBodyByRemovingCards(
built.content,
built.content.length - 260,
{ minCards: 1 },
);
const afterCards = wechatDailyNewsInlineInternals.countInlineCards(trimmed);
assert.ok(beforeCards >= 3);
assert.ok(afterCards < beforeCards);
assert.ok(trimmed.length < built.content.length);
assert.match(trimmed, /<\/section>\s*$/u);
});
test('section list numbering starts at 01 after feature cards', () => {
const html = `<!doctype html><html><body>
<div class="hero"><h1>每日新闻早报</h1><div class="subtitle">头条一 · 头条二</div></div>
<div class="section" id="headlines">
<div class="section-title">今日要闻</div>
<div class="card highlight-box"><h3>头条卡</h3><p>重点摘要</p></div>
<div class="card"><h3>普通要闻</h3><p>普通摘要</p></div>
</div>
<div class="section" id="world">
<div class="section-title">国际热点</div>
<div class="card"><h3>国际一</h3><p>国际摘要一</p></div>
</div>
</body></html>`;
const built = wechatDailyNewsInlineInternals.buildWechatInlineContent(html, {
includeFixedLayout: false,
});
assert.match(built.content, /头条卡[\s\S]*?01<\/span> <strong>普通要闻/u);
assert.match(built.content, /WORLD[\s\S]*?01<\/span> <strong>国际一/u);
assert.doesNotMatch(built.content, /02<\/span> <strong>普通要闻/u);
});
test('hero highlights render as a separate readable digest', () => {
const digest = wechatDailyNewsInlineInternals.renderTodayDigest(
'<div class="subtitle">平陆运河通航 · 海上火箭发射 · 全球市场聚焦议息</div>',
);
assert.match(digest, /今日导读/u);
assert.match(digest, /平陆运河通航<br>/u);
assert.match(digest, /海上火箭发射<br>/u);
});
test('renderHero does not insert the cover image as a large logo', () => {
const html = `<div class="hero"><h1>每日新闻早报</h1><div class="date-badge">9月16日</div></div>
<meta name="mindspace-cover" content='{"cover":"https://mmbiz.qpic.cn/logo.png"}'>`;
const rendered = wechatDailyNewsInlineInternals.renderHero(html, new Map([
['https://mmbiz.qpic.cn/logo.png', 'https://mmbiz.qpic.cn/logo.png'],
]));
assert.match(rendered, /每日新闻早报/u);
assert.doesNotMatch(rendered, /<img\b/u);
assert.doesNotMatch(rendered, /TKMIND · DAILY BRIEF/u);
});
+13 -13
View File
@@ -42,15 +42,14 @@ export function clipWechatDraftBodyToFitFooter(
export function renderReadOriginalLink(publicUrl) {
if (!publicUrl) return '';
return [
'<section style="text-align:center;margin:26px 0 14px;padding:20px 16px 18px;background:linear-gradient(180deg,#eef4ff 0%,#fff 100%);border:2px dashed #576b95;border-radius:16px;box-shadow:0 6px 18px rgba(87,107,149,.12);">',
'<p style="margin:0;font-size:14px;color:#4a5568;line-height:1.6;font-weight:600;">✨ 完整网页版更精彩</p>',
'<p style="margin:6px 0 0;font-size:12px;color:#718096;line-height:1.5;">动画 · 互动 · 高清大图 · 完整内容</p>',
'<p style="margin:14px 0 0;font-size:26px;line-height:1;color:#576b95;letter-spacing:6px;font-weight:700;">⬇ ⬇ ⬇</p>',
'<p style="margin:12px 0 0;">',
`<a href="${publicUrl}" style="display:inline-block;padding:12px 32px;border-radius:999px;background:linear-gradient(135deg,#576b95,#3d5a80);color:#fff;font-size:16px;font-weight:700;text-decoration:none;box-shadow:0 6px 16px rgba(87,107,149,.35);">📖 阅读原文</a>`,
'<section style="text-align:center;margin:28px 0 14px;padding:24px 18px;background:#f4f8ff;border:1px dashed #1a4a8a;border-radius:16px">',
'<p style="margin:0;font-size:15px;font-weight:700;color:#0d2b5c;line-height:1.6">完整网页版更精彩</p>',
'<p style="margin:8px 0 0;font-size:12px;color:#3a5f92;line-height:1.6">全部资讯 · 高清配图 · 来源直达 · 历史早报</p>',
'<p style="margin:15px 0 0;font-size:19px;line-height:1;color:#1a4a8a;letter-spacing:8px">⬇ ⬇ ⬇</p>',
'<p style="margin:15px 0 0">',
`<a href="${publicUrl}" style="display:inline-block;padding:13px 36px;border-radius:999px;background:linear-gradient(135deg,#0d2b5c,#1a4a8a);color:#fff;font-size:16px;font-weight:700;text-decoration:none">📖 阅读原文</a>`,
'</p>',
'<p style="margin:12px 0 0;font-size:13px;color:#576b95;font-weight:700;line-height:1.5;">👆 点击上方按钮,查看完整精彩页面</p>',
'<p style="margin:8px 0 0;font-size:22px;line-height:1;color:#576b95;letter-spacing:4px;">➡️ ➡️ ➡️</p>',
'<p style="margin:14px 0 0;font-size:12px;color:#3a5f92;line-height:1.5">点击上方按钮,查看完整早报</p>',
'</section>',
].join('');
}
@@ -62,15 +61,16 @@ export function renderFollowTkMindSection({
qrcodeImageUrl = '',
} = {}) {
const qrcodeBlock = qrcodeImageUrl
? `<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:${FOLLOW_QRCODE_SIZE_PX}px;height:${FOLLOW_QRCODE_SIZE_PX}px;display:block;margin:12px auto 0;border-radius:10px;border:1px solid #eee;" />`
? `<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:${FOLLOW_QRCODE_SIZE_PX}px;height:${FOLLOW_QRCODE_SIZE_PX}px;display:block;margin:14px auto 0;border-radius:10px" />`
: '';
return [
'<section style="text-align:center;padding:22px 16px 18px;margin:18px 0 8px;background:linear-gradient(180deg,#fff8f8,#fff);border:1px solid #ffcdd2;border-radius:12px;">',
'<p style="margin:0 0 6px;font-size:16px;font-weight:700;color:#b71c1c;line-height:1.5;">欢迎关注 TKMind 智趣</p>',
'<section style="text-align:center;padding:24px 16px;margin:18px 0 8px;background:#fff5f6;border-radius:14px">',
'<p style="margin:0;font-size:16px;font-weight:700;color:#c8102e;line-height:1.5">欢迎关注 TKMind 智趣</p>',
'<p style="margin:8px 0 0;font-size:12px;color:#9b9b9b;line-height:1.6">每日早报 · 智能创作 · 一键成页</p>',
qrcodeBlock,
qrcodeImageUrl
? '<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">长按扫描关注</p>'
: '<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">关注 TKMind 服务号</p>',
? '<p style="margin:12px 0 0;font-size:12px;color:#9b9b9b">长按扫描关注</p>'
: '<p style="margin:12px 0 0;font-size:12px;color:#9b9b9b">关注 TKMind 服务号</p>',
'</section>',
].filter(Boolean).join('');
}
+1 -1
View File
@@ -37,7 +37,7 @@ test('applyWechatDraftArticleLayout renders guided read-original section and sma
});
assert.match(content, /完整网页版更精彩/);
assert.match(content, /⬇ ⬇ ⬇/);
assert.match(content, /点击上方按钮,查看完整精彩页面/);
assert.match(content, /点击上方按钮,查看完整早报/);
assert.match(content, /width:120px;height:120px/);
assert.match(content, /📖 阅读原文/);
const check = verifyWechatDraftPublicationContent(content, { publicUrl: PUBLIC_URL });
+117 -15
View File
@@ -10,8 +10,16 @@ import {
} from './user-publish.mjs';
import { extractMindspaceCoverPath } from './wechat/verify/generated-thumbnail.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { convertDailyNewsHtmlToWechatInlineArticle } from './wechat-daily-news-inline.mjs';
import { uploadWechatDraftBrandAssets } from './wechat-draft-article-layout.mjs';
import {
convertDailyNewsHtmlToWechatInlineArticle,
renderCreatePortalCTA,
trimWechatInlineBodyByRemovingCards,
} from './wechat-daily-news-inline.mjs';
import {
applyWechatDraftArticleLayout,
clipWechatDraftBodyToFitFooter,
uploadWechatDraftBrandAssets,
} from './wechat-draft-article-layout.mjs';
import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs';
const CONFIG_TABLE = 'h5_wechat_admin_config';
@@ -22,7 +30,7 @@ const DEFAULT_DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add';
const DEFAULT_MATERIAL_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/material/add_material';
const DEFAULT_UPLOAD_IMG_URL = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg';
const MAX_THUMB_BYTES = 64 * 1024;
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-15';
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-16';
/** 0910 新闻早报页面结构说明,供定时任务 taskSpec 与草稿转换共用。 */
export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
@@ -33,7 +41,7 @@ export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
'4. 可选 weather、knowledge、history 等区块;',
'5. footer 品牌;',
'6. 文件名 daily-news-MMDD.html(如 daily-news-0910.html),并生成同名 .thumbnail.png',
'7. 推送微信草稿时转为内联 style HTML(参考公众号排版),禁止整页长图;正文需控制在 2 万字符内。',
'7. 推送微信草稿时转为内联 style 版式(与 daily-news-0904 公众号已发版一致,微信不支持 <style> 标签),禁止整页长图;底部追加统一「阅读原文 + 二维码」layout。',
].join('\n');
/** 每日正文必须新搜,禁止把历史早报当新闻来源。 */
@@ -45,7 +53,7 @@ export const NEWS_MORNING_FRESH_CONTENT_SPEC = [
'4. 禁止复制、改写、微调昨日或任何历史 daily-news-*.html 的新闻标题与正文。',
'5. 最近一版 daily-news 只允许当作版式参考(hero / section / card 结构);读完后必须丢弃其中的条目内容。',
'6. 每条卡片必须是当日可核验事件,并写明来源;title、date-badge、导语必须写明当日日期。要闻栏至少 6 条,其中国内、国际、热门事件都要有代表条目,并用 highlight-box 突出头条。',
'7. 必须包含天气区块(section id=weatherweather-today + weather-card)。若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
'7. 必须包含天气区块(section id=weatherweather-today + weather-card)。天气卡片必须写热门旅游城市当日天气(北京、上海、广州、深圳、成都、杭州、西安、重庆、武汉、南京、厦门、三亚等),禁止用区域预报(西部/大部/平原/秋收区)顶替城市卡。若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
].join('\n');
function normalizeBoolean(value, fallback = false) {
@@ -276,18 +284,85 @@ export function rewriteDailyNewsHtmlLinks(html, { publicBaseUrl, userId }) {
});
}
export function buildDailyNewsWechatHtmlContent(html, { publicUrl = '', publicBaseUrl = '', userId = '' } = {}) {
const source = String(html ?? '');
const styleBlock = source.match(/<style>([\s\S]*?)<\/style>/i)?.[0] ?? '';
const bodyBlock = source.match(/<body>([\s\S]*?)<\/body>/i)?.[0] ?? '';
let content = `${styleBlock}${bodyBlock}`;
function stripMindspaceChromeFromDailyNewsBody(bodyHtml) {
let body = String(bodyHtml ?? '');
body = body.replace(/<div[^>]*data-mindspace-public-share[\s\S]*$/i, '');
body = body.replace(/<section[^>]*data-mindspace-seo-outline[\s\S]*$/i, '');
body = body.replace(/<p[^>]*>\s*✨\s*一起创作[\s\S]*?<\/p>/gi, '');
return body.trim();
}
function extractDailyNewsStyleBlocks(html) {
const blocks = [];
for (const match of String(html ?? '').matchAll(/<style([^>]*)>([\s\S]*?)<\/style>/gi)) {
const attrs = match[1] ?? '';
const block = match[0];
if (/mindspace-public-share/i.test(`${attrs}${block}`)) continue;
blocks.push(block);
}
return blocks.join('\n');
}
function replaceDailyNewsHtmlImageRefs(html, imageUrlMap = new Map()) {
if (!imageUrlMap || imageUrlMap.size === 0) return String(html ?? '');
return String(html).replace(/(<img[^>]+src=["'])([^"']+)(["'])/gi, (match, prefix, src, suffix) => {
const mapped =
imageUrlMap.get(src)
?? imageUrlMap.get(src.replace(/^\.\//, ''))
?? imageUrlMap.get(src.replace(/^\//, ''));
return mapped ? `${prefix}${mapped}${suffix}` : match;
});
}
export function buildDailyNewsWechatHtmlContent(
html,
{
publicUrl = '',
publicBaseUrl = '',
userId = '',
imageUrlMap = new Map(),
appendLegacyReadOriginalLink = false,
} = {},
) {
const source = String(html ?? '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const styleBlock = extractDailyNewsStyleBlocks(source);
const bodyInner = stripMindspaceChromeFromDailyNewsBody(
source.match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] ?? source,
);
let content = `${styleBlock}<body>${bodyInner}</body>`;
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
if (publicUrl) {
content = replaceDailyNewsHtmlImageRefs(content, imageUrlMap);
if (appendLegacyReadOriginalLink && publicUrl) {
content += `<p style="margin:16px 0 0;font-size:13px;color:#718096;text-align:center;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`;
}
return content;
}
function estimateDailyNewsDraftFooterLength({
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = 'https://m.tkmind.cn',
} = {}) {
const footer = applyWechatDraftArticleLayout('', { publicUrl, qrcodeImageUrl });
const cta = renderCreatePortalCTA(portalUrl);
return footer.length + (cta ? cta.length + 1 : 0);
}
export function applyDailyNewsWechatDraftFooter(
body,
{ publicUrl = '', qrcodeImageUrl = '', portalUrl = 'https://m.tkmind.cn' } = {},
) {
const footer = applyWechatDraftArticleLayout('', { publicUrl, qrcodeImageUrl });
const cta = renderCreatePortalCTA(portalUrl);
const footerWithCta = [footer, cta].filter(Boolean).join('\n');
const separator = body && footerWithCta ? '\n' : '';
const maxBody = Math.max(0, 20000 - footerWithCta.length - separator.length);
let fittedBody = trimWechatInlineBodyByRemovingCards(String(body ?? '').trim(), maxBody);
fittedBody = clipWechatDraftBodyToFitFooter(fittedBody, footerWithCta);
return [fittedBody, footerWithCta].filter(Boolean).join('\n');
}
export function extractDailyNewsArticleMeta(html) {
const title = extractPageTitle(html) || '每日新闻早报';
const digest = (extractMetaDescription(html) || title).slice(0, 120);
@@ -419,17 +494,38 @@ export function buildDailyNewsWechatDraftArticle({
publicUrl,
qrcodeImageUrl = '',
imageUrlMap = new Map(),
publicBaseUrl = '',
userId = '',
} = {}) {
const article = convertDailyNewsHtmlToWechatInlineArticle(html, {
const meta = extractDailyNewsArticleMeta(html);
const footerLength = estimateDailyNewsDraftFooterLength({ publicUrl, qrcodeImageUrl });
const inline = convertDailyNewsHtmlToWechatInlineArticle(html, {
publicUrl,
qrcodeImageUrl,
imageUrlMap,
includeFixedLayout: false,
fixedLayoutLength: footerLength,
});
let content = applyDailyNewsWechatDraftFooter(inline.content, {
publicUrl,
qrcodeImageUrl,
});
if (publicBaseUrl && userId) {
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
}
return {
...article,
cardCount: extractCardArticles(html).length,
title: meta.title.slice(0, 64),
digest: meta.digest,
content,
contentSourceUrl: publicUrl || undefined,
contentMode: 'inline_html',
contentLength: content.length,
cardCount: inline.cardCount ?? extractCardArticles(html).length,
statsCount: extractStats(html).length,
uploadedImageCount: imageUrlMap.size,
trimLevel: inline.trimLevel,
trimmed: inline.trimmed,
usedFallbackTrim: inline.usedFallbackTrim,
};
}
@@ -831,7 +927,12 @@ export function createWechatNewsMorningDraftService(
}
const html = fs.readFileSync(page.localPath, 'utf8');
const article = isDailyNewsFormat(html)
? buildDailyNewsWechatDraftArticle({ html, publicUrl: pageSummary.publicUrl })
? buildDailyNewsWechatDraftArticle({
html,
publicUrl: pageSummary.publicUrl,
publicBaseUrl: resolveDailyNewsPublicBaseUrl(env),
userId: config.sourceUserId,
})
: convertNewsPageHtmlToWechatArticle(html, { publicUrl: pageSummary.publicUrl });
return {
config,
@@ -1194,6 +1295,7 @@ export const wechatNewsMorningDraftInternals = {
slugPatternToRegExp,
convertNewsPageHtmlToWechatArticle,
buildDailyNewsWechatHtmlContent,
applyDailyNewsWechatDraftFooter,
buildDailyNewsWechatDraftArticle,
findLatestNewsMorningPage,
isNewsMorningPageForToday,
+5 -3
View File
@@ -82,14 +82,15 @@ function createPool(seedRow = null) {
};
}
test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html', () => {
test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html with footer', () => {
const article = convertNewsPageHtmlToWechatArticle(SAMPLE_HTML, {
publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html',
qrcodeImageUrl: 'https://m.tkmind.cn/assets/mp-follow-qrcode.png',
});
assert.match(article.title, /2026年9月10日/u);
assert.equal(article.contentMode, 'inline_html');
assert.match(article.content, /每日新闻早报/u);
assert.match(article.content, /linear-gradient\(135deg,#c8102e/u);
assert.doesNotMatch(article.content, /<style>/u);
assert.match(article.content, /示例热点/u);
assert.match(article.content, /阅读原文/u);
assert.match(article.content, /完整网页版更精彩/u);
@@ -98,7 +99,7 @@ test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html', ()
assert.match(article.content, /mp-follow-qrcode\.png/u);
assert.match(article.content, /一起创作/u);
assert.match(article.content, /点我/u);
assert.doesNotMatch(article.content, /近7天新闻早报/u);
assert.doesNotMatch(article.content, /data-mindspace-public-share/u);
});
test('buildDailyNewsWechatHtmlContent preserves style and body markup', () => {
@@ -214,6 +215,7 @@ test('buildNewsMorningAutoGenerationTask requires fresh daily search', () => {
assert.match(task.taskSpec, /国内热点/u);
assert.match(task.taskSpec, /国际热点/u);
assert.match(task.taskSpec, /热门事件/u);
assert.match(task.taskSpec, /热门旅游城市/u);
assert.match(task.taskSpec, /2026-09-15/u);
assert.match(task.taskSpec, /daily-news-0915\.html/u);
assert.doesNotMatch(task.taskSpec, /可以用昨日正文/u);