From 101609b3df6512302c61ab1077092e79539cce33 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 Oct 2026 05:57:53 +0800 Subject: [PATCH] fix: recover real news photos before scheduled draft delivery --- ...1-news-morning-automatic-photo-recovery.md | 25 +++ wechat-news-morning-draft-worker.mjs | 30 +-- wechat-news-morning-draft.mjs | 53 +++++- wechat-news-morning-image-recovery.mjs | 163 ++++++++++++++++ wechat-news-morning-image-recovery.test.mjs | 179 ++++++++++++++++++ wechat-news-morning-images.mjs | 41 +++- 6 files changed, 462 insertions(+), 29 deletions(-) create mode 100644 docs/incidents/2026-10-21-news-morning-automatic-photo-recovery.md create mode 100644 wechat-news-morning-image-recovery.mjs create mode 100644 wechat-news-morning-image-recovery.test.mjs diff --git a/docs/incidents/2026-10-21-news-morning-automatic-photo-recovery.md b/docs/incidents/2026-10-21-news-morning-automatic-photo-recovery.md new file mode 100644 index 0000000..9148253 --- /dev/null +++ b/docs/incidents/2026-10-21-news-morning-automatic-photo-recovery.md @@ -0,0 +1,25 @@ +# 2026-10-21 早报自动配图恢复 + +## 现场 + +生产 c4078d2 的 10 月 21 日任务尝试 26 条新闻、解析到 0 张报道图片,但 `no-story-images` 只是 warning,仍创建成功草稿。微信正文只有二维码,封面来自页面自动缩略图。当天草稿已按用户要求单独修复,同一 media ID 读回验证通过;该内容修复不等于自动流程修复。 + +## 自动流程修复 + +- news002 的共同推送入口补图,覆盖定时、手动、重生成与已有当天页面。先尝试原报道页,再从原媒体首页的同题链接、标题搜索和官方 RSS 定位报道。标题匹配并拒绝冲突年份、首页、Google News 包装链接与站点品牌图;不拿不相关新闻图片凑数。 +- 新闻图片落地到当日页面目录,保留原新闻标题、正文、来源和顺序。封面元信息指向真实报道图;微信封面、页面缩略图使用同一来源照片。 +- 推送至少要求一张存在于本地的报道图,且最终微信正文仍含已上传的新闻图,封面必须来自新闻照片。二维码、默认图和仅有 URL 的无文件引用不能满足条件。图片不足不创建草稿,记录 failed。 +- 定时推送不提前;设定时间之后提供 30 分钟自动重试窗口。无图错误的重试间隔至少 2 分钟,既有当日成功记录继续去重。 +- 不承诺每条新闻都有图,也不承诺每天固定五张。照片必须确实由对应报道提供且可下载。无可用照片时保持失败,不伪造图片或把无图成功隐藏起来。 + +## 验证 + +90 项新闻配图、草稿服务、调度、搜索与微信转换相关测试通过。新增用例覆盖首页定位、官方 RSS、标题与年份冲突、搜索故障、BBC 品牌图过滤、真实上传及封面生成、上传失败不创建草稿、三种触发入口无图拦截、晚到重试与禁止提前推送。 + +使用当天未经人工补图的原 HTML 进行本地真实网络重放,在搜索未配置时自动取得 1 张符合条件的报道图,并通过图片存在性检查;最初试跑识别出的 BBC 语言首页品牌图已加入拒绝规则。生产搜索服务实测 HTTP 200 但结果为空,新闻引擎当日候选也是空,因此不能依赖搜索状态码保证图片获取成功。 + +本次未发布生产。启用需要测试后的明确上线批准、完整 main、CI 与正式整包发布。上线后须核验次日任务记录、微信原稿、封面和缩略图;不能用“auto push succeeded”日志代替内容核验。 + +## 范围与回滚 + +无数据库 schema 或配置变更。回滚本次提交即恢复旧流程;已生成的页面内容和草稿不回滚。当天内容修复备份在生产 `data/news-image-repair-1792532762135/`,本地验收在 `/tmp/memind-news-1021-repair/`。原工作区改动单独暂存,禁止混入本次提交或发布。 diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs index 4a2dca0..4c502b3 100644 --- a/wechat-news-morning-draft-worker.mjs +++ b/wechat-news-morning-draft-worker.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { executeScheduledTask } from './scheduled-task-executor.mjs'; -import { localDateKey } from './schedule-time.mjs'; +import { localDateKey, getLocalParts } from './schedule-time.mjs'; import { buildNewsMorningAutoGenerationTask, findLatestNewsMorningPage, @@ -39,6 +39,13 @@ import { } from './wechat-news-morning-draft-worker-config.mjs'; import { isPassiveCanaryRuntime } from './server/portal-runtime-role.mjs'; +export function isNewsMorningPushRetryWindow(config, now, toleranceMinutes = 1) { + const parts = getLocalParts(now, config.timezone); + const current = parts.hour * 60 + parts.minute; + const target = Number(config.pushHour) * 60 + Number(config.pushMinute); + return current >= target && current <= target + Math.max(30, toleranceMinutes); +} + function resolveTodayPage(config, h5Root, now = Date.now()) { const page = findLatestNewsMorningPage({ h5Root, @@ -387,6 +394,7 @@ export function startWechatNewsMorningDraftWorker({ let running = false; let generationInFlightDateKey = null; let degradedRegenerateDateKey = null; + let nextPhotoRetryAt = 0; const ensureTodayPage = async (config, dateKey, { now = Date.now(), force = false } = {}) => { if (generationInFlightDateKey === dateKey) return null; @@ -621,13 +629,7 @@ export function startWechatNewsMorningDraftWorker({ if (!config.autoPushEnabled) return; - if ( - !isLocalScheduleDue( - { hour: config.pushHour, minute: config.pushMinute, timezone }, - now, - toleranceMinutes, - ) - ) { + if (!isNewsMorningPushRetryWindow(config, now, toleranceMinutes) || now < nextPhotoRetryAt) { return; } @@ -644,10 +646,14 @@ export function startWechatNewsMorningDraftWorker({ return; } - const result = await wechatNewsMorningDraftService.pushDraft({ - triggeredBy: 'auto-push', - now, - }); + let result; + try { + result = await wechatNewsMorningDraftService.pushDraft({ triggeredBy: 'auto-push', now }); + nextPhotoRetryAt = 0; + } catch (error) { + if (error.code === 'NEWS_MORNING_IMAGES_INCOMPLETE') nextPhotoRetryAt = now + 120_000; + throw error; + } logger.log?.('[NewsMorningDraft] auto push succeeded', { draftMediaId: result.draftMediaId, pageSlug: result.preview?.page?.slug ?? todayPage.slug, diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index 5971c7d..fe7e7dd 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -22,6 +22,8 @@ import { } from './wechat-draft-article-layout.mjs'; import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs'; import { countNewsMorningStoryCards } from './wechat-news-morning-dedup.mjs'; +import { recoverNewsMorningPhotos, assertNewsMorningPhotoCoverage } from './wechat-news-morning-image-recovery.mjs'; +import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs'; import { maskBlockedNewsTerms } from './news-content-safety-rules.mjs'; import { NEWS_MORNING_AI_DISCLOSURE_TEXT, @@ -940,13 +942,19 @@ export async function buildDailyNewsWechatDraftArticleForPush({ pushWarnings.push(`image-upload-missing:${missingUploads.length}`); } } + const rendered = buildDailyNewsWechatDraftArticle({ + html: draftHtml, + publicUrl, + qrcodeImageUrl, + imageUrlMap, + }); + const newsImageCount = extractNews002CardImageUrls(draftHtml).filter(ref => { + const url = imageUrlMap.get(ref) ?? imageUrlMap.get(ref.replace(/^\.\//, '')); + return url && (rendered.content.includes(url) || rendered.content.includes(url.replaceAll('&', '&'))); + }).length; return { - ...buildDailyNewsWechatDraftArticle({ - html: draftHtml, - publicUrl, - qrcodeImageUrl, - imageUrlMap, - }), + ...rendered, + newsImageCount, pushWarnings, }; } @@ -1224,6 +1232,7 @@ export function createWechatNewsMorningDraftService( imageGenerationService = null, env = process.env, wechatFetch = undiciFetch, + recoverNewsPhotos = recoverNewsMorningPhotos, logger = console, } = {}, ) { @@ -1642,7 +1651,24 @@ export function createWechatNewsMorningDraftService( masked: safety.maskedCount, }); } - const html = safety.html; + let html = safety.html; + if (config.templateId === 'news002') { + // This is shared by scheduled, forced and manual pushes, including + // pages generated before this process started. Keep partial recovery + // for the next scan, but do not publish a draft that fails the gate. + try { + assertNewsMorningPhotoCoverage(html, { publishDir: path.dirname(page.localPath) }); + } catch { + const recovered = await recoverNewsPhotos(html, { + env, logger, publishDir: path.dirname(page.localPath), slug: page.slug, + }); + html = recovered.html; + } + fs.writeFileSync(page.localPath, html); + const { cover } = assertNewsMorningPhotoCoverage(html, { publishDir: path.dirname(page.localPath) }); + html = upsertMindspaceCoverMeta(html, { cover }); + fs.writeFileSync(page.localPath, html); + } const thumb = await resolveNewsMorningDraftThumbBuffer({ page, html, @@ -1653,6 +1679,8 @@ export function createWechatNewsMorningDraftService( now, publishDir: path.dirname(page.localPath), memindLibRoot: h5Root, + env: config.templateId === 'news002' + ? { ...env, MEMIND_NEWS_MORNING_COVER_PREFER_NEWS_IMAGE: '1' } : env, }); const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumb.buffer, { wechatFetch }); const article = isDailyNewsFormat(html) @@ -1671,6 +1699,15 @@ export function createWechatNewsMorningDraftService( warnings: article.pushWarnings, }); } + if (config.templateId === 'news002') { + const { required } = assertNewsMorningPhotoCoverage(html, { publishDir: path.dirname(page.localPath) }); + if ((article.newsImageCount ?? 0) < required) { + throw Object.assign(new Error('微信正文新闻图片上传或渲染不足,暂缓创建草稿并等待自动重试。'), { code: 'NEWS_MORNING_IMAGES_INCOMPLETE' }); + } + if (thumb.source !== 'news-image') { + throw Object.assign(new Error('新闻封面未采用已核验的报道图片,暂缓创建草稿。'), { code: 'NEWS_MORNING_IMAGES_INCOMPLETE' }); + } + } if (articleTitleOverride) { article.title = String(articleTitleOverride).trim().slice(0, 64); } @@ -1721,7 +1758,7 @@ export function createWechatNewsMorningDraftService( errorMessage: message, triggeredBy, }); - throw Object.assign(new Error(message), { run }); + throw Object.assign(new Error(message), { run, ...(error?.code ? { code: error.code } : {}) }); } }, diff --git a/wechat-news-morning-image-recovery.mjs b/wechat-news-morning-image-recovery.mjs new file mode 100644 index 0000000..e1f478c --- /dev/null +++ b/wechat-news-morning-image-recovery.mjs @@ -0,0 +1,163 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { searchSearxng } from './mindsearch-providers.mjs'; +import { resolvePortalSearxngEndpoint } from './mindsearch-prefetch.mjs'; +import { + enrichNews002HtmlWithSourceImages, + extractNews002StoryCards, + extractNews002CardImageUrls, + localizeNews002CardImages, +} from './wechat-news-morning-images.mjs'; +import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs'; + +function normalized(value) { + return String(value ?? '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); +} + +export function matchesNewsPhotoStory(title, candidate) { + const a = normalized(title), b = normalized(candidate); + if (a.length < 6 || b.length < 6) return false; + const years = (s) => s.match(/20\d{2}/g) ?? []; + if (years(a).length && years(b).length && !years(a).some(y => years(b).includes(y))) return false; + const pairs = (s) => new Set([...s].slice(1).map((_, i) => s.slice(i, i + 2))); + const x = pairs(a), y = pairs(b); + const shared = [...x].filter(part => y.has(part)).length; + return shared >= 4 && 2 * shared / (x.size + y.size) >= 0.55; +} + +function articleUrl(value) { + try { + const u = new URL(value); + return u.protocol === 'https:' && u.hostname !== 'news.google.com' + && !/^\/(?:index\.[^/]+)?$/i.test(u.pathname) + && !/^\/(?:zhongwen(?:\/(?:simp|trad))?|news|world|business|tech|latest)\/?$/i.test(u.pathname) ? u.href : null; + } catch { return null; } +} + +export function collectNewsPhotoArticleLinks(html, sourceUrl) { + const clean = String(html).replace(/]*>[\s\S]*?<\/script>|/gi, ''); + const rows = []; + for (const match of clean.matchAll(/]*)>([\s\S]*?)<\/a>/gi)) { + const href = match[1].match(/\bhref\s*=\s*(["'])(.*?)\1/i)?.[2]; + if (!href) continue; + const titleAttr = match[1].match(/\btitle\s*=\s*(["'])(.*?)\1/i)?.[2]; + const title = String(titleAttr || match[2].replace(/<[^>]*>/g, '')).trim(); + try { + const url = new URL(href.replaceAll('&', '&'), sourceUrl); + if (!['http:', 'https:'].includes(url.protocol)) continue; + url.protocol = 'https:'; + const originHost = new URL(sourceUrl).hostname.replace(/^www\./, ''); + if (url.hostname !== originHost && !url.hostname.endsWith('.' + originHost)) continue; + if (articleUrl(url.href)) rows.push({ title, url: url.href }); + } catch { /* malformed link */ } + } + return rows; +} + +export function parseNewsPhotoFeed(xml) { + const decode = value => String(value ?? '').replace(/^$/, '$1') + .replaceAll('&', '&').replaceAll('"', '"').replace(/<[^>]*>/g, '').trim(); + return [...String(xml).matchAll(/]*>([\s\S]*?)<\/item>/gi)].slice(0, 200).map(match => ({ + title: decode(match[1].match(/]*>([\s\S]*?)<\/title>/i)?.[1]), + url: decode(match[1].match(/]*>([\s\S]*?)<\/link>/i)?.[1]), + })).filter(row => row.title && articleUrl(row.url)); +} + +export function createNewsPhotoArticleResolver({ + groups = [], env = process.env, searchImpl = searchSearxng, fetchImpl = fetch, logger = console, +} = {}) { + const candidates = groups.flatMap(group => group.results ?? []); + const endpoint = resolvePortalSearxngEndpoint(env); + const cache = new Map(); + const homepages = new Map(); + let feedCandidates; + const readFeeds = () => feedCandidates ??= Promise.all([ + 'https://www.chinanews.com.cn/rss/scroll-news.xml', + 'https://www.chinanews.com.cn/rss/finance.xml', + 'https://www.chinanews.com.cn/rss/world.xml', + ].map(async url => { + try { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(6000) }); + return response.ok ? parseNewsPhotoFeed((await response.text()).slice(0, 2000000)) : []; + } catch { return []; } + })).then(rows => rows.flat()); + let searches = 0; + return (card) => { + if (!cache.has(card.title)) cache.set(card.title, (async () => { + const matching = rows => rows.filter(row => matchesNewsPhotoStory(card.title, row.title)) + .map(row => articleUrl(row.url)).filter(Boolean); + const seeded = matching(candidates); + if (seeded.length) return [...new Set(seeded)].slice(0, 3); + // Read the original newsroom once per host, and follow only title-matched + // article links. Never select its logo or first homepage photo. + let origin; + try { const u = new URL(card.url); if (u.protocol === 'https:' && u.hostname !== 'news.google.com') origin = u.origin; } catch {} + if (origin) { + if (!homepages.has(origin)) homepages.set(origin, (async () => { + try { + const response = await fetchImpl(origin + '/', { signal: AbortSignal.timeout(6000), headers: { 'User-Agent': 'Mozilla/5.0', accept: 'text/html' } }); + return response.ok ? collectNewsPhotoArticleLinks((await response.text()).slice(0, 1500000), origin) : []; + } catch { return []; } + })()); + const linked = matching(await homepages.get(origin)); + if (linked.length) return [...new Set(linked)].slice(0, 3); + } + if (endpoint && searches < 32) { + searches += 1; + try { + const rows = await searchImpl(card.title, { + endpoint, limit: 6, timeoutMs: 6000, language: 'zh-CN', safesearch: 2, + engines: String(env.MEMIND_NEWS_MORNING_SEARXNG_ENGINES ?? 'yandex,bing,360search'), + }); + const searched = matching(Array.isArray(rows) ? rows : []); + if (searched.length) return [...new Set(searched)].slice(0, 3); + } catch (error) { + logger.warn?.('[NewsMorningImages] article lookup failed', { title: card.title, message: error.message }); + } + } + // Official feeds retain real article URLs when search is unavailable. + return [...new Set(matching(await readFeeds()))].slice(0, 3); + })()); + return cache.get(card.title); + }; +} + +export function assertNewsMorningPhotoCoverage(html, { publishDir } = {}) { + const stories = extractNews002StoryCards(html); + // Preserve all stories when some reports have no suitable photos. Require a + // real photo for the cover/body rather than an arbitrary count of five. + const required = Math.min(1, stories.length); + const images = extractNews002CardImageUrls(html).filter(ref => { + if (!publishDir) return true; + if (/^https?:/i.test(ref)) return false; + const file = path.resolve(publishDir, ref); + return file.startsWith(path.resolve(publishDir) + path.sep) + && fs.existsSync(file) && fs.statSync(file).isFile() && fs.statSync(file).size > 32; + }); + if (!required || images.length < required) { + const error = new Error(`新闻配图不足:${images.length}/${required || 1},自动补图后仍不足,暂缓推送并等待自动重试。`); + error.code = 'NEWS_MORNING_IMAGES_INCOMPLETE'; + throw error; + } + return { imageCount: images.length, required, cover: images[0] }; +} + +export async function recoverNewsMorningPhotos(html, { + groups = [], env = process.env, logger = console, publishDir, slug, + searchImpl = searchSearxng, fetchImpl = fetch, +} = {}) { + const enriched = await enrichNews002HtmlWithSourceImages(html, { + fetchImpl, timeoutMs: 6000, concurrency: 4, + resolveArticleCandidates: createNewsPhotoArticleResolver({ groups, env, searchImpl, fetchImpl, logger }), + }); + const localized = await localizeNews002CardImages(enriched.html, { + env, fetchImpl, timeoutMs: 8000, publishDir, slug, + }); + const images = extractNews002CardImageUrls(localized.html); + logger.log?.('[NewsMorningImages] automatic photo recovery', { + slug, stories: enriched.cardCount, resolved: enriched.resolvedCount, + localized: localized.localizedCount, failed: localized.failedCount, imageCount: images.length, + }); + return { ...localized, html: images.length + ? upsertMindspaceCoverMeta(localized.html, { cover: images[0] }) : localized.html }; +} diff --git a/wechat-news-morning-image-recovery.test.mjs b/wechat-news-morning-image-recovery.test.mjs new file mode 100644 index 0000000..57e912c --- /dev/null +++ b/wechat-news-morning-image-recovery.test.mjs @@ -0,0 +1,179 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import sharp from 'sharp'; +import { matchesNewsPhotoStory, createNewsPhotoArticleResolver, recoverNewsMorningPhotos, assertNewsMorningPhotoCoverage } from './wechat-news-morning-image-recovery.mjs'; +import { extractNews002StoryCards } from './wechat-news-morning-images.mjs'; +import { createWechatNewsMorningDraftService } from './wechat-news-morning-draft.mjs'; +import { PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR } from './user-publish.mjs'; + +const title = '2026北外滩国际航运论坛在沪开幕 聚焦航运韧性'; +const log = {log() {}, warn() {}}; +const endpointEnv = { TKMIND_SEARCH_SEARXNG_URL: 'https://search.example/search' }; +const source = `每日新闻早报 · 2026年10月22日

