From c3eec767750bfb5db2d480141a6138b438d4999d Mon Sep 17 00:00:00 2001 From: Vlad Gothbreach Date: Tue, 27 Oct 2026 22:29:47 +0300 Subject: [PATCH] =?UTF-8?q?prod:=20=D1=87=D0=B8=D1=81=D1=82=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=82=D0=B0=20(=D0=B4=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=B5=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D0=BA,=20=D0=B0=D0=BD=D1=82=D0=B8-=D1=88=D1=83=D0=BC=20?= =?UTF-8?q?=D0=B2=20score,=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B9=D0=BD=D1=8B=D0=B9=20=D0=B2=D0=B5=D1=81),=20=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20=D0=B6=D0=B8=D0=B2=D1=8B?= =?UTF-8?q?=D1=85/=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D1=85=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2,=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83=D1=82=D1=8B=20net?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- omni/net.py | 4 +++- omni/rank.py | 50 +++++++++++++++++++++++++++++++++++++++++++++++- omni/serve.py | 2 ++ omni/store.py | 16 +++++++++++++++- tools/rescore.py | 15 +++++++++------ web/index.html | 4 ++-- 6 files changed, 80 insertions(+), 11 deletions(-) diff --git a/omni/net.py b/omni/net.py index fa14544..1f8f9bc 100644 --- a/omni/net.py +++ b/omni/net.py @@ -133,7 +133,8 @@ def _pick_proxy(): return random.choice(ps) if ps else None -def _try_httpx(url, headers, timeout, proxy): +def _try_httpx(url, headers, timeout, proxy, _min=10): + timeout = max(_min, min(timeout, 12)) try: import httpx kw = {"headers": headers, "timeout": timeout, "follow_redirects": True} @@ -148,6 +149,7 @@ def _try_httpx(url, headers, timeout, proxy): def _try_curl(url, headers, timeout, proxy, impersonate="chrome131"): + timeout = max(timeout, 8) try: from curl_cffi import requests as cr kw = {"headers": headers, "timeout": timeout, "impersonate": impersonate, "allow_redirects": True} diff --git a/omni/rank.py b/omni/rank.py index 591b168..5bb2bb8 100644 --- a/omni/rank.py +++ b/omni/rank.py @@ -24,6 +24,53 @@ def _abuse(it): KIND_BONUS = {"tweet": 0.15, "thread": 0.25, "tg_post": 0.2, "card": 0.05, "result": 0.1, "post": 0.1} +# Категорийный вес: то, ради чего лента существует (абузы/панели/ключи/фермы), +# поднимаем выше новостного шума. Значения проверены на текущем индексе. +CAT_BONUS = {"abuse": 0.9, "panels": 0.8, "keys": 0.7, "treg": 0.35, "ai": 0.25, + "web3": 0.2, "tools": 0.2, "news": -0.35, "misc": -0.2} + +# Мусор, который не должен всплывать никогда: служебные заголовки, оглавления, +# changelog/release notes, голые URL, склейки-«лапши» и пустые обрывки. +NOISE_RE = re.compile( + r"(chapter\s*list|release\s*notes|changelog|table\s*of\s*contents|" + r"^\s*(index|readme|docs?|documentation|wiki)\s*$|" + r"^\s*https?://\S+\s*$|\|\|)", re.I) + +SHOUT_RE = re.compile(r"^[^a-zа-я]{12,}$") + + +def _noise(it): + """0 = чисто, иначе штраф. Считается по заголовку и началу текста.""" + title = (it.title or "").strip() + if not title: + return 2.0 + penalty = 0.0 + if len(title) < 8: + penalty += 0.6 + if len(title) > 220: + penalty += 0.8 + if NOISE_RE.search(title): + penalty += 1.6 + # Голый CVE-дамп (тысячи одинаковых описаний из фида) — полезен точечно, + # но не должен занимать верх ленты: топ должен принадлежать находкам. + if re.match(r"^CVE-\d{4}-\d+", title): + penalty += 1.4 + # Обрывок переписки/цитата вместо заголовка: «> be ...», «хотелось бы отметить». + if title.startswith(">"): + penalty += 1.5 + elif title[:1].islower(): + penalty += 1.0 + if SHOUT_RE.match(title): + penalty += 0.35 + if sum(c.isalnum() for c in title) < len(title) * 0.4: + penalty += 0.4 + txt = (it.text or "") + if len(txt) > 4000 and len(title) < 25: + penalty += 0.7 + if not txt and len(title) < 40: + penalty += 0.3 + return min(3.0, penalty) + def _fresh(published): """Unix seconds/milliseconds, ISO or RFC 2822. Return freshness in 0..1.""" @@ -41,8 +88,9 @@ def score(it): priced = 0.35 if it.price else 0.0 body = min(1.0, len(it.text) / 1200.0) eng = min(3.0, base ** 0.5) if base > 1 else base + cat = CAT_BONUS.get((getattr(it, "category", "") or "").lower(), 0.0) total = (w * (1.0 + eng) + fresh * 1.2 + priced + body + KIND_BONUS.get(it.kind, 0.0) - + 0.45 * _abuse(it)) + + 0.45 * _abuse(it) + cat - _noise(it)) it.score = round(total, 4) return it diff --git a/omni/serve.py b/omni/serve.py index d075afa..bb9958c 100644 --- a/omni/serve.py +++ b/omni/serve.py @@ -296,6 +296,8 @@ class Handler(BaseHTTPRequestHandler): "sources_enabled": sum(1 for x in all_s if fl.get(x.name, {}).get("enabled", True)), "alive": sum(1 for x in all_s if (fl.get(x.name, {}).get("items") or 0) > 0), "tested": sum(1 for x in all_s if fl.get(x.name, {}).get("checked")), + "dead": sum(1 for x in all_s if fl.get(x.name, {}).get("checked") + and not (fl.get(x.name, {}).get("items") or 0)), "categories": cats, }) diff --git a/omni/store.py b/omni/store.py index 83ada87..d8019bc 100644 --- a/omni/store.py +++ b/omni/store.py @@ -232,11 +232,25 @@ def browse2(q="", names=None, limit=60, offset=0, sort="score", priced=False, ty "price": "ORDER BY CAST(price AS REAL) DESC, score DESC", "old": "ORDER BY published ASC, id ASC", "quality": "ORDER BY score DESC, id DESC"}.get(sort, "ORDER BY score DESC, id DESC") + pull, skip = limit, offset + if sort == "quality": + # Перепечатки одного и того же поста из разных источников схлопываем: + # берём с запасом и оставляем по одной записи на нормализованный заголовок. + pull, skip = min(600, limit * 4), 0 with db() as c: total = c.execute("SELECT COUNT(*) FROM items " + where, args).fetchone()[0] rows = c.execute("SELECT id,provider,kind,source,title,text,author,published,score,price,currency,url,types " "FROM items " + where + order + " LIMIT ? OFFSET ?", - args + [limit, offset]).fetchall() + args + [pull, skip]).fetchall() + if sort == "quality": + seen, uniq = set(), [] + for r in rows: + t = "".join(ch for ch in (r[4] or "").lower() if ch.isalnum())[:60] or str(r[0]) + if t in seen: + continue + seen.add(t) + uniq.append(r) + rows = uniq[offset:offset + limit] keys = ("id", "provider", "kind", "source", "title", "text", "author", "published", "score", "price", "currency", "url", "types") return [dict(zip(keys, r)) for r in rows], total diff --git a/tools/rescore.py b/tools/rescore.py index c3d476f..4a5f685 100644 --- a/tools/rescore.py +++ b/tools/rescore.py @@ -8,25 +8,28 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from omni import config, rank, store # noqa: E402 +from omni import config, rank, sources, store # noqa: E402 + +CAT = {s.name: (s.category or "") for s in sources.SOURCES} class _It: - __slots__ = ("provider", "kind", "title", "text", "published", "price", "score") + __slots__ = ("provider", "kind", "title", "text", "published", "price", "score", "category") - def __init__(self, provider, kind, title, text, published, price): + def __init__(self, provider, kind, title, text, published, price, category=""): self.provider, self.kind = provider, kind self.title, self.text = title or "", text or "" self.published, self.price, self.score = published or "", price, 0.0 + self.category = category or "" def main(): apply = "--apply" in sys.argv c = store.conn() - rows = c.execute("SELECT id, provider, kind, title, text, published, price, score FROM items").fetchall() + rows = c.execute("SELECT id, provider, kind, title, text, published, price, score, source FROM items").fetchall() upd, top = [], [] - for iid, prov, kind, title, text, pub, price, old in rows: - it = rank.score(_It(prov, kind, title, text, pub, price)) + for iid, prov, kind, title, text, pub, price, old, src in rows: + it = rank.score(_It(prov, kind, title, text, pub, price, CAT.get(src, ""))) upd.append((it.score, iid)) top.append((it.score, old, prov, title or "")) top.sort(reverse=True) diff --git a/web/index.html b/web/index.html index 5f0ef9e..e0a6e57 100644 --- a/web/index.html +++ b/web/index.html @@ -472,7 +472,7 @@ function categoryOptions(value=""){const names=[...new Set([...(S.cats||[]),...( function typeOptions(value="all"){const rows=[...S.types];if(value!=="all"&&!rows.some(t=>t.key===value))rows.push({key:value,label:value});return ''+rows.map(t=>``).join("")} function refreshOptions(){if($("#cat"))$("#cat").innerHTML=categoryOptions(S.cat);if($("#f-type"))$("#f-type").innerHTML=typeOptions(S.type);if($("#w-type"))$("#w-type").innerHTML=typeOptions($("#w-type").value);if($("#s-cat"))$("#s-cat").innerHTML=categoryOptions(S.srcCat)} async function loadMeta(){const values=await Promise.allSettled([api("/api/stats"),api("/api/types")]);if(values[0].status==="fulfilled"){S.stats=values[0].value;S.cats=(S.stats.categories||[]).map(c=>c.name);paintHealth(S.stats)}else $("#health").innerHTML='API недоступен. ';if(values[1].status==="fulfilled"){S.types=values[1].value.types||[];if($("#meta-error"))$("#meta-error").innerHTML=""}else if($("#meta-error"))$("#meta-error").innerHTML=failure(values[1].reason,loadMeta);refreshOptions()} -function paintHealth(h){$("#health").textContent=`${num(h.items)} записей · ${num(h.alive)} из ${num(h.sources)} источников отдавали записи`} +function paintHealth(h){$("#health").textContent=`${num(h.items)} записей · источники: ${num(h.alive)} живых / ${num(h.dead)} мёртвых из ${num(h.sources)}`} async function refreshHealth(){try{paintHealth(await api("/api/health"))}catch{$("#health").innerHTML='Сервер недоступен. '}} const V={}; async function render(focusHeading=false){ @@ -545,7 +545,7 @@ async function runDigest(){const scope=requestScope("digest"),days=$("#d-days")? V.watch=async ctx=>{const d=await api("/api/watch",{signal:ctx.signal});if(!ctx.valid())return;const labels=typeLabels();ctx.mount(`

