diff --git a/docs/news-morning-templates.md b/docs/news-morning-templates.md index 6202071..7e839e1 100644 --- a/docs/news-morning-templates.md +++ b/docs/news-morning-templates.md @@ -39,7 +39,7 @@ DB 值优先于环境变量。非法值一律回落到 `news001`。 - 每张卡片必须带 `data-event-key="{事件指纹}"`,指纹 = 核心主体 + 核心动作, 中英文报道共用同一指纹,全页不得重复。 -### 2. 运行时去重闸门 +### 2. 运行时去重 prompt 层的「禁止重复」无法验证,所以在落盘后再做一次程序化清理: [`wechat-news-morning-dedup.mjs`](../wechat-news-morning-dedup.mjs),由 worker 在生成成功后调用。 @@ -102,9 +102,15 @@ news002 的卡片图会先上传到微信正文素材接口,再由 inline 渲 静态页与微信草稿均使用 **左上角 72~92px 小图**(`.card` / `.lead-card` 左侧缩略图),不再用全宽大图。 能拿到真实配图的卡片必须显示并上传;没有真实图就只保留文字卡,不放默认小图。 远程配图会先下载到当日 MindSpace `public/`,改写成本站相对 URL,避免海外图源在国内打不开。 -推送前不再删除无图故事卡(微信草稿保留纯文字卡);有图卡必须成功上传微信 CDN。 -生成闸门会拦截:配图上传失败、头条不是 3~4 条、事件卡超过 30 条,或页面仍混有 news001 DOM, -才会由生成/推送闸门阻断,不允许进入草稿箱。 +推送前不再删除无图故事卡(微信草稿保留纯文字卡);有图卡未能上传微信 CDN 时只去掉该图,卡片保留文字。 + +配图 / 结构 / 写满检查**只引导修复、不阻断生成和推送**: +检查不过时依次做 SearXNG 补位、补图、头条规整;仍不满足就删掉完全空的栏目, +以现有内容出稿并照常推草稿,同时记 `news002 audit incomplete, publishing best-effort page` 告警。 + +强制重生成(`forceGenerateOnce`)在执行前就清掉标记,只跑一次、失败不重试; +生成失败时把归档的原稿放回原位,当日页面不会因重生成失败而 404。 +降级页自动重生成每天最多一次。 真实图片来源按优先级取 SearXNG 结果里的 `img_src`/`thumbnail`,以及文章来源的 `og:image:secure_url`、`og:image`、`twitter:image`;不使用与事件无法核验关系的泛图片搜索结果。 diff --git a/news-content-safety-rules.mjs b/news-content-safety-rules.mjs index e44c710..69f9e53 100644 --- a/news-content-safety-rules.mjs +++ b/news-content-safety-rules.mjs @@ -48,6 +48,7 @@ function rx(source, flags = 'iu') { const TERM_RULES = Object.freeze([ // 成人低俗 { id: 'adult-explicit', category: 'adult', level: 'block', re: rx('色情(?:网站|视频|图片|直播|片)|黄色(?:网站|视频|小说)|黄网|黄片|成人(?:网站|视频|直播|电影)|AV女优|裸聊|约炮|援交|一夜情|性爱视频|艳照|露点(?:照|视频|写真)|福利姬|情色|擦边(?:视频|直播|写真|女主播)|porn|onlyfans|nsfw|xvideos') }, + { id: 'adult-vulgar', category: 'adult', level: 'block', re: rx('鸡巴|几把|肉棒|屌大|操逼|草逼|骚逼|口交|肛交|乳交|群交|乱伦|啪啪啪|自慰视频|无码(?:视频|片|高清)|熟女|海角(?:网|社区)|黑料(?:网|社|不打烊)|(?:51|91)(?:吃瓜|黑料)|吃瓜(?:网|黑料)') }, { id: 'adult-sexual-crime', category: 'adult', level: 'review', re: rx('性侵|强奸|猥亵|性骚扰|卖淫|嫖娼|淫秽|裸照|不雅视频|偷拍') }, // 自杀自残 @@ -131,7 +132,7 @@ const SELF_MEDIA_DOMAINS = Object.freeze([ 'sohu.com/a/', ]); -const UNSAFE_DOMAIN_RE = /(?:porn|xxx|sex|casino|bet365|gamble|18av|hentai|jav)/i; +const UNSAFE_DOMAIN_RE = /(?:porn|xxx|sex|casino|bet365|gamble|18av|hentai|jav|haijiao|heiliao|51cg|chigua)/i; const POLITICAL_RE = rx(POLITICAL_TERMS); const POLITICAL_SECTION_IDS = new Set(['policy', 'geopolitics', 'politics']); const POLITICAL_TAG_RE = /时政|政策|政务|外交/u; diff --git a/news-content-safety-rules.test.mjs b/news-content-safety-rules.test.mjs index 1750568..f885b68 100644 --- a/news-content-safety-rules.test.mjs +++ b/news-content-safety-rules.test.mjs @@ -17,6 +17,12 @@ test('adult explicit content is blocked, sexual-crime news goes to review', () = assert.equal(levelOf('91每日大赛-今日吃瓜爆料资讯平台,热门黑料绯闻每日更新'), 'block'); assert.equal(levelOf('男子强奸案一审宣判,被判有期徒刑十年'), 'review'); assert.equal(levelOf('央行宣布下调存款准备金率 0.5 个百分点'), 'clean'); + assert.equal(levelOf('AI短剧王鹤棣的3厘米鸡巴第1集 打玻尿酸坏死只剩3cm... | 海角网'), 'block'); + assert.equal(levelOf('熟女 - 热门标签,每日更新最新内容'), 'block'); + assert.equal(levelOf('51吃瓜 今日最新'), 'block'); + assert.equal(assessNewsSourceUrl('https://www.haijiao.com/post/123').level, 'block'); + assert.equal(assessNewsSourceUrl('https://heiliao.com/archives/1').level, 'block'); + assert.equal(levelOf('海角七号重映,票房破亿'), 'clean'); }); test('common news phrases are not false positives', () => { diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs index 0413e2f..4a2dca0 100644 --- a/wechat-news-morning-draft-worker.mjs +++ b/wechat-news-morning-draft-worker.mjs @@ -30,6 +30,7 @@ import { enrichNews002HtmlWithSourceImages, localizeNews002CardImages, promoteNews002LeadCards, + pruneEmptyNews002Sections, } from './wechat-news-morning-images.mjs'; import { isWechatNewsMorningDraftWorkerEnabled, @@ -119,9 +120,7 @@ async function applyNews002ImageGate( { searchGroups = [], safetyExemptSectionIds = [] } = {}, ) { if (config?.templateId !== 'news002') return null; - if (!page?.localPath || !fs.existsSync(page.localPath)) { - throw new Error('news002 配图闸门无法读取生成页面'); - } + if (!page?.localPath || !fs.existsSync(page.localPath)) return null; let source = fs.readFileSync(page.localPath, 'utf8'); const normalizedBefore = promoteNews002LeadCards(source); if (normalizedBefore.promotedCount > 0 || normalizedBefore.demotedCount > 0) { @@ -269,6 +268,20 @@ async function applyNews002ImageGate( } } + if (!audit.ok) { + const pruned = pruneEmptyNews002Sections(localized.html, { requireImage: false }); + if (pruned.removedSections > 0) { + localized = { ...localized, html: pruned.html }; + fs.writeFileSync(page.localPath, localized.html); + audit = auditNews002ImageCoverage(localized.html, { + requireTemplateStructure: true, + requireSectionFill: true, + exemptSectionIds: safetyExemptSectionIds, + env, + }); + } + } + logger.log?.('[NewsMorningDraft] news002 image audit', { slug: page.slug, attempted: enriched.attemptedCount, @@ -278,12 +291,15 @@ async function applyNews002ImageGate( ...audit, }); if (!audit.ok) { - throw new Error( - `news002 配图/结构/写满闸门未通过:${audit.violations.join(', ')}` - + `(卡片 ${audit.cardCount},真实配图 ${audit.imageCount},头条配图 ${audit.leadImageCount}/${audit.leadCount})`, - ); + logger.warn?.('[NewsMorningDraft] news002 audit incomplete, publishing best-effort page', { + slug: page.slug, + violations: audit.violations, + cardCount: audit.cardCount, + imageCount: audit.imageCount, + leadImages: `${audit.leadImageCount}/${audit.leadCount}`, + }); } - return audit; + return { ...audit, degraded: !audit.ok }; } function archiveTodayPageForForce(config, h5Root, now, logger) { @@ -292,15 +308,47 @@ function archiveTodayPageForForce(config, h5Root, now, logger) { const stamp = new Date(now).toISOString().replace(/[:.]/g, '-'); const archived = path.join(path.dirname(page.localPath), `_archived-${page.slug}-${stamp}.html`); fs.renameSync(page.localPath, archived); + let archivedThumb = null; if (page.thumbPath && fs.existsSync(page.thumbPath)) { - const archivedThumb = archived.replace(/\.html$/i, '.thumbnail.png'); + archivedThumb = archived.replace(/\.html$/i, '.thumbnail.png'); fs.renameSync(page.thumbPath, archivedThumb); } logger.log?.('[NewsMorningDraft] archived today page before force generate', { from: page.relativePath, to: path.basename(archived), }); - return archived; + return { + originalPath: page.localPath, + archivedPath: archived, + originalThumbPath: archivedThumb ? page.thumbPath : null, + archivedThumbPath: archivedThumb, + }; +} + +/** 强制重生成失败时把原稿放回原位;新稿已落盘则保留新稿。 */ +function restoreArchivedTodayPage(archived, logger) { + if (!archived?.archivedPath || !fs.existsSync(archived.archivedPath)) return false; + if (fs.existsSync(archived.originalPath)) return false; + try { + fs.renameSync(archived.archivedPath, archived.originalPath); + if ( + archived.archivedThumbPath + && fs.existsSync(archived.archivedThumbPath) + && !fs.existsSync(archived.originalThumbPath) + ) { + fs.renameSync(archived.archivedThumbPath, archived.originalThumbPath); + } + logger.log?.('[NewsMorningDraft] restored archived today page after failed generate', { + to: path.basename(archived.originalPath), + }); + return true; + } catch (error) { + logger.warn?.( + '[NewsMorningDraft] restore archived today page failed:', + error instanceof Error ? error.message : error, + ); + return false; + } } export function startWechatNewsMorningDraftWorker({ @@ -338,13 +386,15 @@ export function startWechatNewsMorningDraftWorker({ let stopped = false; let running = false; let generationInFlightDateKey = null; + let degradedRegenerateDateKey = null; const ensureTodayPage = async (config, dateKey, { now = Date.now(), force = false } = {}) => { if (generationInFlightDateKey === dateKey) return null; generationInFlightDateKey = dateKey; + let archived = null; try { if (force) { - archiveTodayPageForForce(config, h5Root, now, logger); + archived = archiveTodayPageForForce(config, h5Root, now, logger); } let searchBrief = ''; let searchGroups = []; @@ -387,7 +437,14 @@ export function startWechatNewsMorningDraftWorker({ stage: 'post-generate', }); const safetyExemptSectionIds = safety?.exemptSectionIds ?? []; - await applyNews002ImageGate(page, config, logger, env, { searchGroups, safetyExemptSectionIds }); + try { + await applyNews002ImageGate(page, config, logger, env, { searchGroups, safetyExemptSectionIds }); + } catch (imageError) { + logger.warn?.( + '[NewsMorningDraft] news002 image repair failed, keeping generated page:', + imageError instanceof Error ? imageError.message : imageError, + ); + } await applyNewsMorningContentSafety(page, { reviewer: contentReviewer, reviewCache, @@ -411,6 +468,7 @@ export function startWechatNewsMorningDraftWorker({ errorMessage: message, }).catch(() => {}); logger.warn?.('[NewsMorningDraft] auto-generate failed:', message); + restoreArchivedTodayPage(archived, logger); throw error; } finally { if (generationInFlightDateKey === dateKey) { @@ -462,6 +520,7 @@ export function startWechatNewsMorningDraftWorker({ todayPageDegraded && !config.forceGenerateOnce && generationInFlightDateKey !== dateKey + && degradedRegenerateDateKey !== dateKey && ( isWithinNewsMorningGenerateLeadWindow(config, now) || isLocalScheduleDue( @@ -476,6 +535,7 @@ export function startWechatNewsMorningDraftWorker({ userId: config.sourceUserId, dateKey, }); + degradedRegenerateDateKey = dateKey; try { todayPage = await ensureTodayPage(config, dateKey, { now, force: true }); if ( @@ -507,11 +567,19 @@ export function startWechatNewsMorningDraftWorker({ if (config.forceGenerateOnce && generationInFlightDateKey !== dateKey) { try { - todayPage = await ensureTodayPage(config, dateKey, { now, force: true }); await wechatNewsMorningDraftService.updateConfig( { forceGenerateOnce: false }, { updatedBy: 'auto-generate' }, ); + } catch (error) { + logger.warn?.( + '[NewsMorningDraft] clear forceGenerateOnce failed, skip force generate:', + error instanceof Error ? error.message : error, + ); + return; + } + try { + todayPage = await ensureTodayPage(config, dateKey, { now, force: true }); if (config.autoPushEnabled && todayPage) { const result = await wechatNewsMorningDraftService.pushDraft({ triggeredBy: 'manual-generate', @@ -522,8 +590,11 @@ export function startWechatNewsMorningDraftWorker({ pageSlug: result.preview?.page?.slug ?? todayPage.slug, }); } - } catch { - // keep the flag so the next scan retries + } catch (error) { + logger.warn?.( + '[NewsMorningDraft] force generate failed:', + error instanceof Error ? error.message : error, + ); } return; } diff --git a/wechat-news-morning-draft-worker.test.mjs b/wechat-news-morning-draft-worker.test.mjs index 4d59080..cc919dc 100644 --- a/wechat-news-morning-draft-worker.test.mjs +++ b/wechat-news-morning-draft-worker.test.mjs @@ -244,6 +244,140 @@ test('runOnce honors forceGenerateOnce even when today page exists', async () => } }); +test('failed forceGenerateOnce clears the flag, restores the original page and does not retry', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-force-fail-')); + const userId = 'user-news'; + const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + fs.mkdirSync(publicDir, { recursive: true }); + const pagePath = path.join(publicDir, 'daily-news-0911.html'); + fs.writeFileSync(pagePath, HEALTHY_TODAY_HTML); + + const service = createWechatNewsMorningDraftService(createPool(), { + 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, + pageSlugPattern: 'daily-news-*', + }; + 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-x' }; + }; + service.hasSuccessfulAutoPushToday = async () => true; + + let executeCount = 0; + 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 () => { + executeCount += 1; + throw new Error('agent timeout'); + }, + prefetchNewsSearch: async () => ({ brief: '', groups: [] }), + logger: { log() {}, warn() {} }, + intervalMs: 30_000, + runOnStart: false, + setIntervalFn: () => ({ unref() {} }), + }); + + await worker.runOnce(); + await worker.runOnce(); + assert.equal(executeCount, 1); + assert.equal(config.forceGenerateOnce, false); + assert.equal(pushCalls.length, 0); + assert.equal(fs.readFileSync(pagePath, 'utf8'), HEALTHY_TODAY_HTML); + assert.equal( + fs.readdirSync(publicDir).some((name) => name.startsWith('_archived-daily-news-0911-')), + false, + ); + } finally { + Date.now = originalNow; + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + +test('news002 page that misses structure checks is still published with empty sections pruned', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-news002-degraded-')); + const userId = 'user-news'; + const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + fs.mkdirSync(publicDir, { recursive: true }); + + const service = createWechatNewsMorningDraftService(createPool(), { + 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, + H5_WECHAT_NEWS_MORNING_TEMPLATE_ID: 'news002', + }, + }); + const warnings = []; + const html = '

