feat(news): add SearXNG fallback when news002 agent output is thin

When the post-generation audit fails, supplement missing core sections,
headlines, and brief items from prefetched SearXNG groups before re-running
image enrichment and the push gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-21 15:10:07 +08:00
parent 2e436664ce
commit 0092af5c0d
7 changed files with 637 additions and 22 deletions
+46 -8
View File
@@ -10,7 +10,11 @@ import {
isNewsMorningPageForToday,
isWithinNewsMorningGenerateLeadWindow,
} from './wechat-news-morning-draft.mjs';
import { prefetchNewsMorningSearxngBrief } from './wechat-news-morning-search.mjs';
import { prefetchNewsMorningSearxngBundle } from './wechat-news-morning-search.mjs';
import {
shouldUseNewsMorningSearxngFallback,
supplementNews002HtmlFromSearxngGroups,
} from './wechat-news-morning-searxng-fallback.mjs';
import {
dedupeNewsMorningHtml,
formatNewsMorningDedupeSummary,
@@ -65,18 +69,49 @@ function applyNewsMorningDedupe(page, logger) {
}
}
async function applyNews002ImageGate(page, config, logger, env = process.env) {
async function applyNews002ImageGate(
page,
config,
logger,
env = process.env,
{ searchGroups = [] } = {},
) {
if (config?.templateId !== 'news002') return null;
if (!page?.localPath || !fs.existsSync(page.localPath)) {
throw new Error('news002 配图闸门无法读取生成页面');
}
const source = fs.readFileSync(page.localPath, 'utf8');
const enriched = await enrichNews002HtmlWithSourceImages(source, {
let source = fs.readFileSync(page.localPath, 'utf8');
let enriched = await enrichNews002HtmlWithSourceImages(source, {
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
fs.writeFileSync(page.localPath, enriched.html);
const audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
let audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
if (
!audit.ok
&& searchGroups.length > 0
&& shouldUseNewsMorningSearxngFallback(env)
) {
const supplemented = supplementNews002HtmlFromSearxngGroups(
enriched.html,
searchGroups,
audit,
);
if (supplemented.changed) {
logger.log?.('[NewsMorningDraft] searxng fallback supplement', {
slug: page.slug,
...supplemented.stats,
});
enriched = await enrichNews002HtmlWithSourceImages(supplemented.html, {
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
fs.writeFileSync(page.localPath, enriched.html);
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
}
}
logger.log?.('[NewsMorningDraft] news002 image audit', {
slug: page.slug,
attempted: enriched.attemptedCount,
@@ -121,7 +156,7 @@ export function startWechatNewsMorningDraftWorker({
h5Root = null,
env = process.env,
executeTask = executeScheduledTask,
prefetchNewsSearch = prefetchNewsMorningSearxngBrief,
prefetchNewsSearch = prefetchNewsMorningSearxngBundle,
logger = console,
intervalMs = wechatNewsMorningDraftWorkerIntervalMs(env),
executionTimeoutMs = wechatNewsMorningDraftExecutionTimeoutMs(env),
@@ -152,12 +187,15 @@ export function startWechatNewsMorningDraftWorker({
archiveTodayPageForForce(config, h5Root, now, logger);
}
let searchBrief = '';
let searchGroups = [];
try {
searchBrief = await prefetchNewsSearch({
const searchBundle = await prefetchNewsSearch({
now,
timezone: config.timezone,
env,
});
searchBrief = String(searchBundle?.brief ?? searchBundle ?? '').trim();
searchGroups = Array.isArray(searchBundle?.groups) ? searchBundle.groups : [];
} catch (prefetchError) {
logger.warn?.(
'[NewsMorningDraft] searxng prefetch failed:',
@@ -181,7 +219,7 @@ export function startWechatNewsMorningDraftWorker({
throw new Error('新闻早报页面生成未完成');
}
applyNewsMorningDedupe(page, logger);
await applyNews002ImageGate(page, config, logger, env);
await applyNews002ImageGate(page, config, logger, env, { searchGroups });
await wechatNewsMorningDraftService.recordAutoGenerationRun({
status: 'success',
pageSlug: page.slug,
+4 -1
View File
@@ -212,7 +212,10 @@ test('generateToday skips existing page unless force is set', async () => {
executeCalls.push(task.taskSpec);
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), `${SAMPLE_HTML}<!--forced-->`);
},
prefetchNewsSearch: async () => '【专用联网搜索预取】国内国际热门事件',
prefetchNewsSearch: async () => ({
brief: '【专用联网搜索预取】国内国际热门事件',
groups: [],
}),
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
+21 -4
View File
@@ -329,6 +329,11 @@ export function auditNews002ImageCoverage(
if (!/#C8362F/i.test(source) || !/#FAF7F2/i.test(source) || !/#1B3A6B/i.test(source)) {
violations.push('noncanonical-palette');
}
const sectionAliases = {
brief: ['brief'],
business: ['business', 'finance'],
deepdive: ['deepdive', 'deep'],
};
const requiredSectionIds = [
'brief',
'headlines',
@@ -341,10 +346,22 @@ export function auditNews002ImageCoverage(
'knowledge',
'history',
];
const missingSections = requiredSectionIds.filter((id) => (
!new RegExp(`<div\\b[^>]*class=["'][^"']*\\bsection\\b[^"']*["'][^>]*id=["']${id}["']`, 'i')
.test(source)
));
const hasNews002Section = (id) => {
const aliases = sectionAliases[id] ?? [id];
for (const alias of aliases) {
if (alias === 'brief') {
if (/<ol\b[^>]*\bbrief\b[^>]*\bid=["']brief["']/i.test(source)) return true;
if (/<div\b[^>]*\bsection\b[^>]*\bid=["']brief["']/i.test(source)) return true;
continue;
}
if (new RegExp(`<div\\b[^>]*class=["'][^"']*\\bsection\\b[^"']*["'][^>]*id=["']${alias}["']`, 'i')
.test(source)) {
return true;
}
}
return false;
};
const missingSections = requiredSectionIds.filter((id) => !hasNews002Section(id));
if (missingSections.length > 0) violations.push(`missing-sections:${missingSections.join('|')}`);
if (cards.some((card) => !card.eventKey)) violations.push('missing-event-key');
if (cards.some((card) => !card.url)) violations.push('missing-source-url');
+17 -9
View File
@@ -223,7 +223,7 @@ async function searchNewsThenGeneral(query, {
}).catch(() => []);
}
export async function prefetchNewsMorningSearxngBrief({
export async function prefetchNewsMorningSearxngBundle({
now = Date.now(),
timezone = 'Asia/Shanghai',
env = process.env,
@@ -233,16 +233,14 @@ export async function prefetchNewsMorningSearxngBrief({
} = {}) {
const dateLabel = formatLocalDateParts(now, timezone);
const queries = buildNewsMorningSearchQueries(dateLabel);
const emptyBrief = formatNewsMorningSearchBrief(
queries.map((item) => ({ ...item, results: [] })),
{ dateLabel },
);
const emptyGroups = queries.map((item) => ({ ...item, results: [] }));
const emptyBrief = formatNewsMorningSearchBrief(emptyGroups, { dateLabel });
if (!shouldPrefetchNewsMorningSearxng(env)) {
return emptyBrief;
return { brief: emptyBrief, groups: emptyGroups, dateLabel, dedupeRemovedCount: 0 };
}
const endpoint = resolvePortalSearxngEndpoint(env);
if (!endpoint) {
return emptyBrief;
return { brief: emptyBrief, groups: emptyGroups, dateLabel, dedupeRemovedCount: 0 };
}
const timeoutMs = Number(env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000) || 8000;
const groups = await Promise.all(queries.map(async (item) => {
@@ -265,8 +263,18 @@ export async function prefetchNewsMorningSearxngBrief({
maxItems: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_MAX_ITEMS ?? 48) || 48,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
return formatNewsMorningSearchBrief(imageEnrichment.groups, {
return {
brief: formatNewsMorningSearchBrief(imageEnrichment.groups, {
dateLabel,
dedupeRemovedCount: removedCount,
}),
groups: imageEnrichment.groups,
dateLabel,
dedupeRemovedCount: removedCount,
});
};
}
export async function prefetchNewsMorningSearxngBrief(options = {}) {
const bundle = await prefetchNewsMorningSearxngBundle(options);
return bundle.brief;
}
+20
View File
@@ -7,6 +7,7 @@ import {
formatNewsMorningSearchBrief,
isDuplicateNewsMorningStory,
prefetchNewsMorningSearxngBrief,
prefetchNewsMorningSearxngBundle,
} from './wechat-news-morning-search.mjs';
test('buildNewsMorningSearchQueries covers core and extended channels', () => {
@@ -109,6 +110,25 @@ test('prefetchNewsMorningSearxngBrief queries news category then falls back', as
assert.match(brief, /图:https:\/\/cdn\.example\/news\.jpg/u);
});
test('prefetchNewsMorningSearxngBundle returns structured groups for fallback', async () => {
const bundle = await prefetchNewsMorningSearxngBundle({
now: Date.parse('2026-09-15T05:00:00+08:00'),
env: { TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search' },
searchImpl: async () => ([
{ title: '样例新闻', url: 'https://news.example/story', snippet: '摘要' },
]),
fetchImpl: async () => ({
ok: true,
headers: { get: () => 'text/html' },
text: async () => '<meta property="og:image" content="https://cdn.example/story.jpg">',
}),
});
assert.ok(Array.isArray(bundle.groups));
assert.ok(bundle.groups.length > 10);
assert.ok(bundle.groups.some((group) => group.results.length > 0));
assert.match(bundle.brief, /专用联网搜索预取/);
});
test('prefetchNewsMorningSearxngBrief skips live calls without endpoint', async () => {
const brief = await prefetchNewsMorningSearxngBrief({
now: Date.parse('2026-09-15T05:00:00+08:00'),
+434
View File
@@ -0,0 +1,434 @@
import { NEWS002_MAX_STORY_CARDS, NEWS002_SECTIONS } from './news-morning-templates.mjs';
import { extractNews002StoryCards, normalizeNewsMorningImageUrl } from './wechat-news-morning-images.mjs';
import {
isDuplicateNewsMorningStory,
normalizeNewsMorningTitle,
normalizeNewsMorningUrl,
} from './wechat-news-morning-search.mjs';
const SECTION_BY_ID = new Map(NEWS002_SECTIONS.map((item) => [item.id, item]));
/** SearXNG 预取分组 → news002 栏目 idcore 优先)。 */
export const SEARXNG_GROUP_TO_NEWS002_SECTION = {
lead: 'headlines',
domestic: 'domestic',
world: 'world',
finance: 'business',
tech: 'tech',
sports: 'culture',
science: 'science',
culture: 'culture',
local_life: 'living',
policy: 'domestic',
ai_chips: 'tech',
global_markets: 'business',
geopolitics: 'world',
official_feeds: 'world',
};
const SECTION_SEARCH_POOLS = {
headlines: ['lead', 'domestic', 'world', 'finance', 'tech', 'official_feeds'],
domestic: ['domestic', 'policy', 'local_life'],
world: ['world', 'geopolitics', 'official_feeds', 'asia_pacific'],
business: ['finance', 'global_markets', 'commodities'],
tech: ['tech', 'ai_chips', 'science'],
deepdive: ['lead', 'domestic', 'world', 'finance'],
culture: ['culture', 'sports'],
science: ['science', 'ai_chips'],
living: ['local_life', 'domestic'],
knowledge: ['science', 'culture'],
};
const SECTION_ALIASES = {
business: ['business', 'finance'],
deepdive: ['deepdive', 'deep'],
};
function envFlag(value, fallback = true) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
export function shouldUseNewsMorningSearxngFallback(env = process.env) {
return envFlag(env.MEMIND_NEWS_MORNING_SEARXNG_FALLBACK, true);
}
export function isWeakNewsMorningImageUrl(url) {
const value = String(url ?? '').toLowerCase();
if (!value) return true;
return /favicon|\/icon|logo\.png|wikimedia\.org|resize,w_200|generate_sharing_image|social-preview-default|scidaily-icon|arxiv-logo|static\/logo\.png|\/150\.jpg/.test(value);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, '&#39;');
}
function sliceBalancedBlock(html, tagName, openStart, openEnd) {
const open = new RegExp(`<${tagName}\\b`, 'gi');
const close = new RegExp(`</${tagName}\\s*>`, 'gi');
let depth = 1;
let cursor = openEnd;
while (depth > 0 && cursor < html.length) {
open.lastIndex = cursor;
close.lastIndex = cursor;
const nextOpen = open.exec(html);
const nextClose = close.exec(html);
if (!nextClose) return { start: openStart, end: html.length };
if (nextOpen && nextOpen.index < nextClose.index) {
depth += 1;
cursor = nextOpen.index + nextOpen[0].length;
} else {
depth -= 1;
cursor = nextClose.index + nextClose[0].length;
}
}
return { start: openStart, end: cursor };
}
function hasNews002Section(html, sectionId) {
const source = String(html ?? '');
const aliases = SECTION_ALIASES[sectionId] ?? [sectionId];
for (const alias of aliases) {
if (alias === 'brief') {
if (/<ol\b[^>]*\bbrief\b[^>]*\bid=["']brief["']/i.test(source)) return true;
if (/<div\b[^>]*\bsection\b[^>]*\bid=["']brief["']/i.test(source)) return true;
continue;
}
if (new RegExp(`<div\\b[^>]*class=["'][^"']*\\bsection\\b[^"']*["'][^>]*id=["']${alias}["']`, 'i')
.test(source)) {
return true;
}
}
return false;
}
function extractSectionBlock(html, sectionId) {
const aliases = SECTION_ALIASES[sectionId] ?? [sectionId];
for (const alias of aliases) {
const re = new RegExp(
`<div\\b[^>]*class=["'][^"']*\\bsection\\b[^"']*["'][^>]*id=["']${alias}["'][^>]*>`,
'i',
);
const match = re.exec(String(html ?? ''));
if (!match) continue;
const start = match.index;
const end = sliceBalancedBlock(html, 'div', start, start + match[0].length).end;
return { id: alias, start, end, openEnd: start + match[0].length };
}
return null;
}
function parseMissingSectionIds(violations = []) {
const ids = new Set();
for (const violation of violations) {
if (!String(violation).startsWith('missing-sections:')) continue;
for (const id of String(violation).slice('missing-sections:'.length).split('|')) {
if (id.trim()) ids.add(id.trim());
}
}
return [...ids];
}
function hostLabel(url) {
try {
return new URL(String(url)).hostname.replace(/^www\./i, '');
} catch {
return '来源';
}
}
export function buildNewsMorningEventKey(title, used = new Set()) {
let base = String(title ?? '')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'story';
let key = base;
let index = 2;
while (used.has(key)) {
key = `${base}-${index}`;
index += 1;
}
used.add(key);
return key;
}
function truncateText(value, max) {
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return '';
return text.length > max ? `${text.slice(0, max - 1)}` : text;
}
function flattenSearchGroups(groups = []) {
const map = new Map();
for (const group of Array.isArray(groups) ? groups : []) {
const id = String(group?.id ?? '').trim();
if (!id) continue;
map.set(id, {
id,
title: String(group?.title ?? id).trim(),
results: Array.isArray(group?.results) ? group.results : [],
});
}
return map;
}
function collectExistingStoryKeys(html) {
const cards = extractNews002StoryCards(html).filter((card) => card.title);
return {
cards,
seenTitles: cards.map((card) => ({
urlKey: normalizeNewsMorningUrl(card.url),
title: normalizeNewsMorningTitle(card.title),
})),
usedEventKeys: new Set(cards.map((card) => card.eventKey).filter(Boolean)),
};
}
function pickSearchCandidates(groupMap, poolIds, seenTitles, { requireImage = true } = {}) {
const picked = [];
for (const groupId of poolIds) {
const group = groupMap.get(groupId);
if (!group) continue;
for (const item of group.results) {
const title = String(item?.title ?? '').trim();
const url = String(item?.url ?? '').trim();
if (!title || !url) continue;
if (isDuplicateNewsMorningStory(seenTitles, { title, url })) continue;
const image = normalizeNewsMorningImageUrl(item?.image || '');
if (requireImage && (!image || isWeakNewsMorningImageUrl(image))) continue;
picked.push({
title,
url,
image,
snippet: truncateText(item?.snippet, 160),
tag: truncateText(group.title, 12),
groupId,
});
seenTitles.push({
urlKey: normalizeNewsMorningUrl(url),
title: normalizeNewsMorningTitle(title),
});
}
}
return picked;
}
function buildLeadCardHtml(item, eventKey) {
const title = escapeHtml(item.title);
const snippet = escapeHtml(truncateText(item.snippet || item.title, 120));
const tag = escapeHtml(item.tag || '要闻');
const url = escapeAttr(item.url);
const image = escapeAttr(item.image);
return [
`<a class="lead-card" href="${url}" target="_blank" rel="noopener" data-event-key="${escapeAttr(eventKey)}">`,
`<div class="media"><img src="${image}" alt="${title}" loading="lazy" referrerpolicy="no-referrer"></div>`,
`<div class="body"><span class="tag">${tag}</span><h3>${title}</h3><p>${snippet}</p>`,
`<div class="source"><a href="${url}" target="_blank" rel="noopener">${escapeHtml(hostLabel(item.url))}</a></div>`,
'</div></a>',
].join('');
}
function buildStoryCardHtml(item, eventKey) {
const title = escapeHtml(item.title);
const snippet = escapeHtml(truncateText(item.snippet || item.title, 140));
const tag = escapeHtml(item.tag || '要闻');
const url = escapeAttr(item.url);
const image = escapeAttr(item.image);
const why = escapeHtml(truncateText(`来自 ${hostLabel(item.url)} 的当日报道,建议纳入速览。`, 38));
return [
`<div class="card" data-event-key="${escapeAttr(eventKey)}">`,
`<div class="media"><img src="${image}" alt="${title}" loading="lazy" referrerpolicy="no-referrer"></div>`,
`<div class="body"><span class="tag">${tag}</span><h3>${title}</h3><p>${snippet}</p>`,
`<div class="why"><b>为什么重要</b>${why}</div>`,
`<div class="source"><a href="${url}" target="_blank" rel="noopener">${escapeHtml(hostLabel(item.url))}</a></div>`,
'</div></div>',
].join('');
}
function buildSectionShell(sectionId) {
const meta = SECTION_BY_ID.get(sectionId);
if (!meta) return '';
return [
`<div class="section" id="${sectionId}">`,
`<div class="section-title"><span class="icon">${meta.icon}</span><h2>${escapeHtml(meta.label)}</h2></div>`,
].join('');
}
function insertBeforeAnchor(html, anchorPattern, chunk) {
const source = String(html ?? '');
const match = anchorPattern.exec(source);
if (!match) return `${source}\n${chunk}`;
return `${source.slice(0, match.index)}${chunk}\n${source.slice(match.index)}`;
}
function ensureBriefList(html, candidates, stats) {
if (hasNews002Section(html, 'brief')) return html;
const items = candidates.slice(0, 6).map((item, index) => (
`<li><span class="n">${index + 1}</span><span>${escapeHtml(truncateText(item.title, 32))}</span></li>`
)).join('');
if (!items) return html;
stats.addedBriefItems = items.split('<li>').length - 1;
const block = `<ol class="brief" id="brief">${items}</ol>`;
return insertBeforeAnchor(html, /<div class="section" id="headlines"/i, block);
}
function appendIntoSection(html, sectionId, cardsHtml) {
const block = extractSectionBlock(html, sectionId);
if (!block) return html;
return `${html.slice(0, block.end - 6)}${cardsHtml}${html.slice(block.end - 6)}`;
}
function createAndInsertSection(html, sectionId, cardsHtml) {
const shell = buildSectionShell(sectionId);
if (!shell) return html;
const chunk = `${shell}${cardsHtml}</div>`;
const anchor = /<div class="section" id="history"/i.test(html)
? /<div class="section" id="history"/i
: /<div class="footer"/i;
return insertBeforeAnchor(html, anchor, chunk);
}
function countLeadCardsInHeadlines(html) {
const block = extractSectionBlock(html, 'headlines');
if (!block) return 0;
const slice = html.slice(block.start, block.end);
return (slice.match(/\blead-card\b/g) ?? []).length;
}
function countStoryCardsInSection(html, sectionId) {
const block = extractSectionBlock(html, sectionId);
if (!block) return 0;
const slice = html.slice(block.start, block.end);
return (slice.match(/\bdata-event-key=/g) ?? []).length;
}
/**
* Agent 产出不足时,用 SearXNG 预取分组程序化补栏(仅补缺失 core 栏目 / 头条 / brief)。
*/
export function supplementNews002HtmlFromSearxngGroups(
html,
groups = [],
audit = {},
{ maxAdds = NEWS002_MAX_STORY_CARDS } = {},
) {
const source = String(html ?? '');
const groupMap = flattenSearchGroups(groups);
if (!groupMap.size) {
return { html: source, changed: false, stats: { reason: 'empty-groups' } };
}
const { seenTitles, usedEventKeys, cards: existingCards } = collectExistingStoryKeys(source);
const stats = {
addedLeadCards: 0,
addedStoryCards: 0,
addedSections: [],
addedBriefItems: 0,
};
let output = source;
const missingSections = parseMissingSectionIds(audit?.violations ?? []);
const needLeads = (audit?.violations ?? []).includes('invalid-lead-count')
|| countLeadCardsInHeadlines(output) < 3;
let remainingBudget = Math.max(0, maxAdds - existingCards.length);
const leadPool = pickSearchCandidates(
groupMap,
SECTION_SEARCH_POOLS.headlines,
[...seenTitles],
{ requireImage: true },
);
if (needLeads && remainingBudget > 0) {
const currentLeads = countLeadCardsInHeadlines(output);
const needCount = Math.max(0, 3 - currentLeads);
const toAdd = leadPool.splice(0, Math.min(needCount, remainingBudget));
if (toAdd.length) {
const cardsHtml = toAdd.map((item) => (
buildLeadCardHtml(item, buildNewsMorningEventKey(item.title, usedEventKeys))
)).join('');
if (hasNews002Section(output, 'headlines')) {
output = appendIntoSection(output, 'headlines', cardsHtml);
} else {
output = createAndInsertSection(output, 'headlines', cardsHtml);
stats.addedSections.push('headlines');
}
stats.addedLeadCards = toAdd.length;
remainingBudget -= toAdd.length;
}
}
const sectionIds = new Set([
...missingSections,
...['domestic', 'world', 'business', 'tech', 'deepdive', 'knowledge'].filter((id) => {
const meta = SECTION_BY_ID.get(id);
if (!meta) return false;
if (id === 'deepdive') return !hasNews002Section(output, id);
return countStoryCardsInSection(output, id) < meta.min;
}),
]);
for (const sectionId of sectionIds) {
if (remainingBudget <= 0) break;
const meta = SECTION_BY_ID.get(sectionId);
if (!meta || sectionId === 'headlines' || sectionId === 'brief' || sectionId === 'weather' || sectionId === 'history') {
if (sectionId === 'deepdive' && !hasNews002Section(output, 'deepdive')) {
output = createAndInsertSection(output, 'deepdive', '');
stats.addedSections.push('deepdive');
}
continue;
}
const existingCount = sectionId === 'headlines'
? countLeadCardsInHeadlines(output)
: countStoryCardsInSection(output, sectionId);
const needCount = Math.max(0, meta.min - existingCount);
if (!needCount && hasNews002Section(output, sectionId)) continue;
const pool = SECTION_SEARCH_POOLS[sectionId] ?? [SEARXNG_GROUP_TO_NEWS002_SECTION[sectionId]].filter(Boolean);
const candidates = pickSearchCandidates(groupMap, pool, [...seenTitles], { requireImage: true });
const toAdd = candidates.slice(0, Math.min(needCount || meta.min, remainingBudget));
if (!toAdd.length) continue;
const cardsHtml = toAdd.map((item) => (
buildStoryCardHtml(item, buildNewsMorningEventKey(item.title, usedEventKeys))
)).join('');
if (hasNews002Section(output, sectionId)) {
output = appendIntoSection(output, sectionId, cardsHtml);
} else {
output = createAndInsertSection(output, sectionId, cardsHtml);
stats.addedSections.push(sectionId);
}
stats.addedStoryCards += toAdd.length;
remainingBudget -= toAdd.length;
}
if (!hasNews002Section(output, 'brief')) {
const briefCandidates = pickSearchCandidates(
groupMap,
['lead', 'domestic', 'world', 'finance', 'tech'],
[...seenTitles],
{ requireImage: false },
);
output = ensureBriefList(output, briefCandidates, stats);
}
for (const sectionId of ['weather', 'history']) {
if (!hasNews002Section(output, sectionId)) {
output = createAndInsertSection(output, sectionId, '');
stats.addedSections.push(sectionId);
}
}
const changed = output !== source;
return { html: output, changed, stats };
}
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildNewsMorningEventKey,
supplementNews002HtmlFromSearxngGroups,
} from './wechat-news-morning-searxng-fallback.mjs';
import { auditNews002ImageCoverage } from './wechat-news-morning-images.mjs';
const THIN_AGENT_HTML = `<!doctype html><html><head>${[
'<style>:root{--brand:#C8362F;--paper:#FAF7F2;--accent:#1B3A6B;}</style>',
].join('')}</head><body>
<div class="masthead"><h1>每日新闻早报</h1></div>
<div class="lede">今日导读</div>
<div class="container">
<div class="section" id="headlines">
<div class="section-title"><span class="icon">📰</span><h2>今日头条</h2></div>
<a class="lead-card" href="https://news.example/a" data-event-key="a">
<div class="media"><img src="https://cdn.example/a.jpg" alt="已有头条"></div>
<div class="body"><span class="tag">国内</span><h3>已有头条</h3><p>摘要</p><div class="source"><a href="https://news.example/a">example</a></div></div>
</a>
</div>
<div class="section" id="business">
<div class="section-title"><span class="icon">💰</span><h2>财经</h2></div>
<div class="card" data-event-key="biz-1">
<div class="media"><img src="https://cdn.example/biz.jpg" alt="财经一"></div>
<div class="body"><span class="tag">财经</span><h3>财经一</h3><p>摘要</p><div class="why"><b>为什么重要</b>:测试</div><div class="source"><a href="https://news.example/biz">example</a></div></div>
</div>
</div>
</div></body></html>`;
const SEARCH_GROUPS = [
{
id: 'lead',
title: '头条必读',
results: [
{ title: '补栏头条二', url: 'https://news.example/lead2', image: 'https://cdn.example/lead2.jpg', snippet: '第二条' },
{ title: '补栏头条三', url: 'https://news.example/lead3', image: 'https://cdn.example/lead3.jpg', snippet: '第三条' },
],
},
{
id: 'domestic',
title: '国内',
results: [
{ title: '国内补栏一', url: 'https://news.example/dom1', image: 'https://cdn.example/dom1.jpg', snippet: '国内一' },
{ title: '国内补栏二', url: 'https://news.example/dom2', image: 'https://cdn.example/dom2.jpg', snippet: '国内二' },
],
},
{
id: 'world',
title: '国际',
results: [
{ title: '国际补栏一', url: 'https://news.example/w1', image: 'https://cdn.example/w1.jpg', snippet: '国际一' },
{ title: '国际补栏二', url: 'https://news.example/w2', image: 'https://cdn.example/w2.jpg', snippet: '国际二' },
],
},
{
id: 'tech',
title: '科技',
results: [
{ title: '科技补栏一', url: 'https://news.example/t1', image: 'https://cdn.example/t1.jpg', snippet: '科技一' },
{ title: '科技补栏二', url: 'https://news.example/t2', image: 'https://cdn.example/t2.jpg', snippet: '科技二' },
],
},
{
id: 'finance',
title: '财经',
results: [
{ title: '财经补栏二', url: 'https://news.example/f2', image: 'https://cdn.example/f2.jpg', snippet: '财经二' },
],
},
];
test('buildNewsMorningEventKey deduplicates keys', () => {
const used = new Set(['same-title']);
assert.equal(buildNewsMorningEventKey('Same Title', used), 'same-title-2');
});
test('supplementNews002HtmlFromSearxngGroups fills missing core sections and leads', () => {
const beforeAudit = auditNews002ImageCoverage(THIN_AGENT_HTML, { requireTemplateStructure: true });
assert.equal(beforeAudit.ok, false);
const result = supplementNews002HtmlFromSearxngGroups(
THIN_AGENT_HTML,
SEARCH_GROUPS,
beforeAudit,
);
assert.equal(result.changed, true);
assert.ok(result.stats.addedLeadCards >= 2);
assert.ok(result.stats.addedStoryCards >= 4);
assert.match(result.html, /id="domestic"/u);
assert.match(result.html, /id="world"/u);
assert.match(result.html, /id="tech"/u);
assert.match(result.html, /补栏头条二/u);
assert.match(result.html, /国内补栏一/u);
});