omni: веб-панель (serve + /api), дайджест, arxiv API-fallback, HF trending unwrap

This commit is contained in:
dev
2026-10-25 19:11:52 +03:00
parent c4d188520f
commit e1368f2a46
6 changed files with 446 additions and 2 deletions

111
omni/serve.py Normal file
View 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()