Новое правило

Поиск по подстроке, без учёта регистра. Совпадения считаются только с момента добавления правила (watchlist).

Быстро добавить:${["CVE-2026","firecrawl","промо"].map(q=>``).join("")}

Мои правила · ${num((d.rules||[]).length)}

${(d.rules||[]).map(r=>`

${text(r.q)}

${text(r.type==="all"?"Все типы":labels[r.type]||r.type)}Совпадений: ${num(r.count)}
${r.latest?.length?related(r.latest):'

Новых совпадений пока нет. Правило уже работает.

'}
`).join("")||empty("Добавьте первую тему для отслеживания","Введите слово или фразу в форме выше. Свежие совпадения появятся в этом списке.")}
`)}; V.overview=async ctx=>{const [st,tl,runs,watch,latest]=await Promise.all([api("/api/stats",{signal:ctx.signal}),api("/api/timeline?days=30&mode=seen",{signal:ctx.signal}),api("/api/runs?limit=6",{signal:ctx.signal}),api("/api/watch",{signal:ctx.signal}),api("/api/items?limit=6&sort=fresh",{signal:ctx.signal})]);if(!ctx.valid())return;S.stats=st; - ctx.mount(`

Состояние рабочей области

Сохранено записей
${num(st.items)}
Импортировано за 24 часа
${num(tl.last24)}
Источников в реестре
${num(st.sources)}
Включено для сбора
${num(st.sources_enabled)}
Ранее отдавали записи
${num(st.alive)}
Готово API-ключей
${num(st.keys?.ready)} / ${num(st.keys?.keys)}
Правил отслеживания
${num(watch.rules?.length)}

