[\s\S]*?<\/a>\n<\/div>/)[0],
+ { sectionId: 'headlines', imageUrlMap },
+ );
+ const domestic = wechatDailyNewsInlineInternals.renderGenericSection(
+ html.match(/
[\s\S]*$/)[0],
+ { sectionId: 'domestic', imageUrlMap },
+ );
+ assert.match(lead, /mmbiz\.qpic\.cn\/wechat-story\.jpg/u);
+ assert.match(domestic, /mmbiz\.qpic\.cn\/wechat-story\.jpg/u);
+ assert.doesNotMatch(lead, /news\.example\/story\.jpg/u);
+});
+
test('renderHero reads news002 masthead meta and lede', () => {
const rendered = wechatDailyNewsInlineInternals.renderHero(NEWS002_HTML);
assert.match(rendered, /每日新闻早报/u);
diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs
index 6d89011..92f94f5 100644
--- a/wechat-news-morning-draft-worker.mjs
+++ b/wechat-news-morning-draft-worker.mjs
@@ -15,6 +15,10 @@ import {
dedupeNewsMorningHtml,
formatNewsMorningDedupeSummary,
} from './wechat-news-morning-dedup.mjs';
+import {
+ auditNews002ImageCoverage,
+ enrichNews002HtmlWithSourceImages,
+} from './wechat-news-morning-images.mjs';
import {
isWechatNewsMorningDraftWorkerEnabled,
wechatNewsMorningDraftExecutionTimeoutMs,
@@ -61,6 +65,33 @@ function applyNewsMorningDedupe(page, logger) {
}
}
+async function applyNews002ImageGate(page, config, logger, env = process.env) {
+ 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, {
+ 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 });
+ logger.log?.('[NewsMorningDraft] news002 image audit', {
+ slug: page.slug,
+ attempted: enriched.attemptedCount,
+ resolved: enriched.resolvedCount,
+ ...audit,
+ });
+ if (!audit.ok) {
+ throw new Error(
+ `news002 配图/结构闸门未通过:${audit.violations.join(', ')}`
+ + `(卡片 ${audit.cardCount},真实配图 ${audit.imageCount},头条配图 ${audit.leadImageCount}/${audit.leadCount})`,
+ );
+ }
+ return audit;
+}
+
function archiveTodayPageForForce(config, h5Root, now, logger) {
const page = resolveTodayPage(config, h5Root, now);
if (!page?.localPath || !fs.existsSync(page.localPath)) return null;
@@ -150,6 +181,7 @@ export function startWechatNewsMorningDraftWorker({
throw new Error('新闻早报页面生成未完成');
}
applyNewsMorningDedupe(page, logger);
+ await applyNews002ImageGate(page, config, logger, env);
await wechatNewsMorningDraftService.recordAutoGenerationRun({
status: 'success',
pageSlug: page.slug,
diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs
index 687bd1e..c4b1f0d 100644
--- a/wechat-news-morning-draft.mjs
+++ b/wechat-news-morning-draft.mjs
@@ -22,6 +22,10 @@ import {
} from './wechat-draft-article-layout.mjs';
import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs';
import { countNewsMorningStoryCards } from './wechat-news-morning-dedup.mjs';
+import {
+ auditNews002ImageCoverage,
+ extractNews002CardImageUrls,
+} from './wechat-news-morning-images.mjs';
import {
NEWS001_FRESH_CONTENT_SPEC,
NEWS001_SEO_SPEC,
@@ -831,6 +835,20 @@ export async function buildDailyNewsWechatDraftArticleForPush({
wechatFetch,
memindLibRoot,
});
+ if (/class="[^"]*\bmasthead\b/i.test(String(html ?? ''))) {
+ const audit = auditNews002ImageCoverage(html, { requireTemplateStructure: true });
+ if (!audit.ok) {
+ throw new Error(`news002 页面未通过推送闸门:${audit.violations.join(', ')}`);
+ }
+ const missingUploads = extractNews002CardImageUrls(html).filter((ref) => (
+ !imageUrlMap.has(ref)
+ && !imageUrlMap.has(ref.replace(/^\.\//, ''))
+ && !imageUrlMap.has(ref.replace(/^\//, ''))
+ ));
+ if (missingUploads.length > 0) {
+ throw new Error(`news002 有 ${missingUploads.length} 张正文配图未成功上传微信,已阻止草稿推送`);
+ }
+ }
return buildDailyNewsWechatDraftArticle({
html,
publicUrl,
@@ -1201,22 +1219,48 @@ export function createWechatNewsMorningDraftService(
};
}
- async function resolvePreview(configOverride = null, { requireToday = false, now = Date.now() } = {}) {
+ async function resolvePreview(
+ configOverride = null,
+ { requireToday = false, now = Date.now(), pageOverride = null } = {},
+ ) {
const config = configOverride ?? mergeConfig(await readConfigRow());
if (!config.sourceUserId) {
throw new Error('请先配置新闻早报来源用户 ID');
}
- const page = findLatestNewsMorningPage({
- h5Root,
- userId: config.sourceUserId,
- slugPattern: config.pageSlugPattern,
- date: new Date(now),
- timezone: config.timezone,
- });
- if (!page) {
- throw new Error(`未找到匹配 ${config.pageSlugPattern} 的新闻早报页面`);
+ let page;
+ let pageSummary;
+ if (pageOverride?.localPath) {
+ if (!fs.existsSync(pageOverride.localPath)) {
+ throw new Error(`指定页面不存在:${pageOverride.localPath}`);
+ }
+ const slug = String(pageOverride.slug ?? path.basename(pageOverride.localPath, '.html')).trim();
+ page = {
+ slug,
+ relativePath: String(pageOverride.relativePath ?? `${PUBLIC_ZONE_DIR}/${slug}.html`).trim(),
+ localPath: pageOverride.localPath,
+ modifiedAt: fs.statSync(pageOverride.localPath).mtimeMs,
+ thumbPath: pageOverride.thumbPath ?? null,
+ };
+ pageSummary = buildPageSummary(config, page, { now });
+ if (pageOverride.publicUrl) {
+ pageSummary = { ...pageSummary, publicUrl: String(pageOverride.publicUrl).trim() };
+ }
+ if (pageOverride.isTodayPage != null) {
+ pageSummary = { ...pageSummary, isTodayPage: Boolean(pageOverride.isTodayPage) };
+ }
+ } else {
+ page = findLatestNewsMorningPage({
+ h5Root,
+ userId: config.sourceUserId,
+ slugPattern: config.pageSlugPattern,
+ date: new Date(now),
+ timezone: config.timezone,
+ });
+ if (!page) {
+ throw new Error(`未找到匹配 ${config.pageSlugPattern} 的新闻早报页面`);
+ }
+ pageSummary = buildPageSummary(config, page, { now });
}
- const pageSummary = buildPageSummary(config, page, { now });
if (requireToday && !pageSummary.isTodayPage) {
throw new Error(`今日(${pageSummary.dateKey})新闻早报尚未生成,当前最新页面为 ${page.slug}`);
}
@@ -1448,13 +1492,15 @@ export function createWechatNewsMorningDraftService(
dryRun = false,
requireToday = true,
now = Date.now(),
+ pageOverride = null,
+ articleTitleOverride = null,
} = {}) {
const config = await this.getConfig();
if (!config.enabled) {
throw new Error('新闻早报草稿推送未启用');
}
let preview = null;
- preview = await resolvePreview(config, { requireToday, now });
+ preview = await resolvePreview(config, { requireToday, now, pageOverride });
if (dryRun) {
return {
dryRun: true,
@@ -1463,13 +1509,20 @@ export function createWechatNewsMorningDraftService(
}
try {
const accessToken = await getAccessToken();
- const page = findLatestNewsMorningPage({
- h5Root,
- userId: config.sourceUserId,
- slugPattern: config.pageSlugPattern,
- date: new Date(now),
- timezone: config.timezone,
- });
+ const page = pageOverride?.localPath
+ ? {
+ slug: preview.page.slug,
+ relativePath: preview.page.relativePath,
+ localPath: pageOverride.localPath,
+ thumbPath: pageOverride.thumbPath ?? null,
+ }
+ : findLatestNewsMorningPage({
+ h5Root,
+ userId: config.sourceUserId,
+ slugPattern: config.pageSlugPattern,
+ date: new Date(now),
+ timezone: config.timezone,
+ });
const html = fs.readFileSync(page.localPath, 'utf8');
const thumb = await resolveNewsMorningDraftThumbBuffer({
page,
@@ -1490,6 +1543,9 @@ export function createWechatNewsMorningDraftService(
memindLibRoot,
})
: preview.article;
+ if (articleTitleOverride) {
+ article.title = String(articleTitleOverride).trim().slice(0, 64);
+ }
const draft = await addWechatDraftArticle(
accessToken,
{
diff --git a/wechat-news-morning-images.mjs b/wechat-news-morning-images.mjs
new file mode 100644
index 0000000..3b1b590
--- /dev/null
+++ b/wechat-news-morning-images.mjs
@@ -0,0 +1,367 @@
+const DEFAULT_TIMEOUT_MS = 6000;
+const DEFAULT_USER_AGENT =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
+ + '(KHTML, like Gecko) Chrome/126.0 Safari/537.36 TKMind-NewsBot/1.0';
+
+function decodeHtmlAttribute(value) {
+ return String(value ?? '')
+ .replace(/&/gi, '&')
+ .replace(/"/gi, '"')
+ .replace(/'|'/gi, "'")
+ .replace(/</gi, '<')
+ .replace(/>/gi, '>');
+}
+
+function escapeHtmlAttribute(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+const CARD_OPEN_RE = /<(div|a)\b[^>]*\bclass="([^"]*)"[^>]*>/gi;
+const CARD_CLASS_TOKENS = new Set(['card', 'lead-card']);
+
+function hasCardClass(classAttr) {
+ return String(classAttr).split(/\s+/).some((token) => CARD_CLASS_TOKENS.has(token));
+}
+
+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 textOf(value) {
+ return String(value ?? '')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /gi, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function attrOf(tag, name) {
+ return String(tag).match(new RegExp(`${name}=["']([^"']*)["']`, 'i'))?.[1]?.trim() ?? '';
+}
+
+export function extractNews002StoryCards(html) {
+ const source = String(html ?? '');
+ const cards = [];
+ CARD_OPEN_RE.lastIndex = 0;
+ let match;
+ while ((match = CARD_OPEN_RE.exec(source)) != null) {
+ if (!hasCardClass(match[2])) continue;
+ const tagName = match[1].toLowerCase();
+ const { start, end } = sliceBalancedBlock(
+ source,
+ tagName,
+ match.index,
+ match.index + match[0].length,
+ );
+ const raw = source.slice(start, end);
+ const sourceBlock = raw.match(
+ /
]*\bclass="[^"]*\bsource\b[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
+ )?.[1] ?? '';
+ cards.push({
+ start,
+ end,
+ raw,
+ tagName,
+ title: textOf(raw.match(/
]*>([\s\S]*?)<\/h[23]>/i)?.[1] ?? ''),
+ url: attrOf(match[0], 'href') || sourceBlock.match(/href=["'](https?:\/\/[^"']+)["']/i)?.[1] || '',
+ eventKey: attrOf(match[0], 'data-event-key').toLowerCase(),
+ isLead: /\blead-card\b/i.test(match[0]),
+ });
+ CARD_OPEN_RE.lastIndex = end;
+ }
+ return cards;
+}
+
+export function normalizeNewsMorningImageUrl(value, { pageUrl = '' } = {}) {
+ const raw = decodeHtmlAttribute(value).trim();
+ if (!raw || /^data:/i.test(raw)) return '';
+ try {
+ const url = pageUrl ? new URL(raw, pageUrl) : new URL(raw);
+ if (url.protocol !== 'https:') return '';
+ if (['localhost', '127.0.0.1', '::1'].includes(url.hostname)) return '';
+ return url.toString().slice(0, 1600);
+ } catch {
+ return '';
+ }
+}
+
+function readMetaContent(html, key, value) {
+ const source = String(html ?? '');
+ const tags = source.match(/]*>/gi) ?? [];
+ for (const tag of tags) {
+ const keyMatch = tag.match(new RegExp(`\\b${key}\\s*=\\s*["']([^"']+)["']`, 'i'));
+ if (keyMatch?.[1]?.toLowerCase() !== value.toLowerCase()) continue;
+ const content = tag.match(/\bcontent\s*=\s*["']([^"']+)["']/i)?.[1] ?? '';
+ if (content) return content;
+ }
+ return '';
+}
+
+export function extractNewsMorningSourceImage(html, pageUrl = '') {
+ const candidates = [
+ readMetaContent(html, 'property', 'og:image:secure_url'),
+ readMetaContent(html, 'property', 'og:image'),
+ readMetaContent(html, 'name', 'twitter:image'),
+ readMetaContent(html, 'property', 'twitter:image'),
+ readMetaContent(html, 'itemprop', 'image'),
+ ];
+ for (const candidate of candidates) {
+ const normalized = normalizeNewsMorningImageUrl(candidate, { pageUrl });
+ if (normalized) return normalized;
+ }
+ return '';
+}
+
+export async function resolveNewsMorningSourceImage(
+ pageUrl,
+ {
+ fetchImpl = fetch,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ userAgent = DEFAULT_USER_AGENT,
+ } = {},
+) {
+ let normalizedPageUrl = '';
+ try {
+ const parsed = new URL(String(pageUrl ?? '').trim());
+ if (parsed.protocol !== 'https:') return '';
+ normalizedPageUrl = parsed.toString();
+ } catch {
+ return '';
+ }
+
+ try {
+ const response = await fetchImpl(normalizedPageUrl, {
+ redirect: 'follow',
+ signal: AbortSignal.timeout(timeoutMs),
+ headers: {
+ accept: 'text/html,application/xhtml+xml',
+ 'user-agent': userAgent,
+ },
+ });
+ if (!response.ok) return '';
+ const contentType = String(response.headers?.get?.('content-type') ?? '');
+ if (contentType && !/html|xhtml/i.test(contentType)) return '';
+ const html = (await response.text()).slice(0, 500_000);
+ return extractNewsMorningSourceImage(html, response.url || normalizedPageUrl);
+ } catch {
+ return '';
+ }
+}
+
+export async function enrichNewsMorningSearchGroupsWithImages(
+ groups = [],
+ {
+ fetchImpl = fetch,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ maxItems = 48,
+ concurrency = 8,
+ } = {},
+) {
+ const cloned = (Array.isArray(groups) ? groups : []).map((group) => ({
+ ...group,
+ results: (Array.isArray(group?.results) ? group.results : []).map((item) => ({ ...item })),
+ }));
+ const pending = [];
+ for (const group of cloned) {
+ for (const item of group.results) {
+ if (item.image || !item.url || pending.length >= maxItems) continue;
+ pending.push(item);
+ }
+ }
+
+ let cursor = 0;
+ let resolvedCount = 0;
+ async function worker() {
+ while (cursor < pending.length) {
+ const item = pending[cursor];
+ cursor += 1;
+ const image = await resolveNewsMorningSourceImage(item.url, { fetchImpl, timeoutMs });
+ if (image) {
+ item.image = image;
+ item.imageSource = 'article-meta';
+ resolvedCount += 1;
+ }
+ }
+ }
+ await Promise.all(
+ Array.from({ length: Math.min(Math.max(1, concurrency), pending.length || 1) }, () => worker()),
+ );
+ return { groups: cloned, attemptedCount: pending.length, resolvedCount };
+}
+
+function extractCardImage(cardRaw) {
+ const media = String(cardRaw ?? '').match(
+ /]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?
![]()
]*src=["']([^"']+)["']/i,
+ );
+ return normalizeNewsMorningImageUrl(media?.[1] ?? '');
+}
+
+export function extractNews002CardImageUrls(html) {
+ return extractNews002StoryCards(html)
+ .filter((card) => card.title)
+ .map((card) => extractCardImage(card.raw))
+ .filter(Boolean);
+}
+
+function splitCardRaw(card) {
+ const raw = String(card.raw ?? '');
+ const openEnd = raw.indexOf('>') + 1;
+ const closeStart = raw.toLowerCase().lastIndexOf(`${card.tagName}>`);
+ if (openEnd <= 0 || closeStart < openEnd) return null;
+ return {
+ open: raw.slice(0, openEnd),
+ inner: raw.slice(openEnd, closeStart),
+ close: raw.slice(closeStart),
+ };
+}
+
+function removeExistingMedia(inner) {
+ return String(inner ?? '').replace(
+ /
]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<\/div>\s*/i,
+ '',
+ );
+}
+
+function ensureBodyWrapper(inner) {
+ const source = String(inner ?? '')
+ .replace(/
]*)>/i, '')
+ .replace(/<\/h2>/i, '
')
+ .trim();
+ if (/^]*class="[^"]*\bbody\b/i.test(source)) return source;
+ return `
${source}
`;
+}
+
+export async function enrichNews002HtmlWithSourceImages(
+ html,
+ {
+ fetchImpl = fetch,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ concurrency = 8,
+ } = {},
+) {
+ const source = String(html ?? '');
+ const cards = extractNews002StoryCards(source);
+ const unresolved = cards.filter((card) => card.title && !extractCardImage(card.raw) && card.url);
+ let cursor = 0;
+ let resolvedCount = 0;
+ const imageByStart = new Map();
+
+ async function worker() {
+ while (cursor < unresolved.length) {
+ const card = unresolved[cursor];
+ cursor += 1;
+ const image = await resolveNewsMorningSourceImage(card.url, { fetchImpl, timeoutMs });
+ if (image) {
+ imageByStart.set(card.start, image);
+ resolvedCount += 1;
+ }
+ }
+ }
+ await Promise.all(
+ Array.from({ length: Math.min(Math.max(1, concurrency), unresolved.length || 1) }, () => worker()),
+ );
+
+ let output = source;
+ for (const card of [...cards].sort((a, b) => b.start - a.start)) {
+ const parts = splitCardRaw(card);
+ if (!parts) continue;
+ const existingImage = extractCardImage(card.raw);
+ const image = existingImage || imageByStart.get(card.start) || '';
+ const cleanedInner = ensureBodyWrapper(removeExistingMedia(parts.inner));
+ const media = image
+ ? `
`
+ : '
📰
';
+ const replacement = `${parts.open}${media}${cleanedInner}${parts.close}`;
+ output = output.slice(0, card.start) + replacement + output.slice(card.end);
+ }
+
+ return {
+ html: output,
+ cardCount: cards.filter((card) => card.title).length,
+ attemptedCount: unresolved.length,
+ resolvedCount,
+ };
+}
+
+export function auditNews002ImageCoverage(
+ html,
+ { maxStoryCards = 28, requireTemplateStructure = false } = {},
+) {
+ const source = String(html ?? '');
+ const cards = extractNews002StoryCards(source).filter((card) => card.title);
+ const missingImages = cards.filter((card) => !extractCardImage(card.raw));
+ const leads = cards.filter((card) => card.isLead);
+ const missingLeadImages = leads.filter((card) => !extractCardImage(card.raw));
+ const violations = [];
+ if (!/class="[^"]*\bmasthead\b/i.test(source)) violations.push('missing-masthead');
+ if (cards.length > maxStoryCards) violations.push('too-many-story-cards');
+ if (cards.length > 0 && missingImages.length > 0) violations.push('story-image-missing');
+ if (leads.length < 3 || leads.length > 4) violations.push('invalid-lead-count');
+ if (missingLeadImages.length > 0) violations.push('lead-image-missing');
+ if (requireTemplateStructure) {
+ if (/class="[^"]*\b(hero|quick-links|highlight-box)\b/i.test(source)) {
+ violations.push('legacy-news001-dom');
+ }
+ if (!/class="[^"]*\blede\b/i.test(source)) violations.push('missing-lede');
+ if (!/class="[^"]*\bbrief\b/i.test(source)) violations.push('missing-brief');
+ if (!/#C8362F/i.test(source) || !/#FAF7F2/i.test(source) || !/#1B3A6B/i.test(source)) {
+ violations.push('noncanonical-palette');
+ }
+ const requiredSectionIds = [
+ 'brief',
+ 'headlines',
+ 'deepdive',
+ 'domestic',
+ 'world',
+ 'business',
+ 'tech',
+ 'weather',
+ 'knowledge',
+ 'history',
+ ];
+ const missingSections = requiredSectionIds.filter((id) => (
+ !new RegExp(`
]*class=["'][^"']*\\bsection\\b[^"']*["'][^>]*id=["']${id}["']`, 'i')
+ .test(source)
+ ));
+ 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');
+ if (cards.some((card) => !/class="[^"]*\bbody\b/i.test(card.raw))) {
+ violations.push('missing-card-body');
+ }
+ if (cards.some((card) => !card.isLead && !/class="[^"]*\bwhy\b/i.test(card.raw))) {
+ violations.push('missing-card-why');
+ }
+ }
+ return {
+ ok: violations.length === 0,
+ violations,
+ cardCount: cards.length,
+ imageCount: cards.length - missingImages.length,
+ leadCount: leads.length,
+ leadImageCount: leads.length - missingLeadImages.length,
+ missingTitles: missingImages.map((card) => card.title),
+ };
+}
diff --git a/wechat-news-morning-images.test.mjs b/wechat-news-morning-images.test.mjs
new file mode 100644
index 0000000..e7e55ed
--- /dev/null
+++ b/wechat-news-morning-images.test.mjs
@@ -0,0 +1,104 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ auditNews002ImageCoverage,
+ enrichNews002HtmlWithSourceImages,
+ enrichNewsMorningSearchGroupsWithImages,
+ extractNewsMorningSourceImage,
+ normalizeNewsMorningImageUrl,
+} from './wechat-news-morning-images.mjs';
+
+function htmlResponse(html, url = 'https://news.example/story') {
+ return {
+ ok: true,
+ url,
+ headers: { get: () => 'text/html; charset=utf-8' },
+ text: async () => html,
+ };
+}
+
+test('extractNewsMorningSourceImage resolves article-relative og:image', () => {
+ const html = '
';
+ assert.equal(
+ extractNewsMorningSourceImage(html, 'https://news.example/2026/story'),
+ 'https://news.example/media/story.jpg',
+ );
+ assert.equal(normalizeNewsMorningImageUrl('http://news.example/a.jpg'), '');
+});
+
+test('enrichNewsMorningSearchGroupsWithImages reads source article metadata', async () => {
+ const result = await enrichNewsMorningSearchGroupsWithImages([
+ {
+ id: 'domestic',
+ results: [{ title: '真实新闻', url: 'https://news.example/story' }],
+ },
+ ], {
+ fetchImpl: async () => htmlResponse(
+ '
',
+ ),
+ });
+ assert.equal(result.resolvedCount, 1);
+ assert.equal(result.groups[0].results[0].image, 'https://cdn.example/story.webp');
+ assert.equal(result.groups[0].results[0].imageSource, 'article-meta');
+});
+
+test('enrichNews002HtmlWithSourceImages inserts media and body wrappers', async () => {
+ const source = `
+
每日新闻早报
+
+
+`;
+ const fetchImpl = async (url) => htmlResponse(
+ `
`,
+ url,
+ );
+ const result = await enrichNews002HtmlWithSourceImages(source, { fetchImpl });
+ assert.equal(result.resolvedCount, 4);
+ assert.match(result.html, /class="media">
国内<\/span>/u);
+ assert.deepEqual(auditNews002ImageCoverage(result.html), {
+ ok: true,
+ violations: [],
+ cardCount: 4,
+ imageCount: 4,
+ leadCount: 3,
+ leadImageCount: 3,
+ missingTitles: [],
+ });
+});
+
+test('auditNews002ImageCoverage blocks missing images and excess cards', () => {
+ const cards = Array.from({ length: 29 }, (_, index) => (
+ `事件标题${index}
`
+ )).join('');
+ const audit = auditNews002ImageCoverage(
+ `${cards}`,
+ );
+ assert.equal(audit.ok, false);
+ assert.ok(audit.violations.includes('too-many-story-cards'));
+ assert.ok(audit.violations.includes('story-image-missing'));
+});
+
+test('strict news002 audit rejects legacy or incomplete template structure', () => {
+ const audit = auditNews002ImageCoverage(
+ '',
+ { requireTemplateStructure: true },
+ );
+ assert.equal(audit.ok, false);
+ assert.ok(audit.violations.includes('legacy-news001-dom'));
+ assert.ok(audit.violations.includes('missing-lede'));
+ assert.ok(audit.violations.some((item) => item.startsWith('missing-sections:')));
+ assert.ok(audit.violations.includes('noncanonical-palette'));
+});
diff --git a/wechat-news-morning-search.mjs b/wechat-news-morning-search.mjs
index b4b0c1c..b161eb6 100644
--- a/wechat-news-morning-search.mjs
+++ b/wechat-news-morning-search.mjs
@@ -1,6 +1,7 @@
import { searchSearxng } from './mindsearch-providers.mjs';
import { resolvePortalSearxngEndpoint } from './mindsearch-prefetch.mjs';
import { getLocalParts } from './schedule-time.mjs';
+import { enrichNewsMorningSearchGroupsWithImages } from './wechat-news-morning-images.mjs';
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
@@ -227,6 +228,7 @@ export async function prefetchNewsMorningSearxngBrief({
timezone = 'Asia/Shanghai',
env = process.env,
searchImpl = searchSearxng,
+ fetchImpl = fetch,
limit = 5,
} = {}) {
const dateLabel = formatLocalDateParts(now, timezone);
@@ -257,5 +259,14 @@ export async function prefetchNewsMorningSearxngBrief({
return { ...item, results };
}));
const { groups: dedupedGroups, removedCount } = dedupeNewsMorningSearchGroups(groups);
- return formatNewsMorningSearchBrief(dedupedGroups, { dateLabel, dedupeRemovedCount: removedCount });
+ const imageEnrichment = await enrichNewsMorningSearchGroupsWithImages(dedupedGroups, {
+ fetchImpl,
+ timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 5000) || 5000,
+ 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, {
+ dateLabel,
+ dedupeRemovedCount: removedCount,
+ });
}
diff --git a/wechat-news-morning-search.test.mjs b/wechat-news-morning-search.test.mjs
index 5d282b7..6040fea 100644
--- a/wechat-news-morning-search.test.mjs
+++ b/wechat-news-morning-search.test.mjs
@@ -93,6 +93,12 @@ test('prefetchNewsMorningSearxngBrief queries news category then falls back', as
}
return [{ title: `[web-${slug}] 通用源`, url: `https://web.example/${slug}`, snippet: '补搜' }];
},
+ fetchImpl: async (url) => ({
+ ok: true,
+ url,
+ headers: { get: () => 'text/html' },
+ text: async () => '',
+ }),
});
assert.ok(calls.some((item) => item.categories === 'news'));
assert.ok(calls.some((item) => item.categories === '' && /国内/.test(item.query)));
@@ -100,6 +106,7 @@ test('prefetchNewsMorningSearxngBrief queries news category then falls back', as
assert.match(brief, /## 国内[\s\S]*通用源/);
assert.match(brief, /全页去重/);
assert.match(brief, /tkmind_search/);
+ assert.match(brief, /图:https:\/\/cdn\.example\/news\.jpg/u);
});
test('prefetchNewsMorningSearxngBrief skips live calls without endpoint', async () => {