fix(wechat): 早报必须搜当日新闻,禁止用旧稿充草稿

生成说明强制联网搜索当天内容,pushDraft 拒绝非当日页面;forceGenerateOnce 用于发版后重跑今日早报。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-09-15 06:11:36 +08:00
parent b6d85d487a
commit 4fc488ad33
5 changed files with 319 additions and 15 deletions
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Set news_morning_draft.forceGenerateOnce so the 103 Portal worker
* regenerates today's daily-news page on the next scan (~30s).
* Dry-run by default; pass --apply to persist.
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const CONFIG_KEY = 'news_morning_draft';
const CONFIG_TABLE = 'h5_wechat_admin_config';
async function main() {
const apply = process.argv.includes('--apply');
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL missing');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const [rows] = await pool.query(
`SELECT config_json FROM ${CONFIG_TABLE} WHERE config_key = ? LIMIT 1`,
[CONFIG_KEY],
);
const current = rows[0]?.config_json
? (typeof rows[0].config_json === 'string'
? JSON.parse(rows[0].config_json)
: rows[0].config_json)
: {};
const next = { ...current, forceGenerateOnce: true, enabled: current.enabled !== false };
console.log(JSON.stringify({
apply,
currentForceGenerateOnce: Boolean(current.forceGenerateOnce),
sourceUserId: current.sourceUserId ?? null,
enabled: next.enabled,
}, null, 2));
if (apply) {
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), 'manual-force-generate', now],
);
console.log('forceGenerateOnce=true persisted');
}
await pool.end();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
+55 -5
View File
@@ -62,11 +62,11 @@ export function startWechatNewsMorningDraftWorker({
let running = false;
let generationInFlightDateKey = null;
const ensureTodayPage = async (config, dateKey) => {
const ensureTodayPage = async (config, dateKey, { now = Date.now(), force = false } = {}) => {
if (generationInFlightDateKey === dateKey) return null;
generationInFlightDateKey = dateKey;
try {
const task = buildNewsMorningAutoGenerationTask(config);
const task = buildNewsMorningAutoGenerationTask(config, { now });
await executeTask(task, {
userAuth,
tkmindProxy,
@@ -78,7 +78,7 @@ export function startWechatNewsMorningDraftWorker({
timeoutMs: executionTimeoutMs,
logger,
});
const page = resolveTodayPage(config, h5Root);
const page = resolveTodayPage(config, h5Root, now);
if (!page) {
throw new Error('新闻早报页面生成未完成');
}
@@ -89,6 +89,7 @@ export function startWechatNewsMorningDraftWorker({
logger.log?.('[NewsMorningDraft] auto-generated today page', {
slug: page.slug,
userId: config.sourceUserId,
force,
});
return page;
} catch (error) {
@@ -111,7 +112,7 @@ export function startWechatNewsMorningDraftWorker({
running = true;
try {
const config = await wechatNewsMorningDraftService.getConfig();
if (!config.enabled || !config.autoPushEnabled || !config.sourceUserId) return;
if (!config.enabled || !config.sourceUserId) return;
const now = Date.now();
const timezone = config.timezone;
@@ -119,6 +120,31 @@ export function startWechatNewsMorningDraftWorker({
const toleranceMinutes = Math.max(1, Math.ceil(Number(intervalMs) / 60_000));
let todayPage = resolveTodayPage(config, h5Root, now);
if (config.forceGenerateOnce && generationInFlightDateKey !== dateKey) {
try {
todayPage = await ensureTodayPage(config, dateKey, { now, force: true });
await wechatNewsMorningDraftService.updateConfig(
{ forceGenerateOnce: false },
{ updatedBy: 'auto-generate' },
);
if (config.autoPushEnabled && todayPage) {
const result = await wechatNewsMorningDraftService.pushDraft({
triggeredBy: 'manual-generate',
now,
});
logger.log?.('[NewsMorningDraft] force generate push succeeded', {
draftMediaId: result.draftMediaId,
pageSlug: result.preview?.page?.slug ?? todayPage.slug,
});
}
} catch {
// keep the flag so the next scan retries
}
return;
}
if (!config.autoPushEnabled) return;
const shouldGenerate =
config.autoGenerateEnabled !== false
&& !todayPage
@@ -133,7 +159,7 @@ export function startWechatNewsMorningDraftWorker({
if (shouldGenerate && generationInFlightDateKey !== dateKey) {
try {
todayPage = await ensureTodayPage(config, dateKey);
todayPage = await ensureTodayPage(config, dateKey, { now });
} catch {
// keep trying during generate window on later scans
}
@@ -164,6 +190,7 @@ export function startWechatNewsMorningDraftWorker({
const result = await wechatNewsMorningDraftService.pushDraft({
triggeredBy: 'auto-push',
now,
});
logger.log?.('[NewsMorningDraft] auto push succeeded', {
draftMediaId: result.draftMediaId,
@@ -182,6 +209,29 @@ export function startWechatNewsMorningDraftWorker({
return {
runOnce,
async generateToday({ force = false, now = Date.now(), pushDraft = false } = {}) {
const config = await wechatNewsMorningDraftService.getConfig();
if (!config.enabled || !config.sourceUserId) {
throw new Error('新闻早报未配置来源用户或未启用');
}
const dateKey = localDateKey(now, config.timezone);
const existing = resolveTodayPage(config, h5Root, now);
if (existing && !force) {
return { page: existing, skipped: true, reason: 'today-page-already-exists' };
}
const page = await ensureTodayPage(config, dateKey, { now, force: true });
if (!page) {
throw new Error('新闻早报页面正在生成中');
}
let draft = null;
if (pushDraft) {
draft = await wechatNewsMorningDraftService.pushDraft({
triggeredBy: 'manual-generate',
now,
});
}
return { page, skipped: false, draft };
},
stop() {
stopped = true;
clearInterval(timer);
+126 -1
View File
@@ -92,13 +92,138 @@ test('news morning draft worker auto-pushes at configured time', async () => {
await worker.runOnce();
assert.equal(pushCalls.length, 1);
assert.deepEqual(pushCalls[0], { triggeredBy: 'auto-push' });
assert.equal(pushCalls[0].triggeredBy, 'auto-push');
} finally {
Date.now = originalNow;
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('runOnce honors forceGenerateOnce even when today page exists', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-flag-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), SAMPLE_HTML);
const pool = createPool();
const service = createWechatNewsMorningDraftService(pool, {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: tmpRoot,
env: {
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
},
});
let config = {
enabled: true,
sourceUserId: userId,
autoPushEnabled: true,
autoGenerateEnabled: true,
forceGenerateOnce: true,
timezone: 'Asia/Shanghai',
pushHour: 6,
pushMinute: 0,
};
service.getConfig = async () => config;
service.updateConfig = async (payload) => {
config = { ...config, ...payload };
return config;
};
const pushCalls = [];
service.pushDraft = async (options = {}) => {
pushCalls.push(options);
return {
ok: true,
draftMediaId: 'draft-force',
preview: { page: { slug: 'daily-news-0911' } },
};
};
const executeCalls = [];
const fixedNow = Date.UTC(2026, 8, 10, 22, 30, 0);
const originalNow = Date.now;
Date.now = () => fixedNow;
try {
const worker = startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService: service,
mpConfig: { enabled: true },
userAuth: { canUseChat: async () => ({ ok: true }) },
tkmindProxy: { id: 'proxy' },
h5Root: tmpRoot,
env: { H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1' },
executeTask: async (task) => {
executeCalls.push(task.taskSpec);
},
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
await worker.runOnce();
assert.equal(executeCalls.length, 1);
assert.match(executeCalls[0], /禁止复制/u);
assert.equal(config.forceGenerateOnce, false);
assert.equal(pushCalls.length, 1);
assert.equal(pushCalls[0].triggeredBy, 'manual-generate');
} finally {
Date.now = originalNow;
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('generateToday skips existing page unless force is set', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-force-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), SAMPLE_HTML);
const pool = createPool();
const service = createWechatNewsMorningDraftService(pool, {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: tmpRoot,
env: {
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_AUTO: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
},
});
const executeCalls = [];
const fixedNow = Date.UTC(2026, 8, 10, 22, 0, 0);
const worker = startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService: service,
mpConfig: { enabled: true },
userAuth: { canUseChat: async () => ({ ok: true }) },
tkmindProxy: { id: 'proxy' },
h5Root: tmpRoot,
env: { H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1' },
executeTask: async (task) => {
executeCalls.push(task.taskSpec);
},
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
try {
const skipped = await worker.generateToday({ now: fixedNow, force: false });
assert.equal(skipped.skipped, true);
assert.equal(executeCalls.length, 0);
const forced = await worker.generateToday({ now: fixedNow, force: true });
assert.equal(forced.skipped, false);
assert.equal(forced.page.slug, 'daily-news-0911');
assert.equal(executeCalls.length, 1);
assert.match(executeCalls[0], /禁止复制/u);
assert.match(executeCalls[0], /联网搜索/u);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('schedule helpers detect today page and due windows', () => {
const {
isNewsMorningPageForToday,
+35 -7
View File
@@ -22,7 +22,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-10';
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-15';
/** 0910 新闻早报页面结构说明,供定时任务 taskSpec 与草稿转换共用。 */
export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
@@ -36,6 +36,16 @@ export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
'7. 推送微信草稿时转为内联 style HTML(参考公众号排版),禁止整页长图;正文需控制在 2 万字符内。',
].join('\n');
/** 每日正文必须新搜,禁止把历史早报当新闻来源。 */
export const NEWS_MORNING_FRESH_CONTENT_SPEC = [
'内容硬约束(必须遵守,优先于版式参考):',
'1. 必须先联网搜索「执行当日」国内外要闻、财经、科技、体育等,再写页面;不得凭记忆编造。',
'2. 禁止复制、改写、微调昨日或任何历史 daily-news-*.html 的新闻标题与正文。',
'3. 最近一版 daily-news 只允许当作版式参考(hero / section / card 结构);读完后必须丢弃其中的条目内容。',
'4. 每条卡片必须是当日可核验事件,并写明来源;title、date-badge、导语必须写明当日日期。',
'5. 若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
].join('\n');
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
@@ -138,9 +148,10 @@ export function buildNewsMorningAutoGenerationTask(config, { now = Date.now() }
userId: config.sourceUserId,
title: '每日新闻早报页面(自动)',
taskSpec: [
NEWS_MORNING_FRESH_CONTENT_SPEC,
NEWS_MORNING_TEMPLATE_0910_SPEC,
`今日日期:${dateLabel.iso}`,
`输出文件名必须包含今日 MMDDdaily-news-${dateLabel.mmdd}.html。`,
`今日日期:${dateLabel.iso}${dateLabel.year}${dateLabel.month}${dateLabel.day}日)。正文必须是这一天的新闻,不能是其它日期的旧稿`,
`输出文件名必须daily-news-${dateLabel.mmdd}.html(可覆盖同名旧文件)`,
].join('\n'),
recurrence: 'once',
timezone,
@@ -687,6 +698,7 @@ function defaultsFromEnv(env = process.env) {
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',
forceGenerateOnce: false,
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
}
@@ -743,6 +755,7 @@ export function createWechatNewsMorningDraftService(
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,
forceGenerateOnce: normalizeBoolean(stored.forceGenerateOnce, false),
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
}
@@ -822,7 +835,7 @@ export function createWechatNewsMorningDraftService(
article,
template: {
version: NEWS_MORNING_TEMPLATE_VERSION,
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
spec: [NEWS_MORNING_FRESH_CONTENT_SPEC, NEWS_MORNING_TEMPLATE_0910_SPEC].join('\n'),
},
};
}
@@ -879,7 +892,7 @@ export function createWechatNewsMorningDraftService(
getTemplate() {
return {
version: NEWS_MORNING_TEMPLATE_VERSION,
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
spec: [NEWS_MORNING_FRESH_CONTENT_SPEC, NEWS_MORNING_TEMPLATE_0910_SPEC].join('\n'),
};
},
@@ -924,6 +937,10 @@ export function createWechatNewsMorningDraftService(
payload.author === undefined
? current.author
: String(payload.author ?? current.author).trim().slice(0, 8) || current.author,
forceGenerateOnce:
payload.forceGenerateOnce === undefined
? current.forceGenerateOnce
: normalizeBoolean(payload.forceGenerateOnce, current.forceGenerateOnce),
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
const now = Date.now();
@@ -1007,13 +1024,18 @@ export function createWechatNewsMorningDraftService(
};
},
async pushDraft({ triggeredBy = null, dryRun = false } = {}) {
async pushDraft({
triggeredBy = null,
dryRun = false,
requireToday = true,
now = Date.now(),
} = {}) {
const config = await this.getConfig();
if (!config.enabled) {
throw new Error('新闻早报草稿推送未启用');
}
let preview = null;
preview = await resolvePreview(config);
preview = await resolvePreview(config, { requireToday, now });
if (dryRun) {
return {
dryRun: true,
@@ -1027,6 +1049,7 @@ export function createWechatNewsMorningDraftService(
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone: config.timezone,
});
if (page?.thumbPath) {
@@ -1138,6 +1161,10 @@ export function createWechatNewsMorningDraftService(
return rows.length > 0;
},
async requestForceGenerate({ updatedBy = null } = {}) {
return this.updateConfig({ forceGenerateOnce: true }, { updatedBy });
},
async recordAutoGenerationRun({
status,
pageSlug = null,
@@ -1169,6 +1196,7 @@ export const wechatNewsMorningDraftInternals = {
isLocalScheduleDue,
isWithinNewsMorningGenerateLeadWindow,
buildNewsMorningAutoGenerationTask,
NEWS_MORNING_FRESH_CONTENT_SPEC,
formatLocalDateParts,
localDateKey,
};
+41 -2
View File
@@ -161,12 +161,16 @@ test('news morning draft service persists config and records failed push', async
assert.equal(updated.sourceUserId, userId);
assert.equal(updated.templateVersion, NEWS_MORNING_TEMPLATE_VERSION);
const preview = await service.preview();
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, /0910|2026-09-10/u);
assert.match(preview.template.spec, /禁止复制/u);
await assert.rejects(
() => service.pushDraft({ triggeredBy: 'admin-1' }),
() => service.pushDraft({
triggeredBy: 'admin-1',
now: Date.parse('2026-09-10T08:00:00+08:00'),
}),
/微信服务号未启用/u,
);
const runs = await service.listRuns();
@@ -199,6 +203,41 @@ test('getTodayStatus and previewToday require today dated page', async () => {
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, /联网搜索/u);
assert.match(task.taskSpec, /2026-09-15/u);
assert.match(task.taskSpec, /daily-news-0915\.html/u);
assert.doesNotMatch(task.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);