"""Разбор абузов: промо, триалы, ключи, сливы, софт, реселл. Работает поверх уже собранной базы (items) и поверх новых абуз-источников: каждая запись получает набор тегов и вес, панель показывает только то, что реально пахнет абузом, а не весь поток. """ from __future__ import annotations import re import sqlite3 import time 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)), 300) return 80 + cnt, ["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)") cols = {r[1] for r in con.execute("PRAGMA table_info(abuse_idx)")} if "sig" not in cols: con.execute("ALTER TABLE abuse_idx ADD COLUMN sig 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), _sig(r[3], r[4]))) last = r[0] if batch: con.executemany("INSERT OR REPLACE INTO abuse_idx(id, score, tags, sig) 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, a.sig " "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], "sig": r[9], "find": extract((r[3] or "") + " " + body)}) return out finally: con.close() def _sig(title, text): """Подпись для схлопывания перепечаток: нормализованный текст без пунктуации.""" blob = re.sub(r"[^\wа-яё]+", "", ((title or "") + " " + (text or ""))[:400].lower()) return blob[:150] _CACHE = {"t": 0.0, "key": None, "val": None} _CACHE_TTL = 60 def scan(limit=120, tag=None, q=None, since_days=None, window=6000, collapse=True): """limit — сколько отдать; индекс уже отсортирован по весy, window не режет. collapse=True — перепечатки одного и того же поста в разных каналах схлопываются в одну карточку (поле dups = сколько копий), иначе выдача забивается клонами. Результат кэшируется на минуту: чтение окна в 2000 записей стоит ~10 с. """ key = (int(limit), tag, q, since_days, bool(collapse)) if _CACHE["key"] == key and (time.time() - _CACHE["t"]) < _CACHE_TTL: return _CACHE["val"] items = _rows(limit=max(int(limit) * 10, 2000), tag=tag, q=q, since_days=since_days) items.sort(key=lambda x: (-x["abuse_score"], -(x["id"] or 0))) uniq_total = 0 if collapse: seen = {} for it in items: k = it.get("sig") or _sig(it.get("title"), it.get("text")) if len(k) < 40: k = "id:%s" % it.get("id") if k in seen: seen[k]["dups"] = seen[k].get("dups", 0) + 1 continue it["dups"] = 0 seen[k] = it items = list(seen.values()) uniq_total = len(items) 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 res = {"items": items[:limit], "total": total, "uniq": uniq_total or total, "tags": counts, "labels": TAG_LABEL} _CACHE.update(t=time.time(), key=key, val=res) return res _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}