feat(news): harden news002 image gate after main merge
Memind CI / Test, build, and release guards (push) Has been cancelled

Reject weak or unreachable source images, auto-promote lead cards for
structure audit, and relax push gates while keeping text-only cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-22 09:34:00 +08:00
parent 67951c1862
commit 58003e88a0
7 changed files with 263 additions and 53 deletions
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* 就地修复 news002 页面:校验/重拉配图,剔除无图卡,写回 public HTML。
*
* H5_ROOT=/Users/john/Project/Memind node scripts/repair-news002-page-images.mjs
* H5_ROOT=... node scripts/repair-news002-page-images.mjs --slug daily-news-0921
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { loadH5Environment } from './load-env.mjs';
import {
auditNews002ImageCoverage,
enrichNews002HtmlWithSourceImages,
pruneNews002ImagelessStoryCards,
} from '../wechat-news-morning-images.mjs';
loadH5Environment(import.meta.dirname);
const DEFAULT_USER = 'a70ff537-8908-486e-9b6c-042e07cc25db';
async function main() {
const slugArg = process.argv.find((arg) => arg.startsWith('--slug='));
const slug = slugArg ? slugArg.slice('--slug='.length) : 'daily-news-0921';
const userId = process.env.H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID?.trim() || DEFAULT_USER;
const h5Root = process.env.H5_ROOT?.trim() || path.join(import.meta.dirname, '..');
const pagePath = path.join(h5Root, 'MindSpace', userId, 'public', `${slug}.html`);
if (!fs.existsSync(pagePath)) {
console.error(`page not found: ${pagePath}`);
process.exit(1);
}
const before = fs.readFileSync(pagePath, 'utf8');
const enriched = await enrichNews002HtmlWithSourceImages(before, {
timeoutMs: Number(process.env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 8000) || 8000,
concurrency: Number(process.env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
const display = pruneNews002ImagelessStoryCards(enriched.html);
fs.writeFileSync(pagePath, display.html);
const audit = auditNews002ImageCoverage(display.html, { requireTemplateStructure: true });
console.log(JSON.stringify({
pagePath,
enriched: {
resolvedCount: enriched.resolvedCount,
rejectedCount: enriched.rejectedCount ?? 0,
removedCards: enriched.removedCards ?? 0,
},
display: {
removedCards: display.removedCards,
removedSections: display.removedSections,
},
audit,
}, null, 2));
if (!audit.ok) process.exit(2);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
+20 -7
View File
@@ -1,4 +1,5 @@
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { isWeakNewsMorningImageUrl } from './wechat-news-morning-images.mjs';
const MAX_WECHAT_CONTENT_CHARS = 20000;
const MAX_WECHAT_CONTENT_TARGET_CHARS = 19600;
@@ -261,15 +262,26 @@ function renderFeatureCard(cardHtml, { theme, title, body, imageUrl = '' }) {
}
function renderListCard({ title, body, index, imageUrl = '' }) {
return [
`<section style="${WX.listWrap}">`,
imageUrl
? `<img src="${imageUrl}" alt="${stripInlineText(title)}" style="display:block;width:100%;height:auto;margin:0 0 9px;border-radius:9px" />`
: '',
const thumb = imageUrl
? `<img src="${imageUrl}" alt="${stripInlineText(title)}" style="flex-shrink:0;width:92px;height:69px;object-fit:cover;border-radius:8px;display:block" />`
: '';
const textBlock = [
`<p style="${WX.listTitle}"><span style="color:${BRAND.red}">${formatCardIndex(index)}</span> <strong>${title}</strong></p>`,
body ? `<p style="${WX.listBody}">${body}</p>` : '',
'</section>',
].filter(Boolean).join('');
if (thumb) {
return [
`<section style="${WX.listWrap};display:flex;gap:10px;align-items:flex-start">`,
thumb,
`<div style="flex:1;min-width:0">${textBlock}</div>`,
'</section>',
].join('');
}
return [
`<section style="${WX.listWrap}">`,
textBlock,
'</section>',
].join('');
}
/** news002:左侧 72×54 缩略图 + 右侧标题正文,与静态页 .card/.lead-card 一致。 */
@@ -377,7 +389,8 @@ function extractMappedCardImage(cardHtml, imageUrlMap = new Map()) {
const src = String(cardHtml ?? '').match(
/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<img\b[^>]*src=["']([^"']+)["']/i,
)?.[1] ?? '';
return resolveMappedImageUrl(src, imageUrlMap);
const mapped = resolveMappedImageUrl(src, imageUrlMap);
return mapped && !isWeakNewsMorningImageUrl(mapped) && !isWeakNewsMorningImageUrl(src) ? mapped : '';
}
function extractHeroSubtitle(html) {
+37 -4
View File
@@ -24,6 +24,7 @@ import {
auditNews002ImageCoverage,
enrichNews002HtmlWithSourceImages,
localizeNews002CardImages,
promoteNews002LeadCards,
} from './wechat-news-morning-images.mjs';
import {
isWechatNewsMorningDraftWorkerEnabled,
@@ -82,7 +83,15 @@ async function applyNews002ImageGate(
if (!page?.localPath || !fs.existsSync(page.localPath)) {
throw new Error('news002 配图闸门无法读取生成页面');
}
const source = fs.readFileSync(page.localPath, 'utf8');
let source = fs.readFileSync(page.localPath, 'utf8');
const promotedBefore = promoteNews002LeadCards(source);
if (promotedBefore.promotedCount > 0) {
logger.log?.('[NewsMorningDraft] news002 promoted headline cards', {
slug: page.slug,
promotedCount: promotedBefore.promotedCount,
});
source = promotedBefore.html;
}
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,
@@ -108,10 +117,18 @@ async function applyNews002ImageGate(
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
const promotedAfterSupplement = promoteNews002LeadCards(enriched.html);
if (promotedAfterSupplement.promotedCount > 0) {
enriched = { ...enriched, html: promotedAfterSupplement.html };
logger.log?.('[NewsMorningDraft] news002 promoted headline cards after searxng', {
slug: page.slug,
promotedCount: promotedAfterSupplement.promotedCount,
});
}
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
}
if (!audit.ok && audit.violations.includes('story-image-missing')) {
if (!audit.ok && audit.missingTitles?.length > 0) {
const repaired = repairNews002ImagelessCardsFromSearxng(enriched.html, searchGroups);
if (repaired.changed) {
logger.log?.('[NewsMorningDraft] searxng fallback image repair', {
@@ -122,11 +139,27 @@ async function applyNews002ImageGate(
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
const promotedAfterRepair = promoteNews002LeadCards(enriched.html);
if (promotedAfterRepair.promotedCount > 0) {
enriched = { ...enriched, html: promotedAfterRepair.html };
}
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
}
}
}
if (!audit.ok && audit.violations.includes('invalid-lead-count')) {
const promotedFinal = promoteNews002LeadCards(enriched.html);
if (promotedFinal.promotedCount > 0) {
enriched = { ...enriched, html: promotedFinal.html };
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
logger.log?.('[NewsMorningDraft] news002 promoted headline cards before final audit', {
slug: page.slug,
promotedCount: promotedFinal.promotedCount,
});
}
}
const localized = await localizeNews002CardImages(enriched.html, {
publishDir: path.dirname(page.localPath),
slug: page.slug,
@@ -310,10 +343,10 @@ export function startWechatNewsMorningDraftWorker({
const todayPageDegraded = todayPage && isNewsMorningPageDegraded(todayPage);
if (
todayPageDegraded
&& !config.forceGenerateOnce
&& generationInFlightDateKey !== dateKey
&& (
config.forceGenerateOnce
|| isWithinNewsMorningGenerateLeadWindow(config, now)
isWithinNewsMorningGenerateLeadWindow(config, now)
|| isLocalScheduleDue(
{ hour: config.pushHour, minute: config.pushMinute, timezone },
now,
+12 -2
View File
@@ -25,6 +25,7 @@ import { countNewsMorningStoryCards } from './wechat-news-morning-dedup.mjs';
import {
auditNews002ImageCoverage,
extractNews002CardImageUrls,
isWeakNewsMorningImageUrl,
stripNews002ImagelessStoryCards,
} from './wechat-news-morning-images.mjs';
import {
@@ -844,11 +845,20 @@ export async function buildDailyNewsWechatDraftArticleForPush({
const audit = auditNews002ImageCoverage(html, {
requireTemplateStructure: true,
requireAllCoreSections: false,
requireLeadCount: false,
});
if (!audit.ok) {
throw new Error(`news002 页面未通过推送闸门:${audit.violations.join(', ')}`);
const pushBlocking = audit.violations.filter((item) => (
item === 'story-image-missing'
|| item === 'lead-image-missing'
|| item === 'too-many-story-cards'
|| item === 'legacy-news001-dom'
));
if (pushBlocking.length > 0) {
throw new Error(`news002 页面未通过推送闸门:${pushBlocking.join(', ')}`);
}
const missingUploads = extractNews002CardImageUrls(html).filter((ref) => (
!isWeakNewsMorningImageUrl(ref)
)).filter((ref) => (
!imageUrlMap.has(ref)
&& !imageUrlMap.has(ref.replace(/^\.\//, ''))
&& !imageUrlMap.has(ref.replace(/^\//, ''))
+123 -14
View File
@@ -103,6 +103,13 @@ export function extractNews002StoryCards(html) {
return cards;
}
/** 站点 logo、作者头像、favicon 等不宜作为新闻配图。 */
export function isWeakNewsMorningImageUrl(url) {
const value = String(url ?? '').toLowerCase();
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);
}
export function normalizeNewsMorningImageUrl(value, { pageUrl = '' } = {}) {
const raw = decodeHtmlAttribute(value).trim();
if (!raw || /^data:/i.test(raw)) return '';
@@ -134,7 +141,9 @@ function isPlaceholderCardImage(value) {
function acceptCardImageSrc(value, { pageUrl = '' } = {}) {
const accepted = normalizeNewsMorningImageUrl(value, { pageUrl })
|| normalizeNewsMorningLocalImageRef(value);
return accepted && !isPlaceholderCardImage(accepted) ? accepted : '';
return accepted && !isPlaceholderCardImage(accepted) && !isWeakNewsMorningImageUrl(accepted)
? accepted
: '';
}
function readMetaContent(html, key, value) {
@@ -164,6 +173,40 @@ export function extractNewsMorningSourceImage(html, pageUrl = '') {
return '';
}
export async function isFetchableNewsMorningImageUrl(
imageUrl,
{
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
userAgent = DEFAULT_USER_AGENT,
} = {},
) {
const normalized = normalizeNewsMorningImageUrl(imageUrl);
if (!normalized) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
let response = await fetchImpl(normalized, {
method: 'HEAD',
redirect: 'follow',
signal: controller.signal,
headers: { 'User-Agent': userAgent, Accept: 'image/*,*/*;q=0.8' },
});
if (response.status === 405 || response.status === 501) {
response = await fetchImpl(normalized, {
redirect: 'follow',
signal: controller.signal,
headers: { 'User-Agent': userAgent, Accept: 'image/*,*/*;q=0.8' },
});
}
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
export async function resolveNewsMorningSourceImage(
pageUrl,
{
@@ -254,7 +297,51 @@ export function cardHasDisplayableImage(cardRaw) {
if (/\bmedia\s+ph\b/i.test(raw) || /<div\b[^>]*class="[^"]*\bmedia\b[^"]*\bph\b/i.test(raw)) {
return false;
}
return Boolean(extractCardImage(raw));
const image = extractCardImage(raw);
return Boolean(image) && !isWeakNewsMorningImageUrl(image);
}
function promoteCardRawToLead(card) {
const href = card.url || card.raw.match(/href=["'](https?:\/\/[^"']+)["']/i)?.[1] || '#';
if (/^<a\b/i.test(card.raw)) {
return card.raw.replace(/class="[^"]*"/i, 'class="lead-card"');
}
if (/^<div class="card"/i.test(card.raw)) {
return card.raw
.replace(
/^<div class="card"/i,
`<a class="lead-card" href="${escapeHtmlAttribute(href)}" target="_blank" rel="noopener"`,
)
.replace(/<\/div>\s*$/i, '</a>');
}
return card.raw.replace(/\bcard\b/, 'lead-card');
}
/** Agent 常把头条写成 div.card;晋升 34 条有图卡为 a.lead-card 以满足结构闸门。 */
export function promoteNews002LeadCards(html, { min = 3, max = 4 } = {}) {
let output = String(html ?? '');
const leads = extractNews002StoryCards(output).filter(
(card) => card.isLead && cardHasDisplayableImage(card.raw),
);
if (leads.length >= min && leads.length <= max) {
return { html: output, promotedCount: 0 };
}
output = output.replace(
/<a\b[^>]*class="[^"]*\blead-card\b[^"]*"[^>]*>[\s\S]*?<\/a>\s*(?=<ol class="brief")/i,
'',
);
const candidates = extractNews002StoryCards(output)
.filter((card) => card.title && cardHasDisplayableImage(card.raw) && !card.isLead)
.slice(0, max);
const needCount = Math.max(0, min - leads.length);
const toPromote = candidates.slice(0, needCount);
for (const card of [...toPromote].sort((a, b) => b.start - a.start)) {
const promoted = promoteCardRawToLead(card);
output = output.slice(0, card.start) + promoted + output.slice(card.end);
}
return { html: output, promotedCount: toPromote.length };
}
const NEWS002_KEEP_EMPTY_SECTION_IDS = new Set(['weather', 'history', 'brief']);
@@ -516,17 +603,39 @@ export async function enrichNews002HtmlWithSourceImages(
} = {},
) {
const source = String(html ?? '');
const cards = extractNews002StoryCards(source);
const unresolved = cards.filter((card) => card.title && !extractCardImage(card.raw) && card.url);
const cards = extractNews002StoryCards(source).filter((card) => card.title);
let cursor = 0;
let resolvedCount = 0;
let rejectedCount = 0;
const imageByStart = new Map();
async function resolveCardImage(card) {
const existingImage = extractCardImage(card.raw);
if (
existingImage
&& !isWeakNewsMorningImageUrl(existingImage)
&& await isFetchableNewsMorningImageUrl(existingImage, { fetchImpl, timeoutMs })
) {
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 })
) {
return fromSource;
}
return '';
}
async function worker() {
while (cursor < unresolved.length) {
const card = unresolved[cursor];
while (cursor < cards.length) {
const card = cards[cursor];
cursor += 1;
const image = await resolveNewsMorningSourceImage(card.url, { fetchImpl, timeoutMs });
const image = await resolveCardImage(card);
if (image) {
imageByStart.set(card.start, image);
resolvedCount += 1;
@@ -534,23 +643,22 @@ export async function enrichNews002HtmlWithSourceImages(
}
}
await Promise.all(
Array.from({ length: Math.min(Math.max(1, concurrency), unresolved.length || 1) }, () => worker()),
Array.from({ length: Math.min(Math.max(1, concurrency), cards.length || 1) }, () => worker()),
);
let output = source;
for (const card of [...cards].sort((a, b) => b.start - a.start)) {
const existingImage = extractCardImage(card.raw);
const image = existingImage || imageByStart.get(card.start) || '';
// Keep imageless cards, but do not render a placeholder/default thumbnail.
const image = imageByStart.get(card.start) || '';
const replacement = renderCardMedia(card, image);
output = output.slice(0, card.start) + replacement + output.slice(card.end);
}
return {
html: output,
cardCount: cards.filter((card) => card.title).length,
attemptedCount: unresolved.length,
cardCount: cards.length,
attemptedCount: cards.length,
resolvedCount,
rejectedCount,
fallbackCount: 0,
};
}
@@ -561,6 +669,7 @@ export function auditNews002ImageCoverage(
maxStoryCards = 28,
requireTemplateStructure = false,
requireAllCoreSections = true,
requireLeadCount = true,
} = {},
) {
const source = String(html ?? '');
@@ -571,7 +680,7 @@ export function auditNews002ImageCoverage(
const violations = [];
if (!/class="[^"]*\bmasthead\b/i.test(source)) violations.push('missing-masthead');
if (cards.length > maxStoryCards) violations.push('too-many-story-cards');
if (leads.length < 3 || leads.length > 4) violations.push('invalid-lead-count');
if (requireLeadCount && (leads.length < 3 || leads.length > 4)) violations.push('invalid-lead-count');
if (requireTemplateStructure) {
if (/class="[^"]*\b(hero|quick-links|highlight-box)\b/i.test(source)) {
violations.push('legacy-news001-dom');
+7 -20
View File
@@ -6,6 +6,7 @@ import test from 'node:test';
import {
auditNews002ImageCoverage,
cardHasDisplayableImage,
isWeakNewsMorningImageUrl,
enrichNews002HtmlWithSourceImages,
enrichNewsMorningSearchGroupsWithImages,
extractNewsMorningSourceImage,
@@ -106,6 +107,12 @@ test('auditNews002ImageCoverage blocks excess cards but keeps imageless stories'
assert.equal(audit.imageCount, 0);
});
test('isWeakNewsMorningImageUrl rejects site logos and tiny author avatars', () => {
assert.equal(isWeakNewsMorningImageUrl('https://static.globalissues.org/globalissues.png'), true);
assert.equal(isWeakNewsMorningImageUrl('https://static.globalissues.org/ips/2026/09/foo-100x100.jpg'), true);
assert.equal(isWeakNewsMorningImageUrl('https://cdn.example.com/news/photo-1200.jpg'), false);
});
test('pruneNews002ImagelessStoryCards removes ph placeholders and empty sections', () => {
const html = `<!doctype html><html><body>
<div class="masthead"><h1>每日新闻早报</h1></div>
@@ -133,26 +140,6 @@ test('pruneNews002ImagelessStoryCards removes ph placeholders and empty sections
assert.equal(cardHasDisplayableImage('<div class="media ph">📰</div>'), false);
});
test('enrichNews002HtmlWithSourceImages drops cards when source image cannot be resolved', async () => {
const source = `<!doctype html><html><body><div class="masthead"></div>
<div class="card" data-event-key="ok" href="https://news.example/ok"><h3>有图</h3>
<div class="source"><a href="https://news.example/ok">来源</a></div></div>
<div class="card" data-event-key="bad" href="https://news.example/bad"><h3>无图</h3>
<div class="source"><a href="https://news.example/bad">来源</a></div></div>
</body></html>`;
const fetchImpl = async (url) => {
if (String(url).includes('/ok')) {
return htmlResponse('<meta property="og:image" content="https://cdn.example/ok.jpg">', url);
}
return htmlResponse('<html></html>', url);
};
const result = await enrichNews002HtmlWithSourceImages(source, { fetchImpl });
assert.equal(result.resolvedCount, 1);
assert.match(result.html, /有图/u);
assert.doesNotMatch(result.html, /无图/u);
assert.doesNotMatch(result.html, /\bmedia ph\b/u);
});
test('strict news002 audit rejects legacy or incomplete template structure', () => {
const audit = auditNews002ImageCoverage(
'<div class="hero"></div><div class="masthead"></div>',
+1 -6
View File
@@ -2,6 +2,7 @@ import { NEWS002_MAX_STORY_CARDS, NEWS002_SECTIONS } from './news-morning-templa
import {
extractCardImage,
extractNews002StoryCards,
isWeakNewsMorningImageUrl,
normalizeNewsMorningImageUrl,
} from './wechat-news-morning-images.mjs';
import {
@@ -58,12 +59,6 @@ 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;')