omni: редизайн панели + 450 источников (341 живых), health-аудит, чистка html/дат, per-source учёт
This commit is contained in:
@@ -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, живых/мёртвых`;
|
||||||
|
|||||||
37
omni/cli.py
37
omni/cli.py
@@ -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)
|
||||||
|
|||||||
142
omni/feeds.py
142
omni/feeds.py
@@ -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,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
128
omni/serve.py
128
omni/serve.py
@@ -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})
|
||||||
|
|||||||
@@ -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
118
omni/sources_ext2.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
|
|||||||
858
web/index.html
858
web/index.html
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
const esc = s => (s == null ? '' : String(s)).replace(/[&<>"]/g, m => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||||
Reference in New Issue
Block a user