fix: recover real news photos before scheduled draft delivery

This commit is contained in:
john
2026-10-21 05:57:53 +08:00
parent c4078d28e1
commit 101609b3df
6 changed files with 462 additions and 29 deletions
@@ -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/`。原工作区改动单独暂存,禁止混入本次提交或发布。
+18 -12
View File
@@ -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,
+45 -8
View File
@@ -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('&', '&amp;')));
}).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 } : {}) });
}
},
+163
View File
@@ -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(/<script\b[^>]*>[\s\S]*?<\/script>|<!--[\s\S]*?-->/gi, '');
const rows = [];
for (const match of clean.matchAll(/<a\b([^>]*)>([\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('&amp;', '&'), 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(/^<!\[CDATA\[([\s\S]*)\]\]>$/, '$1')
.replaceAll('&amp;', '&').replaceAll('&quot;', '"').replace(/<[^>]*>/g, '').trim();
return [...String(xml).matchAll(/<item\b[^>]*>([\s\S]*?)<\/item>/gi)].slice(0, 200).map(match => ({
title: decode(match[1].match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1]),
url: decode(match[1].match(/<link\b[^>]*>([\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 };
}
+179
View File
@@ -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 = `<html><head><title>每日新闻早报 · 2026年10月22日</title></head><body><div class="masthead"><h1>每日新闻早报</h1></div><div class="section" id="domestic"><div class="section-title">国内</div>${Array.from({length:8},(_,i)=>`<div class="card" data-event-key="story-${i}"><h3>${title}${i}</h3><div class="body"><p>论坛开幕,讨论航运业发展。</p></div><div class="source"><a href="https://publisher.example/">来源</a></div></div>`).join('')}</div></body></html>`;
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(`<article><img src="https://photos.example/photo/${encodeURIComponent(ref)}.png"></article>`,{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(`<article><img src="https://photos.example/${encodeURIComponent(String(url))}.jpg"></article>`,{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(`<img src="/logo.jpg"><a href="/story/2026/forum">${title}</a><a href="https://unrelated.example/story">${title}</a><a href="javascript:alert(1)">${title}</a>`);
},
});
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/')
?`<rss><channel><item><title><![CDATA[${title}]]></title><link>https://www.chinanews.com.cn/gn/2026/10-22/10718888.shtml</link></item></channel></rss>`:'')});
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=`<div class="card"><h3>${title}</h3><div class="source"><a href="https://www.bbc.com/zhongwen/simp">来源</a></div></div>`;
const result=await enrichNews002HtmlWithSourceImages(html,{fetchImpl:async()=>{throw Error('must not fetch language homepage as article');}});
assert.equal(result.resolvedCount,0);
});
+32 -9
View File
@@ -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 '';
}