abuse: схлопывание перепечаток (sig в индексе, uniq в API), кэш выдачи 60с; scan 13с -> 1.5с
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:
Vlad Gothbreach
2026-10-27 03:43:42 +03:00
parent 07dc5cc788
commit 2e3bf4cb24
2 changed files with 48 additions and 10 deletions

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import re import re
import sqlite3 import sqlite3
import time
from .config import DB from .config import DB
@@ -184,6 +185,9 @@ def _connect():
con.execute("PRAGMA busy_timeout=60000") con.execute("PRAGMA busy_timeout=60000")
con.execute("CREATE TABLE IF NOT EXISTS abuse_idx " con.execute("CREATE TABLE IF NOT EXISTS abuse_idx "
"(id INTEGER PRIMARY KEY, score INTEGER, tags TEXT)") "(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 return con
@@ -214,10 +218,10 @@ def rebuild(full=False, chunk=4000, auto=False):
for r in rows: for r in rows:
sc, hits = _qualifies(r[1], r[3], r[4], r[5] or "") sc, hits = _qualifies(r[1], r[3], r[4], r[5] or "")
if sc: if sc:
batch.append((r[0], sc, ",".join(hits))) batch.append((r[0], sc, ",".join(hits), _sig(r[3], r[4])))
last = r[0] last = r[0]
if batch: if batch:
con.executemany("INSERT OR REPLACE INTO abuse_idx(id, score, tags) VALUES (?,?,?)", batch) con.executemany("INSERT OR REPLACE INTO abuse_idx(id, score, tags, sig) VALUES (?,?,?,?)", batch)
added += len(batch) added += len(batch)
con.commit() con.commit()
return added return added
@@ -231,7 +235,7 @@ def _rows(limit=400, tag=None, q=None, since_days=None):
rebuild(auto=True) rebuild(auto=True)
except Exception: except Exception:
pass pass
sql = ("SELECT i.id, i.source, i.url, i.title, i.text, i.published, a.tags, i.score, a.score " 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") "FROM abuse_idx a JOIN items i ON i.id = a.id")
where, args = [], [] where, args = [], []
if tag: if tag:
@@ -255,17 +259,49 @@ def _rows(limit=400, tag=None, q=None, since_days=None):
body = (r[4] or "") body = (r[4] or "")
out.append({"id": r[0], "source": r[1], "url": r[2], "title": r[3], out.append({"id": r[0], "source": r[1], "url": r[2], "title": r[3],
"text": body[:600], "published": r[5], "text": body[:600], "published": r[5],
"tags": hits, "abuse_score": r[8], "rank": r[7], "tags": hits, "abuse_score": r[8], "rank": r[7], "sig": r[9],
"find": extract((r[3] or "") + " " + body)}) "find": extract((r[3] or "") + " " + body)})
return out return out
finally: finally:
con.close() con.close()
def scan(limit=120, tag=None, q=None, since_days=None, window=6000): def _sig(title, text):
"""limit — сколько отдать; индекс уже отсортирован по весy, window не режет.""" """Подпись для схлопывания перепечаток: нормализованный текст без пунктуации."""
items = _rows(limit=max(int(limit), 400), tag=tag, q=q, since_days=since_days) 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))) 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 = {} counts = {}
for it in items: for it in items:
for t in it["tags"]: for t in it["tags"]:
@@ -291,8 +327,10 @@ def scan(limit=120, tag=None, q=None, since_days=None, window=6000):
con.close() con.close()
except Exception: except Exception:
pass pass
return {"items": items[:limit], "total": total, "tags": counts, res = {"items": items[:limit], "total": total, "uniq": uniq_total or total,
"labels": TAG_LABEL} "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_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_CODE2 = re.compile(r"\b([A-Z]{2,}[0-9]{2,}[A-Z0-9_\-]{0,14})\b")

View File

@@ -503,7 +503,7 @@ V.abuse=async ctx=>{
ctx.mount(`<div class="card"><div class="card-header"><nav class="nav nav-tabs card-header-tabs" aria-label="Виды находок"><a class="nav-link ${S.tab==="signals"?"active":""}" href="#/abuse" data-ab-tab="signals" ${S.tab==="signals"?'aria-current="page"':""}>Находки в публикациях</a><a class="nav-link ${S.tab==="panels"?"active":""}" href="#/abuse?tab=panels" data-ab-tab="panels" ${S.tab==="panels"?'aria-current="page"':""}>Бесплатные API-панели</a></nav></div><div id="ab-content">${loading("Загрузка находок…",5)}</div><div id="pn-box" ${S.tab!=="panels"?"hidden":""}></div></div><div id="sum-box" class="mt-4" aria-live="polite"></div>`); ctx.mount(`<div class="card"><div class="card-header"><nav class="nav nav-tabs card-header-tabs" aria-label="Виды находок"><a class="nav-link ${S.tab==="signals"?"active":""}" href="#/abuse" data-ab-tab="signals" ${S.tab==="signals"?'aria-current="page"':""}>Находки в публикациях</a><a class="nav-link ${S.tab==="panels"?"active":""}" href="#/abuse?tab=panels" data-ab-tab="panels" ${S.tab==="panels"?'aria-current="page"':""}>Бесплатные API-панели</a></nav></div><div id="ab-content">${loading("Загрузка находок…",5)}</div><div id="pn-box" ${S.tab!=="panels"?"hidden":""}></div></div><div id="sum-box" class="mt-4" aria-live="polite"></div>`);
if(S.tab==="panels"){$("#ab-content").hidden=true;await loadPanels(ctx);return} if(S.tab==="panels"){$("#ab-content").hidden=true;await loadPanels(ctx);return}
const d=await api("/api/abuse?limit=120"+(S.tag?"&tag="+encodeURIComponent(S.tag):""),{signal:ctx.signal});if(!ctx.valid())return;S.abuse=d.items||[];const tags=Object.entries(d.labels||{}); const d=await api("/api/abuse?limit=120"+(S.tag?"&tag="+encodeURIComponent(S.tag):""),{signal:ctx.signal});if(!ctx.valid())return;S.abuse=d.items||[];const tags=Object.entries(d.labels||{});
$("#ab-content").innerHTML=`<div class="card-body border-bottom"><div class="row g-3 align-items-end"><div class="col-sm-5"><label class="form-label" for="ab-tag">Что искать в публикациях</label><select class="form-select" id="ab-tag"><option value="">Все находки</option>${tags.map(([tag,label])=>`<option value="${esc(tag)}" ${S.tag===tag?"selected":""}>${text(label)}</option>`).join("")}</select></div><div class="col text-secondary">Показано ${num(S.abuse.length)} из ${num(d.total)} находок</div></div><p class="text-secondary small mt-3 mb-0">Это признаки в публикациях, а не подтверждённые предложения. Проверяйте условия в оригинале. Значения API-ключей не отображаются.</p></div><div id="ab-list" class="list-group list-group-flush">${S.abuse.map(it=>itemRow(it)).join("")||empty("Находок пока нет","Выберите другой признак или соберите свежие записи источников.",'<button class="btn" data-tag="" type="button">Все находки</button>')}</div>`; $("#ab-content").innerHTML=`<div class="card-body border-bottom"><div class="row g-3 align-items-end"><div class="col-sm-5"><label class="form-label" for="ab-tag">Что искать в публикациях</label><select class="form-select" id="ab-tag"><option value="">Все находки</option>${tags.map(([tag,label])=>`<option value="${esc(tag)}" ${S.tag===tag?"selected":""}>${text(label)}</option>`).join("")}</select></div><div class="col text-secondary">Показано ${num(S.abuse.length)} из ${num(d.total)} находок${d.uniq&&d.uniq!==d.total?` · ${num(d.uniq)} уникальных`:"\u00a0"}</div></div><p class="text-secondary small mt-3 mb-0">Это признаки в публикациях, а не подтверждённые предложения. Проверяйте условия в оригинале. Значения API-ключей не отображаются.</p></div><div id="ab-list" class="list-group list-group-flush">${S.abuse.map(it=>itemRow(it)).join("")||empty("Находок пока нет","Выберите другой признак или соберите свежие записи источников.",'<button class="btn" data-tag="" type="button">Все находки</button>')}</div>`;
}; };
async function loadPanels(ctx=activeContext){const scope=requestScope("panels",ctx),box=$("#pn-box");if(!box)return;box.innerHTML=loading("Загрузка API-панелей…");try{const d=await api("/api/panels",{signal:scope.signal});if(!scope.valid())return;const all=(d.panels||[]).filter(p=>Number(p.free_count||p.free||0)>0),rows=all.slice(0,40);box.innerHTML=`<div class="card-body border-bottom"><h2 class="card-title">Панели с бесплатными моделями</h2><p class="text-secondary mb-0">${num(all.length)} с бесплатными моделями из ${num(d.total)} в реестре · показано ${num(rows.length)}. Доступность и условия проверяйте на сайте панели. Учётные данные здесь не запрашиваются.</p></div>${rows.length?`<div class="table-responsive"><table class="table table-vcenter mb-0" id="panels-table"><thead><tr><th scope="col">Панель</th><th scope="col">Бесплатных моделей</th><th scope="col">Всего моделей</th><th scope="col">Действие</th></tr></thead><tbody>${rows.map(p=>{const raw=String(p.host||p.url||""),url=safeUrl(/^https?:\/\//i.test(raw)?raw:"https://"+raw),host=url?new URL(url).hostname:"Адрес недоступен",models=Array.isArray(p.models)?p.models.length:(p.models||p.model_count||0);return `<tr><td class="fw-medium text-break">${esc(host)}</td><td>${num(p.free_count||p.free||0)}</td><td>${num(models)}</td><td>${external(url,"Открыть панель","btn btn-sm")}</td></tr>`}).join("")}</tbody></table></div>`:empty("Бесплатных панелей пока нет","В текущем реестре нет панелей с бесплатными моделями. Обновите список позже.",'<button class="btn" data-act="panels-retry" type="button">Обновить список</button>')}`} async function loadPanels(ctx=activeContext){const scope=requestScope("panels",ctx),box=$("#pn-box");if(!box)return;box.innerHTML=loading("Загрузка API-панелей…");try{const d=await api("/api/panels",{signal:scope.signal});if(!scope.valid())return;const all=(d.panels||[]).filter(p=>Number(p.free_count||p.free||0)>0),rows=all.slice(0,40);box.innerHTML=`<div class="card-body border-bottom"><h2 class="card-title">Панели с бесплатными моделями</h2><p class="text-secondary mb-0">${num(all.length)} с бесплатными моделями из ${num(d.total)} в реестре · показано ${num(rows.length)}. Доступность и условия проверяйте на сайте панели. Учётные данные здесь не запрашиваются.</p></div>${rows.length?`<div class="table-responsive"><table class="table table-vcenter mb-0" id="panels-table"><thead><tr><th scope="col">Панель</th><th scope="col">Бесплатных моделей</th><th scope="col">Всего моделей</th><th scope="col">Действие</th></tr></thead><tbody>${rows.map(p=>{const raw=String(p.host||p.url||""),url=safeUrl(/^https?:\/\//i.test(raw)?raw:"https://"+raw),host=url?new URL(url).hostname:"Адрес недоступен",models=Array.isArray(p.models)?p.models.length:(p.models||p.model_count||0);return `<tr><td class="fw-medium text-break">${esc(host)}</td><td>${num(p.free_count||p.free||0)}</td><td>${num(models)}</td><td>${external(url,"Открыть панель","btn btn-sm")}</td></tr>`}).join("")}</tbody></table></div>`:empty("Бесплатных панелей пока нет","В текущем реестре нет панелей с бесплатными моделями. Обновите список позже.",'<button class="btn" data-act="panels-retry" type="button">Обновить список</button>')}`}
catch(error){if(scope.valid()&&error.name!=="AbortError")box.innerHTML=`<div class="p-3">${failure(error,()=>loadPanels(ctx))}</div>`}} catch(error){if(scope.valid()&&error.name!=="AbortError")box.innerHTML=`<div class="p-3">${failure(error,()=>loadPanels(ctx))}</div>`}}