Импорт за 30 дней

${num(tl.total)} записей за период. Это дата импорта; фильтр периода в ленте использует дату публикации.

${(tl.points||[]).slice(-7).map(p=>``).join("")}
День (последняя неделя)Импортировано
${esc(dt(p.day))}${num(p.n)}

Свежие записи

Вся лента
${(latest.items||[]).map(it=>itemRow(it,true)).join("")||empty("Пока нет записей","Добавьте источник и запустите сбор.")}

Источники по категориям

${(st.categories||[]).map(c=>``).join("")||''}
КатегорияВ реестреОтдавали записиВ базе
${text(c.name)}${num(c.total)}${num(c.alive)}${num(c.stored)}
Категорий пока нет

Последние сборы

Вся история
${(runs.items||[]).map(r=>``).join("")||''}
ИсточникКогдаРезультат
${text(r.source)}${esc(datetime(r.when))}${statusBadge(stKey(r.status))}
Сборов пока нет
`)}; + ctx.mount(`

Состояние рабочей области

Сохранено записей
${num(st.items)}
Импортировано за 24 часа
${num(tl.last24)}
Источников в реестре
${num(st.sources)}
Включено для сбора
${num(st.sources_enabled)}
Живых источников
${num(st.alive)}
Мёртвых (проверены, 0 записей)
${num(st.dead)}
Готово API-ключей
${num(st.keys?.ready)} / ${num(st.keys?.keys)}
Правил отслеживания
${num(watch.rules?.length)}

