Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 745e9b691d | |||
| 422ff8647c | |||
| 607e0d758b | |||
| 61a594f10a | |||
| 856096674a | |||
| 1e4f6433ff | |||
| f281f15788 | |||
| 0f692ab68f | |||
| b8bcbcb764 | |||
| 96d8e119ed |
@@ -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 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`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
@@ -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,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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
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" "$@"
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+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