feat(wechat): add Portal worker for daily news morning draft auto push

Run scheduled page generation before push time and write WeChat drafts from admin config so news morning reports no longer require manual pushes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-11 08:48:02 +08:00
parent 93aa35515e
commit 62caf4134b
9 changed files with 607 additions and 18 deletions
+9
View File
@@ -212,6 +212,15 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# H5_SCHEDULED_TASK_SCAN_INTERVAL_MS=30000
# H5_SCHEDULED_TASK_MAX_ATTEMPTS=3
# H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS=900000
# 新闻早报 · 微信草稿箱自动 worker(默认跟随 H5_REMINDER_WORKER_ENABLED
# H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED=1
# H5_WECHAT_NEWS_MORNING_DRAFT_AUTO=1
# H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED=1
# H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID=
# H5_WECHAT_NEWS_MORNING_DRAFT_HOUR=6
# H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE=0
# H5_WECHAT_NEWS_MORNING_DRAFT_AUTO_GENERATE=1
# H5_WECHAT_NEWS_MORNING_DRAFT_GENERATE_LEAD_MINUTES=60
# Intent Transaction LayerPhase ADraft → Confirm → Commit;默认关闭)
# H5_INTENT_TRANSACTION_ENABLED=1
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -10,6 +10,8 @@ import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs';
import { startScheduledTaskWorker } from '../scheduled-task-worker.mjs';
import { isScheduledTaskWorkerEnabled } from '../scheduled-task-worker-config.mjs';
import { startHealthBaselineWorker } from '../health-baseline-worker.mjs';
import { startWechatNewsMorningDraftWorker } from '../wechat-news-morning-draft-worker.mjs';
import { createWechatNewsMorningDraftService } from '../wechat-news-morning-draft.mjs';
import { createHealthEventNotificationService } from '../health-event-notification-service.mjs';
import { isPassiveCanaryRuntime } from './portal-runtime-role.mjs';
import { loadWechatMpModule } from '../wechat-mp-loader.mjs';
@@ -72,6 +74,10 @@ export async function bootstrapPortalIntegrationServices({
startScheduledTaskWorker,
startHealthBaselineWorkerFn =
startHealthBaselineWorker,
startWechatNewsMorningDraftWorkerFn =
startWechatNewsMorningDraftWorker,
createWechatNewsMorningDraftServiceFn =
createWechatNewsMorningDraftService,
createPageEditSessionServiceFn =
createPageEditSessionService,
createToolGatewayFn = createToolGateway,
@@ -337,6 +343,38 @@ export async function bootstrapPortalIntegrationServices({
);
}
const wechatNewsMorningDraftService = createWechatNewsMorningDraftServiceFn(pool, {
mpConfig: wechatMpConfig,
h5Root,
memindLibRoot: codeRoot || h5Root,
env,
});
let wechatNewsMorningDraftWorker = null;
if (
!isPassiveCanaryRuntime(env)
&& wechatMpConfig?.enabled
&& userAuth
&& tkmindProxy
) {
wechatNewsMorningDraftWorker = startWechatNewsMorningDraftWorkerFn({
wechatNewsMorningDraftService,
mpConfig: wechatMpConfig,
userAuth,
tkmindProxy,
agentRunGateway,
cursorExecutorPolicyService: wechatCursorExecutorPolicyService,
sessionSnapshotService,
pool,
h5Root,
env,
logger,
});
if (wechatNewsMorningDraftWorker?.runOnce) {
logger.log?.('WeChat news morning draft worker enabled');
}
}
let healthBaselineWorker = null;
if (!isPassiveCanaryRuntime(env) && healthDataRuntime) {
const healthEventNotificationService = createHealthEventNotificationService({
@@ -414,6 +452,8 @@ export async function bootstrapPortalIntegrationServices({
notificationDispatcher,
scheduleReminderWorker,
scheduledTaskWorker,
wechatNewsMorningDraftService,
wechatNewsMorningDraftWorker,
healthBaselineWorker,
subscriptionExpiryTimer,
mindSpacePageEditSession,
@@ -173,6 +173,14 @@ function createSetup(overrides = {}) {
reminderOptions = receivedOptions;
return { id: 'reminder-worker' };
},
createWechatNewsMorningDraftServiceFn(pool, options) {
calls.push(['news-morning-draft-service', pool, options]);
return { id: 'news-morning-draft-service' };
},
startWechatNewsMorningDraftWorkerFn(receivedOptions) {
calls.push(['news-morning-draft-worker']);
return { id: 'news-morning-draft-worker', runOnce: async () => {} };
},
createPageEditSessionServiceFn(receivedOptions) {
calls.push(['page-edit']);
pageEditOptions = receivedOptions;
@@ -449,6 +457,9 @@ test('preserves notification, recharge, reminder, and page-edit wiring', async (
setup.notificationDispatcher,
});
assert.equal(result.scheduleReminderWorker.id, 'reminder-worker');
assert.equal(result.wechatNewsMorningDraftWorker.id, 'news-morning-draft-worker');
assert.ok(setup.calls.some(([name]) => name === 'news-morning-draft-service'));
assert.ok(setup.calls.some(([name]) => name === 'news-morning-draft-worker'));
assert.deepEqual(captured.pageEditOptions, {
apiTarget: 'http://api',
apiSecret: 'secret',
@@ -498,6 +509,7 @@ test('preserves subscription timer work, logs, and unref', async () => {
test('keeps optional integrations disabled and contains timer failures', async () => {
const setup = createSetup({
env: {},
wechatMpConfig: { enabled: false },
mindSpacePublicFinish: null,
scheduleService: null,
subscriptionService: {
@@ -546,6 +558,7 @@ test('keeps optional integrations disabled and contains timer failures', async (
null,
);
assert.equal(result.scheduleReminderWorker, null);
assert.equal(result.wechatNewsMorningDraftWorker, null);
assert.equal(
captured.notificationOptions.sendWechatTextToUser,
null,
@@ -575,6 +588,7 @@ test('passive candidate runtime disables singleton reminder and subscription tim
const captured = setup.getCaptured();
assert.equal(result.scheduleReminderWorker, null);
assert.equal(result.wechatNewsMorningDraftWorker, null);
assert.equal(result.subscriptionExpiryTimer, null);
assert.equal(captured.reminderOptions, undefined);
assert.equal(captured.timerCallback, undefined);
@@ -0,0 +1,26 @@
/**
* News morning WeChat draft auto worker enablement.
* Default follows H5_REMINDER_WORKER_ENABLED when unset.
*/
export function isWechatNewsMorningDraftWorkerEnabled(env = process.env) {
const explicit = String(env.H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED ?? '').trim();
if (explicit === '1' || explicit === 'true') return true;
if (explicit === '0' || explicit === 'false') return false;
return env.H5_REMINDER_WORKER_ENABLED === '1';
}
export function wechatNewsMorningDraftWorkerIntervalMs(env = process.env) {
const raw = Number(env.H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_INTERVAL_MS ?? 30_000);
if (!Number.isFinite(raw) || raw < 5_000) return 30_000;
return raw;
}
export function wechatNewsMorningDraftExecutionTimeoutMs(env = process.env) {
const raw = Number(
env.H5_WECHAT_NEWS_MORNING_DRAFT_GENERATION_TIMEOUT_MS
?? env.H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS
?? 15 * 60 * 1000,
);
if (!Number.isFinite(raw) || raw < 60_000) return 15 * 60 * 1000;
return raw;
}
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isWechatNewsMorningDraftWorkerEnabled,
wechatNewsMorningDraftWorkerIntervalMs,
} from './wechat-news-morning-draft-worker-config.mjs';
test('news morning draft worker follows reminder worker by default', () => {
assert.equal(
isWechatNewsMorningDraftWorkerEnabled({ H5_REMINDER_WORKER_ENABLED: '1' }),
true,
);
assert.equal(
isWechatNewsMorningDraftWorkerEnabled({ H5_REMINDER_WORKER_ENABLED: '0' }),
false,
);
assert.equal(
isWechatNewsMorningDraftWorkerEnabled({
H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1',
H5_REMINDER_WORKER_ENABLED: '0',
}),
true,
);
});
test('news morning draft worker interval has sane floor', () => {
assert.equal(wechatNewsMorningDraftWorkerIntervalMs({}), 30_000);
assert.equal(
wechatNewsMorningDraftWorkerIntervalMs({
H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_INTERVAL_MS: '1000',
}),
30_000,
);
});
+190
View File
@@ -0,0 +1,190 @@
import { executeScheduledTask } from './scheduled-task-executor.mjs';
import { localDateKey } from './schedule-time.mjs';
import {
buildNewsMorningAutoGenerationTask,
findLatestNewsMorningPage,
isLocalScheduleDue,
isNewsMorningPageForToday,
isWithinNewsMorningGenerateLeadWindow,
} from './wechat-news-morning-draft.mjs';
import {
isWechatNewsMorningDraftWorkerEnabled,
wechatNewsMorningDraftExecutionTimeoutMs,
wechatNewsMorningDraftWorkerIntervalMs,
} from './wechat-news-morning-draft-worker-config.mjs';
import { isPassiveCanaryRuntime } from './server/portal-runtime-role.mjs';
function resolveTodayPage(config, h5Root, now = Date.now()) {
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone: config.timezone,
});
if (!page || !isNewsMorningPageForToday(page, { date: new Date(now), timezone: config.timezone })) {
return null;
}
return page;
}
export function startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService = null,
mpConfig = null,
userAuth = null,
tkmindProxy = null,
agentRunGateway = null,
cursorExecutorPolicyService = null,
sessionSnapshotService = null,
pool = null,
h5Root = null,
env = process.env,
executeTask = executeScheduledTask,
logger = console,
intervalMs = wechatNewsMorningDraftWorkerIntervalMs(env),
executionTimeoutMs = wechatNewsMorningDraftExecutionTimeoutMs(env),
runOnStart = true,
setIntervalFn = setInterval,
} = {}) {
if (
isPassiveCanaryRuntime(env)
|| !isWechatNewsMorningDraftWorkerEnabled(env)
|| !wechatNewsMorningDraftService
|| !mpConfig?.enabled
|| !userAuth
|| !tkmindProxy
|| !h5Root
) {
return { stop() {} };
}
let stopped = false;
let running = false;
let generationInFlightDateKey = null;
const ensureTodayPage = async (config, dateKey) => {
if (generationInFlightDateKey === dateKey) return null;
generationInFlightDateKey = dateKey;
try {
const task = buildNewsMorningAutoGenerationTask(config);
await executeTask(task, {
userAuth,
tkmindProxy,
agentRunGateway,
cursorExecutorPolicyService,
sessionSnapshotService,
pool,
h5Root,
timeoutMs: executionTimeoutMs,
logger,
});
const page = resolveTodayPage(config, h5Root);
if (!page) {
throw new Error('新闻早报页面生成未完成');
}
await wechatNewsMorningDraftService.recordAutoGenerationRun({
status: 'success',
pageSlug: page.slug,
});
logger.log?.('[NewsMorningDraft] auto-generated today page', {
slug: page.slug,
userId: config.sourceUserId,
});
return page;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await wechatNewsMorningDraftService.recordAutoGenerationRun({
status: 'failed',
errorMessage: message,
}).catch(() => {});
logger.warn?.('[NewsMorningDraft] auto-generate failed:', message);
throw error;
} finally {
if (generationInFlightDateKey === dateKey) {
generationInFlightDateKey = null;
}
}
};
const runOnce = async () => {
if (running || stopped) return;
running = true;
try {
const config = await wechatNewsMorningDraftService.getConfig();
if (!config.enabled || !config.autoPushEnabled || !config.sourceUserId) return;
const now = Date.now();
const timezone = config.timezone;
const dateKey = localDateKey(now, timezone);
const toleranceMinutes = Math.max(1, Math.ceil(Number(intervalMs) / 60_000));
let todayPage = resolveTodayPage(config, h5Root, now);
const shouldGenerate =
config.autoGenerateEnabled !== false
&& !todayPage
&& (
isWithinNewsMorningGenerateLeadWindow(config, now)
|| isLocalScheduleDue(
{ hour: config.pushHour, minute: config.pushMinute, timezone },
now,
toleranceMinutes + 120,
)
);
if (shouldGenerate && generationInFlightDateKey !== dateKey) {
try {
todayPage = await ensureTodayPage(config, dateKey);
} catch {
// keep trying during generate window on later scans
}
}
if (
!isLocalScheduleDue(
{ hour: config.pushHour, minute: config.pushMinute, timezone },
now,
toleranceMinutes,
)
) {
return;
}
if (await wechatNewsMorningDraftService.hasSuccessfulAutoPushToday({ now })) {
return;
}
todayPage = todayPage ?? resolveTodayPage(config, h5Root, now);
if (!todayPage) {
logger.warn?.('[NewsMorningDraft] skip auto push: today page missing', {
userId: config.sourceUserId,
dateKey,
});
return;
}
const result = await wechatNewsMorningDraftService.pushDraft({
triggeredBy: 'auto-push',
});
logger.log?.('[NewsMorningDraft] auto push succeeded', {
draftMediaId: result.draftMediaId,
pageSlug: result.preview?.page?.slug ?? todayPage.slug,
});
} catch (error) {
logger.warn?.('[NewsMorningDraft] auto worker failed:', error);
} finally {
running = false;
}
};
const timer = setIntervalFn(runOnce, Math.max(5_000, Number(intervalMs) || 30_000));
timer.unref?.();
if (runOnStart) void runOnce();
return {
runOnce,
stop() {
stopped = true;
clearInterval(timer);
},
};
}
+141
View File
@@ -0,0 +1,141 @@
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 { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } from './user-publish.mjs';
import { startWechatNewsMorningDraftWorker } from './wechat-news-morning-draft-worker.mjs';
import {
createWechatNewsMorningDraftService,
wechatNewsMorningDraftInternals,
} from './wechat-news-morning-draft.mjs';
const SAMPLE_HTML = `<!DOCTYPE html><html><body><h1>📰 每日新闻早报</h1></body></html>`;
function createPool() {
const rows = [];
return {
rows,
async query(sql, params = []) {
if (sql.includes('CREATE TABLE')) return [[], []];
if (sql.includes('FROM h5_wechat_admin_config')) return [[], []];
if (sql.includes('INSERT INTO h5_wechat_news_draft_runs')) {
rows.push({
id: params[0],
status: params[1],
pageSlug: params[2],
pageUrl: params[3],
draftMediaId: params[4],
errorMessage: params[5],
triggeredBy: params[6],
createdAt: Number(params[7]),
});
return [[], []];
}
if (sql.includes('FROM h5_wechat_news_draft_runs')) {
return [rows.slice().reverse(), []];
}
return [[], []];
},
};
}
test('news morning draft worker auto-pushes at configured time', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-worker-'));
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,
H5_WECHAT_NEWS_MORNING_DRAFT_HOUR: '6',
H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE: '0',
},
});
const pushCalls = [];
service.pushDraft = async (options = {}) => {
pushCalls.push(options);
return {
ok: true,
draftMediaId: 'draft-1',
preview: { page: { slug: 'daily-news-0911' } },
};
};
service.hasSuccessfulAutoPushToday = async () => false;
const fixedNow = Date.UTC(2026, 8, 10, 22, 0, 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',
},
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
await worker.runOnce();
assert.equal(pushCalls.length, 1);
assert.deepEqual(pushCalls[0], { triggeredBy: 'auto-push' });
} finally {
Date.now = originalNow;
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('schedule helpers detect today page and due windows', () => {
const {
isNewsMorningPageForToday,
isLocalScheduleDue,
isWithinNewsMorningGenerateLeadWindow,
formatLocalDateParts,
} = wechatNewsMorningDraftInternals;
const now = Date.UTC(2026, 8, 10, 22, 0, 0);
const parts = formatLocalDateParts(new Date(now), 'Asia/Shanghai');
assert.equal(parts.mmdd, '0911');
assert.equal(
isNewsMorningPageForToday(
{ slug: 'daily-news-0911' },
{ date: new Date(now), timezone: 'Asia/Shanghai' },
),
true,
);
assert.equal(
isLocalScheduleDue(
{ hour: 6, minute: 0, timezone: 'Asia/Shanghai' },
now,
1,
),
true,
);
assert.equal(
isWithinNewsMorningGenerateLeadWindow(
{
pushHour: 6,
pushMinute: 0,
generateLeadMinutes: 60,
timezone: 'Asia/Shanghai',
},
Date.UTC(2026, 8, 10, 21, 15, 0),
),
true,
);
});
+152 -17
View File
@@ -10,6 +10,7 @@ import {
} from './user-publish.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { convertDailyNewsHtmlToWechatInlineArticle } from './wechat-daily-news-inline.mjs';
import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs';
const CONFIG_TABLE = 'h5_wechat_admin_config';
const CONFIG_KEY = 'news_morning_draft';
@@ -64,23 +65,17 @@ function clampMinute(value, 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 clampGenerateLeadMinutes(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(24 * 60, Math.max(15, Math.floor(num)));
}
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 ?? '';
function formatLocalDateParts(date = new Date(), timezone = 'Asia/Shanghai') {
const parts = getLocalParts(date, timezone);
const year = String(parts.year);
const month = String(parts.month).padStart(2, '0');
const day = String(parts.day).padStart(2, '0');
return {
year,
month,
@@ -91,6 +86,76 @@ function formatShanghaiDateParts(date = new Date()) {
};
}
export function isNewsMorningPageForToday(
page,
{ date = new Date(), timezone = 'Asia/Shanghai' } = {},
) {
if (!page?.slug) return false;
const { iso, mmdd, compact } = formatLocalDateParts(date, timezone);
const slug = String(page.slug);
return (
slug.endsWith(`-${mmdd}`)
|| slug.includes(iso)
|| slug.includes(compact)
|| slug.includes(iso.replace(/-/g, ''))
);
}
export function isLocalScheduleDue(
{ hour, minute, timezone = 'Asia/Shanghai' },
now = Date.now(),
toleranceMinutes = 1,
) {
const parts = getLocalParts(now, timezone);
const current = parts.hour * 60 + parts.minute;
const target = clampHour(hour, 0) * 60 + clampMinute(minute, 0);
return Math.abs(current - target) <= Math.max(0, toleranceMinutes);
}
export function isWithinNewsMorningGenerateLeadWindow(
{
pushHour,
pushMinute,
generateLeadMinutes = 60,
timezone = 'Asia/Shanghai',
},
now = Date.now(),
) {
const parts = getLocalParts(now, timezone);
const current = parts.hour * 60 + parts.minute;
const push = clampHour(pushHour, 6) * 60 + clampMinute(pushMinute, 0);
const start = push - clampGenerateLeadMinutes(generateLeadMinutes, 60);
return current >= start && current < push;
}
export function buildNewsMorningAutoGenerationTask(config, { now = Date.now() } = {}) {
const timezone = String(config?.timezone ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai';
const dateLabel = formatLocalDateParts(now, timezone);
return {
id: 'news-morning-auto-generate',
userId: config.sourceUserId,
title: '每日新闻早报页面(自动)',
taskSpec: [
NEWS_MORNING_TEMPLATE_0910_SPEC,
`今日日期:${dateLabel.iso}`,
`输出文件名必须包含今日 MMDDdaily-news-${dateLabel.mmdd}.html。`,
].join('\n'),
recurrence: 'once',
timezone,
notifyChannel: 'web',
};
}
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()) {
return formatLocalDateParts(date, 'Asia/Shanghai');
}
export function isDailyNewsFormat(html) {
return /daily-news|每日新闻早报/u.test(String(html ?? ''))
|| /class="date-badge"/u.test(String(html ?? ''));
@@ -359,6 +424,7 @@ export function findLatestNewsMorningPage({
userId,
slugPattern = 'news-hotspots-*',
date = new Date(),
timezone = 'Asia/Shanghai',
} = {}) {
if (!h5Root || !userId) throw new Error('缺少 h5Root 或 userId');
const publicDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR);
@@ -366,7 +432,7 @@ export function findLatestNewsMorningPage({
return null;
}
const matcher = slugPatternToRegExp(slugPattern);
const { iso, mmdd, compact } = formatShanghaiDateParts(date);
const { iso, mmdd, compact } = formatLocalDateParts(date, timezone);
const datedCandidates = [];
const fallbackCandidates = [];
for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) {
@@ -509,9 +575,18 @@ async function ensureRunsTable(pool) {
}
function defaultsFromEnv(env = process.env) {
const autoPushEnabled = normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO, false);
return {
enabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED, false),
autoPushEnabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO, false),
autoPushEnabled,
autoGenerateEnabled: normalizeBoolean(
env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO_GENERATE,
autoPushEnabled,
),
generateLeadMinutes: clampGenerateLeadMinutes(
env.H5_WECHAT_NEWS_MORNING_DRAFT_GENERATE_LEAD_MINUTES,
60,
),
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',
@@ -563,6 +638,11 @@ export function createWechatNewsMorningDraftService(
return {
enabled: normalizeBoolean(stored.enabled, defaults.enabled),
autoPushEnabled: normalizeBoolean(stored.autoPushEnabled, defaults.autoPushEnabled),
autoGenerateEnabled: normalizeBoolean(stored.autoGenerateEnabled, defaults.autoGenerateEnabled),
generateLeadMinutes: clampGenerateLeadMinutes(
stored.generateLeadMinutes,
defaults.generateLeadMinutes,
),
pushHour: clampHour(stored.pushHour, defaults.pushHour),
pushMinute: clampMinute(stored.pushMinute, defaults.pushMinute),
timezone: String(stored.timezone ?? defaults.timezone).trim() || defaults.timezone,
@@ -684,6 +764,14 @@ export function createWechatNewsMorningDraftService(
payload.autoPushEnabled === undefined
? current.autoPushEnabled
: normalizeBoolean(payload.autoPushEnabled, current.autoPushEnabled),
autoGenerateEnabled:
payload.autoGenerateEnabled === undefined
? current.autoGenerateEnabled
: normalizeBoolean(payload.autoGenerateEnabled, current.autoGenerateEnabled),
generateLeadMinutes:
payload.generateLeadMinutes === undefined
? current.generateLeadMinutes
: clampGenerateLeadMinutes(payload.generateLeadMinutes, current.generateLeadMinutes),
pushHour:
payload.pushHour === undefined ? current.pushHour : clampHour(payload.pushHour, current.pushHour),
pushMinute:
@@ -826,6 +914,47 @@ export function createWechatNewsMorningDraftService(
createdAt: Number(row.createdAt ?? 0),
}));
},
async hasSuccessfulAutoPushToday({ now = Date.now() } = {}) {
const config = await this.getConfig();
const timezone = config.timezone;
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone,
});
if (!page || !isNewsMorningPageForToday(page, { date: new Date(now), timezone })) {
return false;
}
await ensureReady();
const dayStart = startOfLocalDay(now, timezone);
const [rows] = await pool.query(
`SELECT id
FROM ${RUNS_TABLE}
WHERE status = 'success'
AND page_slug = ?
AND triggered_by = 'auto-push'
AND created_at >= ?
LIMIT 1`,
[page.slug, dayStart],
);
return rows.length > 0;
},
async recordAutoGenerationRun({
status,
pageSlug = null,
errorMessage = null,
}) {
return recordRun({
status,
pageSlug,
errorMessage,
triggeredBy: 'auto-generate',
});
},
};
}
@@ -841,4 +970,10 @@ export const wechatNewsMorningDraftInternals = {
buildDailyNewsWechatHtmlContent,
buildDailyNewsWechatDraftArticle,
findLatestNewsMorningPage,
isNewsMorningPageForToday,
isLocalScheduleDue,
isWithinNewsMorningGenerateLeadWindow,
buildNewsMorningAutoGenerationTask,
formatLocalDateParts,
localDateKey,
};