Compare commits

...

5 Commits

Author SHA1 Message Date
john 010e277180 fix(wechat): reserve space for uploaded follow QR in draft body
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:43:16 +08:00
john eaba8542a8 feat(wechat): refine daily news draft layout and follow QR block
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:40:51 +08:00
john 84b38f397e fix(wechat): add fallback trim profiles for 20k content limit 2026-09-10 09:28:04 +08:00
john 257edb4917 feat(dev-tools): add shadow portal and dsh gateway local E2E scripts
Add events-only Context Runtime shadow portal verification to save LLM
tokens, wire Tool Gateway → dsh headless smoke, and document both flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:24:57 +08:00
john 6d76618c0b feat(wechat): add news morning draft inline HTML converter
Push daily-news pages to WeChat drafts as inline-styled HTML instead of
full-page long images, with automatic trimming to stay within the 20k limit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:23:24 +08:00
9 changed files with 1900 additions and 1 deletions
+26 -1
View File
@@ -962,7 +962,7 @@ Goose v1.49 本地 canary 收口、Context Runtime + Agent Harness 融合 Phase
审计日期:2026-09-10
分支 HEAD`47d0d2ac`
`origin/main` 对应提交:`47d0d2ac`(待 push
`origin/main` 对应提交:`938f6d6d`
### 原始用途
@@ -978,3 +978,28 @@ fusion-plan §6.6 本机开发工具:`codebase-memory-mcp` 本地安装验证
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `feature/dsh-executor-local-setup`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-10
分支 HEAD:(见 merge commit
`origin/main` 对应提交:(见 merge commit
### 原始用途
`@deepseek-ai/dsh@0.1.1-rc.2` 本机安装、check 脚本、Tool Gateway headless E2EContext Runtime shadow Portal E2Eevents-only 省 token)。
### 验证摘要
- `dsh --version`0.1.1-rc.2
- `node scripts/check-dsh-executor-local.mjs``DSH_EXECUTOR_CLI_OK` / dry-run OK
- `MEMIND_DSH_RUN_LIVE_SMOKE=1` headlessOK(列 3 文件)
- `MEMIND_TOOL_GATEWAY_DSH_ENABLED=1 node scripts/test-dsh-executor-gateway-e2e.mjs``DSH_GATEWAY_E2E_OK`
- Shadow Portal`force_deep_reasoning`run `2ca8be7b…`):`headroom_context_observed` + `context_budget_resolved``mode=shadow`
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
+12
View File
@@ -64,6 +64,18 @@ node scripts/check-codebase-memory-mcp-local.mjs
node scripts/check-dsh-executor-local.mjs
```
## Shadow Portal E2E1 轮,省 token 默认只验事件)
Portal 需带 shadow 五件套(见上文)运行 `pnpm dev`
```bash
node scripts/run-context-runtime-shadow-portal-e2e.mjs
# 默认 CONTEXT_RUNTIME_SHADOW_EVENTS_ONLY=1:拿到 headroom/budget shadow 事件即停,不等 Goose 跑完
# 通过 → CONTEXT_RUNTIME_SHADOW_PORTAL_OK
```
`recall_fusion_resolved` 在 agent memory 关闭时可能缺失,脚本会 WARN 但不阻断。
## 用户自测清单(有 LLM 配额时)
1. 确认 **未设置** `GOOSE_V149_ALLOW_REAL_LLM=1` 除非你明确要跑 live smoke
+9
View File
@@ -62,6 +62,15 @@ node scripts/check-dsh-executor-local.mjs
- **预计超过 10k token 必须先经用户同意**
- 禁止循环重跑、禁止 unattended `check-goosed-v149-all` / phase3 聚合
## Tool Gateway E2E(一次 headless
```bash
MEMIND_TOOL_GATEWAY_DSH_ENABLED=1 node scripts/test-dsh-executor-gateway-e2e.mjs
# → DSH_GATEWAY_E2E_OK
```
路由 `repo_refactor``dsh`;会消耗 LLM token,须人工批准。
## headless 手动探针
```bash
@@ -0,0 +1,232 @@
#!/usr/bin/env node
/**
* Context Runtime shadow portal E2E: one agent turn, verify shadow events in DB.
* Default: poll shadow events only (do not wait for full Goose completion — saves LLM tokens).
*/
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { CONTEXT_RUNTIME_SHADOW_ENV } from '../context-runtime-profile.mjs';
import {
createReporter,
loginViaApi,
resolvePortalBase,
sleep,
waitForRunTerminal,
} from './scenario-test-lib.mjs';
import { waitForAgentRunWorkerIdle } from './goose-v149-worker-idle.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
for (const [key, value] of Object.entries(CONTEXT_RUNTIME_SHADOW_ENV)) {
process.env[key] = process.env[key] ?? value;
}
process.env.HEADROOM_OUTPUT_SHAPER = process.env.HEADROOM_OUTPUT_SHAPER ?? '0';
const baseUrl = resolvePortalBase(Number(process.env.H5_PORT ?? 8081));
const timeoutMs = Number(process.env.CONTEXT_RUNTIME_SHADOW_PORTAL_TIMEOUT_MS ?? 180_000);
const eventsOnly = process.env.CONTEXT_RUNTIME_SHADOW_EVENTS_ONLY !== '0';
const pollMs = Number(process.env.CONTEXT_RUNTIME_SHADOW_POLL_MS ?? 2000);
async function portalReachable() {
try {
const response = await fetch(`${baseUrl}/auth/status`);
return response.ok;
} catch {
return false;
}
}
async function fetchRunEvents(pool, runId) {
const [rows] = await pool.query(
`SELECT event_type, data_json, created_at
FROM h5_agent_run_events
WHERE run_id = ?
ORDER BY created_at ASC`,
[runId],
);
return rows.map((row) => ({
eventType: row.event_type,
data: typeof row.data_json === 'string' ? JSON.parse(row.data_json) : row.data_json,
createdAt: Number(row.created_at),
}));
}
function pickEvents(events, type) {
return events.filter((event) => event.eventType === type);
}
function evaluateShadowEvents(events) {
const headroomEvents = pickEvents(events, 'headroom_context_observed');
const budgetEvents = pickEvents(events, 'context_budget_resolved');
const fusionEvents = pickEvents(events, 'recall_fusion_resolved');
const issues = [];
const warnings = [];
if (!headroomEvents.length) issues.push('missing headroom_context_observed');
if (!budgetEvents.length) issues.push('missing context_budget_resolved');
if (!fusionEvents.length) {
warnings.push('missing recall_fusion_resolved (memory path may be off/skipped)');
}
for (const [label, rows] of [
['headroom', headroomEvents],
['budget', budgetEvents],
['fusion', fusionEvents],
]) {
const mode = rows[0]?.data?.mode;
if (mode && mode !== 'shadow') {
issues.push(`${label} mode=${mode} (expected shadow)`);
}
}
return { headroomEvents, budgetEvents, fusionEvents, issues, warnings };
}
async function pollShadowEvents(pool, runId, deadlineMs) {
while (Date.now() < deadlineMs) {
const events = await fetchRunEvents(pool, runId);
const evaluation = evaluateShadowEvents(events);
const coreReady = evaluation.headroomEvents.length && evaluation.budgetEvents.length;
if (coreReady) {
return { events, evaluation, done: true };
}
await sleep(pollMs);
}
const events = await fetchRunEvents(pool, runId);
return { events, evaluation: evaluateShadowEvents(events), done: false };
}
async function runPortalTurn(pool) {
const username = process.env.RELEASE_GATE_SCENARIO_USERNAME ?? 'john';
const password =
process.env.JOHN_PASSWORD
?? process.env.H5_ACCESS_PASSWORD
?? process.env.MEMIND_PASSWORD
?? '';
if (!password) throw new Error('set JOHN_PASSWORD for portal e2e');
const reporter = createReporter();
const auth = await loginViaApi(baseUrl, { username, password }, reporter);
await waitForAgentRunWorkerIdle(root, process.env, {
logPrefix: '[context-runtime-shadow-e2e]',
});
const startRes = await fetch(`${baseUrl}/api/agent/start`, {
method: 'POST',
headers: { Cookie: auth.cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const started = await startRes.json().catch(() => ({}));
if (!startRes.ok || !started?.id) {
throw new Error(`agent/start failed: ${startRes.status}`);
}
const sessionId = started.id;
const warmResume = await fetch(`${baseUrl}/api/agent/resume`, {
method: 'POST',
headers: { Cookie: auth.cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
load_model_and_extensions: true,
}),
});
if (!warmResume.ok) {
const warmBody = await warmResume.text().catch(() => '');
throw new Error(`pre-run resume failed: ${warmResume.status} ${warmBody.slice(0, 200)}`);
}
const requestId = randomUUID();
const runRes = await fetch(`${baseUrl}/api/agent/runs`, {
method: 'POST',
headers: { Cookie: auth.cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({
request_id: requestId,
session_id: sessionId,
force_deep_reasoning: true,
user_message: {
id: randomUUID(),
role: 'user',
content: [{ type: 'text', text: '帮我搜索一下今天上海的天气,简要回答即可' }],
metadata: {
userVisible: true,
agentVisible: true,
displayText: 'context runtime shadow agent ping',
},
},
}),
});
const runPayload = await runRes.json().catch(() => ({}));
if (!runRes.ok) {
throw new Error(`POST /api/agent/runs ${runRes.status}: ${JSON.stringify(runPayload).slice(0, 300)}`);
}
const runId = runPayload.run?.id ?? runPayload.id;
if (eventsOnly) {
const polled = await pollShadowEvents(pool, runId, Date.now() + Math.min(timeoutMs, 60_000));
return {
sessionId,
runId,
terminal: { status: polled.done ? 'shadow_events_ready' : 'shadow_events_timeout' },
events: polled.events,
evaluation: polled.evaluation,
eventsOnly: true,
};
}
const terminal = await waitForRunTerminal(baseUrl, auth.cookie, runId, timeoutMs);
const events = await fetchRunEvents(pool, runId);
return {
sessionId,
runId,
terminal,
events,
evaluation: evaluateShadowEvents(events),
eventsOnly: false,
};
}
async function main() {
if (!(await portalReachable())) {
throw new Error(`Portal not reachable at ${baseUrl} — start pnpm dev first`);
}
const pool = createDbPool();
const portal = await runPortalTurn(pool);
const { headroomEvents, budgetEvents, fusionEvents, issues, warnings } = portal.evaluation;
console.log('CONTEXT_RUNTIME_SHADOW_PORTAL_E2E:');
console.log(` portal=${baseUrl}`);
console.log(` run_id=${portal.runId}`);
console.log(` mode=${portal.eventsOnly ? 'events_only' : 'full_terminal'}`);
console.log(` terminal=${portal.terminal.status}`);
console.log(` headroom_events=${headroomEvents.length}`);
console.log(` budget_events=${budgetEvents.length}`);
console.log(` fusion_events=${fusionEvents.length}`);
if (headroomEvents[0]?.data) {
console.log(` headroom_sample=${JSON.stringify(headroomEvents[0].data)}`);
}
if (budgetEvents[0]?.data) {
console.log(` budget_sample=${JSON.stringify(budgetEvents[0].data)}`);
}
if (fusionEvents[0]?.data) {
console.log(` fusion_sample=${JSON.stringify(fusionEvents[0].data)}`);
}
for (const warning of warnings) {
console.warn(`CONTEXT_RUNTIME_SHADOW_PORTAL_WARN: ${warning}`);
}
if (issues.length) {
console.error(`CONTEXT_RUNTIME_SHADOW_PORTAL_FAIL: ${issues.join('; ')}`);
process.exit(1);
}
console.log('CONTEXT_RUNTIME_SHADOW_PORTAL_OK');
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Minimal Tool Gateway → dsh executor E2E (one headless task, costs LLM tokens).
*
* Usage:
* MEMIND_TOOL_GATEWAY_DSH_ENABLED=1 \
* node scripts/test-dsh-executor-gateway-e2e.mjs
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildDshExecutorLaunchPlan } from '../dsh-agent-launch.mjs';
import { createToolGateway } from '../tool-gateway.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const cwd = process.argv[2] ?? root;
const instruction = process.env.MEMIND_DSH_GATEWAY_TASK
?? 'List three files in the workspace root and stop. Do not modify any files.';
process.env.MEMIND_TOOL_GATEWAY_ENABLED = process.env.MEMIND_TOOL_GATEWAY_ENABLED ?? '1';
process.env.MEMIND_TOOL_GATEWAY_DRY_RUN = '0';
process.env.MEMIND_TOOL_GATEWAY_DSH_ENABLED = process.env.MEMIND_TOOL_GATEWAY_DSH_ENABLED ?? '1';
process.env.MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES = process.env.MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES ?? 'repo_refactor';
const gateway = createToolGateway({
env: process.env,
llmProviderService: {
async getExecutorLaunchPlan(executor, options) {
if (executor !== 'dsh') {
throw new Error(`unexpected executor ${executor}`);
}
return buildDshExecutorLaunchPlan({
cwd: options.cwd,
instruction: options.instruction,
env: process.env,
runtimeEnv: {
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? process.env.OPENAI_API_KEY ?? '',
},
});
},
},
});
const status = gateway.getStatus();
console.log('DSH_GATEWAY_E2E_PROBE:');
console.log(JSON.stringify({
enabled: status.enabled,
dryRun: status.dryRun,
executors: status.executors,
dshEnabled: status.dshEnabled,
dshTaskTypes: status.dshTaskTypes,
selected: gateway.selectExecutor({ taskType: 'repo_refactor' }),
}, null, 2));
if (!status.dshEnabled) {
console.error('DSH_GATEWAY_E2E_FAIL: MEMIND_TOOL_GATEWAY_DSH_ENABLED is off');
process.exit(1);
}
const result = await gateway.executeJob({
runId: 'dsh-gateway-e2e',
requestId: 'dsh-gateway-req',
userId: 'local-user',
cwd,
taskType: 'repo_refactor',
timeoutMs: Number(process.env.MEMIND_DSH_GATEWAY_TIMEOUT_MS ?? 180_000),
userMessage: {
content: [{ type: 'text', text: instruction }],
metadata: {
memindRun: {
taskType: 'repo_refactor',
toolMode: 'code',
},
},
},
});
const stdoutPreview = String(result.stdout ?? result.displayStdout ?? '').trim().slice(0, 500);
console.log('DSH_GATEWAY_E2E_RESULT:');
console.log(JSON.stringify({
ok: result.ok,
executor: result.executor,
exitCode: result.exitCode,
stdoutPreview,
}, null, 2));
if (!result.ok || result.executor !== 'dsh') {
console.error('DSH_GATEWAY_E2E_FAIL');
process.exit(1);
}
console.log('DSH_GATEWAY_E2E_OK');
+511
View File
@@ -0,0 +1,511 @@
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
const MAX_WECHAT_CONTENT_CHARS = 20000;
const MAX_WECHAT_CONTENT_TARGET_CHARS = 19600;
const DEFAULT_PORTAL_URL = 'https://m.tkmind.cn';
const CARD_VARIANTS = {
'highlight-box': {
wrap: 'background:linear-gradient(135deg,#b71c1c,#d32f2f);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;',
title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;',
body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#ffcdd2;',
source: 'margin:0;font-size:12px;color:#90caf4;',
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;',
},
'highlight-box-orange': {
wrap: 'background:linear-gradient(135deg,#e65100,#bf360c);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;',
title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;',
body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#ffe0b2;',
source: 'margin:0;font-size:12px;color:#ffcdd2;',
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;',
},
'highlight-box-gold': {
wrap: 'background:linear-gradient(135deg,#0d47a1,#1565c0);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;',
title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;',
body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#bbdefb;',
source: 'margin:0;font-size:12px;color:#bbdefb;',
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;',
},
'highlight-box-teal': {
wrap: 'background:linear-gradient(135deg,#004d40,#00695c);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;',
title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;',
body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#b2dfdb;',
source: 'margin:0;font-size:12px;color:#b2dfdb;',
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;',
},
default: {
wrap: 'background:#fff;border-radius:12px;padding:14px;margin:0 0 10px;',
title: 'margin:0 0 4px;font-size:16px;font-weight:600;color:#1a202c;line-height:1.5;',
body: 'margin:0 0 6px;font-size:14px;color:#4a5568;line-height:1.7;',
source: 'margin:0;font-size:12px;color:#a0aec0;',
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:#ffebee;color:#b71c1c;',
},
};
const TAG_CLASS_COLORS = {
'tag-red': 'background:#ffcdd2;color:#b71c1c;',
'tag-blue': 'background:#bbdefb;color:#0d47a1;',
'tag-green': 'background:#c8e6c9;color:#1b5e20;',
'tag-purple': 'background:#e1bee7;color:#6a1b9a;',
'tag-orange': 'background:#ffe0b2;color:#e65100;',
'tag-teal': 'background:#b2dfdb;color:#004d40;',
'tag-pink': 'background:#f8bbd0;color:#880e4f;',
'tag-gray': 'background:#e0e0e0;color:#424242;',
'tag-amber': 'background:#ffecb3;color:#ff6f00;',
};
function extractMetaDescription(html) {
const match = String(html ?? '').match(
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
);
return match?.[1]?.trim() ?? '';
}
function stripScripts(html) {
return String(html ?? '').replace(/<script[\s\S]*?<\/script>/gi, '');
}
function sanitizeInlineHtml(html) {
return stripScripts(String(html ?? ''))
.replace(/<(?!\/?(a\b|br\b|strong\b|span\b|b\b|em\b|i\b)\b)[^>]+>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '')
.trim();
}
function extractInnerHtml(block, pattern) {
const match = String(block ?? '').match(pattern);
return match?.[1] ? sanitizeInlineHtml(match[1]) : '';
}
function detectCardVariant(cardHtml) {
if (/highlight-box-orange/u.test(cardHtml)) return 'highlight-box-orange';
if (/highlight-box-gold/u.test(cardHtml)) return 'highlight-box-gold';
if (/highlight-box-teal/u.test(cardHtml)) return 'highlight-box-teal';
if (/highlight-box/u.test(cardHtml)) return 'highlight-box';
return 'default';
}
function renderTagHtml(cardHtml, styles) {
const tagMatch = String(cardHtml).match(/<span class="tag([^"]*)"[^>]*(?:style="([^"]*)")?[^>]*>([\s\S]*?)<\/span>/i);
if (!tagMatch) return '';
const className = tagMatch[1]?.trim() ?? '';
const inlineStyle = tagMatch[2]?.trim() ?? '';
const label = sanitizeInlineHtml(tagMatch[3]);
if (!label) return '';
const colorStyle = TAG_CLASS_COLORS[className.replace(/^\s+/, '')] ?? '';
const style = inlineStyle || colorStyle || styles.tag;
return `<span style="${style}">${label}</span>`;
}
function renderCard(cardHtml) {
const variant = detectCardVariant(cardHtml);
const styles = CARD_VARIANTS[variant] ?? CARD_VARIANTS.default;
const tag = renderTagHtml(cardHtml, styles);
const title = extractInnerHtml(cardHtml, /<h3[^>]*>([\s\S]*?)<\/h3>/i);
const body = extractInnerHtml(cardHtml, /<p[^>]*>([\s\S]*?)<\/p>/i);
const sourceRaw = extractInnerHtml(cardHtml, /<div class="source"[^>]*>([\s\S]*?)<\/div>/i);
const source = sourceRaw.replace(/^来源:?/u, '').trim();
if (!title) return '';
const parts = [
`<section style="${styles.wrap}">`,
tag ? `<p style="margin:0 0 6px;">${tag}</p>` : '',
`<p style="${styles.title}"><strong>${title}</strong></p>`,
body ? `<p style="${styles.body}">${body}</p>` : '',
source ? `<p style="${styles.source}">来源:${source}</p>` : '',
'</section>',
];
return parts.filter(Boolean).join('');
}
function renderSectionTitle(titleHtml) {
const text = sanitizeInlineHtml(titleHtml).replace(/<span class="icon"[^>]*>([\s\S]*?)<\/span>/gi, '$1');
if (!text) return '';
return `<p style="margin:0 0 12px;padding:12px 0 6px;border-bottom:3px solid #0d47a1;font-size:18px;font-weight:700;color:#b71c1c;line-height:1.4;">${text}</p>`;
}
function renderHero(html) {
const title = sanitizeInlineHtml(String(html).match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1] ?? '') || '📰 每日新闻早报';
const dateBadge = sanitizeInlineHtml(String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
const subtitle = sanitizeInlineHtml(String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
return [
'<section style="text-align:center;padding:24px 14px;background:linear-gradient(135deg,#b71c1c,#d32f2f 50%,#0d47a1);color:#fff;margin:0 0 12px;border-radius:12px;">',
`<p style="margin:0 0 10px;font-size:24px;font-weight:700;line-height:1.35;">${title}</p>`,
dateBadge ? `<p style="margin:0 0 8px;font-size:14px;opacity:.92;">${dateBadge}</p>` : '',
subtitle ? `<p style="margin:0;font-size:14px;opacity:.88;line-height:1.7;">${subtitle}</p>` : '',
'</section>',
].filter(Boolean).join('');
}
function renderReadOriginalTop(publicUrl) {
if (!publicUrl) return '';
return [
'<section style="margin:0 0 16px;text-align:center;">',
`<a href="${publicUrl}" style="display:inline-block;padding:11px 22px;background:linear-gradient(135deg,#b71c1c,#d32f2f);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:999px;letter-spacing:.02em;box-shadow:0 3px 10px rgba(183,28,28,.22);">📖 点击阅读原文</a>`,
'<p style="margin:8px 0 0;font-size:12px;color:#a0aec0;line-height:1.6;">查看完整排版、图片与更多栏目</p>',
'</section>',
].join('');
}
function renderCreatePortalCTA(portalUrl = DEFAULT_PORTAL_URL) {
return `<p style="margin:18px 0 0;font-size:14px;color:#718096;text-align:center;line-height:1.7;">✨ 一起创作 → <a href="${portalUrl}" style="color:#e53935;font-weight:600;text-decoration:underline;">点我</a></p>`;
}
function renderFollowMpSection(qrcodeImageUrl = '') {
if (qrcodeImageUrl) {
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>',
'<p style="margin:0 0 14px;font-size:13px;color:#666;line-height:1.7;">长按扫描关注,每日早报不错过</p>',
`<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:168px;height:168px;display:block;margin:0 auto;border-radius:10px;border:1px solid #eee;" />`,
'<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">长按扫描关注</p>',
'</section>',
].join('');
}
return [
'<section style="text-align:center;padding:20px 16px;margin:18px 0 8px;background:#fafafa;border:1px dashed #e0e0e0;border-radius:12px;">',
'<p style="margin:0 0 6px;font-size:15px;font-weight:600;color:#333;">📣 关注 TKMind 服务号</p>',
'<p style="margin:0;font-size:13px;color:#888;line-height:1.7;">推送草稿时将嵌入公众号二维码</p>',
'</section>',
].join('');
}
function renderWeatherSection(sectionHtml) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
const summary = extractInnerHtml(sectionHtml, /<div class="weather-today"[^>]*>([\s\S]*?)<\/div>/i);
const cityCards = [...String(sectionHtml).matchAll(/<div class="weather-card[^"]*"[^>]*>[\s\S]*?<span class="w-city">([\s\S]*?)<\/span>[\s\S]*?<span class="w-cond">([\s\S]*?)<\/span>[\s\S]*?<span class="w-temp">([\s\S]*?)<\/span>/gi)]
.map((item) => `${sanitizeInlineHtml(item[1])}${sanitizeInlineHtml(item[2])} ${sanitizeInlineHtml(item[3])}`)
.slice(0, 12);
const footer = extractInnerHtml(sectionHtml, /<p style="font-size:12px;color:#a0aec0;">([\s\S]*?)<\/p>/i);
const parts = [renderSectionTitle(title)];
parts.push('<section style="background:linear-gradient(135deg,#e3f2fd,#bbdefb);border:1px solid #90caf9;border-radius:12px;padding:14px 16px;margin-bottom:12px;">');
if (summary) {
parts.push(`<p style="margin:0 0 8px;font-size:14px;color:#0d47a1;line-height:1.75;">${summary.replace(/<div[^>]*>/gi, ' ').replace(/<\/div>/gi, ' ')}</p>`);
}
if (cityCards.length > 0) {
parts.push(`<p style="margin:0;font-size:13px;color:#1565c0;line-height:1.8;">${cityCards.join(' · ')}</p>`);
}
parts.push('</section>');
if (footer) {
parts.push(`<p style="margin:0 0 16px;font-size:12px;color:#a0aec0;line-height:1.6;">${footer}</p>`);
}
return parts.join('');
}
function splitSections(html) {
const body = String(html ?? '').match(/<body>([\s\S]*?)<\/body>/i)?.[1] ?? String(html ?? '');
return body
.split(/<div class="section"[^>]*>/i)
.slice(1)
.map((chunk) => {
const id = chunk.match(/^[^>]*id="([^"]+)"/i)?.[1] ?? '';
const htmlChunk = `<div class="section"${chunk.split(/<div class="section"|<div class="footer"/i)[0]}`;
const title = stripInlineText(extractInnerHtml(htmlChunk, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i));
return { id, title, html: htmlChunk };
});
}
function stripInlineText(value) {
return sanitizeInlineHtml(String(value ?? ''))
.replace(/<span class="icon"[^>]*>([\s\S]*?)<\/span>/gi, '$1')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
const DEFAULT_SECTION_CARD_LIMITS = {
headlines: 6,
world: 3,
domestic: 3,
google: 2,
finance: 3,
tech: 3,
sports: 2,
technews: 2,
hot: 1,
};
const TRIM_PROFILES = [
{ skipHistory: true, sectionCardLimits: null, maxBodyChars: null },
{
skipHistory: true,
sectionCardLimits: DEFAULT_SECTION_CARD_LIMITS,
maxBodyChars: 120,
},
{
skipHistory: true,
sectionCardLimits: { ...DEFAULT_SECTION_CARD_LIMITS, headlines: 5, world: 2, domestic: 2 },
maxBodyChars: 90,
},
{
skipHistory: true,
sectionCardLimits: { headlines: 4, world: 2, domestic: 2, google: 2, finance: 2, tech: 2, sports: 1, technews: 1, hot: 1 },
maxBodyChars: 72,
},
{
skipHistory: true,
sectionCardLimits: { headlines: 3, world: 2, domestic: 2, google: 2, finance: 2, tech: 2, sports: 1, technews: 0, hot: 1 },
maxBodyChars: 64,
},
{
skipHistory: true,
sectionCardLimits: { headlines: 3, world: 2, domestic: 2, google: 1, finance: 2, tech: 2, sports: 1, technews: 0, hot: 0 },
maxBodyChars: 56,
},
];
function buildFallbackTrimProfiles() {
const profiles = [];
for (let headlines = 3; headlines >= 2; headlines -= 1) {
for (let maxBodyChars = 56; maxBodyChars >= 24; maxBodyChars -= 8) {
for (const skipWeather of [false, true]) {
profiles.push({
skipHistory: true,
skipWeather,
sectionCardLimits: {
headlines,
world: 2,
domestic: 2,
google: 1,
finance: 2,
tech: 2,
sports: 1,
technews: 0,
hot: 0,
},
maxBodyChars,
});
}
}
}
return profiles;
}
function resolveSectionCardLimit(section, sectionCardLimits) {
if (!sectionCardLimits) return null;
return sectionCardLimits[section.id] ?? 3;
}
function estimateQrcodeUrlForLayout(qrcodeImageUrl = '') {
const url = String(qrcodeImageUrl ?? '');
if (!url) return '';
if (url.length >= 180) return url;
return `${url}${'x'.repeat(180 - url.length)}`;
}
function estimateFixedLayoutLength({ publicUrl = '', qrcodeImageUrl = '', portalUrl = DEFAULT_PORTAL_URL } = {}) {
const qrcodeForEstimate = estimateQrcodeUrlForLayout(qrcodeImageUrl);
return (
(publicUrl ? renderReadOriginalTop(publicUrl).length : 0)
+ renderFollowMpSection(qrcodeForEstimate).length
+ renderCreatePortalCTA(portalUrl).length
);
}
function buildWechatInlineContent(html, {
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
trimProfile = TRIM_PROFILES[0],
includeFixedLayout = true,
} = {}) {
const sections = splitSections(html);
const bodyParts = [renderHero(html)];
for (const section of sections) {
if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) {
continue;
}
if (resolveSectionCardLimit(section, trimProfile.sectionCardLimits) === 0) {
continue;
}
if (section.id === 'weather') {
if (!trimProfile.skipWeather) {
bodyParts.push(renderWeatherSection(section.html));
}
continue;
}
bodyParts.push(renderGenericSection(section.html, {
maxCards: resolveSectionCardLimit(section, trimProfile.sectionCardLimits),
maxBodyChars: trimProfile.maxBodyChars,
}));
}
bodyParts.push(renderFooter(html));
const ordered = [...bodyParts];
if (includeFixedLayout) {
if (publicUrl) ordered.splice(1, 0, renderReadOriginalTop(publicUrl));
ordered.push(renderFollowMpSection(qrcodeImageUrl));
ordered.push(renderCreatePortalCTA(portalUrl));
}
return {
content: ordered.join(''),
sectionCount: sections.length,
renderedSectionCount: sections.filter((section) => {
if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) return false;
if (resolveSectionCardLimit(section, trimProfile.sectionCardLimits) === 0) return false;
return true;
}).length,
};
}
function truncatePlainText(value, maxChars) {
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!maxChars || text.length <= maxChars) return text;
return `${text.slice(0, Math.max(0, maxChars - 1))}`;
}
function shouldSkipSection(section, trimProfile) {
if (!section) return true;
if (['history', 'knowledge'].includes(section.id)) return true;
if (trimProfile?.skipSectionIds?.includes(section.id)) return true;
return /历史上的今天|近7天新闻早报/u.test(section.title);
}
function renderGenericSection(sectionHtml, { maxCards = null, maxBodyChars = null } = {}) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
if (/近7天新闻早报/u.test(stripInlineText(title))) return '';
const cards = sectionHtml.split(/<div class="card(?:\s|")/i).slice(1);
const parts = [renderSectionTitle(title)];
const limit = maxCards == null ? cards.length : Math.min(maxCards, cards.length);
for (let index = 0; index < limit; index += 1) {
let cardChunk = cards[index];
if (maxBodyChars) {
cardChunk = cardChunk.replace(/<p[^>]*>([\s\S]*?)<\/p>/i, (match, body) => {
const plain = body.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
return match.replace(body, truncatePlainText(plain, maxBodyChars));
});
}
const rendered = renderCard(`<div class="card ${cardChunk}`);
if (rendered) parts.push(rendered);
}
return parts.join('');
}
function renderFooter(html) {
const footer = String(html).match(/<div class="footer"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '';
if (!footer) return '';
const lines = [...footer.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
.map((item) => sanitizeInlineHtml(item[1]))
.filter(Boolean)
.slice(0, 3);
if (lines.length === 0) return '';
return [
'<section style="text-align:center;padding:20px 12px 8px;color:#a0aec0;">',
...lines.map((line) => `<p style="margin:0 0 8px;font-size:13px;line-height:1.7;color:#718096;">${line}</p>`),
'</section>',
].join('');
}
export function convertDailyNewsHtmlToWechatInlineArticle(html, {
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
} = {}) {
const title = (extractPageTitle(html) || '每日新闻早报').slice(0, 32);
const digest = (extractMetaDescription(html) || title).slice(0, 120);
let built = null;
let trimLevel = 0;
let usedFallback = false;
const allProfiles = [...TRIM_PROFILES, ...buildFallbackTrimProfiles()];
const fixedLayoutLength = estimateFixedLayoutLength({ publicUrl, qrcodeImageUrl, portalUrl });
const trimTargetChars = Math.max(12000, MAX_WECHAT_CONTENT_CHARS - fixedLayoutLength - 800);
for (const [index, trimProfile] of allProfiles.entries()) {
built = buildWechatInlineContent(html, {
publicUrl,
qrcodeImageUrl,
portalUrl,
trimProfile,
includeFixedLayout: false,
});
trimLevel = index;
usedFallback = index >= TRIM_PROFILES.length;
if (built.content.length <= trimTargetChars) {
break;
}
}
let selectedProfile = allProfiles[trimLevel] ?? allProfiles[allProfiles.length - 1];
if (built) {
built = buildWechatInlineContent(html, {
publicUrl,
qrcodeImageUrl,
portalUrl,
trimProfile: selectedProfile,
includeFixedLayout: true,
});
let tightenGuard = 0;
while (built.content.length > MAX_WECHAT_CONTENT_CHARS && tightenGuard < 24) {
tightenGuard += 1;
const limits = {
world: 2,
domestic: 2,
google: 1,
tech: 2,
sports: 1,
technews: 0,
hot: 0,
headlines: selectedProfile.sectionCardLimits?.headlines ?? 3,
finance: selectedProfile.sectionCardLimits?.finance ?? 2,
};
const maxBody = selectedProfile.maxBodyChars ?? 64;
if (maxBody > 16) {
selectedProfile.maxBodyChars = maxBody - 4;
} else if (limits.headlines > 2) {
limits.headlines -= 1;
} else if (limits.finance > 1) {
limits.finance -= 1;
} else {
break;
}
selectedProfile = {
...selectedProfile,
skipHistory: true,
skipWeather: true,
maxBodyChars: selectedProfile.maxBodyChars ?? maxBody,
sectionCardLimits: limits,
};
built = buildWechatInlineContent(html, {
publicUrl,
qrcodeImageUrl,
portalUrl,
trimProfile: selectedProfile,
includeFixedLayout: true,
});
usedFallback = true;
}
}
if (!built || built.content.length > MAX_WECHAT_CONTENT_CHARS) {
throw new Error(
`微信正文超过 ${MAX_WECHAT_CONTENT_CHARS} 字符(当前 ${built?.content.length ?? 0}),请缩短 HTML 页面后重试`,
);
}
return {
title,
digest,
content: built.content,
contentSourceUrl: publicUrl || undefined,
contentMode: 'inline_html',
contentLength: built.content.length,
sectionCount: built.sectionCount,
renderedSectionCount: built.renderedSectionCount,
trimLevel,
trimmed: trimLevel > 0,
usedFallbackTrim: usedFallback,
};
}
export const wechatDailyNewsInlineInternals = {
renderCard,
renderHero,
buildWechatInlineContent,
TRIM_PROFILES,
MAX_WECHAT_CONTENT_CHARS,
};
+844
View File
@@ -0,0 +1,844 @@
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import sharp from 'sharp';
import {
buildPublicUrl,
PUBLISH_ROOT_DIR,
PUBLIC_ZONE_DIR,
resolvePublicBaseUrl,
} from './user-publish.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { convertDailyNewsHtmlToWechatInlineArticle } from './wechat-daily-news-inline.mjs';
const CONFIG_TABLE = 'h5_wechat_admin_config';
const CONFIG_KEY = 'news_morning_draft';
const RUNS_TABLE = 'h5_wechat_news_draft_runs';
const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
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-10';
/** 0910 新闻早报页面结构说明,供定时任务 taskSpec 与草稿转换共用。 */
export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
'生成「每日新闻早报」静态 HTML 页面,版式对齐 2026-09-10 生产版(daily-news-MMDD):',
'1. Hero 渐变区:主标题「📰 每日新闻早报」、date-badge、subtitle 导语;',
'2. quick-links 锚点导航(要闻/天气/国际/国内/财经/科技/体育等);',
'3. 多个 sectionsection-title + cardtag、h3 标题、p 正文、source 来源);',
'4. 可选 weather、knowledge、history 等区块;',
'5. footer 品牌;',
'6. 文件名 daily-news-MMDD.html(如 daily-news-0910.html),并生成同名 .thumbnail.png',
'7. 推送微信草稿时转为内联 style HTML(参考公众号排版),禁止整页长图;正文需控制在 2 万字符内。',
].join('\n');
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function parseConfigJson(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
return JSON.parse(String(value));
} catch {
return {};
}
}
function clampHour(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(23, Math.max(0, Math.floor(num)));
}
function clampMinute(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(59, Math.max(0, Math.floor(num)));
}
function slugPatternToRegExp(pattern) {
const raw = String(pattern ?? '').trim() || 'daily-news-*';
const escaped = raw.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}\\.html$`, 'i');
}
function formatShanghaiDateParts(date = new Date()) {
const formatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = formatter.formatToParts(date);
const year = parts.find((item) => item.type === 'year')?.value ?? '';
const month = parts.find((item) => item.type === 'month')?.value ?? '';
const day = parts.find((item) => item.type === 'day')?.value ?? '';
return {
year,
month,
day,
iso: `${year}-${month}-${day}`,
mmdd: `${month}${day}`,
compact: `${year}${month}${day}`,
};
}
function isDailyNewsFormat(html) {
return /daily-news|每日新闻早报/u.test(String(html ?? ''))
|| /class="date-badge"/u.test(String(html ?? ''));
}
function extractMetaDescription(html) {
const match = String(html ?? '').match(
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
);
return match?.[1]?.trim() ?? '';
}
function stripHtml(value) {
return String(value ?? '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function extractCardArticlesFromHotspots(html) {
const source = String(html ?? '');
const parts = source.split(/<div class="card"[^>]*>/i).slice(1);
const cards = parts.map((chunk) => chunk.split(/<div class="(?:trending|note|foot)"/i)[0] ?? chunk);
return cards.map((block, index) => {
const title = stripHtml(block.match(/<h2[^>]*>([\s\S]*?)<\/h2>/i)?.[1] ?? '');
const tag = stripHtml(block.match(/<span class="tag"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
const keys = [...block.matchAll(/<div class="k"[^>]*>([\s\S]*?)<\/div>/gi)]
.map((item) => stripHtml(item[1]))
.filter(Boolean);
const context = stripHtml(block.match(/<div class="ctx"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
const sourceLine = stripHtml(block.match(/<div class="src"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
return { index: index + 1, title, tag, keys, context, source: sourceLine };
}).filter((item) => item.title);
}
function extractCardArticlesFromDailyNews(html) {
const source = String(html ?? '');
const parts = source.split(/<div class="card(?:\s|")/i).slice(1);
return parts.map((block, index) => {
const title = stripHtml(
block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/i)?.[1]
?? block.match(/<h2[^>]*>([\s\S]*?)<\/h2>/i)?.[1]
?? '',
);
const tag = stripHtml(block.match(/<span class="tag"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
const paragraphs = [...block.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
.map((item) => stripHtml(item[1]))
.filter(Boolean);
const context = paragraphs[0] ?? '';
const sourceLine = stripHtml(block.match(/<div class="source"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
return { index: index + 1, title, tag, keys: [], context, source: sourceLine };
}).filter((item) => item.title);
}
function extractCardArticles(html) {
if (isDailyNewsFormat(html)) return extractCardArticlesFromDailyNews(html);
return extractCardArticlesFromHotspots(html);
}
function extractDailyNewsSections(html) {
return [...String(html ?? '').matchAll(/<div class="section-title"[^>]*>([\s\S]*?)<\/div>/gi)]
.map((item) => stripHtml(item[1]))
.filter(Boolean);
}
function extractStats(html) {
return [...String(html ?? '').matchAll(/<div class="stat[^"]*"[^>]*><b>([\s\S]*?)<\/b><span>([\s\S]*?)<\/span><\/div>/gi)]
.map((item) => ({ value: stripHtml(item[1]), label: stripHtml(item[2]) }))
.filter((item) => item.value);
}
function extractTrendingSummary(html) {
const block = String(html ?? '').match(/<div class="trending"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '';
const chips = [...block.matchAll(/<span class="chip"[^>]*>([\s\S]*?)<\/span>/gi)]
.map((item) => stripHtml(item[1]))
.filter(Boolean);
const paragraph = stripHtml(block.match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
return { chips, paragraph };
}
export function resolveDailyNewsPublicBaseUrl(env = process.env) {
return resolvePublicBaseUrl({
...env,
H5_PUBLIC_BASE_URL:
env.MEMIND_PAGE_PUBLIC_BASE_URL
|| env.H5_PORTAL_PUBLIC_BASE_URL
|| 'https://m.tkmind.cn',
});
}
export function rewriteDailyNewsHtmlLinks(html, { publicBaseUrl, userId }) {
const base = String(publicBaseUrl ?? '').replace(/\/$/, '');
const owner = String(userId ?? '').trim();
if (!base || !owner) return String(html ?? '');
const publicPrefix = `${base}/MindSpace/${encodeURIComponent(owner)}/public/`;
return String(html ?? '')
.replace(/\shref=(["'])(?!https?:|#|mailto:|tel:)([^"']+)\1/gi, (match, quote, href) => {
const target = href.startsWith('/') ? `${base}${href}` : `${publicPrefix}${href.replace(/^\.\//, '')}`;
return ` href=${quote}${target}${quote}`;
});
}
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}`;
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
if (publicUrl) {
content += `<p style="margin:16px 0 0;font-size:13px;color:#718096;text-align:center;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`;
}
return content;
}
export function extractDailyNewsArticleMeta(html) {
const title = extractPageTitle(html) || '每日新闻早报';
const digest = (extractMetaDescription(html) || title).slice(0, 120);
return { title: title.slice(0, 64), digest };
}
export function resolveMpFollowQrcodePath(memindLibRoot = process.cwd()) {
const candidates = [
path.join(memindLibRoot, 'wechat/assets/mp-follow-qrcode.png'),
path.join(process.cwd(), 'wechat/assets/mp-follow-qrcode.png'),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
export async function uploadWechatArticleContentImage(
accessToken,
imageBuffer,
{ wechatFetch = undiciFetch, uploadUrl = DEFAULT_UPLOAD_IMG_URL, filename = 'content.png' } = {},
) {
if (!accessToken) throw new Error('缺少微信 access_token');
if (!Buffer.isBuffer(imageBuffer) || imageBuffer.length === 0) {
throw new Error('正文图片为空');
}
const form = new FormData();
form.append(
'media',
new Blob([imageBuffer], { type: 'image/png' }),
filename,
);
const endpoint = new URL(uploadUrl);
endpoint.searchParams.set('access_token', accessToken);
const payload = await readJsonResponse(
await wechatFetch(endpoint.toString(), { method: 'POST', body: form }),
);
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.url ?? '').trim()) {
throw new Error(payload?.errmsg || '上传微信正文图片失败');
}
return String(payload.url);
}
export function buildDailyNewsWechatDraftArticle({
html,
publicUrl,
qrcodeImageUrl = '',
} = {}) {
const article = convertDailyNewsHtmlToWechatInlineArticle(html, { publicUrl, qrcodeImageUrl });
return {
...article,
cardCount: extractCardArticles(html).length,
statsCount: extractStats(html).length,
};
}
export async function buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl,
accessToken = null,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
let qrcodeImageUrl = '';
if (accessToken) {
const qrcodePath = resolveMpFollowQrcodePath(memindLibRoot);
if (qrcodePath) {
qrcodeImageUrl = await uploadWechatArticleContentImage(
accessToken,
fs.readFileSync(qrcodePath),
{ wechatFetch, filename: 'mp-follow-qrcode.png' },
);
}
}
return buildDailyNewsWechatDraftArticle({ html, publicUrl, qrcodeImageUrl });
}
export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '' } = {}) {
const dailyNews = isDailyNewsFormat(html);
if (dailyNews) {
return buildDailyNewsWechatDraftArticle({ html, publicUrl });
}
const title = extractPageTitle(html) || '今日新闻热点分析';
const digest = (extractMetaDescription(html) || title).slice(0, 120);
const heroDate = dailyNews
? stripHtml(String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '')
: stripHtml(String(html).match(/<span class="date"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
const heroSub = dailyNews
? stripHtml(String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '')
: stripHtml(String(html).match(/<p class="sub"[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
const stats = extractStats(html);
const cards = extractCardArticles(html).slice(0, dailyNews ? 12 : undefined);
const trending = extractTrendingSummary(html);
const sectionTitles = dailyNews ? extractDailyNewsSections(html) : [];
const sections = [];
sections.push(`<p style="margin:0 0 12px;font-size:15px;color:#333;">${heroDate || title}</p>`);
if (heroSub) {
sections.push(`<p style="margin:0 0 16px;font-size:14px;color:#666;line-height:1.8;">${heroSub}</p>`);
}
if (sectionTitles.length > 0) {
sections.push(
`<p style="margin:0 0 16px;font-size:13px;color:#888;">栏目:${sectionTitles.slice(0, 8).join(' · ')}</p>`,
);
}
if (stats.length > 0) {
const statLine = stats.map((item) => `${item.value} ${item.label}`).join(' · ');
sections.push(`<p style="margin:0 0 18px;font-size:14px;color:#444;"><strong>速览:</strong>${statLine}</p>`);
}
for (const card of cards) {
const tagPrefix = card.tag ? `[${card.tag}] ` : '';
sections.push(`<section style="margin:0 0 20px;">`);
sections.push(`<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#111;">${card.index}. ${tagPrefix}${card.title}</p>`);
if (card.keys.length > 0) {
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#444;line-height:1.8;">${card.keys.join(' ')}</p>`);
}
if (card.context) {
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#555;line-height:1.9;">${card.context}</p>`);
}
if (card.source) {
sections.push(`<p style="margin:0;font-size:12px;color:#888;">${card.source}</p>`);
}
sections.push('</section>');
}
if (trending.chips.length > 0 || trending.paragraph) {
sections.push('<section style="margin:0 0 16px;">');
sections.push('<p style="margin:0 0 8px;font-size:15px;font-weight:700;color:#111;">社交平台热榜</p>');
if (trending.chips.length > 0) {
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#444;">${trending.chips.join(' · ')}</p>`);
}
if (trending.paragraph) {
sections.push(`<p style="margin:0;font-size:14px;color:#555;line-height:1.8;">${trending.paragraph}</p>`);
}
sections.push('</section>');
}
if (publicUrl) {
sections.push(
`<p style="margin:16px 0 0;font-size:13px;color:#888;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`,
);
}
return {
title: title.slice(0, 64),
digest,
content: sections.join('\n'),
contentSourceUrl: publicUrl || undefined,
cardCount: cards.length,
statsCount: stats.length,
};
}
export function findLatestNewsMorningPage({
h5Root,
userId,
slugPattern = 'news-hotspots-*',
date = new Date(),
} = {}) {
if (!h5Root || !userId) throw new Error('缺少 h5Root 或 userId');
const publicDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR);
if (!fs.existsSync(publicDir)) {
return null;
}
const matcher = slugPatternToRegExp(slugPattern);
const { iso, mmdd, compact } = formatShanghaiDateParts(date);
const datedCandidates = [];
const fallbackCandidates = [];
for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) {
if (!entry.isFile() || !matcher.test(entry.name)) continue;
const fullPath = path.join(publicDir, entry.name);
const stat = fs.statSync(fullPath);
const item = {
slug: entry.name.replace(/\.html$/i, ''),
relativePath: `${PUBLIC_ZONE_DIR}/${entry.name}`,
localPath: fullPath,
modifiedAt: stat.mtimeMs,
};
const slug = entry.name.replace(/\.html$/i, '');
if (
slug.endsWith(`-${mmdd}`)
|| entry.name.includes(iso)
|| entry.name.includes(compact)
|| entry.name.includes(iso.replace(/-/g, ''))
) {
datedCandidates.push(item);
} else {
fallbackCandidates.push(item);
}
}
const pool = datedCandidates.length > 0 ? datedCandidates : fallbackCandidates;
if (pool.length === 0) return null;
const slugRank = (slug) => {
const match = String(slug).match(/(\d{4})(?!.*\d{4})/) ?? String(slug).match(/(\d{4})/);
return match ? Number(match[1]) : 0;
};
pool.sort((a, b) => {
const rankDiff = slugRank(b.slug) - slugRank(a.slug);
if (rankDiff !== 0) return rankDiff;
return b.modifiedAt - a.modifiedAt;
});
const selected = pool[0];
const thumbPath = `${selected.localPath.replace(/\.html$/i, '')}.thumbnail.png`;
return {
...selected,
thumbPath: fs.existsSync(thumbPath) ? thumbPath : null,
};
}
async function readJsonResponse(response) {
const text = await response.text().catch(() => '');
if (!response.ok) {
throw new Error(text || `upstream ${response.status}`);
}
return text ? JSON.parse(text) : null;
}
async function normalizeThumbBuffer(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw new Error('封面图为空');
}
const attempts = [
{ width: 900, quality: 82 },
{ width: 720, quality: 70 },
{ width: 540, quality: 58 },
{ width: 420, quality: 48 },
];
for (const attempt of attempts) {
const normalized = await sharp(buffer, { sequentialRead: true })
.rotate()
.resize({ width: attempt.width, height: attempt.width, fit: 'cover' })
.jpeg({ quality: attempt.quality, mozjpeg: true })
.toBuffer();
if (normalized.length <= MAX_THUMB_BYTES) {
return { buffer: normalized, contentType: 'image/jpeg', filename: 'news-thumb.jpg' };
}
}
throw new Error(`封面图压缩后仍超过微信 thumb 限制(${MAX_THUMB_BYTES} bytes`);
}
export async function uploadWechatPermanentThumb(accessToken, imageBuffer, { wechatFetch = undiciFetch, uploadUrl = DEFAULT_MATERIAL_ADD_URL } = {}) {
if (!accessToken) throw new Error('缺少微信 access_token');
const normalized = await normalizeThumbBuffer(imageBuffer);
const form = new FormData();
form.append(
'media',
new Blob([normalized.buffer], { type: normalized.contentType }),
normalized.filename,
);
const endpoint = new URL(uploadUrl);
endpoint.searchParams.set('access_token', accessToken);
endpoint.searchParams.set('type', 'thumb');
const payload = await readJsonResponse(
await wechatFetch(endpoint.toString(), { method: 'POST', body: form }),
);
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) {
throw new Error(payload?.errmsg || '上传微信封面素材失败');
}
return String(payload.media_id);
}
export async function addWechatDraftArticle(accessToken, article, { wechatFetch = undiciFetch, draftAddUrl = DEFAULT_DRAFT_ADD_URL } = {}) {
if (!accessToken) throw new Error('缺少微信 access_token');
const endpoint = new URL(draftAddUrl);
endpoint.searchParams.set('access_token', accessToken);
const payload = await readJsonResponse(
await wechatFetch(endpoint.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ articles: [article] }),
}),
);
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) {
throw new Error(payload?.errmsg || '写入微信草稿箱失败');
}
return {
draftMediaId: String(payload.media_id),
raw: payload,
};
}
async function ensureConfigTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
config_key VARCHAR(64) PRIMARY KEY,
config_json JSON NOT NULL,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function ensureRunsTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${RUNS_TABLE} (
id CHAR(36) PRIMARY KEY,
status VARCHAR(16) NOT NULL,
page_slug VARCHAR(255) NULL,
page_url VARCHAR(512) NULL,
draft_media_id VARCHAR(128) NULL,
error_message TEXT NULL,
triggered_by CHAR(36) NULL,
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
function defaultsFromEnv(env = process.env) {
return {
enabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED, false),
autoPushEnabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO, false),
pushHour: clampHour(env.H5_WECHAT_NEWS_MORNING_DRAFT_HOUR, 6),
pushMinute: clampMinute(env.H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE, 0),
timezone: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_TZ ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai',
sourceUserId: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID ?? '').trim() || null,
pageSlugPattern: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_SLUG ?? 'daily-news-*').trim() || 'daily-news-*',
author: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTHOR ?? 'TKMind').trim() || 'TKMind',
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
}
export function createWechatNewsMorningDraftService(
pool,
{
mpConfig = null,
h5Root = process.cwd(),
memindLibRoot = h5Root,
env = process.env,
wechatFetch = undiciFetch,
} = {},
) {
let ensurePromise = null;
let accessTokenCache = { token: '', expiresAt: 0 };
async function ensureReady() {
if (!ensurePromise) {
ensurePromise = (async () => {
await ensureConfigTable(pool);
await ensureRunsTable(pool);
})();
}
await ensurePromise;
}
async function readConfigRow() {
await ensureReady();
const [rows] = await pool.query(
`SELECT config_json, updated_by, updated_at
FROM ${CONFIG_TABLE}
WHERE config_key = ?
LIMIT 1`,
[CONFIG_KEY],
);
return rows[0] ?? null;
}
function mergeConfig(row) {
const defaults = defaultsFromEnv(env);
const stored = parseConfigJson(row?.config_json);
return {
enabled: normalizeBoolean(stored.enabled, defaults.enabled),
autoPushEnabled: normalizeBoolean(stored.autoPushEnabled, defaults.autoPushEnabled),
pushHour: clampHour(stored.pushHour, defaults.pushHour),
pushMinute: clampMinute(stored.pushMinute, defaults.pushMinute),
timezone: String(stored.timezone ?? defaults.timezone).trim() || defaults.timezone,
sourceUserId: String(stored.sourceUserId ?? defaults.sourceUserId ?? '').trim() || null,
pageSlugPattern: String(stored.pageSlugPattern ?? defaults.pageSlugPattern).trim() || defaults.pageSlugPattern,
author: String(stored.author ?? defaults.author).trim().slice(0, 8) || defaults.author,
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
}
async function getAccessToken() {
if (!mpConfig?.enabled) throw new Error('微信服务号未启用');
if (accessTokenCache.token && accessTokenCache.expiresAt > Date.now() + 60_000) {
return accessTokenCache.token;
}
const payload = await readJsonResponse(
await wechatFetch(mpConfig.tokenUrl || DEFAULT_WECHAT_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credential',
appid: mpConfig.appId,
secret: mpConfig.appSecret,
force_refresh: false,
}),
}),
);
if (!payload?.access_token) {
throw new Error(payload?.errmsg || '获取微信 access_token 失败');
}
accessTokenCache = {
token: payload.access_token,
expiresAt: Date.now() + Number(payload.expires_in ?? 7200) * 1000,
};
return accessTokenCache.token;
}
async function resolvePreview(configOverride = null) {
const config = configOverride ?? mergeConfig(await readConfigRow());
if (!config.sourceUserId) {
throw new Error('请先配置新闻早报来源用户 ID');
}
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
});
if (!page) {
throw new Error(`未找到匹配 ${config.pageSlugPattern} 的新闻早报页面`);
}
const html = fs.readFileSync(page.localPath, 'utf8');
const publicBaseUrl = resolveDailyNewsPublicBaseUrl(env);
const publicUrl = buildPublicUrl(publicBaseUrl, config.sourceUserId, page.relativePath);
const article = isDailyNewsFormat(html)
? buildDailyNewsWechatDraftArticle({ html, publicUrl })
: convertNewsPageHtmlToWechatArticle(html, { publicUrl });
return {
config,
page: {
slug: page.slug,
relativePath: page.relativePath,
publicUrl,
modifiedAt: page.modifiedAt,
hasThumb: Boolean(page.thumbPath),
},
article,
template: {
version: NEWS_MORNING_TEMPLATE_VERSION,
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
},
};
}
async function recordRun({
status,
pageSlug = null,
pageUrl = null,
draftMediaId = null,
errorMessage = null,
triggeredBy = null,
}) {
await ensureReady();
const id = cryptoRandomId();
const now = Date.now();
await pool.query(
`INSERT INTO ${RUNS_TABLE}
(id, status, page_slug, page_url, draft_media_id, error_message, triggered_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[id, status, pageSlug, pageUrl, draftMediaId, errorMessage, triggeredBy, now],
);
return { id, status, pageSlug, pageUrl, draftMediaId, errorMessage, triggeredBy, createdAt: now };
}
return {
async getConfig() {
const row = await readConfigRow();
return {
...mergeConfig(row),
updatedAt: row?.updated_at ? Number(row.updated_at) : null,
updatedBy: row?.updated_by ?? null,
};
},
getTemplate() {
return {
version: NEWS_MORNING_TEMPLATE_VERSION,
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
};
},
async updateConfig(payload = {}, { updatedBy = null } = {}) {
const current = await this.getConfig();
const next = {
enabled:
payload.enabled === undefined
? current.enabled
: normalizeBoolean(payload.enabled, current.enabled),
autoPushEnabled:
payload.autoPushEnabled === undefined
? current.autoPushEnabled
: normalizeBoolean(payload.autoPushEnabled, current.autoPushEnabled),
pushHour:
payload.pushHour === undefined ? current.pushHour : clampHour(payload.pushHour, current.pushHour),
pushMinute:
payload.pushMinute === undefined
? current.pushMinute
: clampMinute(payload.pushMinute, current.pushMinute),
timezone:
payload.timezone === undefined
? current.timezone
: String(payload.timezone ?? current.timezone).trim() || current.timezone,
sourceUserId:
payload.sourceUserId === undefined
? current.sourceUserId
: String(payload.sourceUserId ?? '').trim() || null,
pageSlugPattern:
payload.pageSlugPattern === undefined
? current.pageSlugPattern
: String(payload.pageSlugPattern ?? current.pageSlugPattern).trim() || current.pageSlugPattern,
author:
payload.author === undefined
? current.author
: String(payload.author ?? current.author).trim().slice(0, 8) || current.author,
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[CONFIG_KEY, JSON.stringify(next), updatedBy, now],
);
return this.getConfig();
},
async preview() {
return resolvePreview();
},
async pushDraft({ triggeredBy = null, dryRun = false } = {}) {
const config = await this.getConfig();
if (!config.enabled) {
throw new Error('新闻早报草稿推送未启用');
}
let preview = null;
preview = await resolvePreview(config);
if (dryRun) {
return {
dryRun: true,
preview,
};
}
try {
const accessToken = await getAccessToken();
let thumbBuffer = null;
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
});
if (page?.thumbPath) {
thumbBuffer = fs.readFileSync(page.thumbPath);
} else if (page?.localPath) {
thumbBuffer = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900"><rect width="900" height="900" fill="#1a1a2e"/><text x="50%" y="50%" fill="#fff" font-size="48" text-anchor="middle" dominant-baseline="middle">今日新闻</text></svg>',
);
thumbBuffer = await sharp(thumbBuffer).png().toBuffer();
}
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch });
const html = fs.readFileSync(page.localPath, 'utf8');
const article = isDailyNewsFormat(html)
? await buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl: preview.page.publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
})
: preview.article;
const draft = await addWechatDraftArticle(
accessToken,
{
title: article.title,
author: config.author,
digest: article.digest,
content: article.content,
content_source_url: article.contentSourceUrl,
thumb_media_id: thumbMediaId,
need_open_comment: 0,
only_fans_can_comment: 0,
},
{ wechatFetch },
);
preview = {
...preview,
article,
};
const run = await recordRun({
status: 'success',
pageSlug: preview.page.slug,
pageUrl: preview.page.publicUrl,
draftMediaId: draft.draftMediaId,
triggeredBy,
});
return {
ok: true,
draftMediaId: draft.draftMediaId,
preview,
run,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const run = await recordRun({
status: 'failed',
pageSlug: preview?.page?.slug ?? null,
pageUrl: preview?.page?.publicUrl ?? null,
errorMessage: message,
triggeredBy,
});
throw Object.assign(new Error(message), { run });
}
},
async listRuns({ limit = 20 } = {}) {
await ensureReady();
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
const [rows] = await pool.query(
`SELECT id, status, page_slug AS pageSlug, page_url AS pageUrl,
draft_media_id AS draftMediaId, error_message AS errorMessage,
triggered_by AS triggeredBy, created_at AS createdAt
FROM ${RUNS_TABLE}
ORDER BY created_at DESC
LIMIT ?`,
[safeLimit],
);
return rows.map((row) => ({
...row,
createdAt: Number(row.createdAt ?? 0),
}));
},
};
}
function cryptoRandomId() {
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const wechatNewsMorningDraftInternals = {
normalizeBoolean,
defaultsFromEnv,
slugPatternToRegExp,
convertNewsPageHtmlToWechatArticle,
buildDailyNewsWechatHtmlContent,
buildDailyNewsWechatDraftArticle,
findLatestNewsMorningPage,
};
+174
View File
@@ -0,0 +1,174 @@
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 {
convertNewsPageHtmlToWechatArticle,
createWechatNewsMorningDraftService,
findLatestNewsMorningPage,
NEWS_MORNING_TEMPLATE_VERSION,
wechatNewsMorningDraftInternals,
} from './wechat-news-morning-draft.mjs';
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } from './user-publish.mjs';
const SAMPLE_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>每日新闻早报 · 2026年9月10日</title>
<meta name="description" content="2026年9月10日新闻热点摘要">
<style>.hero{background:#b71c1c;color:#fff;}</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>📰 每日新闻早报</h1>
<div class="date-badge">2026年9月10日 · 星期三</div>
<div class="subtitle">综合多家权威媒体梳理全网核心热点。</div>
</div>
<div class="stats">
<div class="stat s1"><b>4 大</b><span>核心热点</span></div>
</div>
<div class="section" id="headlines">
<div class="section-title">📰 今日要闻</div>
<div class="card">
<span class="tag tag-red">要闻</span>
<h3>示例热点</h3>
<p>这是示例正文。</p>
<div class="source">来源:示例媒体</div>
</div>
</div>
<div class="trending">
<h3>热榜</h3>
<div class="chips"><span class="chip"><span class="emoji">🔥</span>话题 A</span></div>
<p>今日热榜整体偏科技。</p>
</div>
</div>
</body>
</html>`;
function createPool(seedRow = null) {
const state = { row: seedRow, runs: [] };
return {
async query(sql, params) {
if (sql.includes('CREATE TABLE')) return [[], []];
if (sql.includes('SELECT config_json')) return [state.row ? [state.row] : [], []];
if (sql.includes('INSERT INTO h5_wechat_admin_config')) {
state.row = {
config_json: params[1],
updated_by: params[2],
updated_at: params[3],
};
return [[], []];
}
if (sql.includes('INSERT INTO h5_wechat_news_draft_runs')) {
state.runs.unshift({
id: params[0],
status: params[1],
pageSlug: params[2],
pageUrl: params[3],
draftMediaId: params[4],
errorMessage: params[5],
triggeredBy: params[6],
createdAt: params[7],
});
return [[], []];
}
if (sql.includes('FROM h5_wechat_news_draft_runs')) {
return [state.runs, []];
}
throw new Error(`Unexpected query: ${sql}`);
},
};
}
test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html', () => {
const article = convertNewsPageHtmlToWechatArticle(SAMPLE_HTML, {
publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html',
});
assert.match(article.title, /2026年9月10日/u);
assert.equal(article.contentMode, 'inline_html');
assert.match(article.content, /每日新闻早报/u);
assert.match(article.content, /示例热点/u);
assert.match(article.content, /点击阅读原文/u);
assert.match(article.content, /一起创作/u);
assert.match(article.content, /点我/u);
assert.doesNotMatch(article.content, /近7天新闻早报/u);
});
test('buildDailyNewsWechatHtmlContent preserves style and body markup', () => {
const content = wechatNewsMorningDraftInternals.buildDailyNewsWechatHtmlContent(SAMPLE_HTML, {
publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html',
publicBaseUrl: 'https://m.tkmind.cn',
userId: 'demo-user',
});
assert.match(content, /<style>/u);
assert.match(content, /每日新闻早报/u);
assert.match(content, /class="hero"/u);
assert.match(content, /示例热点/u);
});
test('findLatestNewsMorningPage prefers dated slug for today', async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'news-draft-'));
const userId = 'user-1';
const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
await fs.promises.mkdir(publicDir, { recursive: true });
const oldPath = path.join(publicDir, 'news-hotspots-2026-08-28.html');
const todayPath = path.join(publicDir, 'news-hotspots-2026-09-10.html');
await fs.promises.writeFile(oldPath, SAMPLE_HTML);
await fs.promises.writeFile(todayPath, SAMPLE_HTML);
await fs.promises.utimes(oldPath, new Date('2026-09-09'), new Date('2026-09-09'));
await fs.promises.utimes(todayPath, new Date('2026-09-10'), new Date('2026-09-10'));
const page = findLatestNewsMorningPage({
h5Root: root,
userId,
slugPattern: 'news-hotspots-*',
date: new Date('2026-09-10T01:00:00+08:00'),
});
assert.equal(page.slug, 'news-hotspots-2026-09-10');
});
test('news morning draft service persists config and records failed push', async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'news-draft-service-'));
const userId = 'user-2';
const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
await fs.promises.mkdir(publicDir, { recursive: true });
await fs.promises.writeFile(path.join(publicDir, 'daily-news-0910.html'), SAMPLE_HTML);
const service = createWechatNewsMorningDraftService(createPool(), {
mpConfig: { enabled: false },
h5Root: root,
env: {},
});
const updated = await service.updateConfig(
{
enabled: true,
sourceUserId: userId,
pushHour: 6,
pushMinute: 15,
author: 'TKMind',
},
{ updatedBy: 'admin-1' },
);
assert.equal(updated.enabled, true);
assert.equal(updated.sourceUserId, userId);
assert.equal(updated.templateVersion, NEWS_MORNING_TEMPLATE_VERSION);
const preview = await service.preview();
assert.equal(preview.page.slug, 'daily-news-0910');
assert.match(preview.template.spec, /0910|2026-09-10/u);
await assert.rejects(
() => service.pushDraft({ triggeredBy: 'admin-1' }),
/微信服务号未启用/u,
);
const runs = await service.listRuns();
assert.equal(runs[0].status, 'failed');
});
test('internals normalize booleans consistently', () => {
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('1', false), true);
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('off', true), false);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB