Files
omni-parse/web/index.html
ENI 2320acf3c5
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
vault ключей + мост abuse-radar + китайские источники: всё в одном реестре
- omni/vault.py: пул ключей парсеров (env-first, data/vault.json, ротация, cooldown 401/402/403=1ч, 429=10м), только маски в выводе
- tools/build_vault.py: сборка пула из MASTER_KEYS.csv и локальных дампов (значения не в git)
- tools/vault_check.py: живая проверка с маскированием
- searchers: firecrawl/tavily/exa/serper/jina/apify берут ключ из vault, если нет env
- omni radar: домены+каналы из radar_server.db -> реестр, --import-posts заливает посты каналов
- radar-источники помечены opt_in: общий обход не тормозят (869 против 3337 всего)
- omni/sources_cn.py + tools/wave_cn.py: 9 живых китайских источников (52pojie, nodeseek, kanxue, anquanke, 4hou, xz.aliyun, ithome, cnbeta, v2ex)
- net.get(proxy=...): принудительный прокси для CF-площадок
- /api/keys + строка ключей в панели
- tests/test_vault_radar.py: 7 тестов, всего 29 passed
2026-10-26 23:31:37 +03:00

1027 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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:#08090c; --bg2:#0c0e13; --panel:#101319; --panel2:#141821; --line:#1d2230; --line2:#2a3140;
--txt:#e9edf5; --dim:#98a3b6; --dim2:#6b7688;
--acc:#7c6cf4; --acc2:#2dd4bf; --ok:#34d399; --bad:#fb7185; --warn:#fbbf24;
--mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Consolas,monospace;
--r:14px;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:
radial-gradient(1100px 520px at 76% -12%, rgba(124,108,244,.16), transparent 62%),
radial-gradient(760px 420px at 6% 8%, rgba(45,212,191,.09), transparent 60%),
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)}
.app{display:grid;grid-template-columns:252px minmax(0,1fr);min-height:100vh}
/* ---------- sidebar ---------- */
aside{
position:sticky;top:0;height:100vh;display:flex;flex-direction:column;gap:18px;
padding:20px 16px;border-right:1px solid var(--line);background:linear-gradient(180deg,#0a0c11,#08090c 60%);
}
.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}
.nav{display:flex;flex-direction:column;gap:4px}
.nav button{
display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:10px;
color:var(--dim);font-weight:500;transition:.15s;text-align:left;width:100%;
}
.nav button i{width:16px;height:16px;flex:0 0 auto;opacity:.75;display:grid;place-items:center;font-size:12px;line-height:1}
.nav button:hover{background:#12151d;color:var(--txt)}
.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)}
.nav button b{margin-left:auto;font:11px var(--mono);color:var(--dim2)}
.sect{font:11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--dim2);padding:0 8px}
.cats{display:flex;flex-direction:column;gap:2px;overflow:auto;margin:0 -4px;padding:0 4px;flex:1}
.cat{
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}
.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)}
.legend span{display:flex;align-items:center;gap:5px}
.legend i{width:7px;height:7px;border-radius:2px;display:block}
.lg-a{background:var(--ok)} .lg-d{background:var(--bad)} .lg-u{background:#39414f}
.aside-foot{border-top:1px solid var(--line);padding-top:12px;font:11.5px/1.6 var(--mono);color:var(--dim2)}
.aside-foot b{color:var(--dim);font-weight:500}
/* ---------- main ---------- */
main{min-width:0;display:flex;flex-direction:column}
.top{
position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:12px;
padding:14px 26px;border-bottom:1px solid var(--line);
background:rgba(8,9,12,.72);backdrop-filter:blur(14px);
}
.top h1{font-size:15.5px;margin:0;font-weight:600;letter-spacing:.2px}
.top h1 small{display:block;font:11px var(--mono);color:var(--dim2);letter-spacing:.08em;text-transform:uppercase;font-weight:400;margin-top:2px}
.spacer{flex:1}
.search{
display:flex;align-items:center;gap:8px;background:#0e1117;border:1px solid var(--line);
border-radius:10px;padding:7px 11px;min-width:230px;transition:.15s;
}
.search:focus-within{border-color:rgba(124,108,244,.6);box-shadow:0 0 0 3px rgba(124,108,244,.12)}
.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}
}
/* ---- меню v2 ---- */
.brand .ver{margin-left:auto;font:10.5px var(--mono);color:var(--dim2);background:#151a24;border:1px solid var(--line);border-radius:20px;padding:2px 8px}
.menu-btn{display:flex;align-items:center;gap:9px;margin:0 0 12px;padding:9px 11px;width:100%;border:1px solid var(--line);
background:#11151d;color:var(--dim);border-radius:11px;font:12.5px inherit;cursor:pointer;transition:.15s}
.menu-btn:hover{border-color:var(--line2);color:var(--txt);background:#161c26}
.menu-btn i{font-style:normal;color:var(--acc2)}
.menu-btn .kbd{margin-left:auto}
.kbd{font:10px var(--mono);color:var(--dim2);border:1px solid var(--line2);border-radius:5px;padding:1px 5px;background:#0d1117}
.nav button .kbd{margin-left:auto;margin-right:6px;opacity:.55}
.nav button:hover .kbd{opacity:1}
.sect{display:flex;align-items:center;justify-content:space-between;gap:8px}
.sect .mini{border:0;background:transparent;color:var(--dim2);cursor:pointer;font-size:11px;padding:0 2px}
.sect .mini:hover{color:var(--txt)}
.cats.collapsed{display:none}
.quick{display:grid;gap:6px;margin:2px 0 10px}
.quick button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;border:1px solid var(--line);
background:#0f131a;color:var(--dim);font:12.5px inherit;cursor:pointer;text-align:left;transition:.14s}
.quick button:hover{border-color:var(--line2);color:var(--txt);background:#151b25;transform:translateX(2px)}
.quick button i{font-style:normal;color:var(--acc);width:12px;text-align:center}
.ttlwrap{min-width:0}
.crumbs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:5px}
.crumbs span{font:10.5px var(--mono);color:var(--dim2);border:1px solid var(--line);border-radius:20px;padding:2px 8px;background:#0d1117}
.crumbs span.on{color:#cfd6ec;border-color:#3b3660;background:#181633}
.palette{position:fixed;inset:0;background:rgba(4,5,8,.72);backdrop-filter:blur(6px);display:none;z-index:80;padding-top:14vh;justify-content:center}
.palette.on{display:flex}
.pal-box{width:min(620px,92vw);background:#0f131b;border:1px solid var(--line2);border-radius:16px;
box-shadow:0 30px 80px rgba(0,0,0,.6);overflow:hidden;align-self:flex-start}
.pal-box input{width:100%;border:0;border-bottom:1px solid var(--line);background:transparent;color:var(--txt);
padding:15px 18px;font:14px inherit;outline:none}
.pal-list{max-height:52vh;overflow:auto;padding:6px}
.pal-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:9px;cursor:pointer;color:var(--dim);font-size:13px}
.pal-item .tag{font:10px var(--mono);color:var(--dim2);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
.pal-item .cnt{margin-left:auto;font:11px var(--mono);color:var(--dim2)}
.pal-item.sel,.pal-item:hover{background:#171d29;color:var(--txt)}
.pal-foot{display:flex;gap:6px;align-items:center;padding:9px 14px;border-top:1px solid var(--line);font:10.5px var(--mono);color:var(--dim2)}
.thead.runs,.row.runs{grid-template-columns:132px minmax(120px,1fr) minmax(120px,1.2fr) 90px 100px 110px}
.runs-time{font:11.5px var(--mono);color:var(--dim2)}
.chip b{font:10.5px var(--mono);color:var(--dim2);font-weight:500;margin-left:3px}
.chip.on b{color:#cbc4ff}
.tps{display:flex;flex-wrap:wrap;gap:6px;margin:7px 0 3px}
.tp{font-size:11px;line-height:1;padding:4px 8px;border-radius:999px;background:rgba(120,180,255,.12);
color:#9dc4ff;border:1px solid rgba(120,180,255,.24);letter-spacing:.02em}
#type-tools{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:-2px 0 14px}
</style>
</head>
<body>
<div class="app">
<aside id="side">
<div class="brand">
<div class="mark"></div>
<div><b>omni</b><span>source radar</span></div>
<span class="ver" id="brand-n">—</span>
</div>
<button class="menu-btn" id="btn-palette"><i>⌘</i>Меню<span class="kbd">Ctrl K</span></button>
<div class="nav">
<button id="nav-feed" class="on"><i>▤</i>Лента<span class="kbd">1</span><b id="nav-feed-n">—</b></button>
<button id="nav-src"><i>◈</i>Источники<span class="kbd">2</span><b id="nav-src-n">—</b></button>
<button id="nav-runs"><i>≡</i>Прогоны<span class="kbd">3</span><b id="nav-runs-n">—</b></button>
<button id="nav-abuse"><i>◭</i>Абузы<span class="kbd">4</span><b id="nav-abuse-n">—</b></button>
</div>
<div class="sect"><span>категории</span><button class="mini" id="cats-toggle" title="свернуть">▾</button></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="sect"><span>действия</span></div>
<div class="quick">
<button id="q-feed"><i>↻</i>Собрать ленту</button>
<button id="q-check"><i>✓</i>Проверить всё</button>
<button id="q-add"><i>+</i>Источник</button>
</div>
<div class="aside-foot" id="aside-foot">загрузка…</div>
</aside>
<main>
<div class="top">
<div class="ttlwrap">
<div class="crumbs" id="crumbs"></div>
<h1 id="ttl">Лента<small>свежие записи со всех источников</small></h1>
</div>
<div class="spacer"></div>
<div class="search">
<span class="sicon">⌕</span>
<input id="q" placeholder="поиск по базе">
<kbd>/</kbd>
</div>
<button class="btn" id="btn-palette2" title="меню (Ctrl K)">⌘ Меню</button>
<button class="btn primary" id="btn-feed">Собрать ленту</button>
</div>
<div class="content">
<!-- ЛЕНТА -->
<section id="view-feed">
<div class="kpis" id="kpis"></div>
<div class="toolbar" id="feed-tools"></div>
<div class="filters" id="type-tools"></div>
<div class="cards" id="cards"></div>
<div class="empty" id="cards-more" style="display:none"><button class="btn" id="btn-more">Показать ещё</button></div>
</section>
<!-- ИСТОЧНИКИ -->
<section id="view-abuse" style="display:none">
<div class="strip" id="abuse-strip"></div>
<div class="filters" id="abuse-tags"></div>
<div class="filters" id="abuse-ai">
<button class="chip" id="btn-ai-sum">ИИ-саммари</button>
<span class="kv" id="ai-note">сводка по свежим находкам</span>
</div>
<div id="ai-sum" style="display:none;white-space:pre-wrap;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:14px 16px;margin:10px 0 16px;line-height:1.5"></div>
<div class="cards" id="abuse-cards"></div>
</section>
<section id="view-src" style="display:none">
<div class="filters" id="src-tools">
<button class="btn" id="btn-discover" title="искать фиды на хостах и подключать">авто-добавление</button>
<span class="kv" id="discover-note"></span>
</div>
<div class="strip" id="src-strip"></div>
<div class="filters">
<select id="f-cat"><option value="all">все категории</option></select>
<select id="f-state">
<option value="">любое состояние</option>
<option value="alive">живые</option>
<option value="dead">мёртвые</option>
<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 class="table">
<div class="thead">
<span>вкл</span><span>источник</span><span>категория</span><span>адаптер</span>
<span>состояние</span><span class="num">записей</span><span></span>
</div>
<div id="rows"></div>
</div>
</section>
<!-- ПРОГОНЫ -->
<section id="view-runs" style="display:none">
<div class="strip" id="runs-strip"></div>
<div class="table">
<div class="thead runs">
<span>время</span><span>источник</span><span>запрос</span>
<span class="num">получено</span><span class="num">сохранено</span><span>статус</span>
</div>
<div id="runs-rows"></div>
</div>
</section>
</div>
</main>
</div>
<div class="palette" id="palette">
<div class="pal-box">
<input id="pal-q" placeholder="куда идём: категория, действие, источник…">
<div class="pal-list" id="pal-list"></div>
<div class="pal-foot"><kbd>↑</kbd><kbd>↓</kbd> выбор <kbd>↵</kbd> открыть <kbd>esc</kbd> закрыть</div>
</div>
</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>
const $ = s => document.querySelector(s);
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 => (s == null ? '' : String(s)).replace(/[&<>"]/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));
const fmt = n => (n == null ? '—' : Number(n).toLocaleString('ru-RU'));
const api = async (p, opt) => {
const r = await fetch(p, opt);
const t = await r.text();
try { return JSON.parse(t); } catch (e) { return {error: t.slice(0, 120)}; }
};
const post = (p, b) => api(p, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(b || {})});
const TYPE_LABEL = {news: 'новость', tool: 'софт', release: 'релиз', cve: 'уязвимость', model: 'модель',
question: 'вопрос', price: 'цена', guide: 'гайд', dataset: 'датасет', leak: 'слив', trial: 'триал',
promo: 'промо', resale: 'перепродажа', job: 'вакансия'};
const S = {view: 'feed', cat: 'all', q: '', state: '', srcQ: '', offset: 0, limit: 36, sort: 'score', priced: false, type: 'all', types: [], 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);
if (k === 'priced') b.classList.toggle('on', !!S.priced);
});
}
function setCat(name) {
S.cat = name; S.offset = 0;
renderCats(S.stats ? S.stats.categories : []);
syncChips();
crumb();
try {
const u = new URL(location.href);
name && name !== 'all' ? u.searchParams.set('cat', name) : u.searchParams.delete('cat');
history.replaceState(null, '', u.toString());
} catch (e) {}
if (S.view === 'feed') loadItems(true); else loadSources();
$('#f-cat').value = name;
}
/* ---------- stats / KPI ---------- */
function buildTypes(list) {
const box = $('#type-tools');
if (!box) return;
S.types = list || S.types;
const arr = S.types || [];
box.innerHTML = '<span class="kv" style="margin-right:6px">тип:</span>' +
`<button class="chip ${S.type === 'all' ? 'on' : ''}" data-t="all">все</button>` +
arr.filter(x => x.n > 0).map(x =>
`<button class="chip ${S.type === x.key ? 'on' : ''}" data-t="${esc(x.key)}">${esc(x.label)} <b>${fmt(x.n)}</b></button>`).join('');
box.querySelectorAll('.chip').forEach(b => {
b.onclick = () => { S.type = b.dataset.t; S.offset = 0; buildTypes(S.types); loadItems(true); };
});
}
async function loadTypes() {
const r = await api('/api/types');
if (r.ok) buildTypes(r.types);
}
async function loadKeys() {
try {
const k = await api('/api/keys');
if (!k || k.error) return;
const top = (k.services || []).slice(0, 6)
.map(x => `${x.service} ${fmt(x.keys)}`).join(' · ');
const f = $('#aside-foot');
if (f && !f.dataset.keys) {
f.dataset.keys = '1';
f.innerHTML += `<br><b>${fmt(k.total_keys)}</b> ключей · <b>${(k.services||[]).length}</b> сервисов` +
(top ? `<br><span style="opacity:.65">${top}</span>` : '');
}
} catch (e) {}
}
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);
$('#nav-runs-n').textContent = fmt(s.runs);
$('#brand-n').textContent = fmt(s.alive) + '/' + 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> с ценой`;
$('#aside-foot').dataset.keys = '';
renderCats(s.categories);
buildChips(s.categories);
loadKeys();
loadTypes();
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>` : ''}
${it.types ? `<div class="tps">${String(it.types).split(',').filter(Boolean).map(t => `<span class="tp">${esc(TYPE_LABEL[t] || t)}</span>`).join('')}</div>` : ''}
<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}&priced=${S.priced ? 1 : 0}&type=${encodeURIComponent(S.type)}`;
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() {
const u = `/api/sources?category=${encodeURIComponent(S.cat)}&q=${encodeURIComponent(S.srcQ)}&state=${encodeURIComponent(S.state)}`;
const r = await api(u);
if (r.error) return toast(r.error, 'err');
S.sources = r.items;
$('#src-strip').innerHTML =
`<div>всего<b>${fmt(r.total)}</b></div><div class="g">живых<b>${fmt(r.alive)}</b></div>` +
`<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>` +
`<div style="flex:1;min-width:180px">покрытие проверкой
<b>${Math.round((r.total - r.untested) / Math.max(1, r.total) * 100)}%</b>
<span class="cbar"><i style="width:${(r.total - r.untested) / Math.max(1, r.total) * 100}%"></i></span></div>`;
const rows = $('#rows');
rows.innerHTML = '';
if (!r.items.length) { rows.innerHTML = '<div class="empty">ничего не найдено под эти фильтры</div>'; return; }
const frag = document.createDocumentFragment();
r.items.forEach(s => {
const row = el('div', 'row');
const alive = (s.items || 0) > 0;
const stCls = alive ? 'ok' : (s.checked ? 'bad' : 'no');
const stTxt = alive ? 'живой' : (s.checked ? 'мёртвый' : 'не проверен');
row.innerHTML =
`<div class="sw ${s.enabled ? 'on' : ''}" title="вкл/выкл"></div>
<div class="nm"><b>${esc(s.name)}</b><span>${esc(s.url)}</span></div>
<div class="catpill">${esc(s.category)}</div>
<div><span class="catpill">${esc(s.adapter)}</span></div>
<div><span class="st ${stCls}"><i></i>${stTxt}</span>${!alive && s.note ? `<span class="note">${esc(String(s.note).slice(0, 22))}</span>` : ''}</div>
<div class="num">${fmt(s.stored)}</div>
<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();
};
row.querySelector('.act-check').onclick = () => check(null, s.name);
const del = row.querySelector('.act-del');
if (del) del.onclick = async () => {
const res = await post('/api/sources/remove', {name: s.name});
if (res.error) return toast(res.error, 'err');
toast(`${s.name} удалён`, 'ok'); loadSources(); loadStats();
};
frag.appendChild(row);
});
rows.appendChild(frag);
$('#ttl').innerHTML = `Источники<small>${fmt(r.total)} в реестре · ${fmt(r.shown)} в выборке</small>`;
}
/* ---------- jobs ---------- */
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 runFeed() {
$('#btn-feed').disabled = true;
const {job} = await post('/api/feed', {category: S.cat, limit: 12});
const j = await poll(job, 'сбор ленты');
$('#btn-feed').disabled = false;
if (j && j.state === 'done') {
toast(`собрано: источников ${fmt(j.sources)}, новых ${fmt(j.new)}, дублей ${fmt(j.dup)}`, 'ok');
await loadStats(); await loadItems(true);
}
}
async function check(category, name) {
const body = name ? {name} : {category: category || (S.cat === 'all' ? null : S.cat)};
const {job} = await post('/api/sources/check', body);
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();
}
}
/* ---------- прогоны ---------- */
async function loadRuns() {
const rows = $('#runs-rows');
rows.innerHTML = '<div class="skel"></div><div class="skel"></div><div class="skel"></div>';
const r = await api('/api/runs?limit=100');
if (r.error) { rows.innerHTML = ''; return toast(r.error, 'err'); }
const items = r.items || [];
const ok = items.filter(x => String(x.status || '').toLowerCase().startsWith('ok')).length;
const got = items.reduce((a, x) => a + (x.got || 0), 0);
const kept = items.reduce((a, x) => a + (x.kept || 0), 0);
$('#runs-strip').innerHTML =
`<div>всего прогонов<b>${fmt(r.total || items.length)}</b></div>` +
`<div class="g">успешных<b>${fmt(ok)}</b></div>` +
`<div class="r">пустых<b>${fmt(items.length - ok)}</b></div>` +
`<div>получено<b>${fmt(got)}</b></div>` +
`<div>сохранено<b>${fmt(kept)}</b></div>` +
`<div>конверсия<b>${got ? Math.round(kept / got * 100) : 0}%</b></div>`;
rows.innerHTML = '';
if (!items.length) { rows.innerHTML = '<div class="empty">прогонов нет. нажми «Собрать ленту».</div>'; return; }
items.forEach(x => {
const d = el('div', 'row runs');
const st = String(x.status || '').toLowerCase();
d.innerHTML =
`<span class="runs-time">${esc(x.when || '')}</span>` +
`<span>${esc(x.source || x.provider || '—')}</span>` +
`<span class="mono">${esc(String(x.query || '—').slice(0, 62))}</span>` +
`<span class="num">${fmt(x.got || 0)}</span>` +
`<span class="num">${fmt(x.kept || 0)}</span>` +
`<span class="st ${st.startsWith('ok') ? 'ok' : 'bad'}">${esc(x.status || '—')}</span>`;
rows.appendChild(d);
});
}
function crumb() {
const c = $('#crumbs');
if (!c) return;
const v = {feed: 'лента', src: 'источники', runs: 'прогоны'}[S.view] || S.view;
let h = `<span class="on">${esc(v)}</span>`;
if (S.cat && S.cat !== 'all') h += `<span>${esc(S.cat)}</span>`;
if (S.q) h += `<span>поиск: ${esc(S.q.slice(0, 22))}</span>`;
if (S.view === 'feed') h += `<span>${S.sort === 'score' ? 'по score' : 'по свежести'}</span>`;
if (S.view === 'src' && S.state) h += `<span>${esc(S.state)}</span>`;
c.innerHTML = h;
}
const VIEWMETA = {
feed: ['Лента', 'свежие записи со всех источников'],
src: ['Источники', 'подключение, здоровье, включение'],
runs: ['Прогоны', 'история запусков парсера'],
abuse: ['Абузы', 'промо, триалы, ключи, сливы, софт']
};
/* ---------- абузы ---------- */
const ABUSE_LABELS = {keys: 'ключи', promo: 'промо', trial: 'триалы', leak: 'сливы', tool: 'софт', resale: 'продажа', news: 'новости'};
S.abuseTag = null; S.abuse = null;
async function loadAbuse(tag) {
if (tag !== undefined) S.abuseTag = tag;
const q = new URLSearchParams({limit: 60});
if (S.abuseTag) q.set('tag', S.abuseTag);
const [d, st] = await Promise.all([api('/api/abuse?' + q), S.stats ? Promise.resolve(S.stats) : api('/api/stats')]);
S.abuse = d;
const tags = Object.entries(d.tags || {}).sort((a, b) => b[1] - a[1]);
const strip = $('#abuse-strip');
if (strip) strip.innerHTML =
`<span class="kv"><b>${d.total}</b> находок</span>` +
`<span class="kv"><b>${tags.length}</b> тегов</span>` +
`<span class="kv">в базе <b>${fmt(st.items)}</b></span>` +
`<span class="kv">источников <b>${fmt(st.sources)}</b></span>`;
const box = $('#abuse-tags');
if (box) {
box.innerHTML = `<button class="chip${!S.abuseTag ? ' on' : ''}" data-t="all">всё</button>` +
tags.map(([k, v]) => `<button class="chip${S.abuseTag === k ? ' on' : ''}" data-t="${esc(k)}">${esc(ABUSE_LABELS[k] || k)} <b>${v}</b></button>`).join('');
box.querySelectorAll('.chip').forEach(b => b.onclick = () => loadAbuse(b.dataset.t === 'all' ? null : b.dataset.t));
}
const cards = $('#abuse-cards');
if (!cards) return;
if (!d.items.length) { cards.innerHTML = '<div class="empty">пусто — запусти прогон категории abuse</div>'; return; }
cards.innerHTML = d.items.map(it => `
<div class="card">
<div class="card-h"><span class="src">${esc(it.source)}</span><span class="dots"></span>
<span class="score">${it.abuse_score}</span></div>
<a class="card-t" href="${esc(it.url)}" target="_blank" rel="noreferrer">${esc(it.title)}</a>
<div class="card-m"><span class="tags">${it.tags.map(t => `<i>${esc(ABUSE_LABELS[t] || t)}</i>`).join('')}</span>
<span class="date">${esc((it.published || '').slice(0, 10))}</span></div>
</div>`).join('');
}
$('#btn-ai-sum') && ($('#btn-ai-sum').onclick = async (e) => {
const b = e.currentTarget; const t0 = b.textContent;
const box = $('#ai-sum'); const note = $('#ai-note');
b.disabled = true; b.textContent = 'думаю…';
if (note) note.textContent = '';
try {
const q = new URLSearchParams({limit: 14});
if (S.abuseTag) q.set('tag', S.abuseTag);
const r = await api('/api/summary?' + q);
if (!r.ok) throw new Error(r.error === 'ai_not_configured' ? 'ИИ не настроен: OMNI_AI_KEY' : 'ошибка ИИ');
box.style.display = '';
box.textContent = r.summary || 'саммари пустое';
if (note) note.textContent = 'записей ' + r.n;
} catch (err) {
box.style.display = ''; box.textContent = 'не вышло: ' + err.message;
}
b.disabled = false; b.textContent = t0;
});
$('#btn-discover') && ($('#btn-discover').onclick = async (e) => {
const b = e.currentTarget; const t0 = b.textContent;
b.disabled = true; b.textContent = 'ищу фиды…';
try {
const r = await api('/api/discover?hosts=40&add=25', {method: 'POST'});
b.textContent = `+${(r.added || []).length} подключено`;
const nt = $('#discover-note'); if (nt) nt.textContent = `кандидатов ${r.found || 0} · живых ${r.alive || 0}`;
setTimeout(() => { b.disabled = false; b.textContent = t0; loadStats(); }, 4000);
} catch (err) { b.textContent = 'ошибка: ' + err.message; b.disabled = false; setTimeout(() => b.textContent = t0, 4000); }
});
/* ---------- view switching ---------- */
function setView(v) {
if (!VIEWMETA[v]) v = 'feed';
S.view = v;
['feed', 'src', 'runs', 'abuse'].forEach(k => {
const b = $('#nav-' + k); if (b) b.classList.toggle('on', k === v);
const sec = $('#view-' + k); if (sec) sec.style.display = k === v ? '' : 'none';
});
$('#btn-feed').style.display = v === 'feed' ? '' : 'none';
const ft = $('#feed-tools'); if (ft) ft.style.display = v === 'feed' ? '' : 'none';
if (v === 'abuse') loadAbuse().catch(e => console.error('abuse', e));
const m = VIEWMETA[v];
$('#ttl').innerHTML = `${m[0]}<small>${m[1]}</small>`;
crumb();
try {
const u = new URL(location.href);
u.searchParams.set('view', v);
S.cat && S.cat !== 'all' ? u.searchParams.set('cat', S.cat) : u.searchParams.delete('cat');
S.q ? u.searchParams.set('q', S.q) : u.searchParams.delete('q');
S.priced ? u.searchParams.set('priced', '1') : u.searchParams.delete('priced');
history.replaceState(null, '', u.toString());
} catch (e) {}
if (v === 'feed') loadItems(true);
else if (v === 'src') loadSources();
else loadRuns();
}
/* ---------- wire ---------- */
$('#nav-feed').onclick = () => setView('feed');
$('#nav-src').onclick = () => setView('src');
$('#nav-runs').onclick = () => setView('runs');
$('#nav-abuse').onclick = () => { setView('abuse'); loadAbuse(); };
$('#q-feed').onclick = runFeed;
$('#q-check').onclick = () => check(null, null);
$('#q-add').onclick = () => $('#modal').classList.add('on');
$('#cats-toggle').onclick = e => {
const c = $('#cats'); const hid = c.classList.toggle('collapsed');
e.target.textContent = hid ? '▸' : '▾';
try { localStorage.setItem('omni.cats', hid ? '0' : '1'); } catch (er) {}
};
$('#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);
};
document.addEventListener('keydown', e => {
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName);
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openPalette(); return; }
if (e.key === '/' && !typing) { e.preventDefault(); $('#q').focus(); return; }
if (e.key === 'Escape') { $('#modal').classList.remove('on'); $('#palette').classList.remove('on'); return; }
if (!typing && !e.ctrlKey && !e.metaKey) {
if (e.key === '1') setView('feed');
if (e.key === '2') setView('src');
if (e.key === '3') setView('runs');
if (e.key === '4') { setView('abuse'); loadAbuse(); }
if (e.key === 'r' && !e.shiftKey) runFeed();
}
});
$('#btn-add').onclick = () => $('#modal').classList.add('on');
$('#a-cancel').onclick = () => $('#modal').classList.remove('on');
$('#modal').onclick = e => { if (e.target.id === 'modal') $('#modal').classList.remove('on'); };
$('#a-save').onclick = async () => {
const name = $('#a-name').value.trim(), url = $('#a-url').value.trim();
if (!name || !url) return toast('нужны имя и url', 'err');
const res = await post('/api/sources/add', {
name, url, category: $('#a-cat').value, adapter: $('#a-adapter').value || null
});
if (res.error) return toast(res.error, 'err');
$('#modal').classList.remove('on');
$('#a-name').value = ''; $('#a-url').value = '';
toast(`${name} добавлен`, 'ok');
loadSources(); loadStats();
};
/* feed toolbar: чипы строятся из живого реестра категорий */
function buildChips(cats) {
const box = $('#feed-tools');
if (!box) return;
const list = cats || (S.stats && S.stats.categories) || [];
const tot = {}; list.forEach(c => { tot[c.name] = c.total; });
box.innerHTML =
`<button class="chip" data-k="cat" data-cat="all">всё <b>${fmt(S.stats ? S.stats.sources : 0)}</b></button>` +
list.map(c => `<button class="chip" data-k="cat" data-cat="${esc(c.name)}">${esc(c.name)} <b>${fmt(c.total)}</b></button>`).join('') +
(S.q ? `<button class="chip on" data-k="clear">поиск: ${esc(S.q.slice(0, 18))} ✕</button>` : '') +
'<span style="flex:1"></span>' +
'<button class="chip" data-k="priced">только с ценой</button>' +
'<button class="chip" data-k="sort" data-sort="score">по score</button>' +
'<button class="chip" data-k="sort" data-sort="fresh">по свежести</button>' +
'<button class="chip" data-k="sort" data-sort="price">по цене</button>' +
'<button class="chip" data-k="csv">CSV</button>';
box.querySelectorAll('.chip').forEach(b => {
b.onclick = () => {
if (b.dataset.k === 'sort') { S.sort = b.dataset.sort; syncChips(); loadItems(true); }
else if (b.dataset.k === 'priced') { S.priced = !S.priced; syncChips(); loadItems(true); }
else if (b.dataset.k === 'clear') { S.q = ''; $('#q').value = ''; syncChips(); loadItems(true); }
else if (b.dataset.k === 'csv') {
const u = `/api/export.csv?category=${encodeURIComponent(S.cat)}&q=${encodeURIComponent(S.q)}&sort=${S.sort}&priced=${S.priced ? 1 : 0}&type=${encodeURIComponent(S.type)}`;
window.open(u, '_blank'); toast('CSV выгружается', 'ok');
}
else setCat(b.dataset.cat);
};
});
syncChips();
}
/* ---------- палитра ---------- */
let PAL = [], palSel = 0;
function palItems() {
const out = [
{tag: 'вид', label: 'Лента', hint: 'свежие записи', run: () => setView('feed')},
{tag: 'вид', label: 'Источники', hint: 'менеджер источников', run: () => setView('src')},
{tag: 'вид', label: 'Прогоны', hint: 'история запусков', run: () => setView('runs')},
{tag: 'вид', label: 'Абузы', hint: 'промо/триалы/ключи', run: () => { setView('abuse'); loadAbuse(); }},
{tag: 'действие', label: 'Собрать ленту', hint: 'опрашивает включённые источники', run: runFeed},
{tag: 'действие', label: 'Проверить все источники', hint: 'health-check', run: () => check(null, null)},
{tag: 'действие', label: 'Добавить источник', hint: 'своя лента или API', run: () => $('#modal').classList.add('on')},
{tag: 'действие', label: 'Включить всё в выборке', hint: 'bulk enable', run: async () => { await post('/api/sources/toggle_all', {enabled: true, category: S.cat}); toast('все включены', 'ok'); loadSources(); loadStats(); }},
{tag: 'действие', label: 'Выключить всё в выборке', hint: 'bulk disable', run: async () => { await post('/api/sources/toggle_all', {enabled: false, category: S.cat}); toast('все выключены', 'ok'); loadSources(); loadStats(); }}
];
((S.stats && S.stats.categories) || []).forEach(c => out.push({
tag: 'категория', label: c.name, hint: `${c.alive}/${c.total} живых`, q: c.name,
run: () => { setView('feed'); setCat(c.name); }
}));
(S.sources || []).forEach(x => out.push({
tag: 'источник', label: x.name,
hint: `${x.category} · ${x.stored || 0} записей` + (x.note ? ' · ' + x.note : ''),
hay: `${x.name} ${x.category} ${(x.tags || []).join(' ')}`,
run: () => { S.srcQ = x.name; setView('src'); $('#q').value = x.name; S.q = x.name; loadSources(); }
}));
return out;
}
function renderPal(q) {
const box = $('#pal-list');
PAL = palItems();
const needle = (q || '').toLowerCase().trim();
const list = needle ? PAL.filter(x => (x.label + ' ' + x.tag + ' ' + (x.hint || '') + ' ' + (x.hay || '')).toLowerCase().includes(needle)) : PAL;
PAL = list.slice(0, 60);
if (palSel >= PAL.length) palSel = 0;
box.innerHTML = PAL.length ? '' : '<div class="empty">ничего не нашлось</div>';
PAL.forEach((x, i) => {
const d = el('div', 'pal-item' + (i === palSel ? ' sel' : ''));
d.innerHTML = `<span class="tag">${esc(x.tag)}</span><span>${esc(x.label)}</span><span class="cnt">${esc(x.hint || '')}</span>`;
d.onclick = () => { $('#palette').classList.remove('on'); x.run(); };
box.appendChild(d);
});
}
async function openPalette() {
$('#palette').classList.add('on');
palSel = 0;
$('#pal-q').value = '';
renderPal('');
setTimeout(() => $('#pal-q').focus(), 30);
if (!(S.sources || []).length) {
try {
const r = await api('/api/sources?category=all');
if (r && r.items) { S.sources = r.items; renderPal($('#pal-q').value); }
} catch (e) {}
}
}
$('#btn-palette').onclick = openPalette;
$('#btn-palette2').onclick = openPalette;
$('#palette').onclick = e => { if (e.target.id === 'palette') $('#palette').classList.remove('on'); };
$('#pal-q').oninput = e => { palSel = 0; renderPal(e.target.value); };
$('#pal-q').onkeydown = e => {
if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palSel + 1, PAL.length - 1); renderPal($('#pal-q').value); }
if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(palSel - 1, 0); renderPal($('#pal-q').value); }
if (e.key === 'Enter' && PAL[palSel]) { $('#palette').classList.remove('on'); PAL[palSel].run(); }
};
try { if (localStorage.getItem('omni.cats') === '0') { $('#cats').classList.add('collapsed'); $('#cats-toggle').textContent = '▸'; } } catch (e) {}
(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; }
if (p.get('priced') === '1') S.priced = true;
await loadStats();
const v = p.get('view');
if (v === 'abuse') { setView('abuse'); setTimeout(() => loadAbuse().catch(e => console.error('abuse', e)), 300); }
else if (v === 'src' || v === 'runs') setView(v);
else { setView('feed'); }
if (p.get('pal')) setTimeout(openPalette, 500);
})();
</script>
</body>
</html>