/** * 新闻内容安全底线规则(成人 / 未成年人保护 / 法律合规 / 人文伦理 / 来源资质)。 * * 纯函数、零业务依赖:检索预取、页面清洗、推送复检三处共用同一套规则, * 不得 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(','); }