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=>`${text(t.label)} `).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=>`${esc(q)} `).join("")}
${(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(tl.total)} записей за период. Это дата импорта; фильтр периода в ленте использует дату публикации.
День (последняя неделя) Импортировано ${(tl.points||[]).slice(-7).map(p=>`${esc(dt(p.day))} ${num(p.n)} `).join("")}
${(latest.items||[]).map(it=>itemRow(it,true)).join("")||empty("Пока нет записей","Добавьте источник и запустите сбор.")}
Категория В реестре Отдавали записи В базе ${(st.categories||[]).map(c=>`${text(c.name)} ${num(c.total)} ${num(c.alive)} ${num(c.stored)} `).join("")||'Категорий пока нет '}
Источник Когда Результат ${(runs.items||[]).map(r=>`${text(r.source)} ${esc(datetime(r.when))} ${statusBadge(stKey(r.status))} `).join("")||'Сборов пока нет '}
`)};
+ ctx.mount(`${num(tl.total)} записей за период. Это дата импорта; фильтр периода в ленте использует дату публикации.
День (последняя неделя) Импортировано ${(tl.points||[]).slice(-7).map(p=>`${esc(dt(p.day))} ${num(p.n)} `).join("")}
${(latest.items||[]).map(it=>itemRow(it,true)).join("")||empty("Пока нет записей","Добавьте источник и запустите сбор.")}
Категория В реестре Отдавали записи В базе ${(st.categories||[]).map(c=>`${text(c.name)} ${num(c.total)} ${num(c.alive)} ${num(c.stored)} `).join("")||'Категорий пока нет '}
Источник Когда Результат ${(runs.items||[]).map(r=>`${text(r.source)} ${esc(datetime(r.when))} ${statusBadge(stKey(r.status))} `).join("")||'Сборов пока нет '}
`)};
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")}