omni: редизайн панели + 450 источников (341 живых), health-аудит, чистка html/дат, per-source учёт

This commit is contained in:
Reform
2026-10-26 09:41:04 +03:00
parent 35be863977
commit 81f3e556c4
8 changed files with 1032 additions and 291 deletions

View File

@@ -20,7 +20,7 @@
| `firecrawl` | Firecrawl search | `FIRECRAWL_API_KEY` | | `firecrawl` | Firecrawl search | `FIRECRAWL_API_KEY` |
| `cards` | карточки товаров: JSON-LD Product/Offer, OpenGraph, microdata, цена/валюта/наличие | нет | | `cards` | карточки товаров: JSON-LD Product/Offer, OpenGraph, microdata, цена/валюта/наличие | нет |
| `treg` | treg-поиск | `TREG_TOKEN` | | `treg` | treg-поиск | `TREG_TOKEN` |
| `feed` | обход реестра из 338 лент/API без запроса (см. ниже) | нет | | `feed` | обход реестра из 450 лент/API без запроса (см. ниже) | нет |
`python -m omni providers` печатает, что реально поднялось в этом окружении. `python -m omni providers` печатает, что реально поднялось в этом окружении.
@@ -44,7 +44,7 @@ python -m omni search "scraping twitter without api" --providers reddit,forums,d
python -m omni sources python -m omni sources
python -m omni sources --category papers,security --list python -m omni sources --category papers,security --list
# обход реестра БЕЗ запроса: 338 источников -> база # обход реестра БЕЗ запроса: 450 источников -> база
python -m omni feed --category community --limit 8 python -m omni feed --category community --limit 8
python -m omni feed --limit 15 # всё подряд python -m omni feed --limit 15 # всё подряд
python -m omni feed --only arxiv_cs_ai,hn_top,reddit_localllama python -m omni feed --only arxiv_cs_ai,hn_top,reddit_localllama
@@ -66,7 +66,7 @@ python -m omni export --format jsonl --out data/dump.jsonl
## Реестр источников ## Реестр источников
`omni/sources.py` + `omni/sources_ext.py` — **338 источников в 9 категориях**, у каждого свой адаптер и флаг вкл/выкл: `omni/sources.py` + `omni/sources_ext.py` — **450 источников в 9 категориях**, у каждого свой адаптер и флаг вкл/выкл:
- **papers 49** — arxiv по 25+ разделам (cs.AI/LG/CL/CV/RO/IR/CR/SE/NE/MA/DC/DB/HC/SD/CY, stat.ML, eess.AS/SP/IV, math.OC, q-bio.NC, cs.GR, cs.AR, cs.PF), HF daily papers, OpenAlex, Semantic Scholar, Papers with Code, Connected Papers - **papers 49** — arxiv по 25+ разделам (cs.AI/LG/CL/CV/RO/IR/CR/SE/NE/MA/DC/DB/HC/SD/CY, stat.ML, eess.AS/SP/IV, math.OC, q-bio.NC, cs.GR, cs.AR, cs.PF), HF daily papers, OpenAlex, Semantic Scholar, Papers with Code, Connected Papers
- **company 95** — ленты OpenAI, Google AI/Research/DeepMind, Microsoft Research, NVIDIA, Apple ML, Amazon Science, HF, Meta AI, Mistral, Cohere, Together, Perplexity, Groq, Cerebras, Stability, Databricks, Anyscale, Modal, W&B, LangChain, LlamaIndex, vLLM, Replicate, Fireworks, SambaNova, Scale, xAI, Salesforce, AWS ML, Azure AI, Anthropic, AI2, EleutherAI, LAION, Runway, ElevenLabs, Deepgram, AssemblyAI, Pinecone, Weaviate, Qdrant, Chroma, Unsloth, Axolotl - **company 95** — ленты OpenAI, Google AI/Research/DeepMind, Microsoft Research, NVIDIA, Apple ML, Amazon Science, HF, Meta AI, Mistral, Cohere, Together, Perplexity, Groq, Cerebras, Stability, Databricks, Anyscale, Modal, W&B, LangChain, LlamaIndex, vLLM, Replicate, Fireworks, SambaNova, Scale, xAI, Salesforce, AWS ML, Azure AI, Anthropic, AI2, EleutherAI, LAION, Runway, ElevenLabs, Deepgram, AssemblyAI, Pinecone, Weaviate, Qdrant, Chroma, Unsloth, Axolotl
@@ -93,7 +93,7 @@ python -m omni digest --out data/digest.md
1. **лента** — счётчики (записей, с ценой, прогонов, источников, включённых), поиск по базе, фильтр по категориям, кнопка «собрать ленту» с прогрессом и разбивкой fetched/new/dup/empty. 1. **лента** — счётчики (записей, с ценой, прогонов, источников, включённых), поиск по базе, фильтр по категориям, кнопка «собрать ленту» с прогрессом и разбивкой fetched/new/dup/empty.
2. **источники** — менеджер реестра: 2. **источники** — менеджер реестра:
- таблица всех 338 источников: переключатель вкл/выкл, категория, тип, url, статус (жив / мёртв / не проверен), число записей; - таблица всех 450 источников: переключатель вкл/выкл, категория, тип, url, статус (жив / мёртв / не проверен), число записей;
- фильтр по имени/url и по категории; - фильтр по имени/url и по категории;
- **подключить источник** — форма (имя, url, категория): тип и адаптер определяются по url автоматически (`/rss` → rss, `.json`/`/api/` → generic_json, `github.com/org` → github_org, `t.me/s/` → tg_web), запись живёт в SQLite и сразу участвует в обходах; - **подключить источник** — форма (имя, url, категория): тип и адаптер определяются по url автоматически (`/rss` → rss, `.json`/`/api/` → generic_json, `github.com/org` → github_org, `t.me/s/` → tg_web), запись живёт в SQLite и сразу участвует в обходах;
- **проверить всё / проверить категорию** — фоновой джоб: реальный фетч каждого источника, запись `status/items/note/checked`, прогресс `done/total, живых/мёртвых`; - **проверить всё / проверить категорию** — фоновой джоб: реальный фетч каждого источника, запись `status/items/note/checked`, прогресс `done/total, живых/мёртвых`;

View File

@@ -164,6 +164,39 @@ def cmd_sources(args):
return 0 return 0
def cmd_audit(args):
"""Сводка здоровья реестра: по категориям и список мёртвых с причиной."""
from . import source_state
fl = source_state.flags()
all_s = sources.all_sources()
agg = {}
dead = []
for s in all_s:
f = fl.get(s.name, {})
a = agg.setdefault(s.category, {"t": 0, "alive": 0, "dead": 0, "untested": 0})
a["t"] += 1
if not f.get("checked"):
a["untested"] += 1
elif (f.get("items") or 0) > 0:
a["alive"] += 1
else:
a["dead"] += 1
dead.append((s.category, s.name, f.get("note") or "empty"))
print("источников: %d | живых: %d | мёртвых: %d | не проверено: %d" % (
len(all_s), sum(a["alive"] for a in agg.values()),
sum(a["dead"] for a in agg.values()), sum(a["untested"] for a in agg.values())))
for cat, a in sorted(agg.items(), key=lambda x: -x[1]["t"]):
pct = round(a["alive"] / a["t"] * 100) if a["t"] else 0
print(" %-12s всего %3d живых %3d мёртвых %3d не проверено %3d (%d%%)"
% (cat, a["t"], a["alive"], a["dead"], a["untested"], pct))
if args.dead:
print("")
print("мёртвые:")
for cat, name, note in sorted(dead):
print(" %-12s %-28s %s" % (cat, name, note[:50]))
return 0
def cmd_feed(args): def cmd_feed(args):
picked = sources.pick(args.category, args.only, args.tags) picked = sources.pick(args.category, args.only, args.tags)
if not picked: if not picked:
@@ -265,6 +298,10 @@ def main(argv=None):
sp.add_argument("--all", action="store_true", help="показать и выключенные") sp.add_argument("--all", action="store_true", help="показать и выключенные")
sp.set_defaults(fn=cmd_sources) sp.set_defaults(fn=cmd_sources)
sp = sub.add_parser("audit", help="здоровье реестра источников")
sp.add_argument("--dead", action="store_true", help="показать мёртвые с причиной")
sp.set_defaults(fn=cmd_audit)
sp = sub.add_parser("feed", help="обход реестра источников без запроса") sp = sub.add_parser("feed", help="обход реестра источников без запроса")
sp.add_argument("--category", default=None) sp.add_argument("--category", default=None)
sp.add_argument("--only", default=None) sp.add_argument("--only", default=None)

View File

