fix(news): repair instead of block in daily news generation and push
Memind CI / Test, build, and release guards (push) Successful in 4m43s

- news002 image/structure audit no longer throws: prune empty sections and
  publish a best-effort page with a warning
- WeChat push strips images that failed to upload instead of refusing the draft
- forceGenerateOnce is cleared before running, so a failed run cannot loop;
  the archived page is restored when generation fails
- degraded-page regeneration runs at most once per day
- content safety rules cover vulgar sexual terms and gossip/adult sites

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-23 11:29:35 +08:00
parent 371beb59f1
commit 57a9aadf53
8 changed files with 288 additions and 40 deletions
+10 -4
View File
@@ -39,7 +39,7 @@ DB 值优先于环境变量。非法值一律回落到 `news001`。
- 每张卡片必须带 `data-event-key="{事件指纹}"`,指纹 = 核心主体 + 核心动作,
中英文报道共用同一指纹,全页不得重复。
### 2. 运行时去重闸门
### 2. 运行时去重
prompt 层的「禁止重复」无法验证,所以在落盘后再做一次程序化清理:
[`wechat-news-morning-dedup.mjs`](../wechat-news-morning-dedup.mjs),由 worker 在生成成功后调用。
@@ -102,9 +102,15 @@ news002 的卡片图会先上传到微信正文素材接口,再由 inline 渲
静态页与微信草稿均使用 **左上角 72~92px 小图**(`.card` / `.lead-card` 左侧缩略图),不再用全宽大图。
能拿到真实配图的卡片必须显示并上传;没有真实图就只保留文字卡,不放默认小图。
远程配图会先下载到当日 MindSpace `public/`,改写成本站相对 URL,避免海外图源在国内打不开。
推送前不再删除无图故事卡(微信草稿保留纯文字卡);有图卡必须成功上传微信 CDN。
生成闸门会拦截:配图上传失败、头条不是 3~4 条、事件卡超过 30 条,或页面仍混有 news001 DOM,
才会由生成/推送闸门阻断,不允许进入草稿箱。
推送前不再删除无图故事卡(微信草稿保留纯文字卡);有图卡未能上传微信 CDN 时只去掉该图,卡片保留文字。
配图 / 结构 / 写满检查**只引导修复、不阻断生成和推送**:
检查不过时依次做 SearXNG 补位、补图、头条规整;仍不满足就删掉完全空的栏目,
以现有内容出稿并照常推草稿,同时记 `news002 audit incomplete, publishing best-effort page` 告警。
强制重生成(`forceGenerateOnce`)在执行前就清掉标记,只跑一次、失败不重试;
生成失败时把归档的原稿放回原位,当日页面不会因重生成失败而 404。
降级页自动重生成每天最多一次。
真实图片来源按优先级取 SearXNG 结果里的 `img_src`/`thumbnail`,以及文章来源的
`og:image:secure_url`、`og:image`、`twitter:image`;不使用与事件无法核验关系的泛图片搜索结果。
+2 -1
View File
@@ -48,6 +48,7 @@ function rx(source, flags = 'iu') {
const TERM_RULES = Object.freeze([
// 成人低俗
{ id: 'adult-explicit', category: 'adult', level: 'block', re: rx('色情(?:网站|视频|图片|直播|片)|黄色(?:网站|视频|小说)|黄网|黄片|成人(?:网站|视频|直播|电影)|AV女优|裸聊|约炮|援交|一夜情|性爱视频|艳照|露点(?:照|视频|写真)|福利姬|情色|擦边(?:视频|直播|写真|女主播)|porn|onlyfans|nsfw|xvideos') },
{ id: 'adult-vulgar', category: 'adult', level: 'block', re: rx('鸡巴|几把|肉棒|屌大|操逼|草逼|骚逼|口交|肛交|乳交|群交|乱伦|啪啪啪|自慰视频|无码(?:视频|片|高清)|熟女|海角(?:网|社区)|黑料(?:网|社|不打烊)|(?:51|91)(?:吃瓜|黑料)|吃瓜(?:网|黑料)') },
{ id: 'adult-sexual-crime', category: 'adult', level: 'review', re: rx('性侵|强奸|猥亵|性骚扰|卖淫|嫖娼|淫秽|裸照|不雅视频|偷拍') },
// 自杀自残
@@ -131,7 +132,7 @@ const SELF_MEDIA_DOMAINS = Object.freeze([
'sohu.com/a/',
]);
const UNSAFE_DOMAIN_RE = /(?:porn|xxx|sex|casino|bet365|gamble|18av|hentai|jav)/i;
const UNSAFE_DOMAIN_RE = /(?:porn|xxx|sex|casino|bet365|gamble|18av|hentai|jav|haijiao|heiliao|51cg|chigua)/i;
const POLITICAL_RE = rx(POLITICAL_TERMS);
const POLITICAL_SECTION_IDS = new Set(['policy', 'geopolitics', 'politics']);
const POLITICAL_TAG_RE = /时政|政策|政务|外交/u;
+6
View File
@@ -17,6 +17,12 @@ test('adult explicit content is blocked, sexual-crime news goes to review', () =
assert.equal(levelOf('91每日大赛-今日吃瓜爆料资讯平台,热门黑料绯闻每日更新'), 'block');
assert.equal(levelOf('男子强奸案一审宣判,被判有期徒刑十年'), 'review');
assert.equal(levelOf('央行宣布下调存款准备金率 0.5 个百分点'), 'clean');
assert.equal(levelOf('AI短剧王鹤棣的3厘米鸡巴第1集 打玻尿酸坏死只剩3cm... | 海角网'), 'block');
assert.equal(levelOf('熟女 - 热门标签,每日更新最新内容'), 'block');
assert.equal(levelOf('51吃瓜 今日最新'), 'block');
assert.equal(assessNewsSourceUrl('https://www.haijiao.com/post/123').level, 'block');
assert.equal(assessNewsSourceUrl('https://heiliao.com/archives/1').level, 'block');
assert.equal(levelOf('海角七号重映,票房破亿'), 'clean');
});
test('common news phrases are not false positives', () => {
+86 -15
View File
@@ -30,6 +30,7 @@ import {
enrichNews002HtmlWithSourceImages,
localizeNews002CardImages,
promoteNews002LeadCards,
pruneEmptyNews002Sections,
} from './wechat-news-morning-images.mjs';
import {
isWechatNewsMorningDraftWorkerEnabled,
@@ -119,9 +120,7 @@ async function applyNews002ImageGate(
{ searchGroups = [], safetyExemptSectionIds = [] } = {},
) {
if (config?.templateId !== 'news002') return null;
if (!page?.localPath || !fs.existsSync(page.localPath)) {
throw new Error('news002 配图闸门无法读取生成页面');
}
if (!page?.localPath || !fs.existsSync(page.localPath)) return null;
let source = fs.readFileSync(page.localPath, 'utf8');
const normalizedBefore = promoteNews002LeadCards(source);
if (normalizedBefore.promotedCount > 0 || normalizedBefore.demotedCount > 0) {
@@ -269,6 +268,20 @@ async function applyNews002ImageGate(
}
}
if (!audit.ok) {
const pruned = pruneEmptyNews002Sections(localized.html, { requireImage: false });
if (pruned.removedSections > 0) {
localized = { ...localized, html: pruned.html };
fs.writeFileSync(page.localPath, localized.html);
audit = auditNews002ImageCoverage(localized.html, {
requireTemplateStructure: true,
requireSectionFill: true,
exemptSectionIds: safetyExemptSectionIds,
env,
});
}
}
logger.log?.('[NewsMorningDraft] news002 image audit', {
slug: page.slug,
attempted: enriched.attemptedCount,
@@ -278,12 +291,15 @@ async function applyNews002ImageGate(
...audit,
});
if (!audit.ok) {
throw new Error(
`news002 配图/结构/写满闸门未通过:${audit.violations.join(', ')}`
+ `(卡片 ${audit.cardCount},真实配图 ${audit.imageCount},头条配图 ${audit.leadImageCount}/${audit.leadCount})`,
);
logger.warn?.('[NewsMorningDraft] news002 audit incomplete, publishing best-effort page', {
slug: page.slug,
violations: audit.violations,
cardCount: audit.cardCount,
imageCount: audit.imageCount,
leadImages: `${audit.leadImageCount}/${audit.leadCount}`,
});
}
return audit;
return { ...audit, degraded: !audit.ok };
}
function archiveTodayPageForForce(config, h5Root, now, logger) {
@@ -292,15 +308,47 @@ function archiveTodayPageForForce(config, h5Root, now, logger) {
const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
const archived = path.join(path.dirname(page.localPath), `_archived-${page.slug}-${stamp}.html`);
fs.renameSync(page.localPath, archived);
let archivedThumb = null;
if (page.thumbPath && fs.existsSync(page.thumbPath)) {
const archivedThumb = archived.replace(/\.html$/i, '.thumbnail.png');
archivedThumb = archived.replace(/\.html$/i, '.thumbnail.png');
fs.renameSync(page.thumbPath, archivedThumb);
}
logger.log?.('[NewsMorningDraft] archived today page before force generate', {
from: page.relativePath,
to: path.basename(archived),
});
return archived;
return {
originalPath: page.localPath,
archivedPath: archived,
originalThumbPath: archivedThumb ? page.thumbPath : null,
archivedThumbPath: archivedThumb,
};
}
/** 强制重生成失败时把原稿放回原位;新稿已落盘则保留新稿。 */
function restoreArchivedTodayPage(archived, logger) {
if (!archived?.archivedPath || !fs.existsSync(archived.archivedPath)) return false;
if (fs.existsSync(archived.originalPath)) return false;
try {
fs.renameSync(archived.archivedPath, archived.originalPath);
if (
archived.archivedThumbPath
&& fs.existsSync(archived.archivedThumbPath)
&& !fs.existsSync(archived.originalThumbPath)
) {
fs.renameSync(archived.archivedThumbPath, archived.originalThumbPath);
}
logger.log?.('[NewsMorningDraft] restored archived today page after failed generate', {
to: path.basename(archived.originalPath),
});
return true;
} catch (error) {
logger.warn?.(
'[NewsMorningDraft] restore archived today page failed:',
error instanceof Error ? error.message : error,
);
return false;
}
}
export function startWechatNewsMorningDraftWorker({
@@ -338,13 +386,15 @@ export function startWechatNewsMorningDraftWorker({
let stopped = false;
let running = false;
let generationInFlightDateKey = null;
let degradedRegenerateDateKey = null;
const ensureTodayPage = async (config, dateKey, { now = Date.now(), force = false } = {}) => {
if (generationInFlightDateKey === dateKey) return null;
generationInFlightDateKey = dateKey;
let archived = null;
try {
if (force) {
archiveTodayPageForForce(config, h5Root, now, logger);
archived = archiveTodayPageForForce(config, h5Root, now, logger);
}
let searchBrief = '';
let searchGroups = [];
@@ -387,7 +437,14 @@ export function startWechatNewsMorningDraftWorker({
stage: 'post-generate',
});
const safetyExemptSectionIds = safety?.exemptSectionIds ?? [];
await applyNews002ImageGate(page, config, logger, env, { searchGroups, safetyExemptSectionIds });
try {
await applyNews002ImageGate(page, config, logger, env, { searchGroups, safetyExemptSectionIds });
} catch (imageError) {
logger.warn?.(
'[NewsMorningDraft] news002 image repair failed, keeping generated page:',
imageError instanceof Error ? imageError.message : imageError,
);
}
await applyNewsMorningContentSafety(page, {
reviewer: contentReviewer,
reviewCache,
@@ -411,6 +468,7 @@ export function startWechatNewsMorningDraftWorker({
errorMessage: message,
}).catch(() => {});
logger.warn?.('[NewsMorningDraft] auto-generate failed:', message);
restoreArchivedTodayPage(archived, logger);
throw error;
} finally {
if (generationInFlightDateKey === dateKey) {
@@ -462,6 +520,7 @@ export function startWechatNewsMorningDraftWorker({
todayPageDegraded
&& !config.forceGenerateOnce
&& generationInFlightDateKey !== dateKey
&& degradedRegenerateDateKey !== dateKey
&& (
isWithinNewsMorningGenerateLeadWindow(config, now)
|| isLocalScheduleDue(
@@ -476,6 +535,7 @@ export function startWechatNewsMorningDraftWorker({
userId: config.sourceUserId,
dateKey,
});
degradedRegenerateDateKey = dateKey;
try {
todayPage = await ensureTodayPage(config, dateKey, { now, force: true });
if (
@@ -507,11 +567,19 @@ export function startWechatNewsMorningDraftWorker({
if (config.forceGenerateOnce && generationInFlightDateKey !== dateKey) {
try {
todayPage = await ensureTodayPage(config, dateKey, { now, force: true });
await wechatNewsMorningDraftService.updateConfig(
{ forceGenerateOnce: false },
{ updatedBy: 'auto-generate' },
);
} catch (error) {
logger.warn?.(
'[NewsMorningDraft] clear forceGenerateOnce failed, skip force generate:',
error instanceof Error ? error.message : error,
);
return;
}
try {
todayPage = await ensureTodayPage(config, dateKey, { now, force: true });
if (config.autoPushEnabled && todayPage) {
const result = await wechatNewsMorningDraftService.pushDraft({
triggeredBy: 'manual-generate',
@@ -522,8 +590,11 @@ export function startWechatNewsMorningDraftWorker({
pageSlug: result.preview?.page?.slug ?? todayPage.slug,
});
}
} catch {
// keep the flag so the next scan retries
} catch (error) {
logger.warn?.(
'[NewsMorningDraft] force generate failed:',
error instanceof Error ? error.message : error,
);
}
return;
}
+134
View File
@@ -244,6 +244,140 @@ test('runOnce honors forceGenerateOnce even when today page exists', async () =>
}
});
test('failed forceGenerateOnce clears the flag, restores the original page and does not retry', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-force-fail-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
const pagePath = path.join(publicDir, 'daily-news-0911.html');
fs.writeFileSync(pagePath, HEALTHY_TODAY_HTML);
const service = createWechatNewsMorningDraftService(createPool(), {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: tmpRoot,
env: {
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
},
});
let config = {
enabled: true,
sourceUserId: userId,
autoPushEnabled: true,
autoGenerateEnabled: true,
forceGenerateOnce: true,
timezone: 'Asia/Shanghai',
pushHour: 6,
pushMinute: 0,
pageSlugPattern: 'daily-news-*',
};
service.getConfig = async () => config;
service.updateConfig = async (payload) => {
config = { ...config, ...payload };
return config;
};
const pushCalls = [];
service.pushDraft = async (options = {}) => {
pushCalls.push(options);
return { ok: true, draftMediaId: 'draft-x' };
};
service.hasSuccessfulAutoPushToday = async () => true;
let executeCount = 0;
const fixedNow = Date.UTC(2026, 8, 10, 22, 30, 0);
const originalNow = Date.now;
Date.now = () => fixedNow;
try {
const worker = startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService: service,
mpConfig: { enabled: true },
userAuth: { canUseChat: async () => ({ ok: true }) },
tkmindProxy: { id: 'proxy' },
h5Root: tmpRoot,
env: { H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1' },
executeTask: async () => {
executeCount += 1;
throw new Error('agent timeout');
},
prefetchNewsSearch: async () => ({ brief: '', groups: [] }),
logger: { log() {}, warn() {} },
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
await worker.runOnce();
await worker.runOnce();
assert.equal(executeCount, 1);
assert.equal(config.forceGenerateOnce, false);
assert.equal(pushCalls.length, 0);
assert.equal(fs.readFileSync(pagePath, 'utf8'), HEALTHY_TODAY_HTML);
assert.equal(
fs.readdirSync(publicDir).some((name) => name.startsWith('_archived-daily-news-0911-')),
false,
);
} finally {
Date.now = originalNow;
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('news002 page that misses structure checks is still published with empty sections pruned', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-news002-degraded-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
const service = createWechatNewsMorningDraftService(createPool(), {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: tmpRoot,
env: {
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
H5_WECHAT_NEWS_MORNING_TEMPLATE_ID: 'news002',
},
});
const warnings = [];
const html = '<!DOCTYPE html><html><body><div class="masthead"><h1>每日新闻早报</h1></div>'
+ '<div class="section" id="domestic"><h2>国内</h2>'
+ '<div class="card" data-event-key="c919"><h3>国产大飞机交付第100架</h3>'
+ '<div class="body"><p>商飞公布最新交付数据。</p></div>'
+ '<a href="https://www.news.cn/a/20260911/c919.html">来源</a></div></div>'
+ '<div class="section" id="voices"><h2>声音</h2></div>'
+ '</body></html>';
const worker = startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService: service,
mpConfig: { enabled: true },
userAuth: { canUseChat: async () => ({ ok: true }) },
tkmindProxy: { id: 'proxy' },
h5Root: tmpRoot,
env: {
H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1',
MEMIND_NEWS_MORNING_SEARXNG_FALLBACK: '0',
},
executeTask: async () => {
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), html);
},
prefetchNewsSearch: async () => ({ brief: '', groups: [] }),
contentReviewer: async () => ({ ok: true, violations: [] }),
logger: { log() {}, warn: (...args) => warnings.push(args) },
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
try {
const result = await worker.generateToday({ now: Date.UTC(2026, 8, 10, 22, 0, 0), force: true });
assert.equal(result.skipped, false);
const saved = fs.readFileSync(path.join(publicDir, 'daily-news-0911.html'), 'utf8');
assert.match(saved, /国产大飞机交付第100架/u);
assert.doesNotMatch(saved, /id="voices"/);
assert.ok(warnings.some(([message]) => /publishing best-effort page/.test(String(message))));
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('generateToday skips existing page unless force is set', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-force-'));
const userId = 'user-news';
+36 -18
View File
@@ -31,7 +31,6 @@ import {
import {
auditNews002ImageCoverage,
extractNews002CardImageUrls,
isWeakNewsMorningImageUrl,
} from './wechat-news-morning-images.mjs';
import {
NEWS001_FRESH_CONTENT_SPEC,
@@ -854,38 +853,51 @@ export async function buildDailyNewsWechatDraftArticleForPush({
wechatFetch,
memindLibRoot,
});
if (/class="[^"]*\bmasthead\b/i.test(String(html ?? ''))) {
const audit = auditNews002ImageCoverage(html, {
let draftHtml = String(html ?? '');
const pushWarnings = [];
if (/class="[^"]*\bmasthead\b/i.test(draftHtml)) {
const audit = auditNews002ImageCoverage(draftHtml, {
requireTemplateStructure: true,
requireAllCoreSections: false,
requireLeadCount: false,
});
const pushBlocking = audit.violations.filter((item) => (
pushWarnings.push(...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) => (
)));
const missingUploads = extractNews002CardImageUrls(draftHtml).filter((ref) => (
!imageUrlMap.has(ref)
&& !imageUrlMap.has(ref.replace(/^\.\//, ''))
&& !imageUrlMap.has(ref.replace(/^\//, ''))
));
if (missingUploads.length > 0) {
throw new Error(`news002 有 ${missingUploads.length} 张正文配图未成功上传微信,已阻止草稿推送`);
draftHtml = stripNews002ImagesByRef(draftHtml, missingUploads);
pushWarnings.push(`image-upload-missing:${missingUploads.length}`);
}
}
return buildDailyNewsWechatDraftArticle({
html,
publicUrl,
qrcodeImageUrl,
imageUrlMap,
});
return {
...buildDailyNewsWechatDraftArticle({
html: draftHtml,
publicUrl,
qrcodeImageUrl,
imageUrlMap,
}),
pushWarnings,
};
}
/** 微信正文只认已上传的 mmbiz 图片:未上传成功的配图整块去掉,卡片保留文字。 */
export function stripNews002ImagesByRef(html, refs = []) {
const missing = new Set(refs);
if (missing.size === 0) return String(html ?? '');
return String(html ?? '')
.replace(/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<\/div>/gi, (block) => {
const src = block.match(/<img\b[^>]*\bsrc=["']([^"']+)["']/i)?.[1];
return src && missing.has(src) ? '' : block;
})
.replace(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi, (tag, src) => (missing.has(src) ? '' : tag));
}
export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '', qrcodeImageUrl = '' } = {}) {
@@ -1588,6 +1600,12 @@ export function createWechatNewsMorningDraftService(
publishDir: path.dirname(page.localPath),
})
: preview.article;
if (article.pushWarnings?.length) {
logger.warn?.('[NewsMorningDraft] draft pushed with repairs', {
pageSlug: page.slug,
warnings: article.pushWarnings,
});
}
if (articleTitleOverride) {
article.title = String(articleTitleOverride).trim().slice(0, 64);
}
+11
View File
@@ -10,10 +10,21 @@ import {
createWechatNewsMorningDraftService,
findLatestNewsMorningPage,
NEWS_MORNING_TEMPLATE_VERSION,
stripNews002ImagesByRef,
wechatNewsMorningDraftInternals,
} from './wechat-news-morning-draft.mjs';
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } from './user-publish.mjs';
test('stripNews002ImagesByRef drops only images that failed to upload and keeps card text', () => {
const html = '<div class="card"><div class="media"><img src="./a.jpg"></div><h3>甲</h3></div>'
+ '<div class="card"><div class="media"><img src="./b.jpg"></div><h3>乙</h3></div>';
const out = stripNews002ImagesByRef(html, ['./a.jpg']);
assert.doesNotMatch(out, /a\.jpg/);
assert.match(out, /b\.jpg/);
assert.match(out, /<h3>甲<\/h3>/u);
assert.equal(stripNews002ImagesByRef(html, []), html);
});
const SAMPLE_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
+3 -2
View File
@@ -420,7 +420,7 @@ export function promoteNews002LeadCards(html, { min = 3, max = 4 } = {}) {
const NEWS002_KEEP_EMPTY_SECTION_IDS = new Set(['weather', 'history', 'brief']);
function pruneEmptyNews002Sections(html) {
export function pruneEmptyNews002Sections(html, { requireImage = true } = {}) {
let output = String(html ?? '');
const re = /<div\b[^>]*class=["'][^"']*\bsection\b[^"']*["'][^>]*id=["']([^"']+)["'][^>]*>/gi;
const matches = [...output.matchAll(re)];
@@ -435,7 +435,8 @@ function pruneEmptyNews002Sections(html) {
match.index + match[0].length,
);
const slice = output.slice(start, end);
const cards = extractNews002StoryCards(slice).filter((card) => cardHasDisplayableImage(card.raw));
const cards = extractNews002StoryCards(slice)
.filter((card) => (requireImage ? cardHasDisplayableImage(card.raw) : Boolean(card.title)));
const hasDeep = /<div\b[^>]*class=["'][^"']*\bdeep\b/i.test(slice);
if (cards.length === 0 && !hasDeep) {
output = output.slice(0, start) + output.slice(end);