Files
tg-spam-guard/services/spam_detector.py
T
2026-07-06 19:26:00 +08:00

124 lines
4.6 KiB
Python
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.
"""多层垃圾检测引擎 — 缓存 → 硬性正则 → 正常特征 → AI 兜底"""
import re
import logging
import httpx
import config
from knowledge import OLLAMA_URL, OLLAMA_MODEL
logger = logging.getLogger("spam_guard")
# ============ 硬性垃圾正则 ============
HARD_SPAM_PATTERNS = [
r"菠菜|赌博|博彩|赌场|棋牌|彩票|开奖|中奖|赔率|下注",
r"色情|约炮|外围|楼凤|一夜情|裸聊|成人视频",
r"刷单|传销|杀猪盘|资金盘|庞氏|传销盘",
r"(做USDT|U ?S ?D ?T|搬砖).{0,15}(看主页|看签名|进群|加我|找我)",
r"(看主页|看签名|加我|找我|进群).{0,15}(做USDT|U ?S ?D ?T|搬砖|赚钱|日入|月入)",
r"(一天|日入|月入|赚).{0,10}(万|w|W).{0,8}(项目|路子|渠道|带|做)",
r"(换|开|提).{0,6}(迈巴赫|宝马|奔驰|保时捷|法拉利|兰博基尼)",
r"(小白可带|手把手带|带你赚|带你做)",
r"(t\.me|telegram\.me).{0,30}(join|group|channel)",
]
# 软性信号(需 AI 二次判定)
SOFT_SPAM_SIGNALS = [
r"(免费领|免费送|限时领|点击领取|点击链接|复制链接)",
r"(加微信|wx|加我|私聊|私我|dm|PM)",
r"(赚钱|赚快钱|躺赚|副业|兼职日结)",
r"(开户|入金|出金|充值返|提现)",
r"(代理|加盟|推广返|分润|佣金)",
]
# 正常消息特征
NORMAL_SIGNALS = [
r"^.{0,8}$",
r"[?]",
r"(怎么|如何|为什么|能不能|请问|求|帮忙|谢谢|好的|收到|OK|ok|嗯|是的|不是|可以|不行|对|错|嗯嗯)",
r"(收割机|harvest|签到|站点|cookie|docker|安装|配置|更新|升级|bug|报错|失败|成功)",
r"^\d+$",
r"^[a-zA-Z0-9\s]{1,20}$",
]
SPAM_PROMPT = """判断以下消息是否为垃圾广告。同时检查消息内容和发送者信息。
消息内容: {text}
发送者用户名: {username}
发送者昵称: {first_name}
判断标准(满足任一即为垃圾):
1. 包含色情、赌博、诈骗、菠菜等违法推广内容
2. 包含明显广告推销(加群、加微信、免费领、点击链接等)
3. 纯转发的营销内容
请先给出结论,包含"垃圾"或"正常",再简短说明原因。"""
def normalize(text: str) -> str:
"""去除零宽字符、规范空白"""
t = re.sub(r'[\u200b\u200c\u200d\u2060\u200e\u200f\ufeff\u00a0]', '', text)
t = re.sub(r'\s+', ' ', t).strip()
return t
def match_patterns(text: str, patterns: list[str]) -> bool:
for pat in patterns:
if re.search(pat, text, re.IGNORECASE):
return True
return False
async def detect(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]:
"""
多层垃圾检测。
返回 (is_spam, reason)
"""
norm = normalize(text)
# 第一层:硬性正则
if match_patterns(norm, HARD_SPAM_PATTERNS):
return True, "规则命中(硬性垃圾)"
# 短消息放行
if len(norm) < 20 and not match_patterns(norm, SOFT_SPAM_SIGNALS):
return False, "短消息跳过"
# 正常特征
if match_patterns(norm, NORMAL_SIGNALS) and not match_patterns(norm, SOFT_SPAM_SIGNALS):
return False, "正常消息特征匹配"
# AI 兜底
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": 50, "temperature": 0.1},
"think": False,
},
)
resp.raise_for_status()
reply = resp.json()["message"]["content"]
logger.info(f"🤖 AI回复: {reply[:100]}")
reply_upper = reply.upper()
spam_words = ["垃圾", "广告", "诈骗", "菠菜", "赌博", "色情", "YES"]
normal_words = ["正常", "非垃圾", "不是垃圾", "NO"]
if any(w in reply for w in normal_words) or "NO" in reply_upper:
return False, f"AI判定: {reply}"
if any(w in reply for w in spam_words) or "YES" in reply_upper:
return True, f"AI判定: {reply}"
return False, f"AI判定不明确: {reply}"
except Exception as e:
logger.error(f"❌ Ollama 调用失败: {e}")
if match_patterns(norm, SOFT_SPAM_SIGNALS):
return True, "关键词兜底(AI不可用)"
return False, f"检测异常: {e}"