diff --git a/docs/news-morning-templates.md b/docs/news-morning-templates.md index 6202071..7e839e1 100644 --- a/docs/news-morning-templates.md +++ b/docs/news-morning-templates.md @@ -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`;不使用与事件无法核验关系的泛图片搜索结果。 diff --git a/news-content-safety-rules.mjs b/news-content-safety-rules.mjs index e44c710..69f9e53 100644 --- a/news-content-safety-rules.mjs +++ b/news-content-safety-rules.mjs @@ -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; diff --git a/news-content-safety-rules.test.mjs b/news-content-safety-rules.test.mjs index 1750568..f885b68 100644 --- a/news-content-safety-rules.test.mjs +++ b/news-content-safety-rules.test.mjs @@ -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', () => { diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs index 0413e2f..4a2dca0 100644 --- a/wechat-news-morning-draft-worker.mjs +++ b/wechat-news-morning-draft-worker.mjs @@ -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; } diff --git a/wechat-news-morning-draft-worker.test.mjs b/wechat-news-morning-draft-worker.test.mjs index 4d59080..cc919dc 100644 --- a/wechat-news-morning-draft-worker.test.mjs +++ b/wechat-news-morning-draft-worker.test.mjs @@ -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 = '

每日新闻早报

' + + '

国内

' + + '

国产大飞机交付第100架

' + + '

商飞公布最新交付数据。

' + + '来源
' + + '

声音

' + + ''; + 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'; diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index 3c9b8a7..46ca71c 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -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(/]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<\/div>/gi, (block) => { + const src = block.match(/]*\bsrc=["']([^"']+)["']/i)?.[1]; + return src && missing.has(src) ? '' : block; + }) + .replace(/]*\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); } diff --git a/wechat-news-morning-draft.test.mjs b/wechat-news-morning-draft.test.mjs index e538949..105b1a5 100644 --- a/wechat-news-morning-draft.test.mjs +++ b/wechat-news-morning-draft.test.mjs @@ -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 = '

甲

' + + '

乙

'; + const out = stripNews002ImagesByRef(html, ['./a.jpg']); + assert.doesNotMatch(out, /a\.jpg/); + assert.match(out, /b\.jpg/); + assert.match(out, /

甲<\/h3>/u); + assert.equal(stripNews002ImagesByRef(html, []), html); +}); + const SAMPLE_HTML = ` diff --git a/wechat-news-morning-images.mjs b/wechat-news-morning-images.mjs index e87f7c8..20baa70 100644 --- a/wechat-news-morning-images.mjs +++ b/wechat-news-morning-images.mjs @@ -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 = /]*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 = /]*class=["'][^"']*\bdeep\b/i.test(slice); if (cards.length === 0 && !hasDeep) { output = output.slice(0, start) + output.slice(end);