diff --git a/scripts/force-wechat-news-morning-generate.mjs b/scripts/force-wechat-news-morning-generate.mjs new file mode 100644 index 0000000..e562d31 --- /dev/null +++ b/scripts/force-wechat-news-morning-generate.mjs @@ -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); +}); diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs index d0d8616..c25e252 100644 --- a/wechat-news-morning-draft-worker.mjs +++ b/wechat-news-morning-draft-worker.mjs @@ -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); diff --git a/wechat-news-morning-draft-worker.test.mjs b/wechat-news-morning-draft-worker.test.mjs index 9372315..9c965be 100644 --- a/wechat-news-morning-draft-worker.test.mjs +++ b/wechat-news-morning-draft-worker.test.mjs @@ -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, diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index d340ad0..d4b7d82 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -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}。`, - `输出文件名必须包含今日 MMDD:daily-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, }; diff --git a/wechat-news-morning-draft.test.mjs b/wechat-news-morning-draft.test.mjs index c9b6f76..cccc08a 100644 --- a/wechat-news-morning-draft.test.mjs +++ b/wechat-news-morning-draft.test.mjs @@ -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);