335 lines
16 KiB
Python
335 lines
16 KiB
Python
"""Разбор абузов: промо, триалы, ключи, сливы, софт, реселл.
|
||
|
||
Работает поверх уже собранной базы (items) и поверх новых абуз-источников:
|
||
каждая запись получает набор тегов и вес, панель показывает только то,
|
||
что реально пахнет абузом, а не весь поток.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import sqlite3
|
||
|
||
from .config import DB
|
||
|
||
# тег -> (человеческое имя, вес, регулярки)
|
||
RULES = [
|
||
("keys", "ключи", 40, [
|
||
r"sk-[A-Za-z0-9_\-]{20,}", r"gh[pousr]_[A-Za-z0-9]{20,}", r"xox[baprs]-[A-Za-z0-9\-]{10,}",
|
||
r"(разда|выда|дарю|бесплатн|free|giveaway)[^.]{0,40}(ключ|key|токен|token)",
|
||
r"(ключ|key|токен)[^.]{0,25}(бесплатн|free|дарю|раздач|giveaway)",
|
||
]),
|
||
("promo", "промо", 26, [
|
||
r"промокод", r"promo\s?code", r"промо[\s-]?код", r"скидк[аи]\s+\d{1,3}", r"\d{1,3}\s?%\s?(off|скидк)",
|
||
r"redeem\s?(code|код)", r"invite\s?code", r"реф(еральн|код)", r"бонус\s+на\s+сч[её]т", r"кэшб[еэ]к|cashback",
|
||
]),
|
||
("trial", "триалы", 24, [
|
||
r"\btrial\b", r"триал", r"free\s+credits?", r"бесплатн\w*\s+(кредит|токен|баланс|доступ|период)",
|
||
r"free\s+tier", r"\$\d+\s+free", r"试用", r"免费", r"пробн\w+\s+период", r"без\s+депозита", r"no\s+deposit",
|
||
r"\bfree\b\s*[-–—:]\s*\S", r"\b\d+\s+enrolls?\s+left\b", r"\bfree\s+enroll\w*", r"\bfree\s+access\b",
|
||
r"\b100%\s*(?:free|off)\b", r"\bfree\s+forever\b", r"\b\d+\s*(?:mo|мес)\w*\s+free\b",
|
||
]),
|
||
("leak", "сливы", 26, [
|
||
r"слив(?!\s*(сок|ки|очн|тест|трафик))", r"leaked\s+(database|db|dump|combo)", r"db\s?dump",
|
||
r"combo[\s-]?list", r"stealer\s?log", r"утечк\w*\s+баз", r"база\s+(пассов|почт|данных)",
|
||
r"пароли\s+от\s+\w+\.(com|ru|net)",
|
||
]),
|
||
("tool", "софт", 18, [
|
||
r"софт", r"кряк", r"crack(ed)?", r"bypass", r"обход\s+(блокиров|бан|защит)",
|
||
r"авторег", r"autoreg", r"panel", r"панел[ьи]\s", r"рассылк", r"sender", r"скрипт",
|
||
]),
|
||
("resale", "продажа", 14, [
|
||
r"продаю", r"продам", r"куплю\s+аккаунт", r"\bfor\s+sale\b", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)",
|
||
r"реселлер", r"resell", r"\bnew\s+stock\b", r"\bin\s+stock\b", r"\bв\s+наличии\b", r"\bоптом\b",
|
||
r"\b(?:доступ|подписк\w*|аккаунт\w*)\s+на\s+\d+\s*(?:дн|мес)", r"\b\d{1,3}\s?d\b(?=[^a-z]|$)",
|
||
]),
|
||
("accs", "аккаунты/подписки", 22, [
|
||
r"(chatgpt|claude|cursor|midjourney|perplexity|grok|copilot|gemini|netflix|spotify|youtube\s?premium|notion|figma|canva|devin|windsurf)\b[^\n.]{0,40}\b(plus|pro|max|premium|team|business|ultra|30d|60d|90d|1\s?мес|12\s?мес|год)\b",
|
||
r"\b(plus|pro|premium|ultra|team)\b[^\n.]{0,25}\b(chatgpt|claude|cursor|midjourney|perplexity|grok|copilot|gemini|notion|figma|canva)\b",
|
||
r"\b(аккаунт|акк|подписк|доступ)\w*\s+(chatgpt|claude|cursor|midjourney|perplexity|grok|gemini|notion)\b",
|
||
r"\b(shared|share|private|приватн\w*|общ\w*)\s+(account|аккаунт|подписк)",
|
||
r"\b\d{1,3}\s*(?:d|дн)\s*(?:new\s+)?(?:stock|аккаунт|подписк)",
|
||
]),
|
||
("credits", "кредиты/квоты", 22, [
|
||
r"free\s+(?:api\s+)?credits?", r"\b\d+\s?(?:k|m)?\s?tokens?\s+(?:free|бесплатн)", r"free\s+quota",
|
||
r"бесплатн\w*\s+(?:лимит|квот|токен)", r"кредит\w*\s+(?:бесплатн|в\s+подарок)", r"赠金", r"额度",
|
||
r"unlimited\s+(?:api|access|tokens|usage)", r"безлимит\w*\s+(?:api|ключ|доступ|токен)",
|
||
]),
|
||
("invite", "инвайты/беты", 16, [
|
||
r"\binvite\s?(?:code|link|only)\b", r"инвайт", r"\bwaitlist\b", r"лист\s+ожидан", r"early\s+access",
|
||
r"\bbeta\s+(?:access|invite|key)", r"закрыт\w*\s+бет", r"приглашени",
|
||
]),
|
||
("ltd", "lifetime deal", 18, [
|
||
r"lifetime\s+deal", r"\bltd\b", r"пожизненн\w*\s+(?:доступ|лицензи)", r"пакет\s+навсегда",
|
||
]),
|
||
("coupon", "купоны/скидки", 12, [
|
||
r"\bcoupon\b", r"\bdiscount\s?(?:code)?\b", r"\d{1,2}%\s?off", r"дешёв\w*\s+чем", r"\bcheaper\b",
|
||
]),
|
||
("combo", "комбо/базы", 20, [
|
||
r"[\w.\-]+@[\w.\-]+\.[a-z]{2,}\s?:\s?[^\s]{4,}", r"\bcombolist\b", r"\bcombos?\b\s+(?:list|base)",
|
||
r"\b\d{3,}\s*(?:k|к|тыс)?\s*(?:строк|lines|аккаунтов|accounts)\b",
|
||
]),
|
||
("crack", "кряки/патчи", 16, [
|
||
r"\bkeygen\b", r"\bpatcher\b", r"\bкряк\b", r"\bcrack(?:ed)?\s+(?:version|by)", r"\bcracked\b",
|
||
r"\bnulled\b", r"\bactivated\s+version\b",
|
||
]),
|
||
("selfhost", "селфхост/опенсорс", 8, [
|
||
r"self[\s-]?host(?:ed|ing)?", r"open[\s-]?source\s+alternative", r"альтернатив\w*\s+(?:для|к)\s+\w+",
|
||
r"docker\s+(?:run|compose)", r"\bна\s+своём\s+сервер",
|
||
]),
|
||
("freeapi", "бесплатные API", 24, [
|
||
r"free\s+(?:llm\s+)?api", r"бесплатн\w*\s+api", r"api\s+(?:без|without)\s+(?:ключа|ключа\s+не\s+нужно|key)",
|
||
r"no\s+(?:api\s+)?key\s+(?:required|needed)", r"keyless", r"openai[\s-]?compatible\s+(?:endpoint|api)",
|
||
r"/v1/chat/completions", r"new[\s-]?api\s+(?:panel|панел)", r"openai\s+proxy",
|
||
]),
|
||
("nokyc", "без KYC/карты", 14, [
|
||
r"no\s+kyc", r"без\s+kyc", r"no\s+card\s+(?:required|needed)", r"без\s+карт\w*", r"без\s+депозит",
|
||
r"no\s+sign[\s-]?up",
|
||
]),
|
||
("infra", "прокси/номера/карты", 10, [
|
||
r"\bпрокси\b|\bproxies\b", r"\bsms\s?(?:activ|receive)", r"\bвиртуальн\w*\s+карт", r"\bvcc\b",
|
||
r"\bноутбук\s+номер", r"\bномер\w*\s+для\s+смс",
|
||
]),
|
||
("news", "новости", 6, [
|
||
r"анонс", r"выпустил", r"released?", r"launch", r"обновлени", r"release notes",
|
||
r"новая модель", r"new model",
|
||
]),
|
||
]
|
||
|
||
COMPILED = [(tag, label, w, [re.compile(p, re.I) for p in pats]) for tag, label, w, pats in RULES]
|
||
TAG_LABEL = {t: l for t, l, _, _ in RULES}
|
||
STRONG = {"keys", "promo", "trial", "resale", "accs", "credits", "freeapi", "ltd", "combo"}
|
||
# на специализированных абуз-площадках хватает одного признака: сам источник тематический
|
||
ABUSE_SRC_SC = 14
|
||
# категории-источники, которые вообще имеют смысл для абузов
|
||
ABUSE_CATS = {"abuse", "forum", "community", "company_cn"}
|
||
# «статьи»: слова про ключи и утечки там — текст, а не раздача
|
||
# мусор: заголовок из эмодзи/одного слова без текста — не находка
|
||
JUNK_TITLE = re.compile(r"^[\W_\d]{0,40}$", re.U)
|
||
# «сливать трафик» — арбитраж, не слив базы
|
||
TRAFFIC_NOISE = re.compile(r"слива\w*\s+(трафик|убт|ubt)|pinterest|арбитраж|арбит|tiktok", re.I)
|
||
NOISE = re.compile(r"(blog|devto|medium|substack|newsletter|docs?|wiki)", re.I)
|
||
|
||
|
||
def _source_meta():
|
||
try:
|
||
from . import sources as _s
|
||
return {x.name: x.category for x in _s.all_sources()}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
SRC_CAT = _source_meta()
|
||
|
||
|
||
def classify(title: str, text: str, tags: str = ""):
|
||
blob = (" ".join([title or "", text or "", tags or ""]))[:4000]
|
||
hits = []
|
||
score = 0
|
||
for tag, label, w, pats in COMPILED:
|
||
n = 0
|
||
for p in pats:
|
||
m = p.search(blob)
|
||
if m:
|
||
n += 1
|
||
if n:
|
||
hits.append(tag)
|
||
score += w * min(n, 3)
|
||
return score, hits
|
||
|
||
|
||
def _qualifies(source, title, text, tags):
|
||
sc, hits = classify(title, text, tags)
|
||
if not hits:
|
||
return 0, []
|
||
strong = bool(set(hits) & STRONG)
|
||
cat = SRC_CAT.get(source)
|
||
blob = (title or "") + " " + (text or "")
|
||
if len(re.sub(r"\W", "", title or "")) < 8:
|
||
return 0, []
|
||
if "leak" in hits and TRAFFIC_NOISE.search(blob) and not re.search(r"combo|dump|утечк|база", blob, re.I):
|
||
hits = [h for h in hits if h != "leak"]
|
||
sc -= 26
|
||
if not hits:
|
||
return 0, []
|
||
src0 = str(source or "")
|
||
# new-api панели: «N бесплатных моделей (цена 0)» — это готовая абуз-поверхность
|
||
if src0.startswith("pn_"):
|
||
m = re.search(r"(\d{1,4})\s*бесплатн\w*\s*модел", blob, re.I)
|
||
if m:
|
||
cnt = min(int(m.group(1)), 500)
|
||
return 40 + min(cnt // 10, 30), ["freeapi"]
|
||
raw_key = bool(re.search(r"\b(sk-[A-Za-z0-9_\-]{20,}|gh[pousr]_[A-Za-z0-9]{20,})", blob))
|
||
src = str(source or "")
|
||
known_prefix = src.startswith(("ab_", "tg_", "rad_", "reddit_", "discourse_", "xenforo", "pn_", "gh_", "hn_"))
|
||
if NOISE.search(src) and not (raw_key or known_prefix):
|
||
return 0, []
|
||
if cat is None and not raw_key and not known_prefix:
|
||
return 0, []
|
||
if cat is not None and cat not in ABUSE_CATS and not (raw_key or known_prefix):
|
||
return 0, []
|
||
# панели new-api, github/hn и abuse-источники считаем абузными по факту источника
|
||
if cat == "abuse" or known_prefix:
|
||
if sc >= ABUSE_SRC_SC and (strong or len(hits) >= 2):
|
||
return sc, hits
|
||
if strong and sc >= 16:
|
||
return sc, hits
|
||
if len(hits) >= 2 and sc >= 30:
|
||
return sc, hits
|
||
if len(hits) >= 3 and sc >= 20:
|
||
return sc, hits
|
||
return 0, []
|
||
|
||
def _connect():
|
||
con = sqlite3.connect(str(DB), timeout=60)
|
||
con.execute("PRAGMA busy_timeout=60000")
|
||
con.execute("CREATE TABLE IF NOT EXISTS abuse_idx "
|
||
"(id INTEGER PRIMARY KEY, score INTEGER, tags TEXT)")
|
||
return con
|
||
|
||
|
||
def rebuild(full=False, chunk=4000, auto=False):
|
||
"""Инкрементальный индекс абузов: classify по новым items, без полного перещёлкивания.
|
||
|
||
full=True (или OMNI_ABUSE_REBUILD=1) — пересчитать всю базу после правки правил.
|
||
auto=True (вызов из чтения) игнорирует env, иначе каждый scan делал бы полный проход.
|
||
Возвращает число записей, попавших в индекс на этом проходе.
|
||
"""
|
||
import os
|
||
if not auto and os.environ.get("OMNI_ABUSE_REBUILD") == "1":
|
||
full = True
|
||
con = _connect()
|
||
try:
|
||
if full:
|
||
con.execute("DELETE FROM abuse_idx")
|
||
con.commit()
|
||
last = con.execute("SELECT COALESCE(MAX(id), 0) FROM abuse_idx").fetchone()[0]
|
||
added = 0
|
||
while True:
|
||
rows = con.execute(
|
||
"SELECT id, source, url, title, text, tags FROM items WHERE id > ? ORDER BY id LIMIT ?",
|
||
(last, chunk)).fetchall()
|
||
if not rows:
|
||
break
|
||
batch = []
|
||
for r in rows:
|
||
sc, hits = _qualifies(r[1], r[3], r[4], r[5] or "")
|
||
if sc:
|
||
batch.append((r[0], sc, ",".join(hits)))
|
||
last = r[0]
|
||
if batch:
|
||
con.executemany("INSERT OR REPLACE INTO abuse_idx(id, score, tags) VALUES (?,?,?)", batch)
|
||
added += len(batch)
|
||
con.commit()
|
||
return added
|
||
finally:
|
||
con.close()
|
||
|
||
|
||
def _rows(limit=400, tag=None, q=None, since_days=None):
|
||
"""Читает из индекса abuse_idx; индекс добивается свежими записями перед чтением."""
|
||
try:
|
||
rebuild(auto=True)
|
||
except Exception:
|
||
pass
|
||
sql = ("SELECT i.id, i.source, i.url, i.title, i.text, i.published, a.tags, i.score, a.score "
|
||
"FROM abuse_idx a JOIN items i ON i.id = a.id")
|
||
where, args = [], []
|
||
if tag:
|
||
where.append("(',' || a.tags || ',') LIKE ?")
|
||
args.append("%%," + tag + ",%%")
|
||
if q:
|
||
where.append("(i.title LIKE ? OR i.text LIKE ? OR i.source LIKE ?)")
|
||
args += ["%%%s%%" % q, "%%%s%%" % q, "%%%s%%" % q]
|
||
if since_days:
|
||
where.append("i.published >= date('now', ?)")
|
||
args.append("-%d day" % int(since_days))
|
||
if where:
|
||
sql += " WHERE " + " AND ".join(where)
|
||
sql += " ORDER BY a.score DESC, i.id DESC LIMIT ?"
|
||
args.append(int(limit))
|
||
con = _connect()
|
||
try:
|
||
out = []
|
||
for r in con.execute(sql, args):
|
||
hits = [t for t in (r[6] or "").split(",") if t]
|
||
body = (r[4] or "")
|
||
out.append({"id": r[0], "source": r[1], "url": r[2], "title": r[3],
|
||
"text": body[:600], "published": r[5],
|
||
"tags": hits, "abuse_score": r[8], "rank": r[7],
|
||
"find": extract((r[3] or "") + " " + body)})
|
||
return out
|
||
finally:
|
||
con.close()
|
||
|
||
|
||
def scan(limit=120, tag=None, q=None, since_days=None, window=6000):
|
||
"""limit — сколько отдать; индекс уже отсортирован по весy, window не режет."""
|
||
items = _rows(limit=max(int(limit), 400), tag=tag, q=q, since_days=since_days)
|
||
items.sort(key=lambda x: (-x["abuse_score"], -(x["id"] or 0)))
|
||
counts = {}
|
||
for it in items:
|
||
for t in it["tags"]:
|
||
counts[t] = counts.get(t, 0) + 1
|
||
# total = реальный размер выдачи в индексе, не размер окна чтения
|
||
total = len(items)
|
||
try:
|
||
con = _connect()
|
||
sql = "SELECT COUNT(*) FROM abuse_idx a JOIN items i ON i.id = a.id"
|
||
where, args = [], []
|
||
if tag:
|
||
where.append("(',' || a.tags || ',') LIKE ?")
|
||
args.append("%," + tag + ",%")
|
||
if q:
|
||
where.append("(i.title LIKE ? OR i.text LIKE ? OR i.source LIKE ?)")
|
||
args += ["%%%s%%" % q, "%%%s%%" % q, "%%%s%%" % q]
|
||
if since_days:
|
||
where.append("i.published >= date('now', ?)")
|
||
args.append("-%d day" % int(since_days))
|
||
if where:
|
||
sql += " WHERE " + " AND ".join(where)
|
||
total = con.execute(sql, args).fetchone()[0] or total
|
||
con.close()
|
||
except Exception:
|
||
pass
|
||
return {"items": items[:limit], "total": total, "tags": counts,
|
||
"labels": TAG_LABEL}
|
||
|
||
_RE_CODE = re.compile(r"(?:промокод|промо-?код|promo\s*code|coupon|купо[нш]|code)[^\n::]{0,12}[::\s]+([A-Z0-9][A-Z0-9_\-]{3,23})", re.I)
|
||
_RE_CODE2 = re.compile(r"\b([A-Z]{2,}[0-9]{2,}[A-Z0-9_\-]{0,14})\b")
|
||
_RE_AMT = re.compile(r"(\d[\d\s.,]{0,12})\s*(млрд|млн|тыс|billion|million|k|m|b|токен\w*|tokens?|credits?|\$|₽|юан\w*)", re.I)
|
||
_RE_LINK = re.compile(r"(?:https?://|t\.me/)[^\s)\]\"'<>]{4,120}")
|
||
_RE_DAYS = re.compile(r"(\d{1,3})\s*(дн\w*|day[s]?|час\w*|hour[s]?|недел\w*|week[s]?)", re.I)
|
||
|
||
|
||
def extract(text):
|
||
"""Достаёт из текста абуза полезное: промокоды, объём, ссылки, срок."""
|
||
t = (text or "")[:4000]
|
||
codes = []
|
||
for m in _RE_CODE.finditer(t):
|
||
c = (m.group(1) or "").strip(" .,:;")
|
||
if c and c.upper() not in {x.upper() for x in codes}:
|
||
codes.append(c)
|
||
if not codes and re.search(r"промо|promo|code|код|gift|купо|coupon|подар", t, re.I):
|
||
for m in _RE_CODE2.finditer(t):
|
||
c = m.group(1)
|
||
if c and c.upper() not in {x.upper() for x in codes}:
|
||
codes.append(c)
|
||
if len(codes) >= 3:
|
||
break
|
||
amt = None
|
||
m = _RE_AMT.search(t)
|
||
if m:
|
||
amt = ("%s %s" % (m.group(1).strip(), m.group(2))).replace(" ", " ")
|
||
links = []
|
||
for u in _RE_LINK.findall(t):
|
||
u = u.rstrip(".,);")
|
||
if u not in links:
|
||
links.append(u)
|
||
if len(links) >= 4:
|
||
break
|
||
days = None
|
||
m = _RE_DAYS.search(t)
|
||
if m:
|
||
days = "%s %s" % (m.group(1), m.group(2))
|
||
return {"codes": codes[:4], "amount": amt, "links": links, "term": days}
|