"""Разбор абузов: промо, триалы, ключи, сливы, софт, реселл. Работает поверх уже собранной базы (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"trial", 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", ]), ("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"for\s+sale", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)", r"реселлер", r"resell", ]), ("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", "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, [] raw_key = bool(re.search(r"(sk-[A-Za-z0-9_\-]{20,}|gh[pousr]_[A-Za-z0-9]{20,})", blob)) if NOISE.search(source or "") and not raw_key: return 0, [] if cat is None: # старые записи хранят категорию вместо имени источника if not raw_key and not str(source or "").startswith(("ab_", "tg_", "rad_", "reddit_", "discourse_", "xenforo")): return 0, [] elif cat not in ABUSE_CATS and not raw_key: return 0, [] if cat == "abuse" and sc >= ABUSE_SRC_SC: return sc, hits if strong and sc >= 24: return sc, hits if len(hits) >= 2 and sc >= 40: 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): """Инкрементальный индекс абузов: classify по новым items, без полного перещёлкивания. full=True (или OMNI_ABUSE_REBUILD=1) — пересчитать всю базу после правки правил. Возвращает число записей, попавших в индекс на этом проходе. """ import os if 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() 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] out.append({"id": r[0], "source": r[1], "url": r[2], "title": r[3], "text": (r[4] or "")[:600], "published": r[5], "tags": hits, "abuse_score": r[8], "rank": r[7]}) 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 return {"items": items[:limit], "total": len(items), "tags": counts, "labels": TAG_LABEL}