Files
memind/news-content-safety-rules.mjs
T
john 57a9aadf53
Memind CI / Test, build, and release guards (push) Successful in 4m43s
fix(news): repair instead of block in daily news generation and push
- 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>
2026-09-23 11:29:35 +08:00

262 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 新闻内容安全底线规则(成人 / 未成年人保护 / 法律合规 / 人文伦理 / 来源资质)。
*
* 纯函数、零业务依赖:检索预取、页面清洗、推送复检三处共用同一套规则,
* 不得 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(',');
}