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

250
web/index.html Normal file
View 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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>