Compare commits

...

1 Commits

Author SHA1 Message Date
John 51604d3962 feat(wechat): filter unsafe morning news and assistive trim instead of hard fail
Add content safety filter for adult/junk URLs, strip bad cards without failing drafts, copy-archive on force regenerate, and improve news001/002 WeChat inline layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 09:58:35 +08:00
17 changed files with 727 additions and 59 deletions
+1
View File
@@ -528,6 +528,7 @@ export const NEWS002_FRESH_CONTENT_SPEC = [
'8. 标题规则:中文 15~26 字;英文来源须转写为中文标题,可在括号内保留英文原题;每条必须可核验并写来源 URL。',
'9. 禁止复制、改写历史 daily-news-*.html 的正文;旧稿仅作 HTML 结构参考。',
'10. 禁止八卦绯闻、未经证实的传闻、标题党;每条都要能说清「为什么重要」,说不清就不要写。',
'11. 禁止色情低俗、成人站点、吃瓜黑料、无信息量网址标题;命中则丢弃该条,继续写其它新闻,禁止因此整页失败。',
].join('\n');
/* ------------------------------------------------------------------ *
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -34,6 +34,7 @@ async function ensureDraftConfig(service) {
async function main() {
const dryRun = process.argv.includes('--dry-run');
const allowYesterday = process.argv.includes('--allow-yesterday');
const skipQualityGate = process.argv.includes('--skip-gate');
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL missing');
process.exit(1);
@@ -59,6 +60,7 @@ async function main() {
console.log(JSON.stringify({
dryRun,
allowYesterday,
skipQualityGate,
sourceUserId: config.sourceUserId,
templateId: config.templateId ?? null,
templateVersion: config.templateVersion ?? null,
@@ -87,7 +89,8 @@ async function main() {
const result = await service.pushDraft({
requireToday: !allowYesterday,
triggeredBy: 'manual-push-now',
triggeredBy: skipQualityGate ? 'manual-push-now-skip-gate' : 'manual-push-now',
skipQualityGate,
});
console.log('\n[success]');
console.log(JSON.stringify({
+94 -23
View File
@@ -78,10 +78,13 @@ const SECTION_LABELS = {
local_life: 'CITIES',
official_feeds: 'WIRE',
finance: 'FINANCE',
business: 'FINANCE',
tech: 'TECH',
technews: 'TECH',
sports: 'SPORTS',
living: 'CITIES',
knowledge: 'INSIGHT',
deepdive: 'DEEP',
};
const FEATURE_CARD_THEMES = {
@@ -187,6 +190,12 @@ function isNews002Html(html) {
return /\bclass="[^"]*\blead-card\b/u.test(source) && !/\bclass="hero\b/u.test(source);
}
export function resolveNewsMorningWechatLayoutStyle(html, layoutStyle = '') {
const requested = String(layoutStyle ?? '').trim().toLowerCase();
if (requested === 'news001' || requested === 'news002') return requested;
return isNews002Html(html) ? 'news002' : 'news001';
}
/** feature=渐变头条卡,rank=多行榜单卡,list=带序号的列表条目,lead=news002 头条小图卡。 */
function classifyCard(cardHtml, { news002Mode = false } = {}) {
if (news002Mode && /\blead-card\b/u.test(cardHtml)) return 'lead';
@@ -262,24 +271,23 @@ function renderFeatureCard(cardHtml, { theme, title, body, imageUrl = '' }) {
}
function renderListCard({ title, body, index, imageUrl = '' }) {
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 thumbBlock = imageUrl
? [
`<span style="${WX.compactThumb}">`,
`<img src="${imageUrl}" alt="${stripInlineText(title)}" style="${WX.compactThumbImg}" />`,
'</span>',
].join('')
: '';
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>` : '',
].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}">`,
`<section style="${WX.compactWrap}">`,
thumbBlock,
`<span style="${thumbBlock ? WX.compactBody : 'display:block'}">`,
textBlock,
'</span>',
'</section>',
].join('');
}
@@ -358,7 +366,8 @@ function renderCard(cardHtml, {
theme,
title,
body,
imageUrl,
// news001 头条保持渐变卡,不插全宽图;小缩略图只加在列表卡上。
imageUrl: '',
});
}
return renderListCard({
@@ -417,15 +426,29 @@ function extractHeroDateLine(html) {
* Hero 里的摘要通常是「要点·要点·要点」的长串,拆成导读列表比整段文字好读得多。
* 拆不出两条以上时返回空,由 Hero 继续以整段展示。
*/
function extractBriefDigestItems(html, { maxItems = 8, maxItemChars = 36 } = {}) {
const block = String(html ?? '').match(/<ol[^>]*class="[^"]*\bbrief\b[^"]*"[^>]*>([\s\S]*?)<\/ol>/i)?.[1] ?? '';
const items = [];
for (const match of block.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/gi)) {
const text = stripInlineText(match[1]).replace(/^\d+[\s.、]*/u, '');
if (text.length < 4) continue;
items.push(truncatePlainText(text, maxItemChars));
if (items.length >= maxItems) break;
}
return items;
}
function renderTodayDigest(html, { maxItems = 6, maxItemChars = 30 } = {}) {
const briefItems = extractBriefDigestItems(html, { maxItems: Math.max(maxItems, 8), maxItemChars: 36 });
const raw = extractHeroSubtitle(html);
if (!raw) return '';
const items = raw
.split(/\s*[·|、]\s*/u)
.map((item) => item.trim())
.filter((item) => item.length >= 4)
.slice(0, maxItems)
.map((item) => truncatePlainText(item, maxItemChars));
const items = briefItems.length >= 2
? briefItems
: raw
.split(/\s*[·|、]\s*/u)
.map((item) => item.trim())
.filter((item) => item.length >= 4)
.slice(0, maxItems)
.map((item) => truncatePlainText(item, maxItemChars));
if (items.length < 2) return '';
const lines = items.map((item) => `<span style="color:${BRAND.red}">·</span> ${item}`).join('<br>');
return [
@@ -609,6 +632,26 @@ function renderKnowledgeSection(sectionHtml, { maxItems = 4, maxBodyChars = 130
return [renderSectionTitle(title, 'knowledge'), ...items].join('');
}
function renderDeepdiveSection(sectionHtml, { maxBodyChars = 220 } = {}) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
const heading = extractInnerHtml(sectionHtml, /<h3[^>]*>([\s\S]*?)<\/h3>/i);
const body = truncatePlainText(
[...String(sectionHtml).matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
.map((item) => stripInlineText(item[1]))
.filter(Boolean)
.join(' '),
maxBodyChars,
);
if (!heading && !body) return '';
return [
renderSectionTitle(title, 'deepdive'),
`<section style="${WX.feature};background:${FEATURE_CARD_THEMES['highlight-box-gold']}">`,
heading ? `<p style="${WX.featureTitle}"><strong>${heading}</strong></p>` : '',
body ? `<p style="${WX.featureBody}">${body}</p>` : '',
'</section>',
].filter(Boolean).join('');
}
function renderSectionFigure(figureHtml, imageUrlMap = new Map()) {
const src = String(figureHtml).match(/<img[^>]+src=["']([^"']+)["']/i)?.[1] ?? '';
const mapped = resolveMappedImageUrl(src, imageUrlMap);
@@ -734,10 +777,13 @@ const DEFAULT_SECTION_CARD_LIMITS = {
local_life: 3,
official_feeds: 3,
finance: 5,
business: 5,
tech: 5,
sports: 3,
technews: 2,
living: 3,
knowledge: 3,
deepdive: 1,
};
const DEFAULT_CARD_BODY_CHARS = 132;
@@ -817,8 +863,14 @@ function countSectionCards(sections) {
if (!section.id || section.id === 'weather' || section.id === 'history') continue;
if (/历史上的今天|近7天新闻早报/u.test(section.title)) continue;
const total = section.id === 'knowledge'
? extractClassDivBlocks(section.html, 'knowledge-item').length
: (section.html.match(/<div class="(?:card|highlight-box)/gi) ?? []).length;
? Math.max(
extractClassDivBlocks(section.html, 'knowledge-item').length,
(section.html.match(/<div class="(?:card|highlight-box)/gi) ?? []).length,
)
: (
(section.html.match(/<div class="(?:card|highlight-box)/gi) ?? []).length
+ (section.html.match(/<a class="lead-card/gi) ?? []).length
);
if (total > 0) counts[section.id] = total;
}
return counts;
@@ -847,6 +899,7 @@ function resolveSectionCardLimit(section, sectionCardLimits) {
function shouldSkipSection(section, trimProfile) {
if (!section) return true;
if (section.id === 'hot') return true;
if (section.id === 'brief') return true;
if (section.id === 'history') return true;
if (section.id === 'knowledge' && trimProfile?.skipKnowledge === true) return true;
if (trimProfile?.skipSectionIds?.includes(section.id)) return true;
@@ -958,11 +1011,12 @@ function buildWechatInlineContent(html, {
trimProfile = TRIM_PROFILES[0],
includeFixedLayout = true,
imageUrlMap = new Map(),
layoutStyle = '',
} = {}) {
const sections = splitSections(html);
const news002Mode = isNews002Html(html);
const resolvedLayout = resolveNewsMorningWechatLayoutStyle(html, layoutStyle);
const news002Mode = resolvedLayout === 'news002';
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);
@@ -983,6 +1037,17 @@ function buildWechatInlineContent(html, {
continue;
}
if (section.id === 'deepdive') {
const rendered = renderDeepdiveSection(section.html, {
maxBodyChars: Math.min(trimProfile.maxBodyChars ?? 220, 220),
});
if (rendered) {
contentParts.push(rendered);
renderedSectionCount += 1;
}
continue;
}
if (section.id === 'knowledge') {
const rendered = renderKnowledgeSection(section.html, {
maxItems: resolveSectionCardLimit(section, trimProfile.sectionCardLimits) ?? 4,
@@ -1040,6 +1105,7 @@ function fitInlineContentToBudget(html, bodyBudget, {
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
imageUrlMap = new Map(),
layoutStyle = '',
} = {}) {
const fullCounts = countSectionCards(splitSections(html));
const cappedCounts = mergeSectionCardLimits(fullCounts, DEFAULT_SECTION_CARD_LIMITS);
@@ -1050,6 +1116,7 @@ function fitInlineContentToBudget(html, bodyBudget, {
trimProfile,
includeFixedLayout: false,
imageUrlMap,
layoutStyle,
});
let best = null;
@@ -1133,6 +1200,7 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
imageUrlMap = new Map(),
includeFixedLayout = true,
fixedLayoutLength = null,
layoutStyle = '',
} = {}) {
const title = (extractPageTitle(html) || '每日新闻早报').slice(0, 32);
const digest = (extractMetaDescription(html) || title).slice(0, 120);
@@ -1150,6 +1218,7 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
qrcodeImageUrl,
portalUrl,
imageUrlMap,
layoutStyle,
});
const trimLevel = fitted.bodyCharTier;
const built = fitted.built;
@@ -1204,6 +1273,8 @@ export const wechatDailyNewsInlineInternals = {
renderHero,
renderTodayDigest,
renderBriefStats,
renderDeepdiveSection,
resolveNewsMorningWechatLayoutStyle,
renderWeatherSection,
selectTouristWeatherCards,
isPopularTouristCityName,
+20
View File
@@ -182,6 +182,26 @@ test('news002 card with media block still yields title and body', () => {
assert.match(rendered, /国内正文摘要/u, '.media 在前不应挤掉正文 <p>');
});
test('news001 layout keeps gradient headlines and adds compact thumbs on list cards', () => {
const sourceImage = 'https://news.example/story.jpg';
const wechatImage = 'https://mmbiz.qpic.cn/wechat-story.jpg';
const html = NEWS002_HTML
.replace('<div class="media ph">📰</div>', `<div class="media"><img src="${sourceImage}" alt="头条"></div>`)
.replace('<div class="media ph">🇨🇳</div>', `<div class="media"><img src="${sourceImage}" alt="国内"></div>`);
const imageUrlMap = new Map([[sourceImage, wechatImage]]);
const article = convertDailyNewsHtmlToWechatInlineArticle(html, {
includeFixedLayout: false,
imageUrlMap,
layoutStyle: 'news001',
});
assert.match(article.content, /linear-gradient\(135deg,#c8102e/u, '头条仍走 news001 渐变卡');
assert.match(article.content, /王毅同鲁比奥通电话/u);
assert.match(article.content, /沈阳举行九一八纪念活动/u);
assert.match(article.content, /width:72px;height:54px/u, '列表卡使用 news002 小缩略图');
assert.doesNotMatch(article.content, /calc\(100% \+ 28px\)/u, '头条不插全宽图');
assert.doesNotMatch(article.content, /TKMIND DAILY/u);
});
test('news002 cards render uploaded source images in the wechat draft', () => {
const sourceImage = 'https://news.example/story.jpg';
const wechatImage = 'https://mmbiz.qpic.cn/wechat-story.jpg';
+116
View File
@@ -0,0 +1,116 @@
/**
* 早报内容安全过滤:挡住色情低俗、成人站和无信息量网址标题。
* 只剔除条目,不让整页/整次推送失败。
*/
const UNSAFE_TEXT_PATTERNS = [
/鸡巴/iu,
/吞精/iu,
/海角网/iu,
/黑料/iu,
/吃瓜/iu,
/91每日大赛/iu,
/91视频/iu,
/上科技操/iu,
/打玻尿酸坏死/iu,
/玩偶姐姐/iu,
/hongkongdoll/iu,
/porn/iu,
/xxx\b/iu,
/onlyfans/iu,
];
const UNSAFE_HOST_PATTERNS = [
/91dscg/i,
/haijiao/i,
/swytotbvj/i,
/jav\d/i,
/pornhub/i,
/xvideos/i,
/xnxx/i,
];
const EXTRA_BLOCKLIST_ENV = 'MEMIND_NEWS_MORNING_BLOCKLIST';
function extraBlocklist(env = process.env) {
return String(env[EXTRA_BLOCKLIST_ENV] ?? '')
.split(/[,|\n]/)
.map((item) => item.trim())
.filter((item) => item.length >= 2);
}
function decodeHtml(value) {
return String(value ?? '')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&nbsp;/gi, ' ');
}
export function normalizeNewsMorningSafetyText(value) {
return decodeHtml(value).replace(/\s+/g, ' ').trim();
}
function hostnameOf(url) {
try {
return new URL(String(url ?? '').trim()).hostname.replace(/^www\./i, '').toLowerCase();
} catch {
return '';
}
}
export function isBareNewsMorningUrlTitle(title) {
const text = normalizeNewsMorningSafetyText(title);
if (!text) return false;
if (/^https?:\/\//i.test(text)) return true;
if (/youtube\.com\/watch/i.test(text)) return true;
if (/^[\w.-]+\.(com|net|org|cn|cc|tv|io)(\/|$|\s)/i.test(text) && !/\s/.test(text)) {
return true;
}
return false;
}
export function inspectNewsMorningSafety(item = {}, { env = process.env } = {}) {
const title = normalizeNewsMorningSafetyText(item.title);
const snippet = normalizeNewsMorningSafetyText(item.snippet ?? item.body ?? '');
const url = normalizeNewsMorningSafetyText(item.url);
const eventKey = normalizeNewsMorningSafetyText(item.eventKey);
const blob = `${title} ${snippet} ${url} ${eventKey}`;
const host = hostnameOf(url);
const extras = extraBlocklist(env);
if (isBareNewsMorningUrlTitle(title)) {
return { ok: false, reason: 'bare-url-title' };
}
for (const pattern of UNSAFE_TEXT_PATTERNS) {
if (pattern.test(blob)) return { ok: false, reason: 'unsafe-text' };
}
for (const pattern of UNSAFE_HOST_PATTERNS) {
if (pattern.test(host) || pattern.test(url) || pattern.test(blob)) {
return { ok: false, reason: 'unsafe-host' };
}
}
for (const term of extras) {
if (blob.toLowerCase().includes(term.toLowerCase())) {
return { ok: false, reason: 'custom-blocklist' };
}
}
return { ok: true, reason: null };
}
export function isUnsafeNewsMorningItem(item, options) {
return !inspectNewsMorningSafety(item, options).ok;
}
export function filterSafeNewsMorningItems(items = [], options) {
const kept = [];
const removed = [];
for (const item of Array.isArray(items) ? items : []) {
const verdict = inspectNewsMorningSafety(item, options);
if (verdict.ok) kept.push(item);
else removed.push({ item, reason: verdict.reason });
}
return { items: kept, removed, removedCount: removed.length };
}
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
filterSafeNewsMorningItems,
inspectNewsMorningSafety,
isBareNewsMorningUrlTitle,
isUnsafeNewsMorningItem,
} from './wechat-news-morning-content-filter.mjs';
test('inspectNewsMorningSafety drops adult titles and junk hosts', () => {
assert.equal(inspectNewsMorningSafety({
title: 'AI短剧标题含鸡巴与海角网',
url: 'https://african.swytotbvj.cc/archives/1',
}).ok, false);
assert.equal(inspectNewsMorningSafety({
title: '91每日大赛-今日吃瓜爆料资讯平台',
url: 'https://91dscg3.com/',
}).reason, 'unsafe-text');
assert.equal(inspectNewsMorningSafety({
title: '91视频最新网址 · GitLab',
url: 'https://gitlab.com/dizhi8',
}).ok, false);
assert.equal(inspectNewsMorningSafety({
title: '正规国际快讯',
url: 'https://91dscg3.com/foo',
}).reason, 'unsafe-host');
});
test('inspectNewsMorningSafety drops bare URL titles', () => {
assert.equal(isBareNewsMorningUrlTitle('youtube.com/watch?v=BpKaJR4Ut7o'), true);
assert.equal(isBareNewsMorningUrlTitle('d2pa8mwo9guq2h.cloudfront.net'), true);
assert.equal(isBareNewsMorningUrlTitle('https://map.baidu.com'), true);
assert.equal(isBareNewsMorningUrlTitle('莫斯科遭无人机袭击'), false);
});
test('filterSafeNewsMorningItems keeps legitimate news and honors custom blocklist', () => {
const { items, removedCount } = filterSafeNewsMorningItems([
{ title: '国乒今日出征', url: 'https://news.qq.com/a/20260923' },
{ title: '月排行榜吞精兽', url: 'https://news.example/bad' },
{ title: '自定义屏蔽词稿', url: 'https://news.example/custom' },
], { env: { MEMIND_NEWS_MORNING_BLOCKLIST: '自定义屏蔽词' } });
assert.equal(items.length, 1);
assert.equal(removedCount, 2);
assert.match(items[0].title, /国乒/);
assert.equal(isUnsafeNewsMorningItem({ title: '正规国际快讯', url: 'https://www.reuters.com/world' }), false);
});
+21 -8
View File
@@ -21,6 +21,7 @@ import {
formatNewsMorningDedupeSummary,
} from './wechat-news-morning-dedup.mjs';
import {
assistNews002Html,
auditNews002ImageCoverage,
enrichNews002HtmlWithSourceImages,
localizeNews002CardImages,
@@ -216,21 +217,33 @@ async function applyNews002ImageGate(
}
}
const assisted = assistNews002Html(localized.html, {
requireTemplateStructure: true,
requireSectionFill: true,
env,
});
fs.writeFileSync(page.localPath, assisted.html);
logger.log?.('[NewsMorningDraft] news002 image audit', {
slug: page.slug,
attempted: enriched.attemptedCount,
resolved: enriched.resolvedCount,
localized: localized.localizedCount,
localizeFailed: localized.failedCount,
...audit,
assisted: assisted.actions,
remaining: assisted.auditAfter.violations,
...assisted.auditAfter,
});
if (!audit.ok) {
throw new Error(
`news002 配图/结构/写满闸门未通过:${audit.violations.join(', ')}`
+ `(卡片 ${audit.cardCount},真实配图 ${audit.imageCount},头条配图 ${audit.leadImageCount}/${audit.leadCount}`,
if (!assisted.auditAfter.ok) {
logger.warn?.(
'[NewsMorningDraft] news002 gate remaining after assist:',
assisted.auditAfter.violations.join(', ')
+ `(卡片 ${assisted.auditAfter.cardCount},真实配图 ${assisted.auditAfter.imageCount},头条配图 ${assisted.auditAfter.leadImageCount}/${assisted.auditAfter.leadCount}`,
);
}
return audit;
return {
...assisted.auditAfter,
actions: assisted.actions,
};
}
function archiveTodayPageForForce(config, h5Root, now, logger) {
@@ -238,10 +251,10 @@ function archiveTodayPageForForce(config, h5Root, now, logger) {
if (!page?.localPath || !fs.existsSync(page.localPath)) return null;
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);
fs.copyFileSync(page.localPath, archived);
if (page.thumbPath && fs.existsSync(page.thumbPath)) {
const archivedThumb = archived.replace(/\.html$/i, '.thumbnail.png');
fs.renameSync(page.thumbPath, archivedThumb);
fs.copyFileSync(page.thumbPath, archivedThumb);
}
logger.log?.('[NewsMorningDraft] archived today page before force generate', {
from: page.relativePath,
+104
View File
@@ -244,6 +244,51 @@ test('runOnce honors forceGenerateOnce even when today page exists', async () =>
}
});
test('force generate failure keeps the live today page', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-keep-live-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
const livePath = path.join(publicDir, 'daily-news-0911.html');
fs.writeFileSync(livePath, `${HEALTHY_TODAY_HTML}<!--keep-live-->`);
const pool = createPool();
const service = createWechatNewsMorningDraftService(pool, {
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,
},
});
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 () => {
throw new Error('generate boom');
},
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
const fixedNow = Date.UTC(2026, 8, 10, 22, 0, 0);
await assert.rejects(
() => worker.generateToday({ now: fixedNow, force: true }),
/generate boom|新闻早报页面生成未完成/,
);
assert.equal(fs.existsSync(livePath), true);
assert.match(fs.readFileSync(livePath, 'utf8'), /keep-live/);
assert.ok(
fs.readdirSync(publicDir).some((name) => name.startsWith('_archived-daily-news-0911-')),
);
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';
@@ -307,6 +352,65 @@ test('generateToday skips existing page unless force is set', async () => {
}
});
test('news002 generate keeps going when gate still has remaining violations', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-assist-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
const pool = createPool();
const service = createWechatNewsMorningDraftService(pool, {
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,
},
});
service.getConfig = async () => ({
enabled: true,
sourceUserId: userId,
autoPushEnabled: false,
autoGenerateEnabled: true,
timezone: 'Asia/Shanghai',
pushHour: 6,
pushMinute: 0,
pageSlugPattern: 'daily-news-*',
templateId: 'news002',
});
const warnings = [];
const fixedNow = Date.UTC(2026, 8, 10, 22, 0, 0);
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' },
logger: {
log() {},
warn(message) { warnings.push(String(message)); },
},
executeTask: async () => {
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), `${SAMPLE_HTML}<!--assisted-->`);
},
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
try {
const generated = await worker.generateToday({ now: fixedNow, force: true });
assert.equal(generated.skipped, false);
assert.equal(generated.page.slug, 'daily-news-0911');
assert.match(fs.readFileSync(path.join(publicDir, 'daily-news-0911.html'), 'utf8'), /assisted/);
assert.ok(warnings.some((item) => item.includes('news002 gate remaining after assist')));
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('schedule helpers detect today page and due windows', () => {
const {
isNewsMorningPageForToday,
+34 -21
View File
@@ -23,7 +23,7 @@ import {
import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs';
import { countNewsMorningStoryCards } from './wechat-news-morning-dedup.mjs';
import {
auditNews002ImageCoverage,
assistNews002Html,
extractNews002CardImageUrls,
isWeakNewsMorningImageUrl,
} from './wechat-news-morning-images.mjs';
@@ -794,6 +794,7 @@ export function buildDailyNewsWechatDraftArticle({
imageUrlMap = new Map(),
publicBaseUrl = '',
userId = '',
layoutStyle = '',
} = {}) {
const meta = extractDailyNewsArticleMeta(html);
const footerLength = estimateDailyNewsDraftFooterLength({ publicUrl, qrcodeImageUrl });
@@ -803,6 +804,7 @@ export function buildDailyNewsWechatDraftArticle({
imageUrlMap,
includeFixedLayout: false,
fixedLayoutLength: footerLength,
layoutStyle,
});
let content = applyDailyNewsWechatDraftFooter(inline.content, {
publicUrl,
@@ -834,48 +836,54 @@ export async function buildDailyNewsWechatDraftArticleForPush({
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
publishDir = '',
skipQualityGate = false,
logger = console,
layoutStyle = '',
} = {}) {
const assisted = skipQualityGate
? { html: String(html ?? ''), changed: false, actions: [], auditAfter: { ok: true, violations: [] } }
: assistNews002Html(html, { requireTemplateStructure: true });
const workingHtml = assisted.html;
if (assisted.changed) {
logger.warn?.('[NewsMorningDraft] news002 gate assisted push', {
actions: assisted.actions,
remaining: assisted.auditAfter.violations,
});
}
const { qrcodeImageUrl } = await uploadWechatDraftBrandAssets({
accessToken,
wechatFetch,
memindLibRoot,
});
const imageUrlMap = await uploadDailyNewsBodyImagesForWechat(accessToken, html, {
const imageUrlMap = await uploadDailyNewsBodyImagesForWechat(accessToken, workingHtml, {
publishDir,
wechatFetch,
memindLibRoot,
});
if (/class="[^"]*\bmasthead\b/i.test(String(html ?? ''))) {
const audit = auditNews002ImageCoverage(html, {
requireTemplateStructure: true,
requireAllCoreSections: false,
requireLeadCount: false,
});
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) => (
if (!skipQualityGate && /class="[^"]*\bmasthead\b/i.test(workingHtml)) {
const remaining = Array.isArray(assisted.auditAfter?.violations)
? assisted.auditAfter.violations
: [];
const missingUploads = extractNews002CardImageUrls(workingHtml).filter((ref) => (
!isWeakNewsMorningImageUrl(ref)
)).filter((ref) => (
!imageUrlMap.has(ref)
&& !imageUrlMap.has(ref.replace(/^\.\//, ''))
&& !imageUrlMap.has(ref.replace(/^\//, ''))
));
if (missingUploads.length > 0) {
throw new Error(`news002 有 ${missingUploads.length} 张正文配图未成功上传微信,已阻止草稿推送`);
if (remaining.length > 0 || missingUploads.length > 0) {
logger.warn?.('[NewsMorningDraft] news002 gate remaining after assist', {
violations: remaining,
missingUploads: missingUploads.length,
});
}
}
return buildDailyNewsWechatDraftArticle({
html,
html: workingHtml,
publicUrl,
qrcodeImageUrl,
imageUrlMap,
layoutStyle,
});
}
@@ -1521,6 +1529,8 @@ export function createWechatNewsMorningDraftService(
now = Date.now(),
pageOverride = null,
articleTitleOverride = null,
skipQualityGate = false,
layoutStyle = '',
} = {}) {
const config = await this.getConfig();
if (!config.enabled) {
@@ -1569,6 +1579,9 @@ export function createWechatNewsMorningDraftService(
wechatFetch,
memindLibRoot,
publishDir: path.dirname(page.localPath),
skipQualityGate,
logger,
layoutStyle,
})
: preview.article;
if (articleTitleOverride) {
+22
View File
@@ -5,6 +5,7 @@ import path from 'node:path';
import test from 'node:test';
import {
buildDailyNewsWechatDraftArticle,
buildDailyNewsWechatDraftArticleForPush,
buildNewsMorningWechatPushText,
convertNewsPageHtmlToWechatArticle,
createWechatNewsMorningDraftService,
@@ -342,6 +343,27 @@ test('internals normalize booleans consistently', () => {
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('off', true), false);
});
test('buildDailyNewsWechatDraftArticleForPush assists overflow instead of failing', async () => {
const cards = Array.from({ length: 31 }, (_, index) => (
index < 3
? `<a class="lead-card" data-event-key="lead${index}" href="https://news.example/${index}"><h3>头条${index}</h3><div class="body">正文</div></a>`
: `<div class="card" data-event-key="k${index}"><h3>事件标题${index}</h3><div class="body">正文</div><div class="why">原因</div></div>`
)).join('');
const html = `<html><head><title>每日新闻早报</title></head><body>
<div class="masthead"></div><div class="lede"></div><ol class="brief"></ol>
<div class="section" id="headlines">${cards}</div>
</body></html>`;
const warnings = [];
const article = await buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl: 'https://m.tkmind.cn/daily-news-0923.html',
logger: { warn(message, detail) { warnings.push({ message, detail }); } },
});
assert.equal(typeof article.title, 'string');
assert.ok(article.contentLength > 0);
assert.ok(warnings.some((item) => String(item.message).includes('news002 gate')));
});
test('isNewsMorningContentDegraded counts news002 lead cards', async () => {
const { isNewsMorningContentDegraded } = await import('./wechat-news-morning-draft.mjs');
const leads = Array.from({ length: 4 }, (_, i) =>
+134
View File
@@ -11,6 +11,7 @@ import {
countNews002HeadlineLeadCards,
findNews002SectionIdForOffset,
} from './news-morning-templates.mjs';
import { inspectNewsMorningSafety } from './wechat-news-morning-content-filter.mjs';
const DEFAULT_TIMEOUT_MS = 6000;
const DEFAULT_USER_AGENT =
@@ -845,3 +846,136 @@ export function auditNews002ImageCoverage(
sectionFill,
};
}
export function extractNews002BriefItems(html) {
const source = String(html ?? '');
const match = source.match(/<ol[^>]*\bclass="[^"]*\bbrief\b[^"]*"[^>]*>([\s\S]*?)<\/ol>/i);
if (!match) return [];
const inner = match[1];
const innerStart = source.indexOf(match[0]) + match[0].indexOf(inner);
const items = [];
const liRe = /<li\b[^>]*>[\s\S]*?<\/li>/gi;
let liMatch;
while ((liMatch = liRe.exec(inner)) != null) {
items.push({
start: innerStart + liMatch.index,
end: innerStart + liMatch.index + liMatch[0].length,
raw: liMatch[0],
title: textOf(liMatch[0]),
});
}
return items;
}
function renumberNews002BriefItems(html) {
let index = 0;
return String(html ?? '').replace(
/(<ol[^>]*\bclass="[^"]*\bbrief\b[^"]*"[^>]*>)([\s\S]*?)(<\/ol>)/i,
(_full, open, inner, close) => {
const next = inner.replace(/<li\b[^>]*>[\s\S]*?<\/li>/gi, (li) => {
index += 1;
return li.replace(/<span class="n">\s*\d+\s*<\/span>/i, `<span class="n">${index}</span>`);
});
return `${open}${next}${close}`;
},
);
}
export function stripUnsafeNews002Content(html, { env = process.env } = {}) {
const source = String(html ?? '');
const cards = extractNews002StoryCards(source);
const removedCards = cards.filter((card) => !inspectNewsMorningSafety({
title: card.title,
url: card.url,
snippet: textOf(card.raw),
eventKey: card.eventKey,
}, { env }).ok);
let output = source;
for (const card of [...removedCards].sort((left, right) => right.start - left.start)) {
output = `${output.slice(0, card.start)}${output.slice(card.end)}`;
}
const briefs = extractNews002BriefItems(output);
const removedBriefs = briefs.filter((item) => !inspectNewsMorningSafety({
title: item.title,
}, { env }).ok);
for (const item of [...removedBriefs].sort((left, right) => right.start - left.start)) {
output = `${output.slice(0, item.start)}${output.slice(item.end)}`;
}
if (removedBriefs.length) {
output = renumberNews002BriefItems(output);
}
return {
html: output,
changed: removedCards.length + removedBriefs.length > 0,
removedCardCount: removedCards.length,
removedBriefCount: removedBriefs.length,
keptCardCount: cards.length - removedCards.length,
};
}
export function trimNews002ExcessStoryCards(html, { maxStoryCards = 28 } = {}) {
const source = String(html ?? '');
const cards = extractNews002StoryCards(source).filter((card) => card.title);
if (cards.length <= maxStoryCards) {
return { html: source, changed: false, removedCount: 0, keptCount: cards.length };
}
const leads = cards.filter((card) => card.isLead);
const others = cards.filter((card) => !card.isLead);
const keepLeads = leads.slice(0, Math.min(leads.length, 4));
const keepOthers = others.slice(0, Math.max(0, maxStoryCards - keepLeads.length));
const keep = new Set([...keepLeads, ...keepOthers]);
const removed = cards.filter((card) => !keep.has(card));
let output = source;
for (const card of [...removed].sort((left, right) => right.start - left.start)) {
output = `${output.slice(0, card.start)}${output.slice(card.end)}`;
}
return {
html: output,
changed: removed.length > 0,
removedCount: removed.length,
keptCount: keepLeads.length + keepOthers.length,
};
}
export function assistNews002Html(html, {
maxStoryCards = NEWS002_MAX_STORY_CARDS,
requireTemplateStructure = true,
requireSectionFill = false,
env = process.env,
} = {}) {
const source = String(html ?? '');
const auditOptions = {
maxStoryCards,
requireTemplateStructure,
requireSectionFill,
env,
};
const auditBefore = auditNews002ImageCoverage(source, auditOptions);
const actions = [];
let output = source;
const stripped = stripUnsafeNews002Content(output, { env });
if (stripped.changed) {
output = stripped.html;
if (stripped.removedCardCount) {
actions.push(`stripped-unsafe-cards:${stripped.removedCardCount}`);
}
if (stripped.removedBriefCount) {
actions.push(`stripped-unsafe-briefs:${stripped.removedBriefCount}`);
}
}
if (auditNews002ImageCoverage(output, auditOptions).violations.includes('too-many-story-cards')) {
const trimmed = trimNews002ExcessStoryCards(output, { maxStoryCards });
if (trimmed.changed) {
output = trimmed.html;
actions.push(`trimmed-story-cards:${trimmed.removedCount}`);
}
}
const auditAfter = auditNews002ImageCoverage(output, auditOptions);
return {
html: output,
changed: actions.length > 0,
actions,
auditBefore,
auditAfter,
};
}
+71 -1
View File
@@ -4,7 +4,9 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
assistNews002Html,
auditNews002ImageCoverage,
stripUnsafeNews002Content,
cardHasDisplayableImage,
isWeakNewsMorningImageUrl,
enrichNews002HtmlWithSourceImages,
@@ -16,7 +18,9 @@ import {
normalizeNewsMorningImageUrl,
normalizeNewsMorningLocalImageRef,
pruneNews002ImagelessStoryCards,
trimNews002ExcessStoryCards,
} from './wechat-news-morning-images.mjs';
import { NEWS002_MAX_STORY_CARDS } from './news-morning-templates.mjs';
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
@@ -95,8 +99,74 @@ test('enrichNews002HtmlWithSourceImages inserts media and body wrappers', async
});
});
test('stripUnsafeNews002Content removes adult cards and bare-url titles', () => {
const html = [
'<ol class="brief">',
'<li><span class="n">1</span><span>莫斯科遭无人机袭击</span></li>',
'<li><span class="n">2</span><span>91每日大赛今日吃瓜爆料</span></li>',
'</ol>',
'<a class="lead-card" href="https://news.example/ok" data-event-key="ok"><h3>莫斯科遭无人机袭击</h3></a>',
'<div class="card" data-event-key="adult"><h3>AI短剧标题含鸡巴与海角网</h3><p>上科技操</p></div>',
'<div class="card" data-event-key="url"><h3>youtube.com/watch?v=abc</h3></div>',
].join('');
const stripped = stripUnsafeNews002Content(html);
assert.equal(stripped.removedCardCount, 2);
assert.equal(stripped.removedBriefCount, 1);
assert.match(stripped.html, /莫斯科遭无人机袭击/);
assert.doesNotMatch(stripped.html, /海角网/);
assert.doesNotMatch(stripped.html, /youtube\.com\/watch/);
assert.doesNotMatch(stripped.html, /吃瓜/);
assert.match(stripped.html, /<span class="n">1<\/span><span>莫斯科遭无人机袭击<\/span>/);
});
test('assistNews002Html strips unsafe cards without failing the page', () => {
const source = [
'<div class="masthead"></div>',
'<a class="lead-card" href="https://news.example/ok" data-event-key="ok"><h3>正规头条</h3></a>',
'<div class="card" data-event-key="bad"><h3>91每日大赛-今日吃瓜爆料</h3></div>',
].join('');
const assisted = assistNews002Html(source, { requireTemplateStructure: false });
assert.equal(assisted.changed, true);
assert.ok(assisted.actions.some((item) => item.startsWith('stripped-unsafe-cards:')));
assert.match(assisted.html, /正规头条/);
assert.doesNotMatch(assisted.html, /吃瓜/);
});
test('assistNews002Html trims excess cards instead of failing the page', () => {
const extraIndex = NEWS002_MAX_STORY_CARDS;
const cards = Array.from({ length: NEWS002_MAX_STORY_CARDS + 2 }, (_, index) => (
index < 3
? `<a class="lead-card" data-event-key="lead${index}" href="https://news.example/${index}"><h3>头条${index}</h3></a>`
: `<div class="card" data-event-key="k${index}"><h3>事件标题${index}</h3></div>`
)).join('');
const source = `<div class="masthead"></div>${cards}`;
const assisted = assistNews002Html(source, { requireTemplateStructure: false });
assert.equal(assisted.changed, true);
assert.ok(assisted.actions.some((item) => item.startsWith('trimmed-story-cards:')));
assert.equal(assisted.auditBefore.violations.includes('too-many-story-cards'), true);
assert.equal(assisted.auditAfter.violations.includes('too-many-story-cards'), false);
assert.equal(assisted.auditAfter.cardCount, NEWS002_MAX_STORY_CARDS);
assert.match(assisted.html, /头条0/);
assert.doesNotMatch(assisted.html, new RegExp(`事件标题${extraIndex}`));
});
test('trimNews002ExcessStoryCards keeps lead cards first', () => {
const html = [
'<div class="card" data-event-key="early"><h3>普通在前</h3></div>',
'<a class="lead-card" data-event-key="lead1" href="https://news.example/a"><h3>头条一</h3></a>',
...Array.from({ length: 28 }, (_, index) => (
`<div class="card" data-event-key="k${index}"><h3>灌水${index}</h3></div>`
)),
].join('');
const trimmed = trimNews002ExcessStoryCards(html, { maxStoryCards: 4 });
assert.equal(trimmed.changed, true);
assert.match(trimmed.html, /头条一/);
assert.match(trimmed.html, /普通在前/);
assert.equal(trimmed.keptCount, 4);
});
test('auditNews002ImageCoverage blocks excess cards but keeps imageless stories', () => {
const cards = Array.from({ length: 29 }, (_, index) => (
const cards = Array.from({ length: NEWS002_MAX_STORY_CARDS + 1 }, (_, index) => (
`<div class="card" data-event-key="k${index}"><h3>事件标题${index}</h3></div>`
)).join('');
const audit = auditNews002ImageCoverage(
+16 -4
View File
@@ -10,6 +10,7 @@ import {
rankNewsWithEngine,
shouldUseNewsEngineLatestCollection,
} from './news-engine-client.mjs';
import { isUnsafeNewsMorningItem } from './wechat-news-morning-content-filter.mjs';
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
@@ -104,17 +105,25 @@ const STALE_NEWS_PATTERNS = [
/(\d{4})年(\d{1,2})月(\d{1,2})日.*(\d{4})年(\d{1,2})月(\d{1,2})日/u,
];
export function filterFreshNewsMorningSearchResults(results = [], { year = null } = {}) {
export function filterFreshNewsMorningSearchResults(results = [], { year = null, env = process.env } = {}) {
const currentYear = Number(year ?? new Date().getFullYear());
return (Array.isArray(results) ? results : []).filter((item) => {
const blob = `${item?.title ?? ''} ${item?.url ?? ''} ${item?.snippet ?? ''}`;
if (STALE_NEWS_PATTERNS.some((pattern) => pattern.test(blob))) return false;
if (isUnsafeNewsMorningItem(item, { env })) return false;
const years = [...blob.matchAll(/\b(20\d{2})\b/g)].map((match) => Number(match[1]));
if (years.length && years.every((value) => value < currentYear - 1)) return false;
return Boolean(item?.title || item?.url);
});
}
export function filterSafeNewsMorningGroups(groups = [], options) {
return (Array.isArray(groups) ? groups : []).map((group) => ({
...group,
results: (group.results ?? []).filter((item) => !isUnsafeNewsMorningItem(item, options)),
}));
}
export function normalizeNewsMorningUrl(url) {
try {
const parsed = new URL(String(url ?? '').trim());
@@ -417,9 +426,12 @@ export async function prefetchNewsMorningSearxngBundle({
fetchImpl: newsEngineFetchImpl,
logger,
});
const engineGroups = Array.isArray(ranking?.groups) && ranking.groups.length
? ranking.groups
: groups;
const engineGroups = filterSafeNewsMorningGroups(
Array.isArray(ranking?.groups) && ranking.groups.length
? ranking.groups
: groups,
{ env },
);
const { groups: dedupedGroups, removedCount } = dedupeNewsMorningSearchGroups(engineGroups);
const groupsWithEngineImages = applyRankingImagesToGroups(dedupedGroups, ranking, {
normalizeUrl: normalizeNewsMorningUrl,
+10
View File
@@ -29,6 +29,16 @@ test('buildNewsMorningSearchQueries covers core and extended channels', () => {
assert.ok(queries.some((item) => item.id === 'official_feeds' && /reuters\.com/u.test(item.query)));
});
test('filterFreshNewsMorningSearchResults drops unsafe adult and bare-url hits', () => {
const filtered = filterFreshNewsMorningSearchResults([
{ title: '国乒今日出征亚运会', url: 'https://news.qq.com/a/20260923' },
{ title: '91每日大赛-今日吃瓜爆料', url: 'https://91dscg3.com/' },
{ title: 'youtube.com/watch?v=abc', url: 'https://www.youtube.com/watch?v=abc' },
], { year: 2026 });
assert.equal(filtered.length, 1);
assert.match(filtered[0].title, /国乒/);
});
test('filterFreshNewsMorningSearchResults drops wikinews archive hits', () => {
const filtered = filterFreshNewsMorningSearchResults([
{ title: '2021年9月18日中文報紙頭條', url: 'https://zh.wikinews.org/wiki/2021%E5%B9%B49%E6%9C%8818%E6%97%A5' },
+2
View File
@@ -14,6 +14,7 @@ import {
normalizeNewsMorningTitle,
normalizeNewsMorningUrl,
} from './wechat-news-morning-search.mjs';
import { isUnsafeNewsMorningItem } from './wechat-news-morning-content-filter.mjs';
const SECTION_BY_ID = new Map(NEWS002_SECTIONS.map((item) => [item.id, item]));
@@ -220,6 +221,7 @@ function pickSearchCandidates(groupMap, poolIds, seenTitles, { requireImage = tr
const title = String(item?.title ?? '').trim();
const url = String(item?.url ?? '').trim();
if (!title || !url) continue;
if (isUnsafeNewsMorningItem({ title, url, snippet: item?.snippet })) continue;
if (isDuplicateNewsMorningStory(seenTitles, { title, url })) continue;
const image = normalizeNewsMorningImageUrl(item?.image || '');
if (requireImage && (!image || isWeakNewsMorningImageUrl(image))) continue;
@@ -143,3 +143,34 @@ test('supplementNews002HtmlFromSearxngGroups fills missing core sections and lea
assert.match(result.html, /补栏头条二/u);
assert.match(result.html, /国内补栏一/u);
});
test('supplementNews002HtmlFromSearxngGroups skips unsafe search candidates', () => {
const groups = [
{
id: 'world',
title: '国际',
results: [
{ title: '91每日大赛-今日吃瓜爆料', url: 'https://91dscg3.com/', image: 'https://cdn.example/bad.jpg', snippet: '黑料' },
{ title: 'youtube.com/watch?v=abc', url: 'https://www.youtube.com/watch?v=abc', image: 'https://cdn.example/yt.jpg' },
{ title: '国际安全稿', url: 'https://news.example/safe-world', image: 'https://cdn.example/safe.jpg', snippet: '安全' },
],
},
{
id: 'tech',
title: '科技',
results: [
{ title: '科技安全稿', url: 'https://news.example/safe-tech', image: 'https://cdn.example/tech.jpg', snippet: '科技' },
],
},
];
const result = supplementNews002HtmlFromSearxngGroups(
THIN_AGENT_HTML,
groups,
{ violations: ['missing-sections:world|tech'] },
);
assert.equal(result.changed, true);
assert.match(result.html, /国际安全稿/u);
assert.match(result.html, /科技安全稿/u);
assert.doesNotMatch(result.html, /吃瓜/u);
assert.doesNotMatch(result.html, /youtube\.com\/watch/u);
});