Files
memind/wechat-news-morning-draft.test.mjs
john 57a9aadf53
Memind CI / Test, build, and release guards (push) Successful in 4m43s
fix(news): repair instead of block in daily news generation and push
- news002 image/structure audit no longer throws: prune empty sections and
  publish a best-effort page with a warning
- WeChat push strips images that failed to upload instead of refusing the draft
- forceGenerateOnce is cleared before running, so a failed run cannot loop;
  the archived page is restored when generation fails
- degraded-page regeneration runs at most once per day
- content safety rules cover vulgar sexual terms and gossip/adult sites

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 11:29:35 +08:00

368 lines
15 KiB
JavaScript

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 {
buildDailyNewsWechatDraftArticle,
buildNewsMorningWechatPushText,
convertNewsPageHtmlToWechatArticle,
createWechatNewsMorningDraftService,
findLatestNewsMorningPage,
NEWS_MORNING_TEMPLATE_VERSION,
stripNews002ImagesByRef,
wechatNewsMorningDraftInternals,
} from './wechat-news-morning-draft.mjs';
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } from './user-publish.mjs';
test('stripNews002ImagesByRef drops only images that failed to upload and keeps card text', () => {
const html = '<div class="card"><div class="media"><img src="./a.jpg"></div><h3>甲</h3></div>'
+ '<div class="card"><div class="media"><img src="./b.jpg"></div><h3>乙</h3></div>';
const out = stripNews002ImagesByRef(html, ['./a.jpg']);
assert.doesNotMatch(out, /a\.jpg/);
assert.match(out, /b\.jpg/);
assert.match(out, /<h3>甲<\/h3>/u);
assert.equal(stripNews002ImagesByRef(html, []), html);
});
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('buildDailyNewsWechatDraftArticle keeps news002 imageless cards as text-only blocks', () => {
const html = `<!DOCTYPE html><html><body>
<div class="masthead"><h1>每日新闻早报</h1><div class="meta"><b>2026年9月22日</b></div></div>
<div class="lede">导语</div>
<div class="section" id="headlines"><div class="section-title"><h2>今日头条</h2></div>
<a class="lead-card" href="https://example.com/a" data-event-key="k1"><div class="media"><img src="https://news.example/a.jpg" alt=""></div>
<div class="body"><span class="tag">头条</span><h3>有图头条</h3><p>摘要A</p></div></a>
<a class="lead-card" href="https://example.com/b" data-event-key="k2"><div class="media ph">📰</div>
<div class="body"><span class="tag">头条</span><h3>无图头条</h3><p>摘要B</p></div></a>
</div>
<div class="section" id="domestic"><div class="section-title"><h2>国内</h2></div>
<div class="card" data-event-key="k3"><div class="body"><span class="tag">国内</span><h3>无图国内</h3><p>摘要C</p></div></div>
</div>
</body></html>`;
const article = buildDailyNewsWechatDraftArticle({
html,
publicUrl: 'https://m.tkmind.cn/demo',
imageUrlMap: new Map([['https://news.example/a.jpg', 'https://mmbiz.qpic.cn/a.jpg']]),
});
assert.ok(article.cardCount >= 3, '无图卡应保留为纯文字块');
assert.match(article.content, /有图头条/u);
assert.match(article.content, /无图头条/u);
assert.match(article.content, /无图国内/u);
assert.match(article.content, /mmbiz\.qpic\.cn\/a\.jpg/u);
});
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, /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);
assert.match(article.content, /⬇ ⬇ ⬇/u);
assert.match(article.content, /width:120px;height:120px/u);
assert.match(article.content, /mp-follow-qrcode\.png/u);
assert.match(article.content, /一起创作/u);
assert.match(article.content, /点我/u);
assert.doesNotMatch(article.content, /data-mindspace-public-share/u);
});
test('buildNewsMorningWechatPushText builds service-account digest text', () => {
const text = buildNewsMorningWechatPushText(SAMPLE_HTML, {
publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html',
});
assert.match(text, /📰 每日新闻早报 · 2026年9月10日/u);
assert.match(text, /示例热点/u);
assert.match(text, /阅读全文/u);
});
test('buildNewsMorningCoverPrompt rotates visual concepts by date and keeps subtitle hints', () => {
const html = SAMPLE_HTML.replace(
'<div class="subtitle">综合多家权威媒体梳理全网核心热点。</div>',
'<div class="subtitle">示例热点A·示例热点B·示例热点C</div>',
);
const now = Date.parse('2026-09-10T08:00:00+08:00');
const concept = wechatNewsMorningDraftInternals.resolveNewsMorningCoverConcept({
now,
timezone: 'Asia/Shanghai',
});
const prompt = wechatNewsMorningDraftInternals.buildNewsMorningCoverPrompt(html, {
now,
timezone: 'Asia/Shanghai',
});
assert.equal(concept.isoDate, '2026-09-10');
assert.match(prompt, new RegExp(`Visual concept \\(${concept.id}\\)`, 'i'));
assert.match(prompt, /Daily News Morning Brief/i);
assert.match(prompt, /#b0191e|crimson|navy/i);
assert.match(prompt, /示例热点A/u);
assert.match(prompt, /No text, no letters/i);
assert.match(prompt, /No smartphone, no mobile phone, no newspaper/i);
});
test('resolveNewsMorningCoverConcept is stable within a day and rotates across days', () => {
const timezone = 'Asia/Shanghai';
const dayA = wechatNewsMorningDraftInternals.resolveNewsMorningCoverConcept({
now: Date.parse('2026-09-10T08:00:00+08:00'),
timezone,
});
const dayARepeat = wechatNewsMorningDraftInternals.resolveNewsMorningCoverConcept({
now: Date.parse('2026-09-10T22:00:00+08:00'),
timezone,
});
const dayB = wechatNewsMorningDraftInternals.resolveNewsMorningCoverConcept({
now: Date.parse('2026-09-11T08:00:00+08:00'),
timezone,
});
assert.equal(dayA.id, dayARepeat.id);
assert.notEqual(dayA.id, dayB.id);
assert.equal(
wechatNewsMorningDraftInternals.NEWS_MORNING_COVER_CONCEPTS.length,
7,
);
});
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({ now: Date.parse('2026-09-10T08:00:00+08:00') });
assert.equal(preview.page.slug, 'daily-news-0910');
assert.match(preview.template.spec, /news002|subscriber|domestic/u);
assert.match(preview.template.spec, /禁止复制|禁止用历史 daily-news/u);
await assert.rejects(
() => service.pushDraft({
triggeredBy: 'admin-1',
now: Date.parse('2026-09-10T08:00:00+08:00'),
}),
/微信服务号未启用/u,
);
const runs = await service.listRuns();
assert.equal(runs[0].status, 'failed');
});
test('getTodayStatus and previewToday require today dated page', async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'news-draft-today-'));
const userId = 'user-today';
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);
await fs.promises.writeFile(path.join(publicDir, 'daily-news-0911.html'), SAMPLE_HTML);
const service = createWechatNewsMorningDraftService(createPool(), {
mpConfig: { enabled: false },
h5Root: root,
env: {},
});
await service.updateConfig({ sourceUserId: userId });
const now = Date.UTC(2026, 8, 10, 22, 0, 0);
const status = await service.getTodayStatus({ now });
assert.equal(status.todayPageReady, true);
assert.equal(status.page?.slug, 'daily-news-0911');
assert.equal(status.expectedSlug, 'daily-news-0911');
const preview = await service.preview({ requireToday: true, now });
assert.equal(preview.page.isTodayPage, true);
assert.equal(preview.page.slug, 'daily-news-0911');
});
test('buildNewsMorningAutoGenerationTask requires fresh daily search', () => {
const task = wechatNewsMorningDraftInternals.buildNewsMorningAutoGenerationTask(
{ sourceUserId: 'user-1', timezone: 'Asia/Shanghai' },
{ now: Date.parse('2026-09-15T05:00:00+08:00') },
);
assert.match(task.taskSpec, /禁止复制/u);
assert.match(task.taskSpec, /tkmind_search/u);
assert.match(task.taskSpec, /web_search/u);
assert.match(task.taskSpec, /news002/u);
assert.match(task.taskSpec, /domestic/u);
assert.match(task.taskSpec, /world/u);
assert.match(task.taskSpec, /business/u);
assert.match(task.taskSpec, /tech/u);
assert.match(task.taskSpec, /2026-09-15/u);
assert.match(task.taskSpec, /daily-news-0915\.html/u);
assert.doesNotMatch(task.taskSpec, /可以用昨日正文/u);
const withBrief = wechatNewsMorningDraftInternals.buildNewsMorningAutoGenerationTask(
{ sourceUserId: 'user-1', timezone: 'Asia/Shanghai' },
{
now: Date.parse('2026-09-15T05:00:00+08:00'),
searchBrief: '【专用联网搜索预取】国内热点示例',
},
);
assert.match(withBrief.taskSpec, /专用联网搜索预取/u);
assert.match(withBrief.taskSpec, /国内热点示例/u);
});
test('pushDraft refuses yesterday page when requireToday is on', async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'news-draft-stale-'));
const userId = 'user-stale';
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-0914.html'), SAMPLE_HTML);
const service = createWechatNewsMorningDraftService(createPool(), {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: root,
env: {},
});
await service.updateConfig({ enabled: true, sourceUserId: userId });
await assert.rejects(
() => service.pushDraft({
triggeredBy: 'admin-1',
now: Date.parse('2026-09-15T08:00:00+08:00'),
}),
/今日(2026-09-15)新闻早报尚未生成/u,
);
});
test('internals normalize booleans consistently', () => {
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('1', false), true);
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('off', true), false);
});
test('isNewsMorningContentDegraded counts news002 lead cards', async () => {
const { isNewsMorningContentDegraded } = await import('./wechat-news-morning-draft.mjs');
const leads = Array.from({ length: 4 }, (_, i) =>
`<a class="lead-card" href="#"><div class="body"><h3>头条${i}</h3><p>正文</p></div></a>`).join('');
const cards = Array.from({ length: 4 }, (_, i) =>
`<div class="card" data-event-key="k${i}"><div class="body"><h3>条目${i}</h3><p>正文</p></div></div>`).join('');
const html = `<body><div class="container">${leads}${cards}</div></body>`;
// 旧实现用 `class="card(?:\s|")` 统计,匹配不到 lead-card,4 条头条会被漏数。
assert.equal(isNewsMorningContentDegraded(html, { minStoryCards: 8 }), false);
assert.equal(isNewsMorningContentDegraded(html, { minStoryCards: 9 }), true);
});