Files
omni-parse/omni/abuse.py
Vlad Gothbreach 16512bf4a1
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
абузы: free-панели new-api на вкладке (роут /api/panels + грид), CSS вынесен из JS, панельный классификатор даёт freeapi-вес, тесты
2026-10-27 02:06:32 +03:00

325 lines
15 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""Разбор абузов: промо, триалы, ключи, сливы, софт, реселл.
Работает поверх уже собранной базы (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, []
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 or strong or len(hits) >= 2:
return sc or 1, 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}