fix: recover real news photos before scheduled draft delivery
This commit is contained in:
@@ -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/`。原工作区改动单独暂存,禁止混入本次提交或发布。
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}) });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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('&', '&'), 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('&', '&').replaceAll('"', '"').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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user