реестр: дедуп источников строго по url (2 падавших теста зелёные); инкрементальный abuse-индекс + tgsearch из предыдущей сессии
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

This commit is contained in:
ENI
2026-10-27 01:09:30 +03:00
parent d0fca99dda
commit d0b2187319
7 changed files with 601 additions and 24 deletions

View File

@@ -39,6 +39,46 @@ RULES = [
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",
@@ -47,7 +87,7 @@ RULES = [
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"}
STRONG = {"keys", "promo", "trial", "resale", "credits", "freeapi", "ltd", "combo"}
# на специализированных абуз-площадках хватает одного признака: сам источник тематический
ABUSE_SRC_SC = 14
# категории-источники, которые вообще имеют смысл для абузов
@@ -106,7 +146,7 @@ def _qualifies(source, title, text, tags):
return 0, []
if cat is None:
# старые записи хранят категорию вместо имени источника
if not raw_key and not str(source or "").startswith(("ab_", "tg_", "reddit_", "discourse_", "xenforo")):
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, []
@@ -119,42 +159,89 @@ def _qualifies(source, title, text, tags):
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):
sql = "SELECT id, source, url, title, text, published, tags, score FROM items"
"""Читает из индекса 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("(title LIKE ? OR text LIKE ? OR source LIKE ?)")
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("published >= date('now', ?)")
where.append("i.published >= date('now', ?)")
args.append("-%d day" % int(since_days))
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY id DESC LIMIT ?"
args.append(int(limit) * 6)
con = sqlite3.connect(str(DB), timeout=30)
sql += " ORDER BY a.score DESC, i.id DESC LIMIT ?"
args.append(int(limit))
con = _connect()
try:
cur = con.execute(sql, args)
out = []
for r in cur:
sc, hits = _qualifies(r[1], r[3], r[4], r[6] or "")
if not sc:
continue
if tag and tag not in hits:
continue
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": sc, "rank": r[7]})
if len(out) >= limit:
break
"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 — сколько отдать, window — сколько последних записей просканировать."""
items = _rows(limit=window, tag=tag, q=q, since_days=since_days)
"""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: