omni: веб-панель (serve + /api), дайджест, arxiv API-fallback, HF trending unwrap
This commit is contained in:
30
omni/cli.py
30
omni/cli.py
@@ -1,5 +1,6 @@
|
|||||||
"""CLI: providers / search / fanout / cards / top / find / stats / export."""
|
"""CLI: providers / search / fanout / cards / top / find / stats / export."""
|
||||||
import argparse
|
import argparse
|
||||||
|
import pathlib
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
@@ -175,6 +176,26 @@ def cmd_feed(args):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_digest(args):
|
||||||
|
"""Сводка по базе: топ записей по категориям за прогон, в markdown."""
|
||||||
|
rows = store.top(limit=args.limit * 8)
|
||||||
|
by_cat = {}
|
||||||
|
for prov, kind, src, title, sc, price, cur, url in rows:
|
||||||
|
by_cat.setdefault(src or prov, []).append((sc, title, url))
|
||||||
|
out = [f"# дайджест omni · {__import__('datetime').date.today()}", ""]
|
||||||
|
for cat, entries in sorted(by_cat.items(), key=lambda x: -len(x[1])):
|
||||||
|
out.append(f"## {cat} · {len(entries)}")
|
||||||
|
for sc, title, url in entries[:args.limit]:
|
||||||
|
out.append(f"- [{title[:110]}]({url}) · {sc:.2f}")
|
||||||
|
out.append("")
|
||||||
|
text = chr(10).join(out)
|
||||||
|
path = args.out
|
||||||
|
pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pathlib.Path(path).write_text(text, encoding="utf-8")
|
||||||
|
print(f"дайджест: {len(by_cat)} категорий, {len(rows)} записей -> {path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_export(args):
|
def cmd_export(args):
|
||||||
n, path = store.export(args.out, args.fmt, args.limit)
|
n, path = store.export(args.out, args.fmt, args.limit)
|
||||||
print(f"выгружено {n} записей -> {path}")
|
print(f"выгружено {n} записей -> {path}")
|
||||||
@@ -246,6 +267,15 @@ def main(argv=None):
|
|||||||
sp.add_argument("--verbose", action="store_true")
|
sp.add_argument("--verbose", action="store_true")
|
||||||
sp.set_defaults(fn=cmd_feed)
|
sp.set_defaults(fn=cmd_feed)
|
||||||
|
|
||||||
|
sp = sub.add_parser("serve", help="веб-панель + JSON API")
|
||||||
|
sp.add_argument("--port", type=int, default=8077)
|
||||||
|
sp.set_defaults(fn=lambda a: __import__("omni.serve", fromlist=["serve"]).serve(a.port))
|
||||||
|
|
||||||
|
sp = sub.add_parser("digest", help="markdown-дайджест из базы")
|
||||||
|
sp.add_argument("--limit", type=int, default=8)
|
||||||
|
sp.add_argument("--out", default="data/digest.md")
|
||||||
|
sp.set_defaults(fn=cmd_digest)
|
||||||
|
|
||||||
sp = sub.add_parser("export", help="выгрузка jsonl/csv")
|
sp = sub.add_parser("export", help="выгрузка jsonl/csv")
|
||||||
sp.add_argument("--out", default="data/top.jsonl")
|
sp.add_argument("--out", default="data/top.jsonl")
|
||||||
sp.add_argument("--fmt", default="jsonl", choices=["jsonl", "csv"])
|
sp.add_argument("--fmt", default="jsonl", choices=["jsonl", "csv"])
|
||||||
|
|||||||
@@ -33,6 +33,33 @@ def _item(src, kind, url, title, text="", author="", published="", extra=None):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------- rss ----------------
|
# ---------------- rss ----------------
|
||||||
|
def _arxiv_api(src, limit):
|
||||||
|
"""arxiv RSS часто пустой -> официальный API (Atom), тот же формат."""
|
||||||
|
m = re.search(r"rss/([A-Za-z\-.]+)$", src.url)
|
||||||
|
cat = m.group(1) if m else "cs.AI"
|
||||||
|
url = ("https://export.arxiv.org/api/query?search_query=cat:" + cat +
|
||||||
|
"&sortBy=submittedDate&sortOrder=descending&max_results=" + str(max(limit, 5)))
|
||||||
|
r = net.get(url, headers=UA)
|
||||||
|
if r.status != 200 or "<entry" not in (r.text or ""):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
root = ET.fromstring((r.text or "").encode("utf-8", "ignore"))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||||
|
for e in root.findall(".//a:entry", ns)[:limit]:
|
||||||
|
title = " ".join((e.findtext("a:title", namespaces=ns) or "").split())
|
||||||
|
summ = (e.findtext("a:summary", namespaces=ns) or "").strip()
|
||||||
|
link = e.findtext("a:id", namespaces=ns) or ""
|
||||||
|
pub = e.findtext("a:published", namespaces=ns) or ""
|
||||||
|
auth = e.findtext("a:author/a:name", namespaces=ns) or ""
|
||||||
|
if title:
|
||||||
|
out.append(_item(src, "paper", str(link), " ".join(title.split()), summ, auth, pub,
|
||||||
|
{"cat": cat}))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _rss(src, limit):
|
def _rss(src, limit):
|
||||||
r = net.get(src.url, headers=UA)
|
r = net.get(src.url, headers=UA)
|
||||||
if r.status in (429, 503):
|
if r.status in (429, 503):
|
||||||
@@ -40,7 +67,7 @@ def _rss(src, limit):
|
|||||||
time.sleep(2.5)
|
time.sleep(2.5)
|
||||||
r = net.get(src.url, headers=UA)
|
r = net.get(src.url, headers=UA)
|
||||||
if r.status != 200 or not r.text.strip():
|
if r.status != 200 or not r.text.strip():
|
||||||
return []
|
return _arxiv_api(src, limit) if "arxiv.org" in src.url else []
|
||||||
out = []
|
out = []
|
||||||
try:
|
try:
|
||||||
root = ET.fromstring(r.text.encode("utf-8", "ignore"))
|
root = ET.fromstring(r.text.encode("utf-8", "ignore"))
|
||||||
@@ -57,6 +84,8 @@ def _rss(src, limit):
|
|||||||
body = g("description") or g("summary") or g("content")
|
body = g("description") or g("summary") or g("content")
|
||||||
out.append(_item(src, "entry", link, g("title"), body,
|
out.append(_item(src, "entry", link, g("title"), body,
|
||||||
g("author") or g("dc:creator"), g("pubDate") or g("published") or g("updated")))
|
g("author") or g("dc:creator"), g("pubDate") or g("published") or g("updated")))
|
||||||
|
if not out and "arxiv.org" in src.url:
|
||||||
|
return _arxiv_api(src, limit)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -103,6 +132,8 @@ def _reddit_listing(src, limit):
|
|||||||
return _reddit_atom(src, rr.text, limit)
|
return _reddit_atom(src, rr.text, limit)
|
||||||
r = net.get(src.url, headers={**UA, "Accept": "application/json"}, json_mode=True)
|
r = net.get(src.url, headers={**UA, "Accept": "application/json"}, json_mode=True)
|
||||||
out = []
|
out = []
|
||||||
|
if r.status == 200 and isinstance(r.json(), dict):
|
||||||
|
children = (((r.json() or {}).get("data") or {}).get("children") or [])
|
||||||
for ch in children[:limit]:
|
for ch in children[:limit]:
|
||||||
d = ch.get("data") or {}
|
d = ch.get("data") or {}
|
||||||
if not d.get("title"):
|
if not d.get("title"):
|
||||||
@@ -158,6 +189,8 @@ def _hf_generic(src, limit):
|
|||||||
for x in rows[:limit]:
|
for x in rows[:limit]:
|
||||||
if not isinstance(x, dict):
|
if not isinstance(x, dict):
|
||||||
continue
|
continue
|
||||||
|
if "repoData" in x: # trending: {repoType, repoData:{...}}
|
||||||
|
x = {**x.get("repoData", {}), "repoType": x.get("repoType")}
|
||||||
rid = x.get("id") or x.get("repoId") or x.get("name") or ""
|
rid = x.get("id") or x.get("repoId") or x.get("name") or ""
|
||||||
if not rid:
|
if not rid:
|
||||||
continue
|
continue
|
||||||
|
|||||||
111
omni/serve.py
Normal file
111
omni/serve.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""Веб-панель omni: статика + JSON API поверх той же SQLite.
|
||||||
|
|
||||||
|
Запуск: python -m omni serve --port 8077
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from . import feeds, sources, store
|
||||||
|
|
||||||
|
WEB = pathlib.Path(__file__).resolve().parent.parent / "web"
|
||||||
|
JOBS = {}
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_feed(job_id, category, limit, tags):
|
||||||
|
picked = sources.pick(category, None, tags)
|
||||||
|
try:
|
||||||
|
items, report = feeds.fetch_many(picked, limit=limit, workers=8)
|
||||||
|
new, dup = store.add(items)
|
||||||
|
with _LOCK:
|
||||||
|
JOBS[job_id] = {"state": "done", "sources": len(picked), "fetched": len(items),
|
||||||
|
"new": new, "dup": dup, "empty": sum(1 for _, c in report if not c),
|
||||||
|
"report": sorted(report, key=lambda x: -x[1])[:40]}
|
||||||
|
except Exception as e:
|
||||||
|
with _LOCK:
|
||||||
|
JOBS[job_id] = {"state": "error", "error": f"{type(e).__name__}: {e}"[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "omni/1.0"
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send(self, code, body, ctype="application/json; charset=utf-8"):
|
||||||
|
data = body if isinstance(body, bytes) else str(body).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _json(self, obj, code=200):
|
||||||
|
self._send(code, json.dumps(obj, ensure_ascii=False))
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
u = urllib.parse.urlsplit(self.path)
|
||||||
|
q = urllib.parse.parse_qs(u.query)
|
||||||
|
path = u.path
|
||||||
|
|
||||||
|
if path in ("/", "/index.html"):
|
||||||
|
f = WEB / "index.html"
|
||||||
|
if not f.exists():
|
||||||
|
return self._send(404, "no ui", "text/plain")
|
||||||
|
return self._send(200, f.read_bytes(), "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
if path == "/api/stats":
|
||||||
|
s = store.stats()
|
||||||
|
return self._json({"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_kind": [{"name": n, "count": c} for n, c in s["by_kind"]],
|
||||||
|
"sources": len(sources.SOURCES), "categories": sources.stats()})
|
||||||
|
|
||||||
|
if path == "/api/sources":
|
||||||
|
return self._json({"total": len(sources.SOURCES), "categories": sources.stats(),
|
||||||
|
"items": [{"name": s.name, "category": s.category, "kind": s.kind,
|
||||||
|
"adapter": s.adapter, "url": s.url, "tags": list(s.tags)}
|
||||||
|
for s in sources.SOURCES]})
|
||||||
|
|
||||||
|
if path == "/api/items":
|
||||||
|
cat = (q.get("category") or [None])[0]
|
||||||
|
query = (q.get("q") or [""])[0].strip()
|
||||||
|
limit = min(int((q.get("limit") or ["60"])[0]), 300)
|
||||||
|
offset = int((q.get("offset") or ["0"])[0])
|
||||||
|
rows = store.browse(category=cat, q=query, limit=limit, offset=offset)
|
||||||
|
return self._json({"count": len(rows), "items": rows})
|
||||||
|
|
||||||
|
if path.startswith("/api/job/"):
|
||||||
|
jid = path.rsplit("/", 1)[-1]
|
||||||
|
return self._json(JOBS.get(jid) or {"state": "unknown"})
|
||||||
|
|
||||||
|
return self._send(404, "not found", "text/plain")
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
u = urllib.parse.urlsplit(self.path)
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = {}
|
||||||
|
if n:
|
||||||
|
try:
|
||||||
|
body = json.loads(self.rfile.read(n) or b"{}")
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
if u.path == "/api/feed":
|
||||||
|
jid = f"job{len(JOBS) + 1}"
|
||||||
|
with _LOCK:
|
||||||
|
JOBS[jid] = {"state": "running"}
|
||||||
|
threading.Thread(target=_run_feed, args=(jid, body.get("category"), int(body.get("limit") or 12),
|
||||||
|
body.get("tags")), daemon=True).start()
|
||||||
|
return self._json({"job": jid})
|
||||||
|
return self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(port=8077):
|
||||||
|
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||||
|
print(f"omni ui: http://127.0.0.1:{port} (источников {len(sources.SOURCES)})")
|
||||||
|
srv.serve_forever()
|
||||||
@@ -56,7 +56,8 @@ SOURCES = [
|
|||||||
S("hf_new_models", "models", "json", "https://huggingface.co/api/models?sort=createdAt&direction=-1&limit=25", "hf_models", ("model", "hf")),
|
S("hf_new_models", "models", "json", "https://huggingface.co/api/models?sort=createdAt&direction=-1&limit=25", "hf_models", ("model", "hf")),
|
||||||
S("hf_trending_spaces", "models", "json", "https://huggingface.co/api/trending?type=space", "hf_generic", ("space", "hf")),
|
S("hf_trending_spaces", "models", "json", "https://huggingface.co/api/trending?type=space", "hf_generic", ("space", "hf")),
|
||||||
S("hf_trending_datasets", "models", "json", "https://huggingface.co/api/trending?type=dataset", "hf_generic", ("dataset", "hf")),
|
S("hf_trending_datasets", "models", "json", "https://huggingface.co/api/trending?type=dataset", "hf_generic", ("dataset", "hf")),
|
||||||
S("ollama_library", "models", "json", "https://ollama.com/library", "html_listing", ("model", "local"), detail=False),
|
S("ollama_library", "models", "json", "https://ollama.com/library", "html_listing", ("model", "local"),
|
||||||
|
detail=False, extra={"include_contains": ["/library/"]}),
|
||||||
|
|
||||||
# ---------- benchmark ----------
|
# ---------- benchmark ----------
|
||||||
S("open_llm_leaderboard", "benchmark", "json",
|
S("open_llm_leaderboard", "benchmark", "json",
|
||||||
|
|||||||
@@ -131,6 +131,25 @@ def search(q, limit=20):
|
|||||||
"ORDER BY score DESC LIMIT ?", (like, like, limit)).fetchall()
|
"ORDER BY score DESC LIMIT ?", (like, like, limit)).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def browse(category=None, q="", limit=60, offset=0):
|
||||||
|
"""Список записей для веб-панели: фильтр по категории источника и подстроке."""
|
||||||
|
sql = ("SELECT provider,kind,source,title,text,author,published,score,price,currency,url "
|
||||||
|
"FROM items WHERE 1=1 ")
|
||||||
|
args = []
|
||||||
|
if category and category != "all":
|
||||||
|
sql += "AND source=? "
|
||||||
|
args.append(category)
|
||||||
|
if q:
|
||||||
|
sql += "AND (title LIKE ? OR text LIKE ?) "
|
||||||
|
args += [f"%{q}%", f"%{q}%"]
|
||||||
|
sql += "ORDER BY score DESC, id DESC LIMIT ? OFFSET ?"
|
||||||
|
args += [limit, offset]
|
||||||
|
with conn() as c:
|
||||||
|
rows = c.execute(sql, args).fetchall()
|
||||||
|
keys = ("provider", "kind", "source", "title", "text", "author", "published", "score", "price", "currency", "url")
|
||||||
|
return [dict(zip(keys, r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
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]
|
||||||
|
|||||||
250
web/index.html
Normal file
250
web/index.html
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>omni · радар источников</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0b0d10; --bg2:#11151a; --card:#141a20; --card2:#171e26;
|
||||||
|
--line:#232c35; --txt:#e7edf3; --dim:#8b98a5; --dim2:#5f6b78;
|
||||||
|
--acc:#c6f24e; --acc2:#4ee0c6; --warn:#ffb454;
|
||||||
|
--mono:ui-monospace,"JetBrains Mono","SF Mono",Menlo,monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;height:100%}
|
||||||
|
body{
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 700px at 15% -10%, #1a2430 0%, transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 100% 0%, #14251f 0%, transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
color:var(--txt);
|
||||||
|
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
.wrap{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
|
||||||
|
/* ---------- sidebar ---------- */
|
||||||
|
aside{
|
||||||
|
border-right:1px solid var(--line);padding:22px 16px;position:sticky;top:0;height:100vh;
|
||||||
|
display:flex;flex-direction:column;gap:20px;background:rgba(10,13,16,.6);backdrop-filter:blur(8px);
|
||||||
|
}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px}
|
||||||
|
.mark{width:30px;height:30px;border-radius:9px;background:linear-gradient(140deg,var(--acc),var(--acc2));
|
||||||
|
display:grid;place-items:center;color:#0b0d10;font-weight:800;font-size:15px;font-family:var(--mono)}
|
||||||
|
.brand b{font-size:16px;letter-spacing:.2px}
|
||||||
|
.brand span{display:block;font-size:11px;color:var(--dim2);font-family:var(--mono)}
|
||||||
|
nav{display:flex;flex-direction:column;gap:3px}
|
||||||
|
nav button{
|
||||||
|
all:unset;cursor:pointer;display:flex;justify-content:space-between;align-items:center;
|
||||||
|
padding:8px 10px;border-radius:8px;color:var(--dim);font-size:13.5px;
|
||||||
|
}
|
||||||
|
nav button:hover{background:var(--card);color:var(--txt)}
|
||||||
|
nav button.on{background:var(--card2);color:var(--txt);box-shadow:inset 0 0 0 1px var(--line)}
|
||||||
|
nav button i{font-family:var(--mono);font-size:11px;color:var(--dim2);font-style:normal}
|
||||||
|
nav button.on i{color:var(--acc)}
|
||||||
|
.side-foot{margin-top:auto;font-family:var(--mono);font-size:11px;color:var(--dim2);line-height:1.7}
|
||||||
|
/* ---------- main ---------- */
|
||||||
|
main{padding:26px 32px 60px;max-width:1180px}
|
||||||
|
header{display:flex;gap:14px;align-items:flex-end;justify-content:space-between;flex-wrap:wrap}
|
||||||
|
h1{margin:0;font-size:24px;letter-spacing:-.4px}
|
||||||
|
h1 em{font-style:normal;color:var(--acc)}
|
||||||
|
.sub{color:var(--dim);font-size:13px;margin-top:4px}
|
||||||
|
.tools{display:flex;gap:8px;align-items:center}
|
||||||
|
input[type=search]{
|
||||||
|
background:var(--card);border:1px solid var(--line);border-radius:10px;color:var(--txt);
|
||||||
|
padding:10px 13px;width:270px;font-size:14px;outline:none;
|
||||||
|
}
|
||||||
|
input[type=search]:focus{border-color:#3a4a56;box-shadow:0 0 0 3px rgba(198,242,78,.08)}
|
||||||
|
.btn{
|
||||||
|
cursor:pointer;border:1px solid var(--line);background:var(--card);color:var(--txt);
|
||||||
|
padding:10px 16px;border-radius:10px;font-size:13.5px;font-weight:600;
|
||||||
|
}
|
||||||
|
.btn:hover{border-color:#3a4a56}
|
||||||
|
.btn.primary{background:var(--acc);color:#0b0d10;border-color:transparent}
|
||||||
|
.btn.primary:disabled{opacity:.5;cursor:wait}
|
||||||
|
/* ---------- stats ---------- */
|
||||||
|
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:22px 0 26px}
|
||||||
|
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||||
|
.stat b{display:block;font-family:var(--mono);font-size:24px;letter-spacing:-.5px}
|
||||||
|
.stat span{font-size:11.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.6px}
|
||||||
|
.stat.a b{color:var(--acc)} .stat.b b{color:var(--acc2)}
|
||||||
|
/* ---------- feed ---------- */
|
||||||
|
.bar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||||
|
.chip{
|
||||||
|
font-family:var(--mono);font-size:11px;color:var(--dim);border:1px solid var(--line);
|
||||||
|
border-radius:999px;padding:4px 10px;background:var(--card)
|
||||||
|
}
|
||||||
|
.chip.hot{color:var(--acc);border-color:#2b3a20}
|
||||||
|
.list{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.row{
|
||||||
|
display:grid;grid-template-columns:56px 1fr auto;gap:14px;align-items:start;
|
||||||
|
background:linear-gradient(180deg,var(--card2),var(--card));
|
||||||
|
border:1px solid var(--line);border-radius:14px;padding:14px 16px;transition:border-color .15s,transform .15s;
|
||||||
|
}
|
||||||
|
.row:hover{border-color:#33414d;transform:translateY(-1px)}
|
||||||
|
.score{font-family:var(--mono);font-size:15px;color:var(--acc);text-align:right;padding-top:1px}
|
||||||
|
.score small{display:block;font-size:9.5px;color:var(--dim2);letter-spacing:.5px}
|
||||||
|
.t{font-size:15px;font-weight:600;line-height:1.35;margin:0 0 6px;letter-spacing:-.2px}
|
||||||
|
.t a{color:inherit;text-decoration:none}
|
||||||
|
.t a:hover{color:var(--acc)}
|
||||||
|
.meta{display:flex;gap:8px;flex-wrap:wrap;align-items:center;font-family:var(--mono);font-size:11px;color:var(--dim)}
|
||||||
|
.src{color:var(--acc2)}
|
||||||
|
.txt{color:var(--dim);font-size:13px;margin-top:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||||
|
.ext{font-family:var(--mono);font-size:11px;color:var(--dim2);align-self:center;white-space:nowrap}
|
||||||
|
.empty{color:var(--dim);padding:40px 0;text-align:center;font-family:var(--mono);font-size:13px}
|
||||||
|
.toast{
|
||||||
|
position:fixed;bottom:22px;right:22px;background:var(--card2);border:1px solid var(--line);
|
||||||
|
border-radius:12px;padding:12px 16px;font-size:13px;color:var(--txt);opacity:0;pointer-events:none;
|
||||||
|
transition:opacity .25s;max-width:340px;font-family:var(--mono)
|
||||||
|
}
|
||||||
|
.toast.on{opacity:1}
|
||||||
|
@media(max-width:820px){
|
||||||
|
.wrap{grid-template-columns:1fr} aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;gap:12px;padding:14px}
|
||||||
|
nav{flex-direction:row;flex-wrap:wrap} main{padding:18px 16px 50px} .tools{width:100%} input[type=search]{flex:1;width:auto}
|
||||||
|
.row{grid-template-columns:44px 1fr}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<aside>
|
||||||
|
<div class="brand">
|
||||||
|
<div class="mark">o</div>
|
||||||
|
<div><b>omni</b><span>радар источников</span></div>
|
||||||
|
</div>
|
||||||
|
<nav id="nav"></nav>
|
||||||
|
<div class="side-foot" id="foot">загрузка…</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>что прилетело <em>без запроса</em></h1>
|
||||||
|
<div class="sub" id="sub">ленты, API и форумы в одной базе</div>
|
||||||
|
</div>
|
||||||
|
<div class="tools">
|
||||||
|
<input id="q" type="search" placeholder="поиск по базе…" autocomplete="off">
|
||||||
|
<button class="btn" id="refresh">обновить вид</button>
|
||||||
|
<button class="btn primary" id="run">собрать заново</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats" id="stats"></div>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<span class="chip" id="chipCount">—</span>
|
||||||
|
<span class="chip" id="chipCat">все категории</span>
|
||||||
|
<span class="chip hot" id="chipApi">api /api/items</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list" id="list"></div>
|
||||||
|
<div class="empty" id="empty" style="display:none">пусто — нажми «собрать заново»</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const state = {cat:"all", q:"", items:[], cats:[]};
|
||||||
|
const fmt = n => n==null?"—":(n>=1000?(n/1000).toFixed(1)+"k":String(n));
|
||||||
|
|
||||||
|
function toast(msg, ms=4000){
|
||||||
|
const t=$("#toast"); t.textContent=msg; t.classList.add("on");
|
||||||
|
clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove("on"), ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJSON(url){
|
||||||
|
const r = await fetch(url, {headers:{"Accept":"application/json"}});
|
||||||
|
if(!r.ok) throw new Error(r.status);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats(){
|
||||||
|
const s = await getJSON("/api/stats");
|
||||||
|
const cards = [
|
||||||
|
{cls:"a", v:fmt(s.items), l:"записей в базе"},
|
||||||
|
{cls:"", v:fmt(s.sources), l:"источников"},
|
||||||
|
{cls:"b", v:fmt(s.runs), l:"прогонов"},
|
||||||
|
{cls:"", v:fmt(s.by_provider.length), l:"активных коллекторов"},
|
||||||
|
{cls:"", v:fmt(s.priced), l:"с ценником"},
|
||||||
|
];
|
||||||
|
$("#stats").innerHTML = cards.map(c=>`<div class="stat ${c.cls}"><b>${c.v}</b><span>${c.l}</span></div>`).join("");
|
||||||
|
const cats = s.categories||{};
|
||||||
|
state.cats = Object.entries(cats).sort((a,b)=>b[1]-a[1]);
|
||||||
|
renderNav(s.items);
|
||||||
|
$("#foot").innerHTML = `источников: ${s.sources}<br>категорий: ${state.cats.length}<br>прогонов ok: ${s.runs_ok}/${s.runs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNav(total){
|
||||||
|
const rows = [["all","всё", total], ...state.cats];
|
||||||
|
$("#nav").innerHTML = rows.map(([k,label,n])=>
|
||||||
|
`<button data-cat="${k}" class="${state.cat===k?'on':''}"><span>${label}</span><i>${fmt(n)}</i></button>`).join("");
|
||||||
|
$("#nav").querySelectorAll("button").forEach(b=>b.onclick=()=>{
|
||||||
|
state.cat=b.dataset.cat; renderNav(total); loadItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems(){
|
||||||
|
$("#chipCat").textContent = state.cat==="all" ? "все категории" : state.cat;
|
||||||
|
const url = `/api/items?category=${encodeURIComponent(state.cat)}&q=${encodeURIComponent(state.q)}&limit=80`;
|
||||||
|
const d = await getJSON(url);
|
||||||
|
state.items = d.items||[];
|
||||||
|
$("#chipCount").textContent = `${d.count} записей`;
|
||||||
|
$("#empty").style.display = state.items.length ? "none" : "block";
|
||||||
|
$("#list").innerHTML = state.items.map(it=>{
|
||||||
|
const score = (it.score||0).toFixed(2);
|
||||||
|
const date = (it.published||"").toString().slice(0,16).replace("T"," ");
|
||||||
|
const host = (()=>{try{return new URL(it.url).hostname.replace("www.","")}catch(e){return ""}})();
|
||||||
|
const ext = it.price ? `${it.price} ${it.currency||""}` : (it.kind||"");
|
||||||
|
return `<article class="row">
|
||||||
|
<div class="score">${score}<small>score</small></div>
|
||||||
|
<div>
|
||||||
|
<p class="t"><a href="${it.url||"#"}" target="_blank" rel="noopener">${esc(it.title||"(без заголовка)")}</a></p>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="src">${esc(it.source||it.provider)}</span>
|
||||||
|
<span>${esc(it.provider)}</span>
|
||||||
|
${date?`<span>${esc(date)}</span>`:""}
|
||||||
|
${it.author?`<span>@${esc(it.author)}</span>`:""}
|
||||||
|
</div>
|
||||||
|
${it.text?`<div class="txt">${esc(it.text.slice(0,240))}</div>`:""}
|
||||||
|
</div>
|
||||||
|
<div class="ext">${esc(ext)}${host?`<br>${esc(host)}`:""}</div>
|
||||||
|
</article>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))}
|
||||||
|
|
||||||
|
let tmr;
|
||||||
|
$("#q").addEventListener("input", e=>{
|
||||||
|
clearTimeout(tmr);
|
||||||
|
tmr = setTimeout(()=>{ state.q = e.target.value.trim(); loadItems(); }, 280);
|
||||||
|
});
|
||||||
|
$("#refresh").onclick = ()=>{ loadStats(); loadItems(); toast("вид обновлён"); };
|
||||||
|
|
||||||
|
$("#run").onclick = async ()=>{
|
||||||
|
const b = $("#run"); b.disabled=true; b.textContent="собираю…";
|
||||||
|
try{
|
||||||
|
const cat = state.cat==="all" ? null : state.cat;
|
||||||
|
const r = await fetch("/api/feed", {method:"POST", headers:{"Content-Type":"application/json"},
|
||||||
|
body: JSON.stringify({category:cat, limit:12})});
|
||||||
|
const {job} = await r.json();
|
||||||
|
let tries=0;
|
||||||
|
while(tries++ < 120){
|
||||||
|
await new Promise(x=>setTimeout(x,1500));
|
||||||
|
const j = await getJSON("/api/job/"+job);
|
||||||
|
if(j.state==="done"){
|
||||||
|
toast(`собрано ${j.fetched}, новых ${j.new}, дублей ${j.dup}, пустых источников ${j.empty}`);
|
||||||
|
await loadStats(); await loadItems(); break;
|
||||||
|
}
|
||||||
|
if(j.state==="error"){ toast("ошибка: "+j.error); break; }
|
||||||
|
if(tries%4===0) toast("ещё собираю… "+tries*1.5+"с", 1600);
|
||||||
|
}
|
||||||
|
}catch(e){ toast("не вышло: "+e.message); }
|
||||||
|
finally{ b.disabled=false; b.textContent="собрать заново"; }
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStats().then(loadItems).catch(e=>toast("api недоступен: "+e.message));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user