@@ -28,7 +28,7 @@ def _item(src, kind, url, title, text="", author="", published="", extra=None):
ex = dict(extra or {}) ex = dict(extra or {})
ex["tags"] = list(src.tags) ex["tags"] = list(src.tags)
return Item(src.name, kind, url=str(url or ""), title=_txt(title)[:300], text=_txt(text)[:6000], return Item(src.name, kind, url=str(url or ""), title=_txt(title)[:300], text=_txt(text)[:6000],
author=_txt(author)[:80], published=str(published or ""), source=src.category, author=_txt(author)[:80], published=str(published or ""), source=src.name,
extra=ex) extra=ex)
@@ -515,6 +515,138 @@ def _nvd(src, limit):
return out return out
def _biorxiv(src, limit):
import json as _j
d = _json_of(src.url)
rows = d.get("collection") if isinstance(d, dict) else d
out = []
for r in (rows or [])[:limit]:
title = r.get("title") or ""
doi = r.get("doi") or ""
if not title:
continue
ver = r.get("version") or "1"
host = "medrxiv" if "medrxiv" in src.url else "biorxiv"
url = "https://www.%s.org/content/%sv%s" % (host, doi, ver) if doi else src.url
out.append(_item(src, "paper", url, title, str(r.get("abstract") or r.get("category") or "")[:800],
str(r.get("authors") or ""), str(r.get("date") or "")[:24]))
return out
def _dblp(src, limit):
d = _json_of(src.url)
hits = (((d or {}).get("result") or {}).get("hits") or {}).get("hit") or []
out = []
for h in hits[:limit]:
info = h.get("info") or {}
title = info.get("title") or ""
if not title:
continue
auth = info.get("authors") or {}
if isinstance(auth, dict):
a = auth.get("author")
if isinstance(a, list):
auth = ", ".join(x.get("text", "") if isinstance(x, dict) else str(x) for x in a)
elif isinstance(a, dict):
auth = a.get("text", "")
out.append(_item(src, "paper", str(info.get("url") or ""), title, str(info.get("venue") or ""),
str(auth or ""), str(info.get("year") or "")))
return out
def _hal(src, limit):
d = _json_of(src.url)
docs = ((d or {}).get("response") or {}).get("docs") or []
out = []
for r in docs[:limit]:
t = r.get("title_s")
title = (t[0] if isinstance(t, list) and t else t) or ""
if not title:
continue
ab = r.get("abstract_s")
if isinstance(ab, list):
ab = ab[0] if ab else ""
out.append(_item(src, "paper", str(r.get("uri_s") or ""), title, str(ab or "")[:800], "",
str(r.get("producedDateY_i") or "")))
return out
def _zenodo(src, limit):
d = _json_of(src.url)
rows = ((d or {}).get("hits") or {}).get("hits") or []
out = []
for r in rows[:limit]:
md = r.get("metadata") or {}
title = md.get("title") or ""
if not title:
continue
url = (r.get("links") or {}).get("self_html") or md.get("doi_url") or ""
out.append(_item(src, "paper", str(url), title, str(md.get("description") or "")[:800],
str((md.get("creators") or [{}])[0].get("name") if md.get("creators") else ""),
str(md.get("publication_date") or "")))
return out
def _cve_circl(src, limit):
d = _json_of(src.url)
rows = d if isinstance(d, list) else []
out = []
for r in rows[:limit]:
cid = r.get("id") or ""
if not cid:
continue
out.append(_item(src, "cve", "https://nvd.nist.gov/vuln/detail/" + cid, cid + " — " + str(r.get("summary") or "")[:140],
str(r.get("summary") or "")[:800], "", str(r.get("Published") or "")[:24]))
return out
def _ghsa(src, limit):
d = _json_of(src.url)
rows = d if isinstance(d, list) else (d.get("advisories") or []) if isinstance(d, dict) else []
out = []
for r in rows[:limit]:
gid = r.get("ghsa_id") or ""
title = r.get("summary") or gid
if not title:
continue
sev = r.get("severity") or ""
out.append(_item(src, "advisory", str(r.get("html_url") or ""), str(title),
(sev + " | " + (r.get("description") or ""))[:800], "",
str(r.get("published_at") or "")[:24]))
return out
def _models_dev(src, limit):
d = _json_of(src.url)
out = []
if not isinstance(d, dict):
return out
for pid, prov in d.items():
if not isinstance(prov, dict):
continue
pname = prov.get("name") or pid
models = prov.get("models") or {}
if isinstance(models, dict):
iterable = models.items()
elif isinstance(models, list):
iterable = [(m.get("id", ""), m) for m in models if isinstance(m, dict)]
else:
continue
for mid, m in iterable:
if not isinstance(m, dict):
continue
name = m.get("name") or mid
out.append(_item(src, "model", "https://models.dev/#" + str(pid), "%s / %s" % (pname, name),
"ctx %s | in $%s | out $%s" % (m.get("limit", {}).get("context", "?"),
(m.get("cost") or {}).get("input", "?"),
(m.get("cost") or {}).get("output", "?")),
str(pid), ""))
if len(out) >= limit:
return out
return out
ADAPTERS = { ADAPTERS = {
"rss": _rss, "rss": _rss,
"generic_json": _generic_json, "generic_json": _generic_json,
@@ -537,6 +669,14 @@ ADAPTERS = {
"xenforo_latest": _xenforo_latest, "xenforo_latest": _xenforo_latest,
"tg_web": _tg_web, "tg_web": _tg_web,
"html_listing": _html_listing, "html_listing": _html_listing,
"biorxiv": _biorxiv,
"dblp": _dblp,
"hal": _hal,
"zenodo": _zenodo,
"cve_circl": _cve_circl,
"ghsa": _ghsa,
"models_dev": _models_dev,
} }

View File

@@ -4,7 +4,9 @@
""" """
import json import json
import pathlib import pathlib
import re
import threading import threading
import time
import urllib.parse import urllib.parse
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -14,6 +16,13 @@ from . import feeds, net, sources, source_state, store
WEB = pathlib.Path(__file__).resolve().parent.parent / "web" WEB = pathlib.Path(__file__).resolve().parent.parent / "web"
JOBS = {} JOBS = {}
_LOCK = threading.Lock() _LOCK = threading.Lock()
SEQ = [0]
def _jid(prefix):
with _LOCK:
SEQ[0] += 1
return "%s%d" % (prefix, SEQ[0])
def _job(jid, **kw): def _job(jid, **kw):
@@ -23,20 +32,79 @@ def _job(jid, **kw):
JOBS[jid] = cur JOBS[jid] = cur
def _cat_health():
"""Сводка по категориям: всего/включено/живых/мёртвых/непроверенных."""
fl = source_state.flags()
out = {}
for s in sources.all_sources():
f = fl.get(s.name, {})
c = out.setdefault(s.category, {"name": s.category, "total": 0, "enabled": 0,
"alive": 0, "dead": 0, "untested": 0})
c["total"] += 1
if f.get("enabled", True):
c["enabled"] += 1
if not f.get("checked"):
c["untested"] += 1
elif (f.get("items") or 0) > 0:
c["alive"] += 1
else:
c["dead"] += 1
return sorted(out.values(), key=lambda x: -x["total"])
def _clean_txt(v, n=0):
"""Снимает html-теги/энтити из title/text, схлопывает пробелы."""
import html as _h
if not v:
return ""
t = str(v)
t = re.sub(r"<[^>]{1,200}>", " ", t)
for _ in range(2):
t = _h.unescape(t)
t = re.sub(r"\s+", " ", t).strip()
return t[:n].rstrip() if n else t
def _fmt_date(v):
"""unix-таймстамп -> YYYY-MM-DD, rfc822 оставляет как есть."""
if not v:
return ""
s = str(v).strip()
if s.isdigit() and len(s) >= 9:
import datetime as _dt
try:
return _dt.datetime.utcfromtimestamp(int(s[:10])).strftime("%Y-%m-%d")
except Exception:
return s
m = re.search(r"(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})", s)
if m:
return "%s %s %s" % (m.group(1), m.group(2), m.group(3))
return s[:19]
def _names_in_category(cat):
if not cat or cat in ("all", ""):
return None
return [s.name for s in sources.all_sources() if s.category == cat]
def _run_feed(job_id, category, limit, tags): def _run_feed(job_id, category, limit, tags):
picked = sources.pick(category, None, tags) picked = sources.pick(category, None, tags)
_job(job_id, state="running", total=len(picked), done=0)
try: try:
items, report = feeds.fetch_many(picked, limit=limit, workers=8) items, report = feeds.fetch_many(picked, limit=limit, workers=8)
new, dup = store.add(items) new, dup = store.add(items)
_job(job_id, state="done", sources=len(picked), fetched=len(items), new=new, dup=dup, _job(job_id, state="done", sources=len(picked), fetched=len(items), new=new, dup=dup,
empty=sum(1 for _, c in report if not c), empty=sum(1 for _, c in report if not c), done=len(picked),
report=sorted(report, key=lambda x: -x[1])[:40]) report=sorted(report, key=lambda x: -x[1])[:60])
except Exception as e: except Exception as e:
_job(job_id, state="error", error=f"{type(e).__name__}: {e}"[:200]) _job(job_id, state="error", error=f"{type(e).__name__}: {e}"[:200])
def _check_one(src, limit=5): def _check_one(src, limit=5):
note = "" note = ""
t0 = time.time()
try: try:
items = feeds.fetch(src, limit) items = feeds.fetch(src, limit)
except Exception as e: except Exception as e:
@@ -75,7 +143,7 @@ def _run_check(job_id, names=None, category=None):
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "omni/1.1" server_version = "omni/1.2"
def log_message(self, *a): def log_message(self, *a):
pass pass
@@ -105,52 +173,75 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/stats": if path == "/api/stats":
s = store.stats() s = store.stats()
per_source = store.count_by_source()
fl = source_state.flags() fl = source_state.flags()
all_s = sources.all_sources() all_s = sources.all_sources()
cats = _cat_health()
for c in cats:
c["stored"] = sum(per_source.get(x.name, 0) for x in all_s if x.category == c["name"])
return self._json({ return self._json({
"items": s["items"], "priced": s["priced"], "runs": s["runs"], "runs_ok": s["runs_ok"], "items": s["items"], "priced": s["priced"], "runs": s["runs"], "runs_ok": s["runs_ok"],
"by_provider": [{"name": n, "count": c} for n, c in s["by_provider"]], "by_provider": [{"name": n, "count": c} for n, c in s["by_provider"][:12]],
"by_kind": [{"name": n, "count": c} for n, c in s["by_kind"]], "by_kind": [{"name": n, "count": c} for n, c in s["by_kind"]],
"sources": len(all_s), "sources": len(all_s),
"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)),
"categories": sources.stats(), "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")),
"categories": cats,
}) })
if path == "/api/sources": if path == "/api/sources":
cat = (q.get("category") or [None])[0] cat = (q.get("category") or [None])[0]
query = (q.get("q") or [""])[0].strip().lower() query = (q.get("q") or [""])[0].strip().lower()
state = (q.get("state") or [""])[0]
fl = source_state.flags() fl = source_state.flags()
per_source = store.count_by_source()
custom_names = {d["name"] for d in source_state.custom()} custom_names = {d["name"] for d in source_state.custom()}
items = [] items = []
for s in sources.all_sources(): for s in sources.all_sources():
f = fl.get(s.name, {}) f = fl.get(s.name, {})
alive = (f.get("items") or 0) > 0
if cat and cat not in ("all", "") and s.category != cat: if cat and cat not in ("all", "") and s.category != cat:
continue continue
if query and query not in s.name.lower() and query not in s.url.lower(): if query and query not in s.name.lower() and query not in s.url.lower():
continue continue
if state == "alive" and not alive:
continue
if state == "dead" and not (f.get("checked") and not alive):
continue
if state == "untested" and f.get("checked"):
continue
if state == "off" and f.get("enabled", True):
continue
items.append({ items.append({
"name": s.name, "category": s.category, "kind": s.kind, "adapter": s.adapter, "name": s.name, "category": s.category, "kind": s.kind, "adapter": s.adapter,
"url": s.url, "tags": list(s.tags), "url": s.url, "tags": list(s.tags),
"enabled": f.get("enabled", True), "status": f.get("status"), "enabled": f.get("enabled", True), "status": f.get("status"),
"items": f.get("items"), "note": f.get("note"), "checked": f.get("checked"), "items": f.get("items"), "note": f.get("note"), "checked": f.get("checked"),
"custom": s.name in custom_names, "stored": per_source.get(s.name, 0), "custom": s.name in custom_names,
}) })
items.sort(key=lambda x: (-(x["stored"] or 0), -(x["items"] or 0), x["name"]))
return self._json({ return self._json({
"total": len(sources.all_sources()), "shown": len(items), "total": len(sources.all_sources()), "shown": len(items),
"enabled": sum(1 for x in items if x["enabled"]), "enabled": sum(1 for x in items if x["enabled"]),
"alive": sum(1 for x in items if (x["items"] or 0) > 0), "alive": sum(1 for x in items if (x["items"] or 0) > 0),
"dead": sum(1 for x in items if x["checked"] and not (x["items"] or 0)), "dead": sum(1 for x in items if x["checked"] and not (x["items"] or 0)),
"untested": sum(1 for x in items if not x["checked"]), "untested": sum(1 for x in items if not x["checked"]),
"categories": sources.stats(), "items": items, "categories": _cat_health(), "items": items,
}) })
if path == "/api/items": if path == "/api/items":
cat = (q.get("category") or [None])[0] cat = (q.get("category") or [None])[0]
query = (q.get("q") or [""])[0].strip() query = (q.get("q") or [""])[0].strip()
limit = min(int((q.get("limit") or ["60"])[0]), 300) limit = min(int((q.get("limit") or ["40"])[0]), 200)
offset = int((q.get("offset") or ["0"])[0]) offset = int((q.get("offset") or ["0"])[0])
rows = store.browse(category=cat, q=query, limit=limit, offset=offset) sort = (q.get("sort") or ["score"])[0]
return self._json({"count": len(rows), "items": rows}) rows, total = store.browse2(q=query, names=_names_in_category(cat), limit=limit, offset=offset, sort=sort)
for _r in rows:
_r["title"] = _clean_txt(_r.get("title"), 180)
_r["text"] = _clean_txt(_r.get("text"), 260)
_r["published"] = _fmt_date(_r.get("published"))
return self._json({"count": len(rows), "total": total, "offset": offset, "items": rows})
if path.startswith("/api/job/"): if path.startswith("/api/job/"):
jid = path.rsplit("/", 1)[-1] jid = path.rsplit("/", 1)[-1]
@@ -169,7 +260,7 @@ class Handler(BaseHTTPRequestHandler):
body = {} body = {}
if u.path == "/api/feed": if u.path == "/api/feed":
jid = f"feed{len(JOBS)}" jid = _jid("feed")
_job(jid, state="running") _job(jid, state="running")
threading.Thread(target=_run_feed, threading.Thread(target=_run_feed,
args=(jid, body.get("category"), int(body.get("limit") or 12), body.get("tags")), args=(jid, body.get("category"), int(body.get("limit") or 12), body.get("tags")),
@@ -182,6 +273,17 @@ class Handler(BaseHTTPRequestHandler):
return self._json({"error": "name required"}, 400) return self._json({"error": "name required"}, 400)
return self._json(source_state.set_enabled(name, bool(body.get("enabled", True)))) return self._json(source_state.set_enabled(name, bool(body.get("enabled", True))))
if u.path == "/api/sources/toggle_all":
on = bool(body.get("enabled", True))
cat = body.get("category")
changed = 0
for s in sources.all_sources():
if cat and cat not in ("all", "") and s.category != cat:
continue
source_state.set_enabled(s.name, on)
changed += 1
return self._json({"ok": True, "changed": changed, "enabled": on})
if u.path == "/api/sources/add": if u.path == "/api/sources/add":
try: try:
res = source_state.add_custom(body.get("name"), body.get("category"), body.get("url"), res = source_state.add_custom(body.get("name"), body.get("category"), body.get("url"),
@@ -200,8 +302,8 @@ class Handler(BaseHTTPRequestHandler):
if u.path == "/api/sources/check": if u.path == "/api/sources/check":
names = body.get("names") or ([body["name"]] if body.get("name") else None) names = body.get("names") or ([body["name"]] if body.get("name") else None)
category = None if body.get("all") else body.get("category") category = body.get("category")
jid = f"check{len(JOBS)}" jid = _jid("check")
_job(jid, state="running") _job(jid, state="running")
threading.Thread(target=_run_check, args=(jid, names, category), daemon=True).start() threading.Thread(target=_run_check, args=(jid, names, category), daemon=True).start()
return self._json({"job": jid}) return self._json({"job": jid})

View File

@@ -11,6 +11,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .sources_ext import EXTRA from .sources_ext import EXTRA
from .sources_ext2 import EXTRA2
@dataclass @dataclass
@@ -177,7 +178,17 @@ SOURCES_BASE = [
S("tg_huggingface", "forum", "html", "https://t.me/s/huggingface", "tg_web", ("telegram",)), S("tg_huggingface", "forum", "html", "https://t.me/s/huggingface", "tg_web", ("telegram",)),
] ]
SOURCES = SOURCES_BASE + [Source(**d) for d in EXTRA] def _dedup(rows):
seen, out = set(), []
for s in rows:
if s.url in seen:
continue
seen.add(s.url)
out.append(s)
return out
SOURCES = _dedup(SOURCES_BASE + [Source(**d) for d in EXTRA] + [Source(**d) for d in EXTRA2])
BY_NAME = {s.name: s for s in SOURCES} BY_NAME = {s.name: s for s in SOURCES}
CATEGORIES = sorted({s.category for s in SOURCES}) CATEGORIES = sorted({s.category for s in SOURCES})

118
omni/sources_ext2.py Normal file
View File

@@ -0,0 +1,118 @@
"""Волна 2+3: источники, прошедшие живой фетч (items > 0). Сгенерировано probe_w23.py."""
EXTRA2 = [
dict(name="gaia_bench", category="benchmark", kind="html", url="https://huggingface.co/spaces/gaia-benchmark/leaderboard", adapter="html_listing", tags=["bench"], limit=20, detail=False),
dict(name="osworld_leaderboard", category="benchmark", kind="html", url="https://os-world.github.io/", adapter="html_listing", tags=["bench"], limit=20, detail=False),
dict(name="swe_rebench", category="benchmark", kind="html", url="https://swe-rebench.com/", adapter="html_listing", tags=["bench"], limit=20, detail=False),
dict(name="terminal_bench", category="benchmark", kind="html", url="https://www.tbench.ai/leaderboard", adapter="html_listing", tags=["bench"], limit=20, detail=False),
dict(name="coding_horror", category="community", kind="rss", url="https://blog.codinghorror.com/rss/", adapter="rss", tags=["blog"], limit=20, detail=False),
dict(name="danluu_blog", category="community", kind="rss", url="https://danluu.com/atom.xml", adapter="rss", tags=["blog"], limit=20, detail=False),
dict(name="jay_alammar", category="community", kind="rss", url="https://jalammar.github.io/feed.xml", adapter="rss", tags=["ml", "explainer"], limit=20, detail=False),
dict(name="jvns_blog", category="community", kind="rss", url="https://jvns.ca/atom.xml", adapter="rss", tags=["blog"], limit=20, detail=False),
dict(name="kentcdodds", category="community", kind="rss", url="https://kentcdodds.com/blog/rss.xml", adapter="rss", tags=["frontend"], limit=20, detail=False),
dict(name="lilianweng", category="community", kind="rss", url="https://lilianweng.github.io/index.xml", adapter="rss", tags=["ml", "research"], limit=20, detail=False),
dict(name="lobsters_databases", category="community", kind="rss", url="https://lobste.rs/t/databases.rss", adapter="rss", tags=["lobsters"], limit=20, detail=False),
dict(name="lobsters_linux", category="community", kind="rss", url="https://lobste.rs/t/linux.rss", adapter="rss", tags=["lobsters"], limit=20, detail=False),
dict(name="lobsters_ml", category="community", kind="rss", url="https://lobste.rs/t/ml.rss", adapter="rss", tags=["lobsters"], limit=20, detail=False),
dict(name="lobsters_performance", category="community", kind="rss", url="https://lobste.rs/t/performance.rss", adapter="rss", tags=["lobsters"], limit=20, detail=False),
dict(name="lobsters_web", category="community", kind="rss", url="https://lobste.rs/t/web.rss", adapter="rss", tags=["lobsters"], limit=20, detail=False),
dict(name="lwn_headlines", category="community", kind="rss", url="https://lwn.net/headlines/rss", adapter="rss", tags=["linux"], limit=20, detail=False),
dict(name="martinfowler", category="community", kind="rss", url="https://martinfowler.com/feed.atom", adapter="rss", tags=["arch"], limit=20, detail=False),
dict(name="neuromancer_ai", category="community", kind="rss", url="https://www.interconnects.ai/feed", adapter="rss", tags=["llm", "analysis"], limit=20, detail=False),
dict(name="overreacted", category="community", kind="rss", url="https://overreacted.io/rss.xml", adapter="rss", tags=["frontend"], limit=20, detail=False),
dict(name="phoronix", category="community", kind="rss", url="https://www.phoronix.com/rss.php", adapter="rss", tags=["linux", "hardware"], limit=20, detail=False),
dict(name="reddit_cpp", category="community", kind="reddit", url="https://www.reddit.com/r/cpp/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_docker", category="community", kind="reddit", url="https://www.reddit.com/r/docker/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_flutter", category="community", kind="reddit", url="https://www.reddit.com/r/FlutterDev/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_gamedev", category="community", kind="reddit", url="https://www.reddit.com/r/gamedev/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_gcp", category="community", kind="reddit", url="https://www.reddit.com/r/googlecloud/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_homelab", category="community", kind="reddit", url="https://www.reddit.com/r/homelab/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_llm", category="community", kind="reddit", url="https://www.reddit.com/r/LLM/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_localllm", category="community", kind="reddit", url="https://www.reddit.com/r/LocalLLM/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_php", category="community", kind="reddit", url="https://www.reddit.com/r/PHP/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="reddit_swift", category="community", kind="reddit", url="https://www.reddit.com/r/swift/", adapter="reddit_listing", tags=["reddit"], limit=20, detail=False),
dict(name="scott_helme", category="community", kind="rss", url="https://scotthelme.co.uk/rss/", adapter="rss", tags=["tls"], limit=20, detail=False),
dict(name="sebastian_raschka", category="community", kind="rss", url="https://magazine.sebastianraschka.com/feed", adapter="rss", tags=["llm", "ml"], limit=20, detail=False),
dict(name="simonwillison", category="community", kind="rss", url="https://simonwillison.net/atom/everything/", adapter="rss", tags=["llm", "blog"], limit=20, detail=False),
dict(name="tg_addmetana", category="community", kind="tg", url="https://t.me/s/addmeto", adapter="tg_web", tags=["tg", "ru"], limit=20, detail=False),
dict(name="the_gradient", category="community", kind="rss", url="https://thegradient.pub/rss/", adapter="rss", tags=["ml"], limit=20, detail=False),
dict(name="astro_blog", category="company", kind="rss", url="https://astro.build/rss.xml", adapter="rss", tags=["frontend"], limit=20, detail=False),
dict(name="bun_blog", category="company", kind="rss", url="https://bun.sh/rss.xml", adapter="rss", tags=["runtime"], limit=20, detail=False),
dict(name="dagster_blog", category="company", kind="rss", url="https://dagster.io/blog/rss.xml", adapter="rss", tags=["data"], limit=20, detail=False),
dict(name="datadog_blog", category="company", kind="rss", url="https://www.datadoghq.com/blog/index.xml", adapter="rss", tags=["obs"], limit=20, detail=False),
dict(name="dbt_blog", category="company", kind="rss", url="https://www.getdbt.com/blog/rss.xml", adapter="rss", tags=["data"], limit=20, detail=False),
dict(name="deno_blog", category="company", kind="rss", url="https://deno.com/feed", adapter="rss", tags=["runtime"], limit=20, detail=False),
dict(name="fal_blog", category="company", kind="rss", url="https://blog.fal.ai/rss/", adapter="rss", tags=["inference", "image"], limit=20, detail=False),
dict(name="gh_axolotl", category="company", kind="github", url="axolotl-ai-cloud", adapter="github_org", tags=["finetune"], limit=20, detail=False),
dict(name="gh_crewai", category="company", kind="github", url="crewAIInc", adapter="github_org", tags=["agents"], limit=20, detail=False),
dict(name="gh_litellm", category="company", kind="github", url="BerriAI", adapter="github_org", tags=["gateway"], limit=20, detail=False),
dict(name="gh_llamaindex", category="company", kind="github", url="run-llama", adapter="github_org", tags=["rag"], limit=20, detail=False),
dict(name="gh_mlc", category="company", kind="github", url="mlc-ai", adapter="github_org", tags=["local"], limit=20, detail=False),
dict(name="gh_triton", category="company", kind="github", url="triton-inference-server", adapter="github_org", tags=["inference"], limit=20, detail=False),
dict(name="go_blog", category="company", kind="rss", url="https://go.dev/blog/feed.atom", adapter="rss", tags=["lang"], limit=20, detail=False),
dict(name="grafana_blog", category="company", kind="rss", url="https://grafana.com/blog/index.xml", adapter="rss", tags=["obs"], limit=20, detail=False),
dict(name="nextjs_blog", category="company", kind="rss", url="https://nextjs.org/feed.xml", adapter="rss", tags=["frontend"], limit=20, detail=False),
dict(name="node_blog", category="company", kind="rss", url="https://nodejs.org/en/feed/blog.xml", adapter="rss", tags=["runtime"], limit=20, detail=False),
dict(name="prisma_blog", category="company", kind="rss", url="https://www.prisma.io/blog/rss.xml", adapter="rss", tags=["db"], limit=20, detail=False),
dict(name="python_insider", category="company", kind="rss", url="https://pythoninsider.blogspot.com/feeds/posts/default", adapter="rss", tags=["python"], limit=20, detail=False),
dict(name="react_blog", category="company", kind="rss", url="https://react.dev/rss.xml", adapter="rss", tags=["frontend"], limit=20, detail=False),
dict(name="rust_blog", category="company", kind="rss", url="https://blog.rust-lang.org/feed.xml", adapter="rss", tags=["lang"], limit=20, detail=False),
dict(name="svelte_blog", category="company", kind="rss", url="https://svelte.dev/blog/rss.xml", adapter="rss", tags=["frontend"], limit=20, detail=False),
dict(name="tg_anthropic", category="company", kind="tg", url="https://t.me/s/AnthropicAI", adapter="tg_web", tags=["tg"], limit=20, detail=False),
dict(name="tg_googleai", category="company", kind="tg", url="https://t.me/s/googleai", adapter="tg_web", tags=["tg"], limit=20, detail=False),
dict(name="timescale_blog", category="company", kind="rss", url="https://www.timescale.com/blog/rss/", adapter="rss", tags=["db"], limit=20, detail=False),
dict(name="coupang_tech", category="company_kr", kind="rss", url="https://medium.com/feed/coupang-tech", adapter="rss", tags=["kr"], limit=20, detail=False),
dict(name="bubble_forum", category="forum", kind="json", url="https://forum.bubble.io/latest.json", adapter="discourse", tags=["nocode"], limit=20, detail=False),
dict(name="discourse_meta", category="forum", kind="json", url="https://meta.discourse.org/latest.json", adapter="discourse", tags=["meta"], limit=20, detail=False),
dict(name="discuss_jupyter", category="forum", kind="json", url="https://discourse.jupyter.org/latest.json", adapter="discourse", tags=["data"], limit=20, detail=False),
dict(name="discuss_kotlin", category="forum", kind="json", url="https://discuss.kotlinlang.org/latest.json", adapter="discourse", tags=["kotlin"], limit=20, detail=False),
dict(name="discuss_kubernetes", category="forum", kind="json", url="https://discuss.kubernetes.io/latest.json", adapter="discourse", tags=["infra"], limit=20, detail=False),
dict(name="fastai_forum", category="forum", kind="json", url="https://forums.fast.ai/latest.json", adapter="discourse", tags=["ml"], limit=20, detail=False),
dict(name="macrumors_forum", category="forum", kind="rss", url="https://forums.macrumors.com/forums/-/index.rss", adapter="rss", tags=["apple"], limit=20, detail=False),
dict(name="make_community", category="forum", kind="json", url="https://community.make.com/latest.json", adapter="discourse", tags=["automation"], limit=20, detail=False),
dict(name="pixls_forum", category="forum", kind="json", url="https://discuss.pixls.us/latest.json", adapter="discourse", tags=["photo"], limit=20, detail=False),
dict(name="proxmox_forum", category="forum", kind="rss", url="https://forum.proxmox.com/forums/-/index.rss", adapter="rss", tags=["virt"], limit=20, detail=False),
dict(name="streamlit_forum", category="forum", kind="json", url="https://discuss.streamlit.io/latest.json", adapter="discourse", tags=["data"], limit=20, detail=False),
dict(name="xda_forum", category="forum", kind="rss", url="https://xdaforums.com/forums/-/index.rss", adapter="rss", tags=["mobile"], limit=20, detail=False),
dict(name="hf_datasets_recent", category="models", kind="json", url="https://huggingface.co/api/datasets?sort=lastModified&limit=20", adapter="hf_generic", tags=["hf", "dataset"], limit=20, detail=False),
dict(name="hf_models_audio", category="models", kind="json", url="https://huggingface.co/api/models?pipeline_tag=automatic-speech-recognition&sort=downloads&limit=20", adapter="hf_models", tags=["hf", "audio"], limit=20, detail=False),
dict(name="hf_models_ocr", category="models", kind="json", url="https://huggingface.co/api/models?pipeline_tag=image-text-to-text&sort=downloads&limit=20", adapter="hf_models", tags=["hf", "vlm"], limit=20, detail=False),
dict(name="hf_models_video", category="models", kind="json", url="https://huggingface.co/api/models?pipeline_tag=text-to-video&sort=downloads&limit=20", adapter="hf_models", tags=["hf", "video"], limit=20, detail=False),
dict(name="arxiv_cs_cc", category="papers", kind="rss", url="https://rss.arxiv.org/rss/cs.CC", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_cs_ce", category="papers", kind="rss", url="https://rss.arxiv.org/rss/cs.CE", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_cs_cg", category="papers", kind="rss", url="https://rss.arxiv.org/rss/cs.CG", adapter="rss", tags=["paper", "arxiv", "graphics"], limit=20, detail=False),
dict(name="arxiv_cs_dm", category="papers", kind="rss", url="https://rss.arxiv.org/rss/cs.DM", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_cs_gl", category="papers", kind="rss", url="https://rss.arxiv.org/rss/cs.GL", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_cs_oh", category="papers", kind="rss", url="https://rss.arxiv.org/rss/cs.OH", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_econ_gn", category="papers", kind="rss", url="https://rss.arxiv.org/rss/econ.GN", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_eess_sy", category="papers", kind="rss", url="https://rss.arxiv.org/rss/eess.SY", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_math_st", category="papers", kind="rss", url="https://rss.arxiv.org/rss/math.ST", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_physics_socph", category="papers", kind="rss", url="https://rss.arxiv.org/rss/physics.soc-ph", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_qbio_qm", category="papers", kind="rss", url="https://rss.arxiv.org/rss/q-bio.QM", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_qfin_cp", category="papers", kind="rss", url="https://rss.arxiv.org/rss/q-fin.CP", adapter="rss", tags=["paper", "arxiv", "finance"], limit=20, detail=False),
dict(name="arxiv_stat_co", category="papers", kind="rss", url="https://rss.arxiv.org/rss/stat.CO", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="arxiv_stat_me", category="papers", kind="rss", url="https://rss.arxiv.org/rss/stat.ME", adapter="rss", tags=["paper", "arxiv"], limit=20, detail=False),
dict(name="biorxiv_recent", category="papers", kind="json", url="https://api.biorxiv.org/details/biorxiv/2026-09-01/2026-10-25/0", adapter="biorxiv", tags=["paper", "bio"], limit=20, detail=False),
dict(name="hal_ai", category="papers", kind="json", url="https://api.archives-ouvertes.fr/search/?q=llm&fl=title_s,uri_s,abstract_s,producedDateY_i&rows=25&wt=json", adapter="hal", tags=["paper", "hal"], limit=20, detail=False),
dict(name="medrxiv_recent", category="papers", kind="json", url="https://api.medrxiv.org/details/medrxiv/2026-09-01/2026-10-25/0", adapter="biorxiv", tags=["paper", "med"], limit=20, detail=False),
dict(name="zenodo_ai", category="papers", kind="json", url="https://zenodo.org/api/records?q=large+language+model&sort=newest&size=20", adapter="zenodo", tags=["paper", "zenodo"], limit=20, detail=False),
dict(name="zenodo_security", category="papers", kind="json", url="https://zenodo.org/api/records?q=vulnerability&sort=newest&size=20", adapter="zenodo", tags=["paper", "zenodo"], limit=20, detail=False),
dict(name="0xdf_blog", category="security", kind="rss", url="https://0xdf.gitlab.io/feed.xml", adapter="rss", tags=["bb", "htb"], limit=20, detail=False),
dict(name="ctftime_upcoming", category="security", kind="rss", url="https://ctftime.org/event/list/upcoming/rss/", adapter="rss", tags=["ctf"], limit=20, detail=False),
dict(name="cve_circl", category="security", kind="json", url="https://cve.circl.lu/api/last/30", adapter="cve_circl", tags=["cve"], limit=20, detail=False),
dict(name="dfir_report", category="security", kind="rss", url="https://thedfirreport.com/feed/", adapter="rss", tags=["dfir"], limit=20, detail=False),
dict(name="gitguardian_blog", category="security", kind="rss", url="https://blog.gitguardian.com/rss/", adapter="rss", tags=["secrets"], limit=20, detail=False),
dict(name="hackread", category="security", kind="rss", url="https://hackread.com/feed/", adapter="rss", tags=["news"], limit=20, detail=False),
dict(name="huntress_blog", category="security", kind="rss", url="https://www.huntress.com/blog/rss.xml", adapter="rss", tags=["dfir"], limit=20, detail=False),
dict(name="intigriti_blog", category="security", kind="rss", url="https://blog.intigriti.com/feed/", adapter="rss", tags=["bb"], limit=20, detail=False),
dict(name="jfrog_blog", category="security", kind="rss", url="https://jfrog.com/blog/feed/", adapter="rss", tags=["supply"], limit=20, detail=False),
dict(name="medium_bugbounty", category="security", kind="rss", url="https://medium.com/feed/tag/bug-bounty", adapter="rss", tags=["bb", "writeups"], limit=20, detail=False),
dict(name="medium_pentest", category="security", kind="rss", url="https://medium.com/feed/tag/penetration-testing", adapter="rss", tags=["pentest"], limit=20, detail=False),
dict(name="medium_redteam", category="security", kind="rss", url="https://medium.com/feed/tag/red-team", adapter="rss", tags=["redteam"], limit=20, detail=False),
dict(name="risky_business", category="security", kind="rss", url="https://risky.biz/feeds/risky-business-news/", adapter="rss", tags=["news"], limit=20, detail=False),
dict(name="schneier_blog", category="security", kind="rss", url="https://www.schneier.com/blog/atom.xml", adapter="rss", tags=["crypto", "privacy"], limit=20, detail=False),
dict(name="seclists_oss", category="security", kind="rss", url="https://seclists.org/rss/oss-sec.rss", adapter="rss", tags=["oss", "cve"], limit=20, detail=False),
dict(name="sonatype_blog", category="security", kind="rss", url="https://blog.sonatype.com/rss.xml", adapter="rss", tags=["supply"], limit=20, detail=False),
dict(name="troy_hunt", category="security", kind="rss", url="https://www.troyhunt.com/rss/", adapter="rss", tags=["breach"], limit=20, detail=False),
dict(name="watchtowr_blog", category="security", kind="rss", url="https://labs.watchtowr.com/rss/", adapter="rss", tags=["research"], limit=20, detail=False),
]

View File

@@ -150,6 +150,29 @@ def browse(category=None, q="", limit=60, offset=0):
return [dict(zip(keys, r)) for r in rows] return [dict(zip(keys, r)) for r in rows]
def browse2(q="", names=None, limit=60, offset=0, sort="score"):
"""Записи для панели: фильтр по списку источников (категория), подстрока, пагинация + total."""
where = "WHERE 1=1 "
args = []
if names:
where += "AND source IN (%s) " % ",".join("?" * len(names))
args += list(names)
if q:
where += "AND (title LIKE ? OR text LIKE ?) "
args += [f"%{q}%", f"%{q}%"]
with conn() as c:
total = c.execute("SELECT COUNT(*) FROM items " + where, args).fetchone()[0]
rows = c.execute("SELECT provider,kind,source,title,text,author,published,score,price,currency,url "
"FROM items " + where + ("ORDER BY published DESC, id DESC LIMIT ? OFFSET ?" if sort == "fresh" else "ORDER BY score DESC, id DESC LIMIT ? OFFSET ?"),
args + [limit, offset]).fetchall()
keys = ("provider", "kind", "source", "title", "text", "author", "published", "score", "price", "currency", "url")
return [dict(zip(keys, r)) for r in rows], total
def count_by_source():
with conn() as c:
return dict(c.execute("SELECT source, COUNT(*) FROM items GROUP BY source").fetchall())
def stats(): def stats():
with conn() as c: with conn() as c:
total = c.execute("SELECT COUNT(*) FROM items").fetchone()[0] total = c.execute("SELECT COUNT(*) FROM items").fetchone()[0]

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -6,320 +6,630 @@
<title>omni · радар источников</title> <title>omni · радар источников</title>
<style> <style>
:root{ :root{
--bg:#0b0d10; --bg2:#11151a; --bg3:#161b22; --line:#232a33; --bg:#08090c; --bg2:#0c0e13; --panel:#101319; --panel2:#141821; --line:#1d2230; --line2:#2a3140;
--tx:#e6edf3; --dim:#8b949e; --dim2:#5b6673; --txt:#e9edf5; --dim:#98a3b6; --dim2:#6b7688;
--acc:#4ee39b; --acc2:#7aa2ff; --warn:#f0b429; --bad:#f85149; --acc:#7c6cf4; --acc2:#2dd4bf; --ok:#34d399; --bad:#fb7185; --warn:#fbbf24;
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; --mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Consolas,monospace;
--r:14px;
} }
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--tx); html,body{margin:0;height:100%}
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif} body{
a{color:var(--acc2);text-decoration:none} background:
a:hover{text-decoration:underline} radial-gradient(1100px 520px at 76% -12%, rgba(124,108,244,.16), transparent 62%),
button{font:inherit;cursor:pointer} radial-gradient(760px 420px at 6% 8%, rgba(45,212,191,.09), transparent 60%),
.wrap{max-width:1240px;margin:0 auto;padding:22px 18px 60px} var(--bg);
color:var(--txt);
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;
-webkit-font-smoothing:antialiased;
}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
::selection{background:rgba(124,108,244,.35)}
header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px} .app{display:grid;grid-template-columns:252px minmax(0,1fr);min-height:100vh}
.logo{font:700 20px/1 var(--mono);letter-spacing:.5px}
.logo b{color:var(--acc)}
.sub{color:var(--dim);font-size:12.5px}
.tabs{display:flex;gap:6px;margin:16px 0 18px;border-bottom:1px solid var(--line)}
.tab{padding:9px 14px;border:0;background:none;color:var(--dim);border-bottom:2px solid transparent;
font-size:13.5px;letter-spacing:.2px}
.tab.on{color:var(--tx);border-bottom-color:var(--acc)}
.tab:hover{color:var(--tx)}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(126px,1fr));gap:8px;margin-bottom:18px} /* ---------- sidebar ---------- */
.stat{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:11px 13px} aside{
.stat .v{font:700 19px/1.2 var(--mono)} position:sticky;top:0;height:100vh;display:flex;flex-direction:column;gap:18px;
.stat .k{color:var(--dim);font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;margin-top:3px} padding:20px 16px;border-right:1px solid var(--line);background:linear-gradient(180deg,#0a0c11,#08090c 60%);
.stat.ok .v{color:var(--acc)} .stat.bad .v{color:var(--bad)} .stat.warn .v{color:var(--warn)} }
.brand{display:flex;align-items:center;gap:10px;padding:2px 6px 12px}
.mark{
width:30px;height:30px;border-radius:9px;flex:0 0 auto;
background:conic-gradient(from 200deg,var(--acc),#3b82f6 40%,var(--acc2) 72%,var(--acc));
box-shadow:0 0 22px rgba(124,108,244,.5) inset,0 4px 14px rgba(0,0,0,.5);
}
.brand b{font-size:16px;letter-spacing:.2px}
.brand span{display:block;font:11px/1.3 var(--mono);color:var(--dim2);letter-spacing:.06em;text-transform:uppercase}
.bar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center} .nav{display:flex;flex-direction:column;gap:4px}
input[type=text],select{background:var(--bg3);border:1px solid var(--line);color:var(--tx); .nav button{
border-radius:8px;padding:8px 11px;font:13px var(--mono);outline:none;min-width:0} display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:10px;
input[type=text]:focus,select:focus{border-color:var(--acc2)} color:var(--dim);font-weight:500;transition:.15s;text-align:left;width:100%;
input[type=text]{flex:1 1 220px} }
.btn{background:var(--bg3);border:1px solid var(--line);color:var(--tx);border-radius:8px; .nav button i{width:16px;height:16px;flex:0 0 auto;opacity:.75;display:grid;place-items:center;font-size:12px;line-height:1}
padding:8px 13px;font-size:13px;white-space:nowrap} .nav button:hover{background:#12151d;color:var(--txt)}
.btn:hover{border-color:var(--acc2)} .nav button.on{background:linear-gradient(90deg,rgba(124,108,244,.18),rgba(124,108,244,.05));color:#fff;box-shadow:inset 0 0 0 1px rgba(124,108,244,.3)}
.btn.pri{background:rgba(78,227,155,.12);border-color:rgba(78,227,155,.45);color:var(--acc)} .nav button b{margin-left:auto;font:11px var(--mono);color:var(--dim2)}
.btn.gh{background:none;color:var(--dim)}
.btn:disabled{opacity:.4;cursor:default}
.add{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:13px;margin-bottom:16px} .sect{font:11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--dim2);padding:0 8px}
.add .ttl{font:600 12px/1 var(--mono);color:var(--dim);text-transform:uppercase;letter-spacing:.8px;margin-bottom:10px} .cats{display:flex;flex-direction:column;gap:2px;overflow:auto;margin:0 -4px;padding:0 4px;flex:1}
.add .row{display:flex;gap:8px;flex-wrap:wrap} .cat{
.add .row input[type=text]{flex:1 1 180px} display:grid;grid-template-columns:1fr auto;gap:3px 8px;align-items:center;
padding:8px 9px;border-radius:9px;cursor:pointer;border:1px solid transparent;
}
.cat:hover{background:#0f1218}
.cat.on{background:#12151d;border-color:var(--line2)}
.cat .cn{font-size:13.5px;color:#c3cad8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cat.on .cn{color:#fff}
.cat .cc{font:11.5px var(--mono);color:var(--dim2)}
.bar{grid-column:1/-1;height:3px;border-radius:3px;background:#1a1f2b;overflow:hidden;display:flex}
.bar i{display:block;height:100%}
.bar .a{background:var(--ok)}
.bar .d{background:var(--bad)}
.bar .u{background:#39414f}
.listhead{display:flex;justify-content:space-between;align-items:center;color:var(--dim); .legend{display:flex;gap:10px;flex-wrap:wrap;padding:8px 8px 0;border-top:1px solid var(--line);font:10.5px var(--mono);color:var(--dim2)}
font:11.5px var(--mono);text-transform:uppercase;letter-spacing:.7px;padding:0 4px 8px} .legend span{display:flex;align-items:center;gap:5px}
.list{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--bg2)} .legend i{width:7px;height:7px;border-radius:2px;display:block}
.src{display:grid;grid-template-columns:38px 1fr 132px 104px 62px 34px;gap:10px;align-items:center; .lg-a{background:var(--ok)} .lg-d{background:var(--bad)} .lg-u{background:#39414f}
padding:9px 12px;border-bottom:1px solid var(--line)} .aside-foot{border-top:1px solid var(--line);padding-top:12px;font:11.5px/1.6 var(--mono);color:var(--dim2)}
.src:last-child{border-bottom:0} .aside-foot b{color:var(--dim);font-weight:500}
.src:hover{background:rgba(255,255,255,.022)}
.src.off{opacity:.45}
.nm{font-size:13.5px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.u{color:var(--dim2);font:11.5px var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cat{font:11px var(--mono);color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
.cat.k{color:var(--acc2)}
.pill{font:11.5px var(--mono);padding:2px 8px;border-radius:20px;border:1px solid var(--line);
color:var(--dim);white-space:nowrap}
.pill.ok{color:var(--acc);border-color:rgba(78,227,155,.35);background:rgba(78,227,155,.08)}
.pill.bad{color:var(--bad);border-color:rgba(248,81,73,.35);background:rgba(248,81,73,.07)}
.pill.wait{color:var(--warn);border-color:rgba(240,180,41,.3)}
.cnt{font:12px var(--mono);color:var(--dim);text-align:right}
.del{background:none;border:0;color:var(--dim2);font-size:15px;line-height:1;padding:4px}
.del:hover{color:var(--bad)}
.sw{position:relative;width:34px;height:19px;display:inline-block}
.sw input{opacity:0;width:0;height:0;position:absolute}
.sw span{position:absolute;inset:0;background:var(--bg3);border:1px solid var(--line);
border-radius:20px;transition:.15s}
.sw span:before{content:"";position:absolute;width:13px;height:13px;left:2px;top:2px;border-radius:50%;
background:var(--dim);transition:.15s}
.sw input:checked + span{background:rgba(78,227,155,.18);border-color:rgba(78,227,155,.5)}
.sw input:checked + span:before{transform:translateX(15px);background:var(--acc)}
.card{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:13px 15px;margin-bottom:10px} /* ---------- main ---------- */
.card h3{margin:0 0 6px;font:600 14.5px/1.35 inherit} main{min-width:0;display:flex;flex-direction:column}
.card .m{color:var(--dim);font:11.5px var(--mono);display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px} .top{
.card p{margin:0;color:#b9c2cc;font-size:13px;line-height:1.45} position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:12px;
.card .body{margin-top:7px;color:#8b949e;font-size:12.5px;max-height:4.4em;overflow:hidden} padding:14px 26px;border-bottom:1px solid var(--line);
.empty{color:var(--dim);text-align:center;padding:36px 10px;font:12.5px var(--mono)} background:rgba(8,9,12,.72);backdrop-filter:blur(14px);
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%) translateY(20px); }
background:var(--bg3);border:1px solid var(--line);padding:9px 15px;border-radius:9px; .top h1{font-size:15.5px;margin:0;font-weight:600;letter-spacing:.2px}
font:12.5px var(--mono);opacity:0;pointer-events:none;transition:.2s;z-index:9} .top h1 small{display:block;font:11px var(--mono);color:var(--dim2);letter-spacing:.08em;text-transform:uppercase;font-weight:400;margin-top:2px}
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)} .spacer{flex:1}
.prog{font:12px var(--mono);color:var(--warn);margin-bottom:10px} .search{
.hide{display:none!important} display:flex;align-items:center;gap:8px;background:#0e1117;border:1px solid var(--line);
@media(max-width:760px){ border-radius:10px;padding:7px 11px;min-width:230px;transition:.15s;
.src{grid-template-columns:34px 1fr 34px;grid-template-areas:"sw nm del" ". st ." ". cat .";gap:6px 10px} }
.src .sw{grid-area:sw}.src .meta{grid-area:nm}.src .del{grid-area:del} .search:focus-within{border-color:rgba(124,108,244,.6);box-shadow:0 0 0 3px rgba(124,108,244,.12)}
.src .catcell{grid-area:cat}.src .stcell{grid-area:st}.src .cnt{display:none} .search input{background:none;border:0;outline:0;color:var(--txt);width:100%;font-size:13.5px}
.search kbd{font:10.5px var(--mono);color:var(--dim2);border:1px solid var(--line2);border-radius:5px;padding:1px 5px}
.btn{
display:inline-flex;align-items:center;gap:7px;padding:8px 13px;border-radius:10px;
border:1px solid var(--line2);background:#11141b;font-size:13.5px;font-weight:500;transition:.15s;white-space:nowrap;
}
.btn:hover{background:#161a23;border-color:#39414f}
.btn.primary{background:linear-gradient(180deg,#8b7cf6,#6d5ce0);border-color:#8b7cf6;color:#fff;box-shadow:0 6px 18px rgba(109,92,224,.3)}
.btn.primary:hover{filter:brightness(1.07)}
.btn.ghost{background:none}
.btn.sm{padding:5px 9px;font-size:12.5px;border-radius:8px}
.btn:disabled{opacity:.45;cursor:not-allowed}
.content{padding:28px 30px 74px;max-width:1280px;width:100%}
/* KPI */
.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:20px}
.kpi{
position:relative;overflow:hidden;padding:15px 16px;border-radius:var(--r);border:1px solid var(--line);
background:linear-gradient(180deg,var(--panel2),var(--panel));
}
.kpi:after{content:"";position:absolute;inset:auto -30% -60% auto;width:150px;height:150px;border-radius:50%;background:radial-gradient(circle,rgba(124,108,244,.22),transparent 70%)}
.kpi .k{font:11px var(--mono);letter-spacing:.09em;text-transform:uppercase;color:var(--dim2)}
.kpi .v{font:600 27px/1.25 var(--mono);letter-spacing:-.5px;margin-top:6px}
.kpi .v small{font-size:13px;color:var(--dim);font-weight:400;letter-spacing:0}
.kpi .s{font-size:12px;color:var(--dim);margin-top:2px}
/* feed cards */
.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px}
.chip{
padding:6px 11px;border-radius:999px;border:1px solid var(--line);background:#0e1117;
font-size:12.5px;color:var(--dim);transition:.15s;
}
.chip:hover{color:var(--txt);border-color:var(--line2)}
.chip.on{background:rgba(124,108,244,.16);border-color:rgba(124,108,244,.55);color:#fff}
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(342px,1fr));gap:14px}
.card{
display:flex;flex-direction:column;gap:9px;padding:15px 16px;border-radius:var(--r);
border:1px solid var(--line);background:linear-gradient(180deg,#12151c,#0e1116);
transition:.16s;position:relative;
}
.card:hover{transform:translateY(-1px);border-color:#39414f;box-shadow:0 10px 26px rgba(0,0,0,.35)}
.card .head{display:flex;align-items:baseline;gap:8px;font:11.5px var(--mono);color:var(--dim2);min-width:0}
.card .head .src{color:var(--acc2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:52%}
.card .head .score{margin-left:auto;color:var(--warn);font-weight:600;min-width:28px;text-align:right;font-variant-numeric:tabular-nums}
.card h3{margin:0;font-size:14.5px;line-height:1.4;font-weight:600;letter-spacing:.1px;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.card p{margin:0;font-size:13px;line-height:1.5;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.card .meta{display:flex;gap:10px;font:11px var(--mono);color:var(--dim2);margin-top:auto;padding-top:4px}
.tag{font:10.5px var(--mono);padding:1px 6px;border-radius:5px;background:#181d27;color:#9aa5b8}
.price{color:var(--ok);border:1px solid rgba(52,211,153,.3);background:rgba(52,211,153,.08);border-radius:6px;padding:0 6px;font:11px var(--mono)}
/* sources table */
.strip{display:flex;gap:22px;flex-wrap:wrap;padding:13px 16px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel);margin-bottom:14px}
.strip div{font:12px var(--mono);color:var(--dim2)}
.strip b{display:block;font-size:17px;color:var(--txt);font-weight:600;margin-top:2px;font-family:var(--mono)}
.cbar{display:block;height:4px;border-radius:4px;background:#1a1f2b;margin-top:6px;overflow:hidden}
.cbar i{display:block;height:100%;background:linear-gradient(90deg,var(--acc),var(--acc2))}
.strip .g b{color:var(--ok)} .strip .r b{color:var(--bad)} .strip .y b{color:var(--warn)}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
select,input[type=text],input[type=url]{
background:#0e1117;border:1px solid var(--line);border-radius:9px;padding:7px 10px;color:var(--txt);font-size:13px;outline:0;
}
select:focus,input:focus{border-color:rgba(124,108,244,.6)}
.table{border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--panel)}
.thead,.row{display:grid;grid-template-columns:42px minmax(200px,1fr) 104px 96px 116px 68px 84px;gap:10px;align-items:center;padding:9px 12px}
.thead{
position:sticky;top:56px;z-index:5;background:#0d1016;border-bottom:1px solid var(--line);
font:10.5px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--dim2);
}
.row{border-bottom:1px solid #151a23;font-size:13px}
.row:last-child{border-bottom:0}
.row:hover{background:#10141b}
.row .nm{min-width:0}
.row .nm b{display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .nm span{display:block;font:10.5px var(--mono);color:var(--dim2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.catpill{font:10.5px var(--mono);color:#a9b3c6;background:#151a24;border:1px solid #202634;border-radius:6px;padding:2px 6px;justify-self:start;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.st{display:inline-flex;align-items:center;gap:5px;font:11.5px var(--mono)}
.st i{width:6px;height:6px;border-radius:50%;background:#4b5563}
.st.ok{color:var(--ok)} .st.ok i{background:var(--ok);box-shadow:0 0 8px rgba(52,211,153,.8)}
.st.bad{color:var(--bad)} .st.bad i{background:var(--bad)}
.st.no{color:var(--dim2)}
.note{display:block;font:10px var(--mono);color:#5a6373;max-width:112px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.num{font:12px var(--mono);color:var(--dim);text-align:right}
.acts{display:flex;gap:6px;justify-content:flex-end}
.icon{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;border:1px solid var(--line);color:var(--dim);font-size:12px}
.icon:hover{color:#fff;border-color:var(--line2);background:#151a23}
/* switch */
.sw{position:relative;width:32px;height:18px;border-radius:999px;background:#242b38;cursor:pointer;transition:.16s;justify-self:center}
.sw:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#7d8798;transition:.16s}
.sw.on{background:rgba(124,108,244,.55)}
.sw.on:after{left:16px;background:#fff}
/* progress */
.prog{
position:fixed;left:0;right:0;bottom:0;z-index:40;display:none;align-items:center;gap:12px;
padding:10px 22px;border-top:1px solid var(--line2);background:rgba(10,12,17,.95);backdrop-filter:blur(10px);
font:12.5px var(--mono);color:var(--dim);
}
.prog.on{display:flex}
.prog .track{flex:1;height:4px;border-radius:4px;background:#1a1f2b;overflow:hidden}
.prog .track i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--acc),var(--acc2));transition:width .3s}
/* modal */
.modal{position:fixed;inset:0;z-index:60;display:none;place-items:center;background:rgba(4,5,8,.66);backdrop-filter:blur(3px)}
.modal.on{display:grid}
.sheet{width:min(520px,92vw);border:1px solid var(--line2);border-radius:16px;background:linear-gradient(180deg,#14181f,#0e1116);padding:20px;box-shadow:0 30px 70px rgba(0,0,0,.6)}
.sheet h2{margin:0 0 4px;font-size:16px}
.sheet p.hint{margin:0 0 14px;font-size:12.5px;color:var(--dim)}
.field{display:flex;flex-direction:column;gap:5px;margin-bottom:10px}
.field label{font:11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--dim2)}
.field input,.field select{width:100%}
.sheet .foot{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
.toast{position:fixed;right:20px;bottom:20px;z-index:80;display:flex;flex-direction:column;gap:8px}
.toast div{
padding:10px 14px;border-radius:10px;border:1px solid var(--line2);background:#14181f;font-size:13px;
box-shadow:0 12px 30px rgba(0,0,0,.5);animation:in .18s ease-out;
}
.toast div.ok{border-color:rgba(52,211,153,.45)}
.toast div.err{border-color:rgba(251,113,133,.45)}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
.empty{padding:40px;text-align:center;color:var(--dim2);font-size:13.5px}
.skel{height:96px;border-radius:var(--r);background:linear-gradient(90deg,#11151c,#151a23,#11151c);background-size:200% 100%;animation:sh 1.2s infinite}
@keyframes sh{to{background-position:-200% 0}}
@media (max-width:1000px){
.app{grid-template-columns:1fr}
aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line)}
.cats{flex-direction:row;overflow-x:auto;flex:1 0 100%}
.cat{min-width:150px}
.kpis{grid-template-columns:repeat(2,1fr)}
.thead,.row{grid-template-columns:38px minmax(140px,1fr) 84px 80px 64px}
.thead span:nth-child(4),.row .catpill{display:none}
} }
</style> </style>
</head> </head>
<body> <body>
<div class="wrap"> <div class="app">
<header> <aside>
<div class="logo">omni<b>·</b>radar</div> <div class="brand">
<div class="sub" id="sub">загрузка…</div> <div class="mark"></div>
</header> <div><b>omni</b><span>source radar</span></div>
<div class="tabs">
<button class="tab on" data-view="feed">лента</button>
<button class="tab" data-view="src">источники</button>
</div> </div>
<!-- ============ FEED ============ --> <div class="nav">
<button id="nav-feed" class="on"><i>▤</i>Лента<b id="nav-feed-n">—</b></button>
<button id="nav-src"><i>◈</i>Источники<b id="nav-src-n">—</b></button>
</div>
<div class="sect">категории</div>
<div class="cats" id="cats"></div>
<div class="legend"><span><i class="lg-a"></i>живые</span><span><i class="lg-d"></i>мёртвые</span><span><i class="lg-u"></i>не проверены</span></div>
<div class="aside-foot" id="aside-foot">загрузка…</div>
</aside>
<main>
<div class="top">
<h1 id="ttl">Лента<small>свежие записи со всех источников</small></h1>
<div class="spacer"></div>
<div class="search">
<span style="color:#5d6779">⌕</span>
<input id="q" placeholder="поиск по базе">
<kbd>/</kbd>
</div>
<button class="btn primary" id="btn-feed">Собрать ленту</button>
</div>
<div class="content">
<!-- ЛЕНТА -->
<section id="view-feed"> <section id="view-feed">
<div class="stats" id="stats"></div> <div class="kpis" id="kpis"></div>
<div class="bar"> <div class="toolbar" id="feed-tools"></div>
<input type="text" id="q" placeholder="поиск по заголовку, описанию, url…"> <div class="cards" id="cards"></div>
<select id="cat"><option value="">все категории</option></select> <div class="empty" id="cards-more" style="display:none"><button class="btn" id="btn-more">Показать ещё</button></div>
<button class="btn pri" id="feed">собрать ленту</button>
</div>
<div class="prog hide" id="fprog"></div>
<div id="items"></div>
</section> </section>
<!-- ============ SOURCES ============ --> <!-- ИСТОЧНИКИ -->
<section id="view-src" class="hide"> <section id="view-src" style="display:none">
<div class="stats" id="sstats"></div> <div class="strip" id="src-strip"></div>
<div class="bar"> <div class="filters">
<input type="text" id="sq" placeholder="фильтр по имени или url…"> <select id="f-cat"><option value="all">все категории</option></select>
<select id="scat"><option value="all">все категории</option></select> <select id="f-state">
<button class="btn" id="checkAll">проверить всё</button> <option value="">любое состояние</option>
<button class="btn" id="checkCat">проверить категорию</button> <option value="alive">живые</option>
<button class="btn gh" id="onAll">вкл все</button> <option value="dead">мёртвые</option>
<button class="btn gh" id="offAll">выкл все</button> <option value="untested">не проверены</option>
<option value="off">выключены</option>
</select>
<input type="text" id="f-q" placeholder="фильтр по имени или url" style="min-width:220px">
<div class="spacer"></div>
<button class="btn sm" id="btn-check-cat">Проверить категорию</button>
<button class="btn sm" id="btn-check-all">Проверить все</button>
<button class="btn sm" id="btn-on">Включить все</button>
<button class="btn sm" id="btn-off">Выключить все</button>
<button class="btn sm primary" id="btn-add">+ Источник</button>
</div> </div>
<div class="prog hide" id="sprog"></div> <div class="table">
<div class="thead">
<div class="add"> <span>вкл</span><span>источник</span><span>категория</span><span>адаптер</span>
<div class="ttl">подключить источник</div> <span>состояние</span><span class="num">записей</span><span></span>
<div class="row">
<input type="text" id="aname" placeholder="имя (латиницей, без пробелов)" style="flex:0 1 190px">
<input type="text" id="aurl" placeholder="https://example.com/feed.xml | …/latest.json | github.com/org">
<select id="acat"></select>
<button class="btn pri" id="add">подключить</button>
</div> </div>
<div id="rows"></div>
</div> </div>
<div class="listhead"><span id="scount">—</span><span>статус · записей</span></div>
<div class="list" id="slist"></div>
</section> </section>
</div> </div>
<div id="toast"></div> </main>
</div>
<div class="prog" id="prog">
<span id="prog-txt">работаю…</span>
<div class="track"><i id="prog-bar"></i></div>
<span id="prog-n"></span>
</div>
<div class="modal" id="modal">
<div class="sheet">
<h2>Добавить источник</h2>
<p class="hint">любой публичный rss / json / html. ключи не нужны.</p>
<div class="field"><label>имя</label><input id="a-name" placeholder="мой_блог"></div>
<div class="field"><label>url</label><input id="a-url" placeholder="https://example.com/feed.xml"></div>
<div class="field"><label>категория</label>
<select id="a-cat">
<option>community</option><option>papers</option><option>company</option><option>security</option>
<option>forum</option><option>models</option><option>benchmark</option><option>company_cn</option><option>company_kr</option>
</select>
</div>
<div class="field"><label>адаптер</label>
<select id="a-adapter">
<option value="">определить самому</option>
<option value="rss">rss / atom</option>
<option value="json">json (auto)</option>
<option value="discourse">discourse</option>
<option value="reddit_listing">reddit</option>
<option value="html_listing">html listing</option>
<option value="telegram_web">telegram web</option>
</select>
</div>
<div class="foot">
<button class="btn ghost" id="a-cancel">Отмена</button>
<button class="btn primary" id="a-save">Добавить</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script> <script>
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
const el = (t, c, h) => { const d = document.createElement(t); if (c) d.className = c; if (h != null) d.innerHTML = h; return d; }; const el = (t, c, h) => { const n = document.createElement(t); if (c) n.className = c; if (h != null) n.innerHTML = h; return n; };
const esc = s => String(s == null ? "" : s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); const esc = s => (s == null ? '' : String(s)).replace(/[&<>"]/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));
let CATS = {}, STATE = {view: "feed", scat: "all", sq: "", cat: ""}; const fmt = n => (n == null ? '—' : Number(n).toLocaleString('ru-RU'));
const api = async (p, opt) => {
function toast(m){ const t = $("#toast"); t.textContent = m; t.classList.add("on"); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove("on"), 2200); } const r = await fetch(p, opt);
async function api(u, b){ const t = await r.text();
const r = await fetch(u, b ? {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(b)} : undefined); try { return JSON.parse(t); } catch (e) { return {error: t.slice(0, 120)}; }
return r.json();
}
/* ---------- вкладки ---------- */
document.querySelectorAll(".tab").forEach(t => t.onclick = () => {
document.querySelectorAll(".tab").forEach(x => x.classList.toggle("on", x === t));
STATE.view = t.dataset.view;
$("#view-feed").classList.toggle("hide", STATE.view !== "feed");
$("#view-src").classList.toggle("hide", STATE.view !== "src");
if (STATE.view === "src") loadSources();
});
/* ---------- лента ---------- */
async function loadStats(){
const s = await api("/api/stats");
CATS = s.categories || {};
$("#sub").textContent = `${s.sources} источников · ${s.sources_enabled} включено · ${s.items} записей · ${s.priced} с ценой`;
const box = $("#stats"); box.innerHTML = "";
[["записей", s.items, ""], ["с ценой", s.priced, ""], ["прогонов", s.runs, ""],
["источников", s.sources, "ok"], ["включено", s.sources_enabled, "ok"]]
.forEach(([k, v, c]) => { const d = el("div", "stat " + c, `<div class="v">${v ?? 0}</div><div class="k">${k}</div>`); box.appendChild(d); });
const sel = $("#cat"), cur = sel.value;
sel.innerHTML = '<option value="">все категории</option>' +
Object.entries(CATS).map(([k, v]) => `<option value="${k}">${k} · ${v}</option>`).join("");
sel.value = cur;
const scat = $("#scat"), curs = scat.value || "all";
scat.innerHTML = '<option value="all">все категории</option>' +
Object.entries(CATS).map(([k, v]) => `<option value="${k}">${k} · ${v}</option>`).join("");
scat.value = curs;
const acat = $("#acat");
acat.innerHTML = Object.keys(CATS).map(k => `<option value="${k}">${k}</option>`).join("") + '<option value="custom">custom</option>';
}
async function loadItems(){
const q = $("#q").value.trim(), cat = $("#cat").value;
const d = await api(`/api/items?q=${encodeURIComponent(q)}&category=${encodeURIComponent(cat)}&limit=80`);
const box = $("#items"); box.innerHTML = "";
if (!d.items.length) { box.appendChild(el("div", "empty", "пусто — нажми «собрать ленту»")); return; }
d.items.forEach(it => {
const c = el("div", "card");
const price = it.price ? ` · $${it.price}${it.currency ? " " + it.currency : ""}` : "";
c.innerHTML = `<h3><a href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.title)}</a></h3>
<div class="m"><span>${esc(it.provider || "—")}</span><span>${esc(it.kind || "")}</span>
<span>${esc(it.author || "")}</span><span>★ ${it.score ?? 0}</span>${price ? `<span>${esc(price)}</span>` : ""}</div>
${it.body ? `<div class="body">${esc(it.body)}</div>` : ""}`;
box.appendChild(c);
});
}
$("#q").oninput = (() => { let h; return () => { clearTimeout(h); h = setTimeout(loadItems, 260); }; })();
$("#cat").onchange = loadItems;
$("#feed").onclick = async () => {
const b = $("#feed"); b.disabled = true;
const cat = $("#cat").value;
const {job} = await api("/api/feed", {category: cat || null, limit: 10});
$("#fprog").classList.remove("hide");
const tick = setInterval(async () => {
const j = await api("/api/job/" + job);
if (j.state === "running") { $("#fprog").textContent = "собираю…"; return; }
clearInterval(tick); b.disabled = false;
$("#fprog").textContent = j.state === "done"
? `источников ${j.sources} · собрано ${j.fetched} · новых ${j.new} · дублей ${j.dup} · пустых ${j.empty}`
: "ошибка: " + (j.error || "?");
setTimeout(() => $("#fprog").classList.add("hide"), 6000);
await loadStats(); await loadItems();
}, 1200);
}; };
const post = (p, b) => api(p, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(b || {})});
/* ---------- источники ---------- */ const S = {view: 'feed', cat: 'all', q: '', state: '', srcQ: '', offset: 0, limit: 36, sort: 'score', stats: null, sources: [], job: null};
function toast(msg, cls) {
const d = el('div', cls || '', esc(msg));
$('#toast').appendChild(d);
setTimeout(() => d.remove(), 3200);
}
/* ---------- sidebar ---------- */
function renderCats(list) {
const box = $('#cats');
box.innerHTML = '';
const all = el('div', 'cat' + (S.cat === 'all' ? ' on' : ''));
all.innerHTML = `<span class="cn">все источники</span><span class="cc">${fmt(S.stats ? S.stats.sources : 0)}</span>`;
all.onclick = () => setCat('all');
box.appendChild(all);
(list || []).forEach(c => {
const d = el('div', 'cat' + (S.cat === c.name ? ' on' : ''));
const t = c.total || 1;
d.innerHTML = `<span class="cn">${esc(c.name)}</span><span class="cc">${fmt(c.total)}</span>
<span class="bar"><i class="a" style="width:${c.alive / t * 100}%"></i><i class="d" style="width:${c.dead / t * 100}%"></i><i class="u" style="width:${c.untested / t * 100}%"></i></span>`;
d.onclick = () => setCat(c.name);
box.appendChild(d);
});
}
function syncChips() {
document.querySelectorAll('#feed-tools .chip').forEach(b => {
const k = b.dataset.k || 'cat';
if (k === 'cat') b.classList.toggle('on', b.dataset.cat === S.cat);
if (k === 'sort') b.classList.toggle('on', b.dataset.sort === S.sort);
});
}
function setCat(name) {
S.cat = name; S.offset = 0;
renderCats(S.stats ? S.stats.categories : []);
syncChips();
if (S.view === 'feed') loadItems(true); else loadSources();
$('#f-cat').value = name;
}
/* ---------- stats / KPI ---------- */
async function loadStats() {
const s = await api('/api/stats');
if (s.error) return toast(s.error, 'err');
S.stats = s;
$('#nav-feed-n').textContent = fmt(s.items);
$('#nav-src-n').textContent = fmt(s.sources);
$('#aside-foot').innerHTML =
`<b>${fmt(s.sources)}</b> источников · <b>${fmt(s.sources_enabled)}</b> вкл<br>` +
`<b>${fmt(s.alive)}</b> живых · <b>${fmt(s.tested)}</b> проверено<br>` +
`<b>${fmt(s.runs)}</b> прогонов · <b>${fmt(s.priced)}</b> с ценой`;
renderCats(s.categories);
const ks = [
['записей', s.items, 'в sqlite + fts5', ''],
['источников', s.sources, `${s.sources_enabled} включено`, ''],
['живых', s.alive, `из ${s.tested} проверенных`, 'g'],
['с ценой', s.priced, 'извлечено из текста', 'y'],
];
$('#kpis').innerHTML = ks.map(([k, v, sub, cls]) =>
`<div class="kpi"><div class="k">${k}</div><div class="v">${fmt(v)}</div><div class="s">${sub}</div></div>`).join('');
const sel = $('#f-cat');
sel.innerHTML = '<option value="all">все категории</option>' +
s.categories.map(c => `<option value="${esc(c.name)}">${esc(c.name)} · ${c.total}</option>`).join('');
sel.value = S.cat;
}
/* ---------- feed ---------- */
function card(it) {
const c = el('article', 'card');
const text = (it.text || '').replace(/\s+/g, ' ').trim();
const price = it.price != null ? `<span class="price">${esc(it.price)} ${esc(it.currency || '')}</span>` : '';
const pub = it.published ? String(it.published).slice(0, 16).replace('T', ' ') : '';
c.innerHTML =
`<div class="head"><span class="src">${esc(it.source)}</span><span>${esc(it.kind || '')}</span>
<span class="score">${it.score != null ? (+it.score).toFixed(1) : ''}</span></div>
<h3>${esc(it.title || '(без заголовка)')}</h3>
${text ? `<p>${esc(text)}</p>` : ''}
<div class="meta">${it.author ? `<span>${esc(String(it.author).slice(0, 22))}</span>` : ''}
${pub ? `<span>${esc(pub)}</span>` : ''}${price}</div>`;
if (it.url) c.onclick = () => window.open(it.url, '_blank', 'noopener');
c.style.cursor = it.url ? 'pointer' : 'default';
return c;
}
async function loadItems(reset) {
if (reset) { S.offset = 0; $('#cards').innerHTML = '<div class="skel"></div><div class="skel"></div><div class="skel"></div>'; }
const u = `/api/items?category=${encodeURIComponent(S.cat)}&q=${encodeURIComponent(S.q)}&limit=${S.limit}&offset=${S.offset}&sort=${S.sort}`;
const r = await api(u);
if (r.error) return toast(r.error, 'err');
if (reset) $('#cards').innerHTML = '';
if (!r.items.length && reset) {
$('#cards').innerHTML = '<div class="empty">пусто. собери ленту или добавь свои источники.</div>';
}
r.items.forEach(it => $('#cards').appendChild(card(it)));
S.offset += r.items.length;
$('#cards-more').style.display = S.offset < r.total ? 'block' : 'none';
$('#btn-more').textContent = `Показать ещё (${fmt(Math.max(0, r.total - S.offset))})`;
const t = $('#ttl');
t.innerHTML = `Лента<small>${fmt(r.total)} записей · ${S.cat === 'all' ? 'все источники' : esc(S.cat)}</small>`;
}
/* ---------- sources ---------- */
async function loadSources() { async function loadSources() {
const cat = STATE.scat, q = STATE.sq; const u = `/api/sources?category=${encodeURIComponent(S.cat)}&q=${encodeURIComponent(S.srcQ)}&state=${encodeURIComponent(S.state)}`;
const d = await api(`/api/sources?category=${encodeURIComponent(cat === "all" ? "" : cat)}&q=${encodeURIComponent(q)}`); const r = await api(u);
const sb = $("#sstats"); sb.innerHTML = ""; if (r.error) return toast(r.error, 'err');
[["всего", d.total, ""], ["в списке", d.shown, ""], ["включено", d.enabled, "ok"], S.sources = r.items;
["живых", d.alive, "ok"], ["мёртвых", d.dead, "bad"], ["не проверено", d.untested, "warn"]] $('#src-strip').innerHTML =
.forEach(([k, v, c]) => sb.appendChild(el("div", "stat " + c, `<div class="v">${v ?? 0}</div><div class="k">${k}</div>`))); `<div>всего<b>${fmt(r.total)}</b></div><div class="g">живых<b>${fmt(r.alive)}</b></div>` +
$("#scount").textContent = `${d.shown} из ${d.total}`; `<div class="r">мёртвых<b>${fmt(r.dead)}</b></div><div class="y">не проверено<b>${fmt(r.untested)}</b></div>` +
`<div>включено<b>${fmt(r.enabled)}</b></div><div>в выборке<b>${fmt(r.shown)}</b></div>` +
const box = $("#slist"); box.innerHTML = ""; `<div style="flex:1;min-width:180px">покрытие проверкой
if (!d.items.length) { box.appendChild(el("div", "empty", "ничего не найдено")); return; } <b>${Math.round((r.total - r.untested) / Math.max(1, r.total) * 100)}%</b>
d.items.forEach(s => { <span class="cbar"><i style="width:${(r.total - r.untested) / Math.max(1, r.total) * 100}%"></i></span></div>`;
const row = el("div", "src" + (s.enabled ? "" : " off")); const rows = $('#rows');
const st = s.items > 0 rows.innerHTML = '';
? `<span class="pill ok">${s.items} зап.</span>` if (!r.items.length) { rows.innerHTML = '<div class="empty">ничего не найдено под эти фильтры</div>'; return; }
: (s.checked ? `<span class="pill bad">${esc(s.note || "мёртв")}</span>` : `<span class="pill">не проверен</span>`); const frag = document.createDocumentFragment();
row.innerHTML = ` r.items.forEach(s => {
<label class="sw"><input type="checkbox" ${s.enabled ? "checked" : ""}><span></span></label> const row = el('div', 'row');
<div class="meta"><div class="nm">${esc(s.name)}${s.custom ? ' <span class="pill wait">свой</span>' : ""}</div> const alive = (s.items || 0) > 0;
<div class="u" title="${esc(s.url)}">${esc(s.url)}</div></div> const stCls = alive ? 'ok' : (s.checked ? 'bad' : 'no');
<div class="catcell"><span class="cat">${esc(s.category)}</span> <span class="cat k">${esc(s.kind)}</span></div> const stTxt = alive ? 'живой' : (s.checked ? 'мёртвый' : 'не проверен');
<div class="stcell">${st}</div> row.innerHTML =
<div class="cnt">${s.items || ""}</div> `<div class="sw ${s.enabled ? 'on' : ''}" title="вкл/выкл"></div>
<button class="del" title="удалить">${s.custom ? "✕" : ""}</button>`; <div class="nm"><b>${esc(s.name)}</b><span>${esc(s.url)}</span></div>
row.querySelector("input").onchange = async e => { <div class="catpill">${esc(s.category)}</div>
const on = e.target.checked; <div><span class="catpill">${esc(s.adapter)}</span></div>
row.classList.toggle("off", !on); <div><span class="st ${stCls}"><i></i>${stTxt}</span>${!alive && s.note ? `<span class="note">${esc(String(s.note).slice(0, 22))}</span>` : ''}</div>
await api("/api/sources/toggle", {name: s.name, enabled: on}); <div class="num">${fmt(s.stored)}</div>
toast(`${s.name}: ${on ? "включён" : "выключен"}`); <div class="acts">
<button class="icon act-check" title="проверить">↻</button>
${s.url ? `<a class="icon" href="${esc(s.url)}" target="_blank" rel="noopener" title="открыть">↗</a>` : ''}
${s.custom ? '<button class="icon act-del" title="удалить">✕</button>' : ''}
</div>`;
row.querySelector('.sw').onclick = async (e) => {
const on = !e.currentTarget.classList.contains('on');
e.currentTarget.classList.toggle('on', on);
const res = await post('/api/sources/toggle', {name: s.name, enabled: on});
if (res.error) { e.currentTarget.classList.toggle('on', !on); return toast(res.error, 'err'); }
toast(`${s.name}: ${on ? 'включён' : 'выключен'}`, 'ok');
loadStats(); loadStats();
}; };
const del = row.querySelector(".del"); row.querySelector('.act-check').onclick = () => check(null, s.name);
if (s.custom) del.onclick = async () => { const del = row.querySelector('.act-del');
if (!confirm(`удалить источник ${s.name}?`)) return; if (del) del.onclick = async () => {
await api("/api/sources/remove", {name: s.name}); const res = await post('/api/sources/remove', {name: s.name});
toast("удалён"); loadSources(); loadStats(); if (res.error) return toast(res.error, 'err');
toast(`${s.name} удалён`, 'ok'); loadSources(); loadStats();
}; };
box.appendChild(row); frag.appendChild(row);
}); });
rows.appendChild(frag);
$('#ttl').innerHTML = `Источники<small>${fmt(r.total)} в реестре · ${fmt(r.shown)} в выборке</small>`;
} }
$("#sq").oninput = (() => { let h; return () => { clearTimeout(h); h = setTimeout(() => { STATE.sq = $("#sq").value.trim(); loadSources(); }, 240); }; })(); /* ---------- jobs ---------- */
$("#scat").onchange = () => { STATE.scat = $("#scat").value; loadSources(); }; async function poll(job, label) {
S.job = job;
const box = $('#prog');
box.classList.add('on');
for (;;) {
const j = await api('/api/job/' + job);
if (j.state === 'unknown') { box.classList.remove('on'); return null; }
const total = j.total || 0, done = j.done || 0;
const pct = total ? Math.min(100, done / total * 100) : (j.state === 'done' ? 100 : 8);
$('#prog-bar').style.width = pct + '%';
$('#prog-txt').textContent = j.state === 'done' ? label + ': готово' : label + (j.current ? ` · ${j.current}` : '…');
$('#prog-n').textContent = total ? `${fmt(done)} / ${fmt(total)}` : '';
if (j.state === 'done' || j.state === 'error') {
box.classList.remove('on');
if (j.state === 'error') toast(j.error || 'ошибка джобы', 'err');
return j;
}
await new Promise(r => setTimeout(r, 700));
}
}
async function runCheck(body, label){ async function runFeed() {
const {job} = await api("/api/sources/check", body); $('#btn-feed').disabled = true;
const p = $("#sprog"); p.classList.remove("hide"); const {job} = await post('/api/feed', {category: S.cat, limit: 12});
const tick = setInterval(async () => { const j = await poll(job, 'сбор ленты');
const j = await api("/api/job/" + job); $('#btn-feed').disabled = false;
if (j.state === "running") { if (j && j.state === 'done') {
p.textContent = `${label}: ${j.done || 0}/${j.total || "?"} · живых ${j.alive || 0} · мёртвых ${j.dead || 0} · ${j.current || ""}`; toast(`собрано: источников ${fmt(j.sources)}, новых ${fmt(j.new)}, дублей ${fmt(j.dup)}`, 'ok');
return; await loadStats(); await loadItems(true);
} }
clearInterval(tick);
p.textContent = `готово: живых ${j.alive || 0}, мёртвых ${j.dead || 0}`;
setTimeout(() => p.classList.add("hide"), 6000);
loadSources(); loadStats();
}, 1300);
} }
$("#checkAll").onclick = () => runCheck({all: true}, "проверка всех");
$("#checkCat").onclick = () => { async function check(category, name) {
const c = STATE.scat === "all" ? "" : STATE.scat; const body = name ? {name} : {category: category || (S.cat === 'all' ? null : S.cat)};
if (!c) return toast("выбери категорию в селекте"); const {job} = await post('/api/sources/check', body);
runCheck({category: c}, "проверка " + c); const j = await poll(job, 'проверка источников');
if (j && j.state === 'done') {
toast(`проверено ${fmt(j.done)}: живых ${fmt(j.alive)}, мёртвых ${fmt(j.dead)}`, 'ok');
await loadStats(); if (S.view === 'src') await loadSources();
}
}
/* ---------- view switching ---------- */
function setView(v) {
S.view = v;
$('#nav-feed').classList.toggle('on', v === 'feed');
$('#nav-src').classList.toggle('on', v === 'src');
$('#view-feed').style.display = v === 'feed' ? '' : 'none';
$('#view-src').style.display = v === 'src' ? '' : 'none';
$('#btn-feed').style.display = v === 'feed' ? '' : 'none';
$('#feed-tools').style.display = v === 'feed' ? '' : 'none';
if (v === 'feed') loadItems(true); else loadSources();
}
/* ---------- wire ---------- */
$('#nav-feed').onclick = () => setView('feed');
$('#nav-src').onclick = () => setView('src');
$('#btn-feed').onclick = runFeed;
$('#btn-more').onclick = () => loadItems(false);
$('#btn-check-all').onclick = () => check(null, null);
$('#btn-check-cat').onclick = () => check(S.cat === 'all' ? null : S.cat, null);
$('#btn-on').onclick = async () => { await post('/api/sources/toggle_all', {enabled: true, category: S.cat}); toast('все включены', 'ok'); loadSources(); loadStats(); };
$('#btn-off').onclick = async () => { await post('/api/sources/toggle_all', {enabled: false, category: S.cat}); toast('все выключены', 'ok'); loadSources(); loadStats(); };
$('#f-cat').onchange = e => { S.cat = e.target.value; renderCats(S.stats.categories); S.view === 'src' ? loadSources() : loadItems(true); };
$('#f-state').onchange = e => { S.state = e.target.value; loadSources(); };
let qt = null;
$('#q').oninput = e => {
clearTimeout(qt);
qt = setTimeout(() => {
S.q = e.target.value.trim();
if (S.view === 'feed') loadItems(true); else { S.srcQ = S.q; loadSources(); }
}, 320);
}; };
$("#onAll").onclick = async () => { document.addEventListener('keydown', e => {
const d = await api(`/api/sources?category=${encodeURIComponent(STATE.scat === "all" ? "" : STATE.scat)}&q=${encodeURIComponent(STATE.sq)}`); if (e.key === '/' && document.activeElement !== $('#q')) { e.preventDefault(); $('#q').focus(); }
await Promise.all(d.items.map(s => api("/api/sources/toggle", {name: s.name, enabled: true}))); if (e.key === 'Escape') $('#modal').classList.remove('on');
toast(`включено: ${d.items.length}`); loadSources(); loadStats(); });
};
$("#offAll").onclick = async () => { $('#btn-add').onclick = () => $('#modal').classList.add('on');
const d = await api(`/api/sources?category=${encodeURIComponent(STATE.scat === "all" ? "" : STATE.scat)}&q=${encodeURIComponent(STATE.sq)}`); $('#a-cancel').onclick = () => $('#modal').classList.remove('on');
await Promise.all(d.items.map(s => api("/api/sources/toggle", {name: s.name, enabled: false}))); $('#modal').onclick = e => { if (e.target.id === 'modal') $('#modal').classList.remove('on'); };
toast(`выключено: ${d.items.length}`); loadSources(); loadStats(); $('#a-save').onclick = async () => {
}; const name = $('#a-name').value.trim(), url = $('#a-url').value.trim();
$("#add").onclick = async () => { if (!name || !url) return toast('нужны имя и url', 'err');
const name = $("#aname").value.trim().toLowerCase().replace(/\s+/g, "_"); const res = await post('/api/sources/add', {
const url = $("#aurl").value.trim(); name, url, category: $('#a-cat').value, adapter: $('#a-adapter').value || null
const category = $("#acat").value; });
if (!name || !url) return toast("нужны имя и url"); if (res.error) return toast(res.error, 'err');
const r = await api("/api/sources/add", {name, url, category}); $('#modal').classList.remove('on');
if (r.error) return toast(r.error); $('#a-name').value = ''; $('#a-url').value = '';
toast(`подключён: ${r.name} (${r.adapter})`); toast(`${name} добавлен`, 'ok');
$("#aname").value = ""; $("#aurl").value = "";
loadSources(); loadStats(); loadSources(); loadStats();
}; };
/* ---------- старт ---------- */ /* feed toolbar */
loadStats().then(loadItems); $('#feed-tools').innerHTML =
['all', 'community', 'papers', 'security', 'company', 'forum', 'models', 'benchmark']
.map(c => `<button class="chip" data-k="cat" data-cat="${c}">${c === 'all' ? 'всё' : c}</button>`).join('') +
'<span style="flex:1"></span>' +
'<button class="chip" data-k="sort" data-sort="score">по score</button>' +
'<button class="chip" data-k="sort" data-sort="fresh">по свежести</button>';
document.querySelectorAll('#feed-tools .chip').forEach(b => {
b.onclick = () => {
if (b.dataset.k === 'sort') { S.sort = b.dataset.sort; syncChips(); loadItems(true); }
else setCat(b.dataset.cat);
};
});
syncChips();
(async () => {
const p = new URLSearchParams(location.search);
if (p.get('cat')) S.cat = p.get('cat');
if (p.get('q')) { S.q = p.get('q'); $('#q').value = S.q; }
await loadStats();
if (p.get('view') === 'src') { setView('src'); } else { await loadItems(true); }
})();
</script> </script>
</body> </body>
</html> </html>