每日新闻早报

国内
${Array.from({length:8},(_,i)=>`

${title}${i}

论坛开幕,讨论航运业发展。

`).join('')}
`; + +function pool() { + const runs=[]; + return {runs, async query(sql, values=[]) { + if(sql.includes('CREATE TABLE'))return [[],[]]; + if(sql.includes('SELECT config_json'))return [[],[]]; + if(sql.includes('INSERT INTO h5_wechat_news_draft_runs')){runs.push(values);return [[],[]];} + if(sql.includes('FROM h5_wechat_news_draft_runs'))return [[],[]]; + throw Error(sql); + }}; +} + +test('story lookup rejects unrelated, homepage, wrapper and conflicting-year results', async () => { + assert.equal(matchesNewsPhotoStory(title,'2026北外滩国际航运论坛开幕'),true); + assert.equal(matchesNewsPhotoStory(title,'2025北外滩国际航运论坛开幕'),false); + assert.equal(matchesNewsPhotoStory(title,'无关公司发布财报'),false); + let calls=0; + const resolve=createNewsPhotoArticleResolver({env:endpointEnv,logger:log,searchImpl:async()=>{ + calls++;return [ + {title,url:'https://publisher.example/'}, + {title,url:'https://news.google.com/rss/articles/token'}, + {title:'无关公司发布财报',url:'https://publisher.example/wrong'}, + {title:'2026北外滩国际航运论坛开幕',url:'https://publisher.example/2026/story'}, + ]; + }}); + assert.deepEqual(await resolve({title}),['https://publisher.example/2026/story']); + await resolve({title});assert.equal(calls,1); +}); + +test('prefetched matching articles avoid a second search; search outage returns no guessed images',async()=>{ + const resolve=createNewsPhotoArticleResolver({groups:[{results:[{title,url:'https://publisher.example/story'}]}],env:endpointEnv,searchImpl:async()=>{throw Error('must not search');}}); + assert.deepEqual(await resolve({title}),['https://publisher.example/story']); + const unavailable=createNewsPhotoArticleResolver({env:endpointEnv,logger:log,fetchImpl:async()=>new Response(''),searchImpl:async()=>{throw Error('offline');}}); + assert.deepEqual(await unavailable({title}),[]); +}); + +test('automatic recovery finds real story photos from homepage-only cards and preserves stories',async()=>{ + const root=fs.mkdtempSync(path.join(os.tmpdir(),'news-photo-recovery-')); + try{ + const bytes=await sharp({create:{width:120,height:80,channels:3,background:'#234567'}}).png().toBuffer(); + const result=await recoverNewsMorningPhotos(source,{ + publishDir:root,slug:'daily-news-1022',env:endpointEnv,logger:log, + searchImpl:async query=>[{title:query,url:'https://publisher.example/story/'+encodeURIComponent(query)}], + fetchImpl:async(url,options={})=>{ + const ref=String(url); + if(ref.includes('/photo/'))return options.method==='HEAD'?new Response(null,{status:200}):new Response(bytes,{headers:{'content-type':'image/png'}}); + assert.ok(!ref.endsWith('/'),'must not fetch homepage as a story'); + return new Response(`
`,{headers:{'content-type':'text/html'}}); + }, + }); + const before=extractNews002StoryCards(source).map(c=>[c.title,c.url,c.eventKey]); + assert.deepEqual(extractNews002StoryCards(result.html).map(c=>[c.title,c.url,c.eventKey]),before); + assert.equal(assertNewsMorningPhotoCoverage(result.html,{publishDir:root}).imageCount,8); + assert.match(result.html,/mindspace-cover/); + assert.throws(()=>assertNewsMorningPhotoCoverage(result.html,{publishDir:path.join(root,'missing')}),{code:'NEWS_MORNING_IMAGES_INCOMPLETE'}); + }finally{fs.rmSync(root,{recursive:true,force:true});} +}); + +for (const triggeredBy of ['auto-push','manual-push','degraded-regenerate']) { + test(`${triggeredBy} refuses to create an imageless draft after bounded recovery`,async()=>{ + const root=fs.mkdtempSync(path.join(os.tmpdir(),'news-photo-gate-')); + const pub=path.join(root,PUBLISH_ROOT_DIR,'user-news',PUBLIC_ZONE_DIR); + fs.mkdirSync(pub,{recursive:true});fs.writeFileSync(path.join(pub,'daily-news-1022.html'),source); + const db=pool();let recoveries=0,creates=0; + const service=createWechatNewsMorningDraftService(db,{ + mpConfig:{enabled:true,appId:'app',appSecret:'secret'},h5Root:root,logger:log, + env:{H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED:'1',H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID:'user-news',H5_WECHAT_NEWS_MORNING_TEMPLATE_ID:'news002'}, + recoverNewsPhotos:async html=>{recoveries++;return {html};}, + wechatFetch:async url=>{ + if(String(url).includes('stable_token'))return new Response(JSON.stringify({access_token:'test',expires_in:7200})); + if(String(url).includes('draft/add'))creates++; + throw Error('Unexpected WeChat write '+url); + }, + }); + try{ + await assert.rejects(service.pushDraft({triggeredBy,now:Date.UTC(2026,9,21,22)}),{code:'NEWS_MORNING_IMAGES_INCOMPLETE'}); + assert.equal(recoveries,1);assert.equal(creates,0); + assert.ok(db.runs.some(row=>row[1]==='failed')); + }finally{fs.rmSync(root,{recursive:true,force:true});} + }); +} + +for (const uploadWorks of [true, false]) { + test(`scheduled push ${uploadWorks ? 'delivers recovered photos and a real cover' : 'stops when WeChat photo upload fails'}`,async()=>{ + const root=fs.mkdtempSync(path.join(os.tmpdir(),'news-photo-delivery-')); + const pub=path.join(root,PUBLISH_ROOT_DIR,'user-news',PUBLIC_ZONE_DIR); + fs.mkdirSync(pub,{recursive:true});fs.writeFileSync(path.join(pub,'daily-news-1022.html'),source); + const bytes=await sharp({create:{width:180,height:120,channels:3,background:'#234567'}}).jpeg().toBuffer(); + let created=null,uploads=0,recoveries=0; + const db=pool(); + const service=createWechatNewsMorningDraftService(db,{ + mpConfig:{enabled:true,appId:'app',appSecret:'secret'},h5Root:root,logger:log, + env:{H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED:'1',H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID:'user-news',H5_WECHAT_NEWS_MORNING_TEMPLATE_ID:'news002'}, + recoverNewsPhotos:async html=>{ + recoveries++; + return recoverNewsMorningPhotos(html,{ + publishDir:pub,slug:'daily-news-1022',env:endpointEnv,logger:log, + searchImpl:async query=>[{title:query,url:'https://publisher.example/story/'+encodeURIComponent(query)}], + fetchImpl:async(url,opts={})=>String(url).includes('photos.example') + ? opts.method==='HEAD'?new Response(null,{status:200}):new Response(bytes) + :new Response(`
`,{headers:{'content-type':'text/html'}}), + }); + }, + wechatFetch:async(url,opts={})=>{ + const u=String(url); + if(u.includes('stable_token'))return new Response(JSON.stringify({access_token:'test',expires_in:7200})); + if(u.includes('material/add_material'))return new Response(JSON.stringify({media_id:'real-cover'})); + if(u.includes('media/uploadimg'))return new Response(JSON.stringify(uploadWorks?{url:`https://mmbiz.qpic.cn/mmbiz_jpg/photo-${++uploads}/0`}:{errcode:40009,errmsg:'image rejected'})); + if(u.includes('draft/add')){created=JSON.parse(opts.body);return new Response(JSON.stringify({media_id:'draft-recovered'}));} + throw Error('Unexpected WeChat request '+u); + }, + }); + try{ + if(!uploadWorks){ + await assert.rejects(service.pushDraft({triggeredBy:'auto-push',now:Date.UTC(2026,9,21,22)})); + assert.equal(created,null);return; + } + const result=await service.pushDraft({triggeredBy:'auto-push',now:Date.UTC(2026,9,21,22)}); + assert.equal(recoveries,1);assert.equal(result.cover.source,'news-image'); + assert.equal(created.articles[0].thumb_media_id,'real-cover'); + assert.ok(result.preview.article.newsImageCount>=3); + assert.ok(fs.existsSync(path.join(pub,'daily-news-1022.thumbnail.png'))); + assert.ok(db.runs.some(row=>row[1]==='success')); + }finally{fs.rmSync(root,{recursive:true,force:true});} + }); +} + +test('newsroom fallback follows matching article links once per host without search',async()=>{ + let reads=0,searches=0; + const resolve=createNewsPhotoArticleResolver({env:endpointEnv,logger:log, + searchImpl:async()=>{searches++;return [];}, + fetchImpl:async()=>{ + reads++; + return new Response(`${title}${title}${title}`); + }, + }); + const urls=await resolve({title,url:'https://publisher.example/'}); + assert.deepEqual(urls,['https://publisher.example/story/2026/forum']); + await resolve({title:title+'最新',url:'https://publisher.example/'}); + assert.equal(reads,1);assert.equal(searches,0); +}); + +test('photo retry window allows recovery after the due minute without pushing early or all day',async()=>{ + const {isNewsMorningPushRetryWindow}=await import('./wechat-news-morning-draft-worker.mjs'); + const config={pushHour:6,pushMinute:0,timezone:'Asia/Shanghai'}; + assert.equal(isNewsMorningPushRetryWindow(config,Date.UTC(2026,9,21,21,59)),false); + assert.equal(isNewsMorningPushRetryWindow(config,Date.UTC(2026,9,21,22,10)),true); + assert.equal(isNewsMorningPushRetryWindow(config,Date.UTC(2026,9,21,22,31)),false); +}); + +test('official RSS supplies title-matched article URLs when search and homepage are unavailable',async()=>{ + const resolve=createNewsPhotoArticleResolver({env:endpointEnv,logger:log, + searchImpl:async()=>[],fetchImpl:async url=>new Response(String(url).includes('/rss/') + ?`<![CDATA[${title}]]>https://www.chinanews.com.cn/gn/2026/10-22/10718888.shtml`:'')}); + assert.deepEqual(await resolve({title,url:'https://publisher.example/'}),['https://www.chinanews.com.cn/gn/2026/10-22/10718888.shtml']); +}); + +test('BBC site branding and language homepages cannot count as a story photo',async()=>{ + const {isWeakNewsMorningImageUrl,enrichNews002HtmlWithSourceImages}=await import('./wechat-news-morning-images.mjs'); + assert.equal(isWeakNewsMorningImageUrl('https://static.files.bbci.co.uk/ws/simorgh-assets/public/zhongwen/images/metadata/poster-1024x576.png'),true); + const html=`

