57a9aadf53
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>
262 lines
12 KiB
JavaScript
262 lines
12 KiB
JavaScript
/**
|
||
* 新闻内容安全底线规则(成人 / 未成年人保护 / 法律合规 / 人文伦理 / 来源资质)。
|
||
*
|
||
* 纯函数、零业务依赖:检索预取、页面清洗、推送复检三处共用同一套规则,
|
||
* 不得 import 其它业务模块,避免与 wechat-news-morning-* 形成循环依赖。
|
||
*
|
||
* level:
|
||
* - block 命中即删(模型复审不能放行)
|
||
* - review 交给模型复审;复审不可用时按失败即删处理
|
||
*
|
||
* REGRESSION GUARD: 规则只能加严或修正误伤,不得为了「写满栏目」放宽 block 级规则。
|
||
*/
|
||
|
||
export const NEWS_CONTENT_SAFETY_VERSION = '2026-09-23.v1';
|
||
|
||
export const NEWS_SAFETY_CATEGORY_LABELS = Object.freeze({
|
||
adult: '成人低俗',
|
||
minor: '未成年人保护',
|
||
'self-harm': '自杀自残',
|
||
violence: '暴恐血腥',
|
||
gambling: '赌博',
|
||
drugs: '毒品',
|
||
finance: '金融违规(荐股/虚拟货币炒作)',
|
||
health: '医疗虚假宣传',
|
||
discrimination: '歧视侮辱',
|
||
ethics: '人文伦理',
|
||
gossip: '八卦隐私',
|
||
rumor: '未经证实',
|
||
clickbait: '标题党',
|
||
provenance: '来源资质',
|
||
legal: '其它违法有害',
|
||
});
|
||
|
||
const MINOR_TERMS = '未成年|少年|少女|男童|女童|儿童|幼童|幼儿|婴儿|小学生|中学生|初中生|高中生|学生|孩子|幼儿园|男孩|女孩|\\d{1,2}岁(?:男|女)?(?:孩|童|生)';
|
||
const MINOR_HARM_TERMS = '性侵|强奸|猥亵|性骚扰|虐待|虐童|殴打|欺凌|霸凌|自杀|轻生|跳楼|自残|遇害|身亡|溺亡|死亡|失踪|拐卖|诱拐|坠楼|烫伤|体罚';
|
||
const TRAGEDY_TERMS = '遇难|身亡|死亡|丧生|罹难|地震|空难|坠机|火灾|爆炸|溺亡|踩踏|伤亡|遗体|海啸|洪灾|塌方|车祸';
|
||
const FRIVOLOUS_TERMS = '😂|🤣|😆|😁|😄|🎉|🥳|哈哈|笑死|活该|喜大普奔|吃瓜|狂欢|爽翻';
|
||
const CRYPTO_TERMS = '比特币|以太坊|虚拟货币|加密货币|数字货币|币圈|山寨币|meme币|代币|NFT|狗狗币|BTC|ETH|USDT';
|
||
const CRYPTO_HYPE_TERMS = '暴涨|抄底|百倍|千倍|起飞|翻倍|飙升';
|
||
const CRYPTO_PROMO_TERMS = '梭哈|上车|空投|注册返佣|开户返佣|交易所注册|返佣|带单|喊单|财富自由|稳赚';
|
||
const POLITICAL_TERMS = '外交部|国务院|中共中央|中央政治局|政治局|全国人大|人大常委|全国政协|国防部|解放军|国家主席|总书记|国务院总理|省委书记|市委书记|时政|领导人|党中央|中央纪委|国台办|港澳办|涉台|涉港|涉疆|涉藏';
|
||
|
||
function rx(source, flags = 'iu') {
|
||
return new RegExp(source, flags);
|
||
}
|
||
|
||
/** 单条规则:命中 pattern 即判定。 */
|
||
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('性侵|强奸|猥亵|性骚扰|卖淫|嫖娼|淫秽|裸照|不雅视频|偷拍') },
|
||
|
||
// 自杀自残
|
||
{ id: 'self-harm-method', category: 'self-harm', level: 'block', re: rx('(?:自杀|轻生|自残|自缢)(?:方法|方式|教程|攻略|步骤)|割腕|烧炭自杀|服毒自杀') },
|
||
{ id: 'self-harm-topic', category: 'self-harm', level: 'review', re: rx('自杀|轻生|自残|自缢|跳楼(?!价|甩卖|大甩卖)') },
|
||
|
||
// 暴恐
|
||
{ id: 'violence-howto', category: 'violence', level: 'block', re: rx('(?:制作|自制|组装)(?:炸弹|炸药|爆炸物|枪支)|买卖枪支|枪支出售|出售枪支|恐怖主义宣传|圣战(?:号召|宣传)') },
|
||
{ id: 'violence-gore', category: 'violence', level: 'review', re: rx('斩首|碎尸|肢解|血腥视频|虐杀|恐怖袭击|恐袭|砍杀') },
|
||
|
||
// 赌博 / 毒品
|
||
{ id: 'gambling-promo', category: 'gambling', level: 'block', re: rx('博彩(?:平台|网站|公司)|线上赌场|网上赌场|在线赌场|百家乐|时时彩|赌球平台|娱乐城|菠菜网|彩票(?:预测|必中|内部)') },
|
||
{ id: 'drugs-trade', category: 'drugs', level: 'block', re: rx('(?:购买|出售|代购|哪里买|求购)(?:冰毒|大麻|K粉|海洛因|摇头丸|可卡因|笑气)') },
|
||
|
||
// 金融违规
|
||
{ id: 'finance-stock-advice', category: 'finance', level: 'block', re: rx('荐股|牛股推荐|必涨|稳赚不赔|包赚|保本高收益|内幕消息|明日涨停|跟着(?:买|操作)|(?:建议|推荐)(?:大家|读者|投资者|你)?(?:买入|满仓|加仓|梭哈)') },
|
||
{ id: 'finance-bottom-fishing', category: 'finance', level: 'review', re: rx('抄底|满仓|梭哈|翻倍股') },
|
||
|
||
// 医疗虚假宣传
|
||
{ id: 'health-false-claim', category: 'health', level: 'block', re: rx('包治百病|根治(?:癌症|糖尿病|高血压)|神药|祖传秘方|无任何副作用|药到病除') },
|
||
|
||
// 歧视侮辱
|
||
{ id: 'discrimination-slur', category: 'discrimination', level: 'block', re: rx('支那|黑鬼|尼哥|高丽棒子|小日本|娘炮|绿茶婊|婊子|滚出中国|低等民族|劣等民族|死胖子') },
|
||
|
||
// 八卦隐私
|
||
{ id: 'gossip', category: 'gossip', level: 'block', re: rx('出轨|劈腿|绯闻|婚变|小三|私生活|偷情|开房记录') },
|
||
|
||
// 未经证实
|
||
{ id: 'rumor', category: 'rumor', level: 'review', re: rx('网传|据传|传闻|爆料|小道消息|未经证实|疑似曝光') },
|
||
|
||
// 标题党
|
||
{ id: 'clickbait', category: 'clickbait', level: 'review', re: rx('震惊|惊呆了|吓尿|速看|速删|转疯了|不看后悔|99%的人不知道|刚刚.{0,6}突发') },
|
||
]);
|
||
|
||
/** 组合规则:同一段文字同时命中两组词才判定。 */
|
||
const COMBO_RULES = Object.freeze([
|
||
{ id: 'minor-harm', category: 'minor', level: 'review', all: [rx(MINOR_TERMS), rx(MINOR_HARM_TERMS)] },
|
||
{ id: 'tragedy-frivolous', category: 'ethics', level: 'block', all: [rx(TRAGEDY_TERMS), rx(FRIVOLOUS_TERMS)] },
|
||
{ id: 'crypto-promo', category: 'finance', level: 'block', all: [rx(CRYPTO_TERMS), rx(CRYPTO_PROMO_TERMS)] },
|
||
{ id: 'crypto-hype', category: 'finance', level: 'review', all: [rx(CRYPTO_TERMS), rx(CRYPTO_HYPE_TERMS)] },
|
||
]);
|
||
|
||
/** 非新闻来源:考试培训、内容农场等,不管什么题材都不得作为新闻来源。 */
|
||
const NON_NEWS_SOURCE_DOMAINS = Object.freeze([
|
||
'huatu.com',
|
||
'offcn.com',
|
||
'chinagwy.org',
|
||
'zgjsks.com',
|
||
'51test.net',
|
||
'ruiwen.com',
|
||
'diyifanwen.com',
|
||
'wenmi.com',
|
||
'docin.com',
|
||
'doc88.com',
|
||
'wenku.baidu.com',
|
||
'renrendoc.com',
|
||
]);
|
||
|
||
/** 自媒体 / 境外社交平台:可作为科技、文娱线索,但不得作为时政新闻的来源。 */
|
||
const SELF_MEDIA_DOMAINS = Object.freeze([
|
||
'x.com',
|
||
'twitter.com',
|
||
'facebook.com',
|
||
'instagram.com',
|
||
'youtube.com',
|
||
'tiktok.com',
|
||
'reddit.com',
|
||
't.me',
|
||
'telegram.org',
|
||
'medium.com',
|
||
'substack.com',
|
||
'weibo.com',
|
||
'weibo.cn',
|
||
'zhihu.com',
|
||
'douyin.com',
|
||
'bilibili.com',
|
||
'xiaohongshu.com',
|
||
'baijiahao.baidu.com',
|
||
'mp.weixin.qq.com',
|
||
'toutiao.com',
|
||
'sohu.com/a/',
|
||
]);
|
||
|
||
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;
|
||
|
||
function hostAndPath(url) {
|
||
try {
|
||
const parsed = new URL(String(url ?? '').trim());
|
||
return {
|
||
host: parsed.hostname.toLowerCase().replace(/^www\./, ''),
|
||
pathname: parsed.pathname,
|
||
};
|
||
} catch {
|
||
return { host: '', pathname: '' };
|
||
}
|
||
}
|
||
|
||
function matchesDomain(host, pathname, domain) {
|
||
const slash = domain.indexOf('/');
|
||
const domainHost = slash >= 0 ? domain.slice(0, slash) : domain;
|
||
const domainPath = slash >= 0 ? domain.slice(slash) : '';
|
||
const hostMatches = host === domainHost || host.endsWith(`.${domainHost}`);
|
||
return hostMatches && (!domainPath || pathname.startsWith(domainPath));
|
||
}
|
||
|
||
/**
|
||
* 来源资质判定。
|
||
* @returns {{ level: 'block'|'clean', hits: Array }}
|
||
*/
|
||
export function assessNewsSourceUrl(url, { political = false } = {}) {
|
||
const { host, pathname } = hostAndPath(url);
|
||
const hits = [];
|
||
if (!host) return { level: 'clean', hits };
|
||
if (UNSAFE_DOMAIN_RE.test(host)) {
|
||
hits.push({ id: 'source-unsafe-domain', category: 'adult', level: 'block', match: host });
|
||
} else if (NON_NEWS_SOURCE_DOMAINS.some((domain) => matchesDomain(host, pathname, domain))) {
|
||
hits.push({ id: 'source-non-news', category: 'provenance', level: 'block', match: host });
|
||
} else if (political && SELF_MEDIA_DOMAINS.some((domain) => matchesDomain(host, pathname, domain))) {
|
||
hits.push({ id: 'source-self-media-politics', category: 'provenance', level: 'block', match: host });
|
||
}
|
||
return { level: hits.length ? 'block' : 'clean', hits };
|
||
}
|
||
|
||
export function isPoliticalNewsText(text, { sectionId = '', tag = '' } = {}) {
|
||
if (POLITICAL_SECTION_IDS.has(String(sectionId))) return true;
|
||
if (POLITICAL_TAG_RE.test(String(tag))) return true;
|
||
return POLITICAL_RE.test(String(text ?? ''));
|
||
}
|
||
|
||
function resolveLevel(hits) {
|
||
if (hits.some((hit) => hit.level === 'block')) return 'block';
|
||
if (hits.some((hit) => hit.level === 'review')) return 'review';
|
||
return 'clean';
|
||
}
|
||
|
||
/**
|
||
* 判定一段新闻文字(标题 + 摘要 + 为什么重要等)及其来源。
|
||
* @returns {{ level: 'block'|'review'|'clean', hits: Array<{id, category, level, match}> }}
|
||
*/
|
||
export function classifyNewsSafetyText(text, { url = '', sectionId = '', tag = '' } = {}) {
|
||
const source = String(text ?? '');
|
||
const hits = [];
|
||
for (const rule of TERM_RULES) {
|
||
const match = source.match(rule.re);
|
||
if (match) hits.push({ id: rule.id, category: rule.category, level: rule.level, match: match[0] });
|
||
}
|
||
for (const rule of COMBO_RULES) {
|
||
const matches = rule.all.map((re) => source.match(re));
|
||
if (matches.every(Boolean)) {
|
||
hits.push({
|
||
id: rule.id,
|
||
category: rule.category,
|
||
level: rule.level,
|
||
match: matches.map((item) => item[0]).join('+'),
|
||
});
|
||
}
|
||
}
|
||
if (url) {
|
||
const political = isPoliticalNewsText(source, { sectionId, tag });
|
||
hits.push(...assessNewsSourceUrl(url, { political }).hits);
|
||
}
|
||
return { level: resolveLevel(hits), hits };
|
||
}
|
||
|
||
/** 只用于兜底打码:单条 block 级词表(不含组合规则与来源规则)。 */
|
||
const MASKABLE_BLOCK_RULES = TERM_RULES.filter((rule) => rule.level === 'block');
|
||
|
||
/** 把残留的 block 级词替换成「**」,用于 meta、JSON-LD、知识卡等非卡片文本。 */
|
||
export function maskBlockedNewsTerms(text) {
|
||
let output = String(text ?? '');
|
||
let count = 0;
|
||
for (const rule of MASKABLE_BLOCK_RULES) {
|
||
const global = new RegExp(rule.re.source, 'giu');
|
||
output = output.replace(global, () => {
|
||
count += 1;
|
||
return '**';
|
||
});
|
||
}
|
||
return { text: output, count };
|
||
}
|
||
|
||
/**
|
||
* 检索预取层过滤:删掉 block 级结果,review 级保留给页面层复审。
|
||
* 预取结果会被 SearXNG 补位直接写进页面,所以这里必须先过一遍。
|
||
*/
|
||
export function filterNewsSearchGroupsForSafety(groups = []) {
|
||
const removed = [];
|
||
const filtered = (Array.isArray(groups) ? groups : []).map((group) => {
|
||
const results = (Array.isArray(group?.results) ? group.results : []).filter((item) => {
|
||
const verdict = classifyNewsSafetyText(
|
||
[item?.title, item?.snippet, item?.content].filter(Boolean).join(' '),
|
||
{ url: item?.url ?? '', sectionId: group?.id ?? '' },
|
||
);
|
||
if (verdict.level !== 'block') return true;
|
||
removed.push({ groupId: group?.id ?? '', title: item?.title ?? '', url: item?.url ?? '', hits: verdict.hits });
|
||
return false;
|
||
});
|
||
return { ...group, results };
|
||
});
|
||
return { groups: filtered, removed };
|
||
}
|
||
|
||
export function formatNewsSafetyHits(hits = []) {
|
||
return hits
|
||
.map((hit) => `${NEWS_SAFETY_CATEGORY_LABELS[hit.category] ?? hit.category}:${hit.match}`)
|
||
.join(',');
|
||
}
|