prod: чистая лента (дедуп перепечаток, анти-шум в score, категорийный вес), счётчик живых/мёртвых источников, таймауты net
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 22:29:47 +03:00
parent 3ca87f1db0
commit c3eec76775
6 changed files with 80 additions and 11 deletions

View File

@@ -133,7 +133,8 @@ def _pick_proxy():
return random.choice(ps) if ps else None 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: try:
import httpx import httpx
kw = {"headers": headers, "timeout": timeout, "follow_redirects": True} 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"): def _try_curl(url, headers, timeout, proxy, impersonate="chrome131"):
timeout = max(timeout, 8)
try: try:
from curl_cffi import requests as cr from curl_cffi import requests as cr
kw = {"headers": headers, "timeout": timeout, "impersonate": impersonate, "allow_redirects": True} kw = {"headers": headers, "timeout": timeout, "impersonate": impersonate, "allow_redirects": True}

View File

@@ -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} 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): def _fresh(published):
"""Unix seconds/milliseconds, ISO or RFC 2822. Return freshness in 0..1.""" """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 priced = 0.35 if it.price else 0.0
body = min(1.0, len(it.text) / 1200.0) body = min(1.0, len(it.text) / 1200.0)
eng = min(3.0, base ** 0.5) if base > 1 else base 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) 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) it.score = round(total, 4)
return it return it

View File

@@ -296,6 +296,8 @@ class Handler(BaseHTTPRequestHandler):
"sources_enabled": sum(1 for x in all_s if fl.get(x.name, {}).get("enabled", True)), "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), "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")), "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, "categories": cats,
}) })

View File

@@ -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", "price": "ORDER BY CAST(price AS REAL) DESC, score DESC",
"old": "ORDER BY published ASC, id ASC", "old": "ORDER BY published ASC, id ASC",
"quality": "ORDER BY score DESC, id DESC"}.get(sort, "ORDER BY score DESC, id DESC") "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: with db() as c:
total = c.execute("SELECT COUNT(*) FROM items " + where, args).fetchone()[0] 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 " rows = c.execute("SELECT id,provider,kind,source,title,text,author,published,score,price,currency,url,types "
"FROM items " + where + order + " LIMIT ? OFFSET ?", "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") keys = ("id", "provider", "kind", "source", "title", "text", "author", "published", "score", "price", "currency", "url", "types")
return [dict(zip(keys, r)) for r in rows], total return [dict(zip(keys, r)) for r in rows], total

View File

@@ -8,25 +8,28 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 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: 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.provider, self.kind = provider, kind
self.title, self.text = title or "", text or "" self.title, self.text = title or "", text or ""
self.published, self.price, self.score = published or "", price, 0.0 self.published, self.price, self.score = published or "", price, 0.0
self.category = category or ""
def main(): def main():
apply = "--apply" in sys.argv apply = "--apply" in sys.argv
c = store.conn() 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 = [], [] upd, top = [], []
for iid, prov, kind, title, text, pub, price, old in rows: for iid, prov, kind, title, text, pub, price, old, src in rows:
it = rank.score(_It(prov, kind, title, text, pub, price)) it = rank.score(_It(prov, kind, title, text, pub, price, CAT.get(src, "")))
upd.append((it.score, iid)) upd.append((it.score, iid))
top.append((it.score, old, prov, title or "")) top.append((it.score, old, prov, title or ""))
top.sort(reverse=True) top.sort(reverse=True)

View File

@@ -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 '<option value="all">Все типы</option>'+rows.map(t=>`<option value="${esc(t.key)}" ${t.key===value?"selected":""}>${text(t.label)}</option>`).join("")} function typeOptions(value="all"){const rows=[...S.types];if(value!=="all"&&!rows.some(t=>t.key===value))rows.push({key:value,label:value});return '<option value="all">Все типы</option>'+rows.map(t=>`<option value="${esc(t.key)}" ${t.key===value?"selected":""}>${text(t.label)}</option>`).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)} 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 недоступен. <button class="btn btn-link btn-sm p-0" data-act="refresh-health" type="button">Повторить</button>';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()} 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 недоступен. <button class="btn btn-link btn-sm p-0" data-act="refresh-health" type="button">Повторить</button>';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='Сервер недоступен. <button class="btn btn-link btn-sm p-0" data-act="refresh-health" type="button">Повторить</button>'}} async function refreshHealth(){try{paintHealth(await api("/api/health"))}catch{$("#health").innerHTML='Сервер недоступен. <button class="btn btn-link btn-sm p-0" data-act="refresh-health" type="button">Повторить</button>'}}
const V={}; const V={};
async function render(focusHeading=false){ 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(`<div class="card"><div class="card-body border-bottom"><h2 class="card-title">Новое правило</h2><form id="watch-form" class="row g-3 align-items-end"><div class="col-sm-6"><label class="form-label" for="w-q">Слово или фраза</label><input class="form-control" id="w-q" placeholder="Например, CVE-2026 или название сервиса" minlength="2" required></div><div class="col-sm-3"><label class="form-label" for="w-type">Тип записи</label><select class="form-select" id="w-type">${typeOptions()}</select></div><div class="col-sm-3"><button class="btn btn-primary w-100" type="submit" data-lock="watch-write">Добавить правило</button></div></form><p class="text-secondary small mt-3 mb-0">Поиск по подстроке, без учёта регистра. Совпадения считаются только с момента добавления правила (watchlist).</p><div class="d-flex flex-wrap align-items-center gap-2 mt-3"><span class="text-secondary small">Быстро добавить:</span>${["CVE-2026","firecrawl","промо"].map(q=>`<button class="btn btn-sm" type="button" data-watch-q="${esc(q)}" data-lock="watch-write">${esc(q)}</button>`).join("")}</div></div><div class="card-header"><h2 class="card-title">Мои правила · ${num((d.rules||[]).length)}</h2></div><div class="list-group list-group-flush" id="watch-rules">${(d.rules||[]).map(r=>`<section class="list-group-item"><div class="d-flex flex-wrap align-items-center gap-2"><h3 class="h3 mb-0 me-2 text-break">${text(r.q)}</h3><span class="badge bg-secondary-lt">${text(r.type==="all"?"Все типы":labels[r.type]||r.type)}</span><span class="text-secondary small">Совпадений: ${num(r.count)}</span><button class="btn btn-ghost-danger btn-sm ms-auto" type="button" data-watch-del="${esc(r.id)}" data-lock="watch-write" aria-label="Удалить правило ${text(r.q)}">Удалить</button></div>${r.latest?.length?related(r.latest):'<p class="text-secondary small mt-3 mb-0">Новых совпадений пока нет. Правило уже работает.</p>'}</section>`).join("")||empty("Добавьте первую тему для отслеживания","Введите слово или фразу в форме выше. Свежие совпадения появятся в этом списке.")}</div></div>`)}; V.watch=async ctx=>{const d=await api("/api/watch",{signal:ctx.signal});if(!ctx.valid())return;const labels=typeLabels();ctx.mount(`<div class="card"><div class="card-body border-bottom"><h2 class="card-title">Новое правило</h2><form id="watch-form" class="row g-3 align-items-end"><div class="col-sm-6"><label class="form-label" for="w-q">Слово или фраза</label><input class="form-control" id="w-q" placeholder="Например, CVE-2026 или название сервиса" minlength="2" required></div><div class="col-sm-3"><label class="form-label" for="w-type">Тип записи</label><select class="form-select" id="w-type">${typeOptions()}</select></div><div class="col-sm-3"><button class="btn btn-primary w-100" type="submit" data-lock="watch-write">Добавить правило</button></div></form><p class="text-secondary small mt-3 mb-0">Поиск по подстроке, без учёта регистра. Совпадения считаются только с момента добавления правила (watchlist).</p><div class="d-flex flex-wrap align-items-center gap-2 mt-3"><span class="text-secondary small">Быстро добавить:</span>${["CVE-2026","firecrawl","промо"].map(q=>`<button class="btn btn-sm" type="button" data-watch-q="${esc(q)}" data-lock="watch-write">${esc(q)}</button>`).join("")}</div></div><div class="card-header"><h2 class="card-title">Мои правила · ${num((d.rules||[]).length)}</h2></div><div class="list-group list-group-flush" id="watch-rules">${(d.rules||[]).map(r=>`<section class="list-group-item"><div class="d-flex flex-wrap align-items-center gap-2"><h3 class="h3 mb-0 me-2 text-break">${text(r.q)}</h3><span class="badge bg-secondary-lt">${text(r.type==="all"?"Все типы":labels[r.type]||r.type)}</span><span class="text-secondary small">Совпадений: ${num(r.count)}</span><button class="btn btn-ghost-danger btn-sm ms-auto" type="button" data-watch-del="${esc(r.id)}" data-lock="watch-write" aria-label="Удалить правило ${text(r.q)}">Удалить</button></div>${r.latest?.length?related(r.latest):'<p class="text-secondary small mt-3 mb-0">Новых совпадений пока нет. Правило уже работает.</p>'}</section>`).join("")||empty("Добавьте первую тему для отслеживания","Введите слово или фразу в форме выше. Свежие совпадения появятся в этом списке.")}</div></div>`)};
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; 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(`<div class="row g-4"><div class="col-lg-5"><section class="card"><div class="card-header"><h2 class="card-title">Состояние рабочей области</h2></div><div class="card-body"><dl class="row mb-0"><dt class="col-8">Сохранено записей</dt><dd class="col-4 text-end">${num(st.items)}</dd><dt class="col-8">Импортировано за 24 часа</dt><dd class="col-4 text-end">${num(tl.last24)}</dd><dt class="col-8">Источников в реестре</dt><dd class="col-4 text-end">${num(st.sources)}</dd><dt class="col-8">Включено для сбора</dt><dd class="col-4 text-end">${num(st.sources_enabled)}</dd><dt class="col-8">Ранее отдавали записи</dt><dd class="col-4 text-end">${num(st.alive)}</dd><dt class="col-8">Готово API-ключей</dt><dd class="col-4 text-end">${num(st.keys?.ready)} / ${num(st.keys?.keys)}</dd><dt class="col-8">Правил отслеживания</dt><dd class="col-4 text-end">${num(watch.rules?.length)}</dd></dl><label class="form-check mt-3 mb-0"><input class="form-check-input" type="checkbox" id="auto-refresh" ${S.auto?"checked":""}><span class="form-check-label">Обновлять данные раз в минуту</span></label></div></section><section class="card mt-4"><div class="card-header"><h2 class="card-title">Импорт за 30 дней</h2></div><div class="card-body"><p class="text-secondary mb-3">${num(tl.total)} записей за период. Это дата импорта; фильтр периода в ленте использует дату публикации.</p><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th scope="col">День (последняя неделя)</th><th scope="col" class="text-end">Импортировано</th></tr></thead><tbody>${(tl.points||[]).slice(-7).map(p=>`<tr><td>${esc(dt(p.day))}</td><td class="text-end">${num(p.n)}</td></tr>`).join("")}</tbody></table></div></div></section></div><div class="col-lg-7"><section class="card"><div class="card-header d-flex justify-content-between gap-2"><h2 class="card-title">Свежие записи</h2><a href="#/feed" data-nav="feed">Вся лента</a></div><div class="list-group list-group-flush">${(latest.items||[]).map(it=>itemRow(it,true)).join("")||empty("Пока нет записей","Добавьте источник и запустите сбор.")}</div></section></div><div class="col-lg-6"><section class="card"><div class="card-header"><h2 class="card-title">Источники по категориям</h2></div><div class="table-responsive"><table class="table mb-0"><thead><tr><th scope="col">Категория</th><th scope="col" class="text-end">В реестре</th><th scope="col" class="text-end">Отдавали записи</th><th scope="col" class="text-end">В базе</th></tr></thead><tbody>${(st.categories||[]).map(c=>`<tr><td>${text(c.name)}</td><td class="text-end">${num(c.total)}</td><td class="text-end">${num(c.alive)}</td><td class="text-end">${num(c.stored)}</td></tr>`).join("")||'<tr><td colspan="4">Категорий пока нет</td></tr>'}</tbody></table></div></section></div><div class="col-lg-6"><section class="card"><div class="card-header d-flex justify-content-between gap-2"><h2 class="card-title">Последние сборы</h2><a href="#/runs" data-nav="runs">Вся история</a></div><div class="table-responsive"><table class="table mb-0"><thead><tr><th scope="col">Источник</th><th scope="col">Когда</th><th scope="col">Результат</th></tr></thead><tbody>${(runs.items||[]).map(r=>`<tr><td>${text(r.source)}</td><td class="text-secondary small">${esc(datetime(r.when))}</td><td>${statusBadge(stKey(r.status))}</td></tr>`).join("")||'<tr><td colspan="3">Сборов пока нет</td></tr>'}</tbody></table></div></section></div></div>`)}; ctx.mount(`<div class="row g-4"><div class="col-lg-5"><section class="card"><div class="card-header"><h2 class="card-title">Состояние рабочей области</h2></div><div class="card-body"><dl class="row mb-0"><dt class="col-8">Сохранено записей</dt><dd class="col-4 text-end">${num(st.items)}</dd><dt class="col-8">Импортировано за 24 часа</dt><dd class="col-4 text-end">${num(tl.last24)}</dd><dt class="col-8">Источников в реестре</dt><dd class="col-4 text-end">${num(st.sources)}</dd><dt class="col-8">Включено для сбора</dt><dd class="col-4 text-end">${num(st.sources_enabled)}</dd><dt class="col-8">Живых источников</dt><dd class="col-4 text-end">${num(st.alive)}</dd><dt class="col-8">Мёртвых (проверены, 0 записей)</dt><dd class="col-4 text-end">${num(st.dead)}</dd><dt class="col-8">Готово API-ключей</dt><dd class="col-4 text-end">${num(st.keys?.ready)} / ${num(st.keys?.keys)}</dd><dt class="col-8">Правил отслеживания</dt><dd class="col-4 text-end">${num(watch.rules?.length)}</dd></dl><label class="form-check mt-3 mb-0"><input class="form-check-input" type="checkbox" id="auto-refresh" ${S.auto?"checked":""}><span class="form-check-label">Обновлять данные раз в минуту</span></label></div></section><section class="card mt-4"><div class="card-header"><h2 class="card-title">Импорт за 30 дней</h2></div><div class="card-body"><p class="text-secondary mb-3">${num(tl.total)} записей за период. Это дата импорта; фильтр периода в ленте использует дату публикации.</p><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th scope="col">День (последняя неделя)</th><th scope="col" class="text-end">Импортировано</th></tr></thead><tbody>${(tl.points||[]).slice(-7).map(p=>`<tr><td>${esc(dt(p.day))}</td><td class="text-end">${num(p.n)}</td></tr>`).join("")}</tbody></table></div></div></section></div><div class="col-lg-7"><section class="card"><div class="card-header d-flex justify-content-between gap-2"><h2 class="card-title">Свежие записи</h2><a href="#/feed" data-nav="feed">Вся лента</a></div><div class="list-group list-group-flush">${(latest.items||[]).map(it=>itemRow(it,true)).join("")||empty("Пока нет записей","Добавьте источник и запустите сбор.")}</div></section></div><div class="col-lg-6"><section class="card"><div class="card-header"><h2 class="card-title">Источники по категориям</h2></div><div class="table-responsive"><table class="table mb-0"><thead><tr><th scope="col">Категория</th><th scope="col" class="text-end">В реестре</th><th scope="col" class="text-end">Отдавали записи</th><th scope="col" class="text-end">В базе</th></tr></thead><tbody>${(st.categories||[]).map(c=>`<tr><td>${text(c.name)}</td><td class="text-end">${num(c.total)}</td><td class="text-end">${num(c.alive)}</td><td class="text-end">${num(c.stored)}</td></tr>`).join("")||'<tr><td colspan="4">Категорий пока нет</td></tr>'}</tbody></table></div></section></div><div class="col-lg-6"><section class="card"><div class="card-header d-flex justify-content-between gap-2"><h2 class="card-title">Последние сборы</h2><a href="#/runs" data-nav="runs">Вся история</a></div><div class="table-responsive"><table class="table mb-0"><thead><tr><th scope="col">Источник</th><th scope="col">Когда</th><th scope="col">Результат</th></tr></thead><tbody>${(runs.items||[]).map(r=>`<tr><td>${text(r.source)}</td><td class="text-secondary small">${esc(datetime(r.when))}</td><td>${statusBadge(stKey(r.status))}</td></tr>`).join("")||'<tr><td colspan="3">Сборов пока нет</td></tr>'}</tbody></table></div></section></div></div>`)};
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})})} 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")} 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")}