fix(news): hide imageless news002 cards and prune empty sections

Drop story cards without real images from HTML and WeChat drafts instead
of showing ph placeholders, and remove emptied sections for layout balance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-21 16:42:50 +08:00
parent 4695e47668
commit b53ee25738
7 changed files with 154 additions and 22 deletions
+4 -4
View File
@@ -209,7 +209,7 @@ export const NEWS002_STYLE_BLOCK = `<style>
.card .why{margin-top:7px;padding-left:9px;border-left:2px solid var(--brand);
font-size:12.5px;color:var(--ink-2)}
.card .why b{color:var(--brand);font-weight:600}
/* 占位:无图时用栏目色 + emoji,保持版式不塌 */
/* ph 仅作浏览器端 onerror 降级;落盘页由 worker 剔除无图卡,禁止保留 ph 占位卡 */
.media.ph{display:flex;align-items:center;justify-content:center;font-size:26px;
background:linear-gradient(135deg,var(--brand-soft),var(--accent-soft))}
.tag{display:inline-block;font-size:10.5px;font-weight:600;letter-spacing:.06em;
@@ -318,9 +318,9 @@ export const NEWS002_TEMPLATE_SPEC = [
'',
'五、配图(news002 新增,必须做)',
'1. 预取 brief 里每条结果若带 `图:{URL}`,就把该 URL 用作这条新闻的配图。',
'1b. 正式 news002 的每张新闻卡都必须有与来源文章绑定的真实 https 配图;没有图的候选事件不得入选,core 栏目须换选另一条有图事件,flex 栏目可整节省略。',
'2. 有图写 `<div class="media"><img src="{图URL}" alt="{标题}" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentNode.classList.add(\'ph\');this.remove()"></div>`。',
'3. `.media.ph` 只允许作为图片加载失败时的视觉降级,正式落盘验收不接受无 `<img>` 的占位卡;禁止为了过闸门编造图片地址。',
'1b. 正式 news002 的每张新闻卡都必须有与来源文章绑定的真实 https 配图;没有图的候选事件不得入选、不得落盘,core 栏目须换选另一条有图事件,flex 栏目可整节省略。',
'2. 有图写 `<div class="media"><img src="{图URL}" alt="{标题}" loading="lazy" referrerpolicy="no-referrer"></div>`;禁止输出 `.media.ph` 占位卡。',
'3. 无真实配图的事件直接不写;落盘后 worker 会剔除无图卡并删除空栏目,禁止为了过闸门编造图片地址或保留无图条目。',
'3b. 知识卡片的 emoji 用 `<div class="k-icon">`,否则字号会掉到正文大小。',
'4. 头条 3~4 条及所有普通新闻卡必须 100% 有真实配图;配图必须来自对应来源文章的 `og:image` / `twitter:image`,禁止拿甲事件的图配乙事件。',
'5. 图片一律 https,禁止 data URI 长图,禁止整页长图截图。',
+10 -1
View File
@@ -591,7 +591,13 @@ function splitSections(html) {
function renderGenericSection(
sectionHtml,
{ sectionId = '', maxCards = null, maxBodyChars = null, imageUrlMap = new Map() } = {},
{
sectionId = '',
maxCards = null,
maxBodyChars = null,
imageUrlMap = new Map(),
requireCardImage = false,
} = {},
) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
if (/近7天新闻早报/u.test(stripInlineText(title))) return '';
@@ -617,6 +623,7 @@ function renderGenericSection(
if (maxCards != null && cardCount >= maxCards) continue;
const cardChunk = token.replace(/^(?:<div class="(?:card|highlight-box)|<a class="lead-card)(?:\s|")/i, '');
const cardHtml = `<div class="card ${isLeadCard ? 'lead-card ' : ''}${cardChunk}`;
if (requireCardImage && !extractMappedCardImage(cardHtml, imageUrlMap)) continue;
// 序号只给列表条目,头条卡与榜单卡靠自身样式区分,不占号。
const rendered = renderCard(cardHtml, {
maxBodyChars,
@@ -881,6 +888,7 @@ function buildWechatInlineContent(html, {
} = {}) {
const sections = splitSections(html);
const digest = renderTodayDigest(html);
const isNews002 = /class="[^"]*\bmasthead\b/i.test(String(html ?? ''));
const bodyParts = [renderHero(html, imageUrlMap, { includeSubtitle: !digest })];
if (digest) bodyParts.push(digest);
@@ -918,6 +926,7 @@ function buildWechatInlineContent(html, {
maxCards: resolveSectionCardLimit(section, trimProfile.sectionCardLimits),
maxBodyChars: trimProfile.maxBodyChars,
imageUrlMap,
requireCardImage: isNews002,
});
if (rendered) {
contentParts.push(rendered);
+6 -9
View File
@@ -162,23 +162,20 @@ const NEWS002_HTML = `<!DOCTYPE html><html><body>
</div>
</div></body></html>`;
test('news002 lead-card renders as a feature card in the wechat draft', () => {
test('news002 lead-card without image is omitted from wechat draft', () => {
const rendered = wechatDailyNewsInlineInternals.renderGenericSection(
NEWS002_HTML.match(/<div class="section" id="headlines">[\s\S]*?<\/a>\n<\/div>/)[0],
{ sectionId: 'headlines' },
{ sectionId: 'headlines', requireCardImage: true },
);
assert.match(rendered, /王毅同鲁比奥通电话/u);
assert.match(rendered, /中美关系/u, 'lead-card 的 tag 必须保留');
assert.doesNotMatch(rendered, /01<\/span>/u, '头条卡不占列表序号');
assert.equal(rendered, '', '无图头条卡不应进入微信草稿');
});
test('news002 card with media block still yields title and body', () => {
test('news002 card without image is omitted from wechat draft', () => {
const rendered = wechatDailyNewsInlineInternals.renderGenericSection(
NEWS002_HTML.match(/<div class="section" id="domestic">[\s\S]*$/)[0],
{ sectionId: 'domestic' },
{ sectionId: 'domestic', requireCardImage: true },
);
assert.match(rendered, /沈阳举行九一八纪念活动/u);
assert.match(rendered, /国内正文摘要/u, '.media 在前不应挤掉正文 <p>');
assert.equal(rendered, '', '无图普通卡不应进入微信草稿');
});
test('news002 cards render uploaded source images in the wechat draft', () => {
+15 -1
View File
@@ -23,6 +23,7 @@ import {
import {
auditNews002ImageCoverage,
enrichNews002HtmlWithSourceImages,
pruneNews002ImagelessStoryCards,
} from './wechat-news-morning-images.mjs';
import {
isWechatNewsMorningDraftWorkerEnabled,
@@ -70,6 +71,19 @@ function applyNewsMorningDedupe(page, logger) {
}
}
function finalizeNews002Html(html, logger, page, label) {
const pruned = pruneNews002ImagelessStoryCards(html);
if (pruned.removedCards > 0 || pruned.removedSections > 0) {
logger.log?.('[NewsMorningDraft] news002 pruned imageless cards', {
slug: page.slug,
phase: label,
removedCards: pruned.removedCards,
removedSections: pruned.removedSections,
});
}
return pruned.html;
}
async function applyNews002ImageGate(
page,
config,
@@ -81,7 +95,7 @@ async function applyNews002ImageGate(
if (!page?.localPath || !fs.existsSync(page.localPath)) {
throw new Error('news002 配图闸门无法读取生成页面');
}
let source = fs.readFileSync(page.localPath, 'utf8');
let source = finalizeNews002Html(fs.readFileSync(page.localPath, 'utf8'), logger, page, 'pre-enrich');
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,
+2
View File
@@ -25,6 +25,7 @@ import { countNewsMorningStoryCards } from './wechat-news-morning-dedup.mjs';
import {
auditNews002ImageCoverage,
extractNews002CardImageUrls,
pruneNews002ImagelessStoryCards,
} from './wechat-news-morning-images.mjs';
import {
NEWS001_FRESH_CONTENT_SPEC,
@@ -836,6 +837,7 @@ export async function buildDailyNewsWechatDraftArticleForPush({
memindLibRoot,
});
if (/class="[^"]*\bmasthead\b/i.test(String(html ?? ''))) {
html = pruneNews002ImagelessStoryCards(String(html ?? '')).html;
const audit = auditNews002ImageCoverage(html, { requireTemplateStructure: true });
if (!audit.ok) {
throw new Error(`news002 页面未通过推送闸门:${audit.violations.join(', ')}`);
+68 -7
View File
@@ -218,6 +218,60 @@ export function extractCardImage(cardRaw) {
return normalizeNewsMorningImageUrl(media?.[1] ?? '');
}
/** 无真实 https 配图或仅有 ph 占位时视为不可展示。 */
export function cardHasDisplayableImage(cardRaw) {
const raw = String(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 NEWS002_KEEP_EMPTY_SECTION_IDS = new Set(['weather', 'history', 'brief']);
function pruneEmptyNews002Sections(html) {
let output = String(html ?? '');
const re = /<div\b[^>]*class=["'][^"']*\bsection\b[^"']*["'][^>]*id=["']([^"']+)["'][^>]*>/gi;
const matches = [...output.matchAll(re)];
let removedSections = 0;
for (const match of [...matches].reverse()) {
const sectionId = match[1];
if (NEWS002_KEEP_EMPTY_SECTION_IDS.has(sectionId)) continue;
const { start, end } = sliceBalancedBlock(
output,
'div',
match.index,
match.index + match[0].length,
);
const slice = output.slice(start, end);
const cards = extractNews002StoryCards(slice).filter((card) => cardHasDisplayableImage(card.raw));
const hasDeep = /<div\b[^>]*class=["'][^"']*\bdeep\b/i.test(slice);
if (cards.length === 0 && !hasDeep) {
output = output.slice(0, start) + output.slice(end);
removedSections += 1;
}
}
return { html: output, removedSections };
}
/** 移除无图故事卡并清理空栏目,保证页面只展示有配图的条目。 */
export function pruneNews002ImagelessStoryCards(html) {
let output = String(html ?? '');
let removedCards = 0;
const imageless = extractNews002StoryCards(output)
.filter((card) => card.title && !cardHasDisplayableImage(card.raw));
for (const card of [...imageless].sort((a, b) => b.start - a.start)) {
output = output.slice(0, card.start) + output.slice(card.end);
removedCards += 1;
}
const prunedSections = pruneEmptyNews002Sections(output);
return {
html: prunedSections.html,
removedCards,
removedSections: prunedSections.removedSections,
};
}
export function extractNews002CardImageUrls(html) {
return extractNews002StoryCards(html)
.filter((card) => card.title)
@@ -289,19 +343,26 @@ export async function enrichNews002HtmlWithSourceImages(
if (!parts) continue;
const existingImage = extractCardImage(card.raw);
const image = existingImage || imageByStart.get(card.start) || '';
if (!image) {
output = output.slice(0, card.start) + output.slice(card.end);
continue;
}
const cleanedInner = ensureBodyWrapper(removeExistingMedia(parts.inner));
const media = image
? `<div class="media"><img src="${escapeHtmlAttribute(image)}" alt="${escapeHtmlAttribute(card.title)}" loading="lazy" referrerpolicy="no-referrer"></div>`
: '<div class="media ph">📰</div>';
const media = `<div class="media"><img src="${escapeHtmlAttribute(image)}" alt="${escapeHtmlAttribute(card.title)}" loading="lazy" referrerpolicy="no-referrer"></div>`;
const replacement = `${parts.open}${media}${cleanedInner}${parts.close}`;
output = output.slice(0, card.start) + replacement + output.slice(card.end);
}
const pruned = pruneNews002ImagelessStoryCards(output);
const remainingCards = extractNews002StoryCards(pruned.html).filter((card) => card.title);
return {
html: output,
cardCount: cards.filter((card) => card.title).length,
html: pruned.html,
cardCount: remainingCards.length,
attemptedCount: unresolved.length,
resolvedCount,
removedCards: pruned.removedCards,
removedSections: pruned.removedSections,
};
}
@@ -311,9 +372,9 @@ export function auditNews002ImageCoverage(
) {
const source = String(html ?? '');
const cards = extractNews002StoryCards(source).filter((card) => card.title);
const missingImages = cards.filter((card) => !extractCardImage(card.raw));
const missingImages = cards.filter((card) => !cardHasDisplayableImage(card.raw));
const leads = cards.filter((card) => card.isLead);
const missingLeadImages = leads.filter((card) => !extractCardImage(card.raw));
const missingLeadImages = leads.filter((card) => !cardHasDisplayableImage(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');
+49
View File
@@ -2,10 +2,12 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
auditNews002ImageCoverage,
cardHasDisplayableImage,
enrichNews002HtmlWithSourceImages,
enrichNewsMorningSearchGroupsWithImages,
extractNewsMorningSourceImage,
normalizeNewsMorningImageUrl,
pruneNews002ImagelessStoryCards,
} from './wechat-news-morning-images.mjs';
function htmlResponse(html, url = 'https://news.example/story') {
@@ -91,6 +93,53 @@ test('auditNews002ImageCoverage blocks missing images and excess cards', () => {
assert.ok(audit.violations.includes('story-image-missing'));
});
test('pruneNews002ImagelessStoryCards removes ph placeholders and empty sections', () => {
const html = `<!doctype html><html><body>
<div class="masthead"><h1>每日新闻早报</h1></div>
<div class="section" id="headlines">
<a class="lead-card" data-event-key="k1" href="https://news.example/a">
<div class="media"><img src="https://cdn.example/a.jpg" alt="有图"></div>
<div class="body"><h3>有图头条</h3></div>
</a>
<a class="lead-card" data-event-key="k2" href="https://news.example/b">
<div class="media ph">📰</div>
<div class="body"><h3>无图头条</h3></div>
</a>
</div>
<div class="section" id="world">
<div class="section-title"><h2>国际</h2></div>
<div class="card" data-event-key="k3"><div class="media ph">🌍</div><div class="body"><h3>无图国际</h3></div></div>
</div>
</body></html>`;
const result = pruneNews002ImagelessStoryCards(html);
assert.equal(result.removedCards, 2);
assert.ok(result.removedSections >= 1);
assert.match(result.html, /有图头条/u);
assert.doesNotMatch(result.html, /无图头条/u);
assert.doesNotMatch(result.html, /id="world"/u);
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>',