Импорт за 30 дней

${num(tl.total)} записей за период. Это дата импорта; фильтр периода в ленте использует дату публикации.

${(tl.points||[]).slice(-7).map(p=>``).join("")}
День (последняя неделя)Импортировано
${esc(dt(p.day))}${num(p.n)}

Свежие записи

Вся лента
${(latest.items||[]).map(it=>itemRow(it,true)).join("")||empty("Пока нет записей","Добавьте источник и запустите сбор.")}

Источники по категориям

${(st.categories||[]).map(c=>``).join("")||''}
КатегорияВ реестреОтдавали записиВ базе
${text(c.name)}${num(c.total)}${num(c.alive)}${num(c.stored)}
Категорий пока нет

Последние сборы

Вся история
${(runs.items||[]).map(r=>``).join("")||''}
ИсточникКогдаРезультат
${text(r.source)}${esc(datetime(r.when))}${statusBadge(stKey(r.status))}
Сборов пока нет
`)}; function sleep(ms,signal){return new Promise((resolve,reject)=>{if(signal?.aborted){reject(new DOMException("Aborted","AbortError"));return}const done=()=>{signal?.removeEventListener("abort",abort);resolve()},timer=setTimeout(done,ms),abort=()=>{clearTimeout(timer);reject(new DOMException("Aborted","AbortError"))};signal?.addEventListener("abort",abort,{once:true})})} async function monitorJob(job,ctx,onProgress){if(!job)throw new Error("Сервер не вернул номер задачи");while(ctx.valid()){const state=await api("/api/job/"+encodeURIComponent(job),{signal:ctx.signal});if(!ctx.valid())throw new DOMException("Aborted","AbortError");if(state.state==="error")throw new Error(state.error||"Задача завершилась с ошибкой");if(state.state==="done"||state.ok===true)return state;if(state.state==="unknown")throw new Error("Сервер больше не знает эту задачу. Обновите данные раздела.");onProgress?.(state);await sleep(1400,ctx.signal)}throw new DOMException("Aborted","AbortError")}