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:
@@ -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,
|
||||
|
||||
@@ -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() {} }),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 栏目 id(core 优先)。 */
|
||||
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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user