diff --git a/omni/abuse.py b/omni/abuse.py index 510b47f..52aedf1 100644 --- a/omni/abuse.py +++ b/omni/abuse.py @@ -8,6 +8,7 @@ from __future__ import annotations import re import sqlite3 +import time from .config import DB @@ -184,6 +185,9 @@ def _connect(): 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 @@ -214,10 +218,10 @@ def rebuild(full=False, chunk=4000, auto=False): 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))) + 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) VALUES (?,?,?)", batch) + con.executemany("INSERT OR REPLACE INTO abuse_idx(id, score, tags, sig) VALUES (?,?,?,?)", batch) added += len(batch) con.commit() return added @@ -231,7 +235,7 @@ def _rows(limit=400, tag=None, q=None, since_days=None): 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 " + 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: @@ -255,17 +259,49 @@ def _rows(limit=400, tag=None, q=None, since_days=None): 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], + "tags": hits, "abuse_score": r[8], "rank": r[7], "sig": r[9], "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) +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"]: @@ -291,8 +327,10 @@ def scan(limit=120, tag=None, q=None, since_days=None, window=6000): con.close() except Exception: pass - return {"items": items[:limit], "total": total, "tags": counts, - "labels": TAG_LABEL} + 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") diff --git a/web/index.html b/web/index.html index c75b2ab..4f7f673 100644 --- a/web/index.html +++ b/web/index.html @@ -503,7 +503,7 @@ V.abuse=async ctx=>{ ctx.mount(`
${loading("Загрузка находок…",5)}
`); 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||{}); - $("#ab-content").innerHTML=`
Показано ${num(S.abuse.length)} из ${num(d.total)} находок

Это признаки в публикациях, а не подтверждённые предложения. Проверяйте условия в оригинале. Значения API-ключей не отображаются.

${S.abuse.map(it=>itemRow(it)).join("")||empty("Находок пока нет","Выберите другой признак или соберите свежие записи источников.",'')}
`; + $("#ab-content").innerHTML=`
Показано ${num(S.abuse.length)} из ${num(d.total)} находок${d.uniq&&d.uniq!==d.total?` · ${num(d.uniq)} уникальных`:"\u00a0"}

Это признаки в публикациях, а не подтверждённые предложения. Проверяйте условия в оригинале. Значения API-ключей не отображаются.

${S.abuse.map(it=>itemRow(it)).join("")||empty("Находок пока нет","Выберите другой признак или соберите свежие записи источников.",'')}
`; }; 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=`

Панели с бесплатными моделями

${num(all.length)} с бесплатными моделями из ${num(d.total)} в реестре · показано ${num(rows.length)}. Доступность и условия проверяйте на сайте панели. Учётные данные здесь не запрашиваются.

${rows.length?`
${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 ``}).join("")}
ПанельБесплатных моделейВсего моделейДействие
${esc(host)}${num(p.free_count||p.free||0)}${num(models)}${external(url,"Открыть панель","btn btn-sm")}
`:empty("Бесплатных панелей пока нет","В текущем реестре нет панелей с бесплатными моделями. Обновите список позже.",'')}`} catch(error){if(scope.valid()&&error.name!=="AbortError")box.innerHTML=`
${failure(error,()=>loadPanels(ctx))}
`}}