${title}

`; + const result=await enrichNews002HtmlWithSourceImages(html,{fetchImpl:async()=>{throw Error('must not fetch language homepage as article');}}); + assert.equal(result.resolvedCount,0); +}); diff --git a/wechat-news-morning-images.mjs b/wechat-news-morning-images.mjs index 7e7a011..a56f95a 100644 --- a/wechat-news-morning-images.mjs +++ b/wechat-news-morning-images.mjs @@ -114,6 +114,7 @@ export function extractNews002StoryCards(html) { /** 站点 logo、作者头像、favicon 等不宜作为新闻配图。 */ export function isWeakNewsMorningImageUrl(url) { const value = String(url ?? '').toLowerCase(); + if (/simorgh-assets\/.*\/images\/metadata\//.test(value)) return true; if (!value) return true; return /favicon|\/icon(?:\/|$)|logo\.png|globalissues\.png|_share\.png|versant_share|siteicon|default.*\.(?:png|jpe?g|webp)|wikimedia\.org|resize,w_200|generate_sharing_image|social-preview-default|scidaily-icon|arxiv-logo|static\/logo\.png|\/150\.jpg|-100x100\.|\/100x100\.|unsplash\.com|pexels\.com|placeholder/.test(value); } @@ -188,7 +189,9 @@ export function extractNewsMorningSourceImage(html, pageUrl = '') { while ((match = bodyOpen.exec(source))) { const classes = attrOf(match[0], 'class').split(/\s+/); const isBody = attrOf(match[0], 'itemprop').split(/\s+/).includes('articleBody') - || classes.some((name) => ['left_zw', 'article-body', 'article-content', 'entry-content'].includes(name)); + || match[1].toLowerCase() === 'article' + || ['zoom', 'articlecontent', 'article-content', 'articlebody', 'maintext'].includes(attrOf(match[0], 'id').toLowerCase()) + || classes.some((name) => ['left_zw', 'article-body', 'article-content', 'entry-content', 'TRS_Editor', 'TRS_UEDITOR'].includes(name)); if (!isBody) continue; const { end } = sliceBalancedBlock(source, match[1], match.index, bodyOpen.lastIndex); const body = source.slice(bodyOpen.lastIndex, end); @@ -706,6 +709,7 @@ export async function enrichNews002HtmlWithSourceImages( timeoutMs = DEFAULT_TIMEOUT_MS, concurrency = 8, fallbackImage: _unusedFallbackImage = '', + resolveArticleCandidates = null, } = {}, ) { const source = String(html ?? ''); @@ -726,14 +730,33 @@ export async function enrichNews002HtmlWithSourceImages( return existingImage; } if (existingImage) rejectedCount += 1; - if (!card.url) return ''; - const fromSource = await resolveNewsMorningSourceImage(card.url, { fetchImpl, timeoutMs }); - if ( - fromSource - && !isWeakNewsMorningImageUrl(fromSource) - && await isFetchableNewsMorningImageUrl(fromSource, { fetchImpl, timeoutMs, referrer: card.url }) - ) { - return fromSource; + async function trySource(pageUrl) { + try { + const url = new URL(pageUrl); + if (url.hostname === 'news.google.com' || /^\/(?:index\.[^/]+)?$/i.test(url.pathname) + || /^\/(?:zhongwen(?:\/(?:simp|trad))?|news|world|business|tech|latest)\/?$/i.test(url.pathname)) return ''; + } catch { return ''; } + const fromSource = await resolveNewsMorningSourceImage(pageUrl, { fetchImpl, timeoutMs }); + if ( + fromSource + && !isWeakNewsMorningImageUrl(fromSource) + && await isFetchableNewsMorningImageUrl(fromSource, { fetchImpl, timeoutMs, referrer: pageUrl }) + ) { + return fromSource; + } + return ''; + } + const originalImage = await trySource(card.url); + if (originalImage) return originalImage; + // Search only after the article fails. The caller title-matches candidates; + // never use a homepage, a Google News wrapper or generic image search. + let alternatives = []; + if (resolveArticleCandidates) { + try { alternatives = await resolveArticleCandidates(card); } catch { /* keep the card without a guessed image */ } + } + for (const pageUrl of [...new Set(alternatives)].filter(url => url !== card.url)) { + const image = await trySource(pageUrl); + if (image) return image; } return ''; }