"""中文垃圾广告检测引擎 策略: 1. 先做本地规则评分(成熟中文广告/黑灰产话术体系) 2. 高置信垃圾直接拦截 3. 明确正常消息直接放行 4. 低置信/模糊样本交给 AI 兜底 核心原则:短消息不能无条件放行;必须先识别广告意图。 """ import logging import re from dataclasses import dataclass from pathlib import Path import httpx from knowledge import OLLAMA_URL, OLLAMA_MODEL logger = logging.getLogger("spam_guard") @dataclass(frozen=True) class Rule: name: str pattern: str score: int ROOT_DIR = Path(__file__).resolve().parent.parent RULES_DIR = ROOT_DIR / "rules" def load_keyword_rules(path: Path, default_score: int = 45) -> list[Rule]: """加载外部关键词词库:词条 或 词条|分数。""" rules: list[Rule] = [] if not path.exists(): return rules for line in path.read_text(encoding="utf-8").splitlines(): raw = line.strip() if not raw or raw.startswith("#"): continue if "|" in raw: word, score_text = raw.rsplit("|", 1) try: score = int(score_text.strip()) except ValueError: word, score = raw, default_score else: word, score = raw, default_score word = word.strip() if word: rules.append(Rule(f"外部关键词:{word}", re.escape(word), score)) return rules def load_regex_rules(path: Path, default_score: int = 70) -> list[Rule]: """加载外部正则库:正则 或 正则|分数。""" rules: list[Rule] = [] if not path.exists(): return rules for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): raw = line.strip() if not raw or raw.startswith("#"): continue if "|" in raw: pattern, score_text = raw.rsplit("|", 1) try: score = int(score_text.strip()) except ValueError: pattern, score = raw, default_score else: pattern, score = raw, default_score try: re.compile(pattern) rules.append(Rule(f"外部正则:{i}", pattern, score)) except re.error as e: logger.warning(f"⚠️ 跳过无效外部正则 {path}:{i}: {e}") return rules def load_adblock_domains(path: Path) -> set[str]: """加载 Adblock 域名过滤库。仅用于消息中 URL/域名匹配,不参与普通关键词扫描。""" if not path.exists(): return set() domains: set[str] = set() for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): raw = line.strip().lower() if not raw or raw.startswith("#"): continue domains.add(raw.lstrip(".")) return domains EXTERNAL_KEYWORD_RULES = load_keyword_rules(RULES_DIR / "spam_keywords.txt") EXTERNAL_REGEX_RULES = load_regex_rules(RULES_DIR / "spam_regex.txt") ADBLOCK_DOMAINS = load_adblock_domains(RULES_DIR / "adblock_domains.txt") logger.info(f"🧩 已加载外部垃圾规则:关键词 {len(EXTERNAL_KEYWORD_RULES)} 条,正则 {len(EXTERNAL_REGEX_RULES)} 条,Adblock 域名 {len(ADBLOCK_DOMAINS)} 条") def reload_external_rules() -> dict: """重新加载外部关键词/正则/Adblock 域名规则,用于 Web 写规则或导入后热更新。""" global EXTERNAL_KEYWORD_RULES, EXTERNAL_REGEX_RULES, ADBLOCK_DOMAINS EXTERNAL_KEYWORD_RULES = load_keyword_rules(RULES_DIR / "spam_keywords.txt") EXTERNAL_REGEX_RULES = load_regex_rules(RULES_DIR / "spam_regex.txt") ADBLOCK_DOMAINS = load_adblock_domains(RULES_DIR / "adblock_domains.txt") logger.info(f"🧩 已热加载外部垃圾规则:关键词 {len(EXTERNAL_KEYWORD_RULES)} 条,正则 {len(EXTERNAL_REGEX_RULES)} 条,Adblock 域名 {len(ADBLOCK_DOMAINS)} 条") return {"keywords": len(EXTERNAL_KEYWORD_RULES), "regex": len(EXTERNAL_REGEX_RULES), "adblock_domains": len(ADBLOCK_DOMAINS)} # 高危黑产:单项命中即可高度可疑或直接垃圾 HARD_RULES = [ Rule("博彩赌博", r"菠菜|赌博|博彩|赌场|棋牌|彩票|开奖|中奖|赔率|下注|百家乐|时时彩|六合彩", 100), Rule("色情引流", r"色情|约炮|外围|楼凤|一夜情|裸聊|成人视频|萝莉|上门服务", 100), Rule("诈骗/传销/资金盘", r"刷单|传销|杀猪盘|资金盘|庞氏|返利盘|跑分|洗钱|代收款", 100), Rule("非法交易", r"办证|假证|代开发票|买卖账号|出售账号|卖号|接码|猫池", 100), Rule("Telegram 引流", r"(?:t\.me|telegram\.me|tg://|飞机群).{0,40}(?:join|group|channel|\+|进群|频道)", 90), ] # 广告意图/黑灰产常见话术:评分组合 DOMAIN_TEXT_RE = re.compile(r"(?i)(?:https?://|www\.)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)") COMMON_TEXT_DOMAINS = {"ptools.fun", "github.com", "gitee.com", "telegram.org", "t.me", "qq.com", "weixin.qq.com"} def extract_domains_from_text(text: str) -> set[str]: domains: set[str] = set() for m in DOMAIN_TEXT_RE.finditer(text or ""): d = m.group(1).lower().strip(".-_") if d and d not in COMMON_TEXT_DOMAINS: domains.add(d) return domains def match_adblock_domains(text: str) -> list[Rule]: """只在文本出现 URL/域名时匹配广告域名,降低普通聊天误杀。""" if not ADBLOCK_DOMAINS or not re.search(r"(?i)(https?://|www\.|[a-z0-9-]+\.[a-z]{2,})", text or ""): return [] hits: list[Rule] = [] for domain in extract_domains_from_text(text): parts = domain.split(".") candidates = [".".join(parts[i:]) for i in range(max(0, len(parts) - 4), len(parts) - 1)] candidates.insert(0, domain) for c in candidates: if c in ADBLOCK_DOMAINS: hits.append(Rule(f"Adblock域名:{c}", re.escape(c), 80)) break return hits SCORE_RULES = [ # 收益承诺 Rule("收益承诺-日赚", r"(?:日赚|日入|每天赚|一天赚|轻松赚|稳赚|躺赚|副业收入).{0,10}(?:\d{2,}|几百|上千|过千|一千|几千|上万|万元?|k|K|w|W)", 75), Rule("收益承诺-月入", r"(?:月入|月赚|月收益|月收入).{0,10}(?:\d{3,}|过万|上万|几万|万元?|w|W)", 70), Rule("收益承诺-夸张", r"(?:暴富|翻倍|稳赚不赔|保本收益|无风险收益|零风险|高回报|高收益)", 55), # 兼职/刷单/项目 Rule("兼职刷单", r"(?:兼职|副业|日结|周结|在家做|手机操作|无需经验|宝妈|学生党).{0,18}(?:赚钱|收入|结算|工资|项目|单)", 55), Rule("项目引流", r"(?:项目|路子|渠道|资源|教程).{0,12}(?:带你|私聊|加我|看主页|看签名|进群)", 55), Rule("带人赚钱", r"(?:带你赚|带你做|限时带人|带人|手把手带|小白可带|包教包会|一对一带)", 65), Rule("黑U灰产", r"(?:黑\s*[uU]|黑油|黑资|黑钱|黑产|灰产|黑钱包|资金盘|交易所).{0,18}(?:项目|路子|带人|联系|兄弟|稳|最稳)", 80), Rule("限时名额诱导", r"(?:限时|名额有限|先到先得|名额|机会不多).{0,20}(?:联系|私聊|看简介|看简界|简介|主页|项目|带人)", 65), Rule("简介引流", r"(?:看我|看下|点我|主页|简介|简界|签名).{0,10}(?:简介|简界|主页|签名|头像)", 55), # 联系/引流动作 Rule("联系方式-微信", r"(?:加微信|微信号|微[信xX]|vx|v信|薇信|扣扣|QQ|q群|私信|私聊|PM|DM|联系我|找我)", 45), Rule("引导动作", r"(?:看主页|看签名|主页有|签名有|点我头像|点头像|进群|拉群|频道|群内|开户链接)", 45), Rule("链接引流", r"(?:https?://|www\.|\.com|\.cn|\.top|\.xyz|\.cc|短链接|复制链接|点击链接)", 40), # 金融/币圈/贷款 Rule("虚拟币搬砖", r"(?:USDT|U\s?S\s?D\s?T|泰达币|虚拟币|币圈|搬砖|套利).{0,18}(?:赚钱|收益|项目|加我|看主页|进群)", 65), Rule("贷款套现", r"(?:贷款|借款|套现|信用卡|花呗|白条|提额|下款|黑户|征信).{0,18}(?:秒批|当天|无视|包过|渠道)", 60), Rule("投资理财", r"(?:投资|理财|股票|期货|外汇|量化|跟单).{0,18}(?:老师|带单|收益|稳赚|回本|翻倍)", 60), # 营销常见词 Rule("免费福利", r"(?:免费领|免费送|限时领|点击领取|扫码领取|福利|红包|返现|佣金|返佣)", 40), Rule("代理加盟", r"(?:代理|加盟|招商|推广返|分润|招募|招代理|合作共赢)", 45), Rule("豪车炫富", r"(?:换|开|提).{0,8}(?:迈巴赫|宝马|奔驰|保时捷|法拉利|兰博基尼)", 65), Rule("月入收益承诺", r"(?:一个月|每月|月入|月赚|下个月).{0,12}(?:\d{1,4}|几万|几十万).{0,4}(?:w|W|万|个w|个W)", 75), Rule("安全收益承诺", r"(?:安全|稳赚|保本|不要本钱|不要本|零成本).{0,18}(?:赚|收益|月入|月赚|开奔驰|提车)", 70), ] # 组合加权:成熟广告识别关键在“组合意图”,不是单词命中 COMBO_RULES = [ Rule("收益+金额", r"(?:赚|收入|收益|工资|佣金|返现|日结|副业|一天|随便|随随便便).{0,12}(?:\d{3,}|几百|上千|过千|几千|上万|万元?|万\s*\+|k|K|w|W)", 55), Rule("收益+引流", r"(?:赚钱|收益|收入|副业|项目|项木|路子|黑\s*[uU]|交易所).{0,24}(?:加我|私聊|看主页|看签名|看简介|看简界|进群|联系我|直接联系|微信|vx|QQ)", 60), Rule("金额+引流", r"(?:\d{3,}|几百|上千|过千|几千|上万|万元?|万\s*\+|k|K|w|W).{0,24}(?:加我|私聊|看主页|看签名|看简介|看简界|进群|联系我|直接联系|微信|vx|QQ)", 55), Rule("灰产收益组合", r"(?:黑\s*[uU]|交易所|项木|项目).{0,30}(?:带人|联系|稳|最稳).{0,40}(?:一天|日赚|日入|\d+\s*万|\d+\s*w|名额有限|先到先得)", 85), ] NORMAL_RULES = [ Rule("项目技术讨论", r"(?:收割机|harvest|签到|站点|cookie|docker|compose|安装|配置|更新|升级|bug|报错|失败|成功|数据库|postgres|sqlite|redis)", 30), Rule("疑问求助", r"(?:怎么|如何|为什么|能不能|请问|求|帮忙|麻烦|谢谢|这个|那个).{0,30}[??]?$", 25), Rule("常见短回复", r"^(?:ok|OK|yes|YES|no|NO|收到|好的|可以|不行|对|错|嗯|嗯嗯|是的|不是|谢谢|感谢)$", 60), Rule("纯数字", r"^\d{1,8}$", 30), ] SPAM_PROMPT = """判断以下群聊消息是否为中文垃圾广告/黑灰产推广。 消息内容: {text} 发送者用户名: {username} 发送者昵称: {first_name} 垃圾广告包括:赚钱项目、刷单兼职、日赚/月入承诺、加微信/私聊引流、博彩色情、贷款套现、虚拟币搬砖、黑U/交易所/跑分/灰产项目、投资带单、免费福利诱导、名额有限/先到先得/看简介等引流话术。 正常消息包括:技术讨论、问答、简短确认、正常聊天。 只输出一行:垃圾 或 正常,并简短说明原因。""" def normalize(text: str) -> str: """去零宽字符、统一空白和常见混淆字符。""" t = re.sub(r"[\u200b\u200c\u200d\u2060\u200e\u200f\ufeff\u00a0]", "", text or "") t = re.sub(r"\s+", " ", t).strip() return t def compact(text: str) -> str: """去掉空格,处理 日赚 1000 / 日赚1000 这类绕过。""" return re.sub(r"\s+", "", text) def hit_rules(text: str, rules: list[Rule]) -> list[Rule]: return [r for r in rules if re.search(r.pattern, text, re.IGNORECASE)] USERNAME_RULES = [ Rule("用户名-广告收益", r"(?:日赚|月入|赚钱|兼职|副业|返佣|代理|招商|刷单|项目|福利)", 65), Rule("用户名-灰产金融", r"(?:贷款|套现|下款|黑户|USDT|币圈|搬砖|套利|博彩|菠菜|棋牌)", 75), Rule("用户名-色情引流", r"(?:约炮|裸聊|外围|楼凤|上门|看片|成人视频)", 90), Rule("用户名-联系方式", r"(?:加微|微信|vx|v信|QQ|私聊|看主页|频道|群)", 45), Rule("用户名-收益数字", r"(?:赚|入|佣金|返现).{0,8}(?:\d{3,}|过万|上万|w|W|k|K)", 70), ] def classify_username_spam(username: str = "", first_name: str = "", full_name: str = "") -> tuple[bool, str, int]: """新入群用户的用户名/昵称广告检测。只用本地规则,避免 AI 误判。""" text = " ".join(x for x in [username, first_name, full_name] if x).strip() if not text: return False, "无用户名信息", 0 norm = normalize(text) tight = compact(norm) targets = [norm, tight] hits = [] external_hits = [] for target in targets: hits.extend(hit_rules(target, HARD_RULES)) hits.extend(hit_rules(target, USERNAME_RULES)) external_hits.extend(hit_rules(target, EXTERNAL_KEYWORD_RULES)) external_hits.extend(hit_rules(target, EXTERNAL_REGEX_RULES)) dedup = lambda xs: list({x.name: x for x in xs}.values()) hits = dedup(hits) external_hits = dedup(external_hits) score = sum(r.score for r in hits) + sum(r.score for r in external_hits) if any(r.name.startswith(("博彩", "色情", "诈骗")) or r.name in ("用户名-色情引流", "用户名-灰产金融") for r in hits): names = ",".join(r.name for r in (hits + external_hits)[:5]) return True, f"新用户名称硬规则命中: {names}", max(score, 100) if score >= 70: names = ",".join(r.name for r in (hits + external_hits)[:5]) return True, f"新用户名称广告评分 {score}: {names}", score return False, f"新用户名称未命中高风险规则,评分 {score}", score def local_classify(text: str) -> tuple[bool | None, str, int]: """本地规则分类。返回 (是否垃圾/None需AI, 原因, 分数)。""" norm = normalize(text) tight = compact(norm) targets = [norm, tight] hard_hits = [] score_hits = [] combo_hits = [] external_hits = [] normal_hits = [] for target in targets: hard_hits.extend(hit_rules(target, HARD_RULES)) score_hits.extend(hit_rules(target, SCORE_RULES)) combo_hits.extend(hit_rules(target, COMBO_RULES)) external_hits.extend(hit_rules(target, EXTERNAL_KEYWORD_RULES)) external_hits.extend(hit_rules(target, EXTERNAL_REGEX_RULES)) normal_hits.extend(hit_rules(target, NORMAL_RULES)) external_hits.extend(match_adblock_domains(norm)) # 去重 dedup = lambda xs: list({x.name: x for x in xs}.values()) hard_hits, score_hits, combo_hits, external_hits, normal_hits = map(dedup, [hard_hits, score_hits, combo_hits, external_hits, normal_hits]) if hard_hits: names = ",".join(r.name for r in hard_hits[:3]) return True, f"硬规则命中: {names}", 100 spam_score = sum(r.score for r in score_hits) + sum(r.score for r in combo_hits) + sum(r.score for r in external_hits) normal_score = sum(r.score for r in normal_hits) # 广告组合是核心:即使很短也要先判垃圾 if spam_score >= 75: names = ",".join(r.name for r in (score_hits + combo_hits + external_hits)[:5]) return True, f"广告规则评分 {spam_score}: {names}", spam_score # 纯短消息只有在没有广告分时才放行 if len(norm) <= 8 and spam_score == 0: return False, "短正常消息", -normal_score # 明显正常技术/求助消息,且无明显广告分 if normal_score >= 30 and spam_score < 45: names = ",".join(r.name for r in normal_hits[:3]) return False, f"正常规则命中: {names}", spam_score - normal_score # 长文本或命中弱灰产/引流信号时交给 AI,避免 0 分直接放过混淆广告 weak_ai_signal = re.search(r"黑\s*[uU]|交易所|项木|项目|带人|直\s*接\s*联系|联系|名额|先到先得|简介|简界|主页|日赚|日入|一天|万\s*\+|w\s*\+", norm, re.IGNORECASE) if spam_score == 0 and (weak_ai_signal or len(norm) >= 80): return None, "本地无强规则,进入AI兜底", 0 # 低风险且没有广告信号 if spam_score == 0: return False, "无广告信号", 0 # 模糊样本交给 AI names = ",".join(r.name for r in (score_hits + combo_hits + external_hits)[:5]) return None, f"疑似广告评分 {spam_score}: {names}", spam_score async def ai_classify(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]: prompt = SPAM_PROMPT.format(text=text, username=username, first_name=first_name) try: async with httpx.AsyncClient(timeout=30, proxy=None) as client: resp = await client.post( f"{OLLAMA_URL}/api/chat", json={ "model": OLLAMA_MODEL, "messages": [{"role": "user", "content": prompt}], "stream": False, "options": {"num_predict": 80, "temperature": 0.1}, "think": False, }, ) resp.raise_for_status() reply = resp.json()["message"]["content"].strip() logger.info(f"🤖 垃圾检测 AI 回复: {reply[:100]}") if re.search(r"垃圾|广告|诈骗|菠菜|赌博|色情", reply, re.IGNORECASE): return True, f"AI判定: {reply}" if re.search(r"正常|非垃圾|不是垃圾", reply, re.IGNORECASE): return False, f"AI判定: {reply}" return False, f"AI判定不明确: {reply}" except Exception as e: logger.error(f"❌ Ollama 调用失败: {e}") return False, f"AI异常: {e}" async def detect(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]: """返回 (is_spam, reason)。""" decision, reason, score = local_classify(text) if decision is not None: return decision, reason # AI 不可用时,中等以上广告分直接兜底拦截;低分放行 ai_decision, ai_reason = await ai_classify(text, username, first_name) if ai_reason.startswith("AI异常") and score >= 45: return True, f"规则兜底(AI不可用): {reason}" return ai_decision, f"{reason}; {ai_reason}"