абузы: extract промокодов/сумм/ссылок в API и панели, +16 проверенных источников (TG/reddit/discourse/RSS/github/HN), чистка HTML в деталях, клэмп days
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0e13; --bg2:#0c1219; --panel:#111823; --panel2:#0e151e; --line:#1c2735; --line2:#27364a;
|
||||
--text:#e9eff7; --muted:#93a3b8; --dim:#65758b;
|
||||
--text:#e9eff7; --muted:#93a3b8; --dim:#7b8ba1;
|
||||
--acc:#37d67a; --acc2:#5aa9ff; --warn:#f5b544; --bad:#ef5b5b; --violet:#a78bfa;
|
||||
--r:10px; --r2:14px;
|
||||
--mono:"Cascadia Mono",Consolas,"SF Mono",monospace;
|
||||
@@ -105,6 +105,15 @@ nav{display:flex;flex-direction:column;gap:1px;padding:6px 8px;overflow:auto}
|
||||
.chip.tool{background:#132028;color:#8fd8e8;border-color:#234450}
|
||||
.chip.model{background:#1b1630;color:#c2b2f7;border-color:#332a52}
|
||||
.chip.promo{background:#12251a;color:#9fe6b4;border-color:#245035}
|
||||
.chip.news{background:#151f2b;color:#a9bdd2;border-color:#26374b}
|
||||
.chip.trial{background:#221a33;color:#cbb3f2;border-color:#3a2c58}
|
||||
.chip.resale{background:#2a1626;color:#f0a8cf;border-color:#4a2340}
|
||||
.chip.release{background:#12233a;color:#9dc4f5;border-color:#21405f}
|
||||
.chip.guide{background:#12282a;color:#96dede;border-color:#1f4a4c}
|
||||
.chip.dataset{background:#1a2438;color:#a9bdf0;border-color:#2c3c5c}
|
||||
.chip.job{background:#2a2413;color:#ecd08a;border-color:#4a3f1f}
|
||||
.chip.price{background:#12251a;color:#9fe6b4;border-color:#245035}
|
||||
.chip.question{background:#1b2029;color:#b6c2d4;border-color:#2c3746}
|
||||
.chip.on{background:#12351f;border-color:#1f5c33;color:#b9f5cd}
|
||||
.chip.off{background:#211517;border-color:#4a2429;color:#f0a9a9}
|
||||
.chip.click{cursor:pointer}
|
||||
@@ -127,10 +136,10 @@ td .u{color:var(--dim);font-size:11px;display:block;max-width:420px;overflow:hid
|
||||
/* feed */
|
||||
.item{border-bottom:1px solid #151f2b;padding:11px 13px;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px}
|
||||
.item:hover{background:#0f1822}
|
||||
.item .t{font-size:14px;font-weight:600;letter-spacing:.1px;line-height:1.35;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.item .t{font-size:14.5px;font-weight:600;letter-spacing:.1px;line-height:1.35;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.item .m{display:flex;gap:8px;align-items:center;flex-wrap:wrap;color:var(--dim);font-size:11.5px}
|
||||
.item .src{color:#9db7d4}
|
||||
.item .x{color:var(--muted);font-size:12px;margin-top:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.item .x{color:var(--muted);font-size:12.5px;margin-top:5px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.item .side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;justify-content:center}
|
||||
.sc{font:11px var(--mono);color:var(--muted);border:1px solid var(--line2);border-radius:6px;padding:1px 5px}
|
||||
.sc.hi{color:#b9f5cd;border-color:#1f5c33;background:#12251a}
|
||||
@@ -188,6 +197,7 @@ label.chk{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font
|
||||
.md code{font-family:var(--mono);font-size:11.5px;background:#132029;padding:1px 5px;border-radius:5px;color:#9fd7c8}
|
||||
.md a{color:#8fc2ff}
|
||||
.pager{display:flex;gap:8px;justify-content:center;padding:14px}
|
||||
.legend{font-size:11px;color:var(--dim);display:flex;gap:14px;align-items:center;flex-wrap:wrap}
|
||||
.hint{font-size:11.5px;color:var(--dim)}
|
||||
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
|
||||
@media (max-width:1100px){.g4{grid-template-columns:repeat(2,1fr)}.g21{grid-template-columns:1fr}}
|
||||
@@ -343,6 +353,14 @@ async function boot(){
|
||||
$("#q").placeholder=`Поиск по ${num(st.items)} записям: CVE, ключи, сливы, модели…`;
|
||||
}catch(e){ $("#health").innerHTML=`<span class="dot bad"></span><span>API недоступен</span>`; }
|
||||
route();
|
||||
setInterval(refreshHealth,60000);
|
||||
}
|
||||
async function refreshHealth(){
|
||||
try{
|
||||
const h=await api("/api/health");
|
||||
$("#health").innerHTML=`<span class="dot ${h.alive>0?"":"bad"}"></span><span>${num(h.items)} записей · ${num(h.alive)}/${num(h.sources)} живых · аптайм ${Math.round(h.uptime/60)} мин</span>`;
|
||||
$("#q").placeholder=`Поиск по ${num(h.items)} записям: CVE, ключи, сливы, модели…`;
|
||||
}catch(e){ $("#health").innerHTML=`<span class="dot bad"></span><span>API недоступен</span>`; }
|
||||
}
|
||||
|
||||
/* ============================= views ============================= */
|
||||
@@ -354,12 +372,12 @@ V.overview=async()=>{
|
||||
box.innerHTML=`<div class="grid g4">${Array(4).fill(`<div class="card"><div class="skel" style="width:40%"></div><div class="skel" style="width:70%;height:26px;margin-top:10px"></div><div class="skel" style="width:55%;margin-top:10px"></div></div>`).join("")}</div>
|
||||
<div class="grid g21" style="margin-top:14px"><div class="card"><div class="skel" style="height:130px"></div></div><div class="card"><div class="skel" style="height:130px"></div></div></div>`;
|
||||
const [st,tl,runs,watch,ab]=await Promise.all([
|
||||
api("/api/stats"), api("/api/timeline?days=30"), api("/api/runs?limit=6"),
|
||||
api("/api/stats"), api("/api/timeline?days=30&mode=seen"), api("/api/runs?limit=6"),
|
||||
api("/api/watch").catch(()=>({rules:[]})), api("/api/abuse?limit=8").catch(()=>({items:[]}))
|
||||
]);
|
||||
S.stats=st;
|
||||
const day=tl.points[tl.points.length-1]?.n||0, prev=tl.points[tl.points.length-2]?.n||0;
|
||||
const dPct=prev?Math.round((day-prev)/prev*100):0;
|
||||
const day=tl.last24||tl.points[tl.points.length-1]?.n||0, prev=tl.points[tl.points.length-2]?.n||0;
|
||||
const dPct=prev?Math.round((day-prev)/prev*100):null;
|
||||
const live=(st.sources?(st.alive/st.sources*100):0).toFixed(0);
|
||||
const okRate=st.runs?(st.runs_ok/st.runs*100).toFixed(0):0;
|
||||
const kpi=(label,val,sub,accent)=>`<div class="card hov"><div class="sub">${label}</div>
|
||||
@@ -372,16 +390,17 @@ V.overview=async()=>{
|
||||
<button class="btn ${S.auto?"primary":""}" data-act="auto">${S.auto?"Автообновление вкл":"Автообновление выкл"}</button>
|
||||
</div></div>
|
||||
<div class="grid g4">
|
||||
${kpi("Записей всего",num(st.items),`+${num(day)} за последний день <span class="${dPct>=0?"up":"down"}">${dPct>=0?"▲":"▼"} ${Math.abs(dPct)}%</span>`)}
|
||||
${kpi("Записей всего",num(st.items),`+${num(day)} за сутки импорта${dPct===null?"":` <span class="${dPct>=0?"up":"down"}">${dPct>=0?"▲":"▼"} ${Math.abs(dPct)}%</span>`}`)}
|
||||
${kpi("Источников живых",`${num(st.alive)}<small>/ ${num(st.sources)}</small>`,`${live}% реестра отдают записи`)}
|
||||
${kpi("Ключей готово",`${num(st.keys?.ready||0)}<small>/ ${num(st.keys?.keys||0)}</small>`,`${num(st.keys?.services||0)} сервисов · ротация и остывание`)}
|
||||
${kpi("Watchlist",num((watch.rules||[]).length),`${num(hits)} совпадений по правилам`)}
|
||||
</div>
|
||||
|
||||
<div class="grid g21" style="margin-top:14px">
|
||||
<div class="card"><h3>Поток за 30 дней <span class="r">${num(tl.total)} записей · пик ${num(tl.max)}</span></h3>
|
||||
<div class="card"><h3>Импорт за 30 дней <span class="r">${num(tl.total)} записей · пик ${num(tl.max)}/день</span></h3>
|
||||
${chart(tl.points)}
|
||||
<div class="chart-x"><span>${dt(tl.points[0]?.day)}</span><span>${dt(tl.points[Math.floor(tl.points.length/2)]?.day)}</span><span>сегодня</span></div>
|
||||
<div class="legend" style="margin-top:9px"><span>столбец = записей за день импорта</span><span>наведение — дата и число CVE</span><span>переключатель «сутки» в ленте фильтрует по дате публикации</span></div>
|
||||
</div>
|
||||
<div class="card"><h3>Источники по категориям</h3>
|
||||
${(st.categories||[]).slice(0,8).map(c=>{const r=c.total?Math.round((c.alive||0)/c.total*100):0;
|
||||
@@ -422,8 +441,8 @@ V.feed=async()=>{
|
||||
const box=$("#view");
|
||||
box.innerHTML=`<div class="page-h"><div><h2>Лента</h2><p>Все записи реестра: фильтры по типу, периоду, сортировке; клик — детали и соседи.</p></div>
|
||||
<div class="right">
|
||||
<a class="btn sm" href="/api/export.csv?${exQS()}" download>CSV</a>
|
||||
<a class="btn sm" href="/api/export.json?${exQS()}" download>JSON</a>
|
||||
<a class="btn sm" href="/api/export.csv?${exQS()}" download title="Экспорт текущего фильтра, до 1000 записей">CSV</a>
|
||||
<a class="btn sm" href="/api/export.json?${exQS()}" download title="Экспорт текущего фильтра, до 1000 записей">JSON</a>
|
||||
</div></div>
|
||||
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
||||
<span class="dim" style="font-size:11.5px">Типы:</span>
|
||||
@@ -452,6 +471,14 @@ async function loadItems(reset){
|
||||
: `<span class="dim">показано ${num(S.items.length)} из ${num(S.total)}</span>`;
|
||||
}
|
||||
|
||||
function findChips(f){
|
||||
if(!f) return "";
|
||||
const p=[];
|
||||
(f.codes||[]).slice(0,2).forEach(c=>p.push(`<span class="chip promo" title="промокод/код">code: ${esc(c)}</span>`));
|
||||
if(f.amount) p.push(`<span class="chip credits">${esc(f.amount)}</span>`);
|
||||
if(f.term) p.push(`<span class="chip invite">${esc(f.term)}</span>`);
|
||||
return p.join("");
|
||||
}
|
||||
function itemRow(it,compact){
|
||||
const types=(it.types||"").split(",").filter(Boolean).slice(0,3);
|
||||
const labels=typeLabels();
|
||||
@@ -463,7 +490,7 @@ function itemRow(it,compact){
|
||||
<div class="t">${esc(it.title||"(без заголовка)")}</div>
|
||||
<div class="m"><span class="src">${esc(it.source||"")}</span>
|
||||
${types.map(t=>`<span class="chip t ${t}">${esc(labels[t]||t)}</span>`).join("")}
|
||||
${price}<span>${dt(it.published)}</span>${it.views?`<span>👁 ${num(it.views)}</span>`:""}</div>
|
||||
${price}${findChips(it.find)}<span>${dt(it.published)}</span>${it.views?`<span>👁 ${num(it.views)}</span>`:""}</div>
|
||||
${compact?"":`<div class="x">${esc(it.text||"")}</div>`}
|
||||
</div>
|
||||
<div class="side">
|
||||
@@ -510,7 +537,7 @@ function closeDrawer(){$("#drawer").classList.remove("on");$("#backdrop").classL
|
||||
|
||||
/* ---------- abuse ---------- */
|
||||
V.abuse=async()=>{
|
||||
const d=await api("/api/abuse?limit=60");
|
||||
const d=await api("/api/abuse?limit=120");
|
||||
const tags=Object.entries(d.tags||{}).slice(0,14);
|
||||
$("#view").innerHTML=`
|
||||
<div class="page-h"><div><h2>Абузы</h2><p>Скам/промо/триалы/ключи в постах каналов и форумов. Вес считается по признакам: обещания, цены, «в личку», свежесть.</p></div>
|
||||
@@ -620,20 +647,28 @@ V.runs=async()=>{
|
||||
const ok=(d.items||[]).filter(r=>r.status==="ok").length;
|
||||
$("#view").innerHTML=`
|
||||
<div class="page-h"><div><h2>Прогоны</h2><p>Журнал сборов: что запросили, сколько получили и сколько нового легло в базу.</p></div>
|
||||
<div class="right"><span class="chip on">ok ${ok}</span><span class="chip off">ошибки ${d.total-ok}</span></div></div>
|
||||
<div class="right">
|
||||
<span class="chip click on" data-rf="all">все <span class="n">${num(d.total)}</span></span>
|
||||
<span class="chip click" data-rf="ok">ok <span class="n">${ok}</span></span>
|
||||
<span class="chip click" data-rf="err">ошибки <span class="n">${d.total-ok}</span></span></div></div>
|
||||
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
||||
<input id="r-q" placeholder="фильтр: источник или запрос" style="min-width:280px">
|
||||
<span class="spacer"></span><span class="dim mono">всего ${num(d.total)}</span></div></div>
|
||||
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="r-tbl"><thead><tr>
|
||||
<th>когда</th><th>источник</th><th>запрос</th><th class="right">got</th><th class="right">kept</th><th class="right">байт</th><th>статус</th></tr></thead>
|
||||
<tbody>${(d.items||[]).map(r=>`<tr><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
|
||||
<tbody>${(d.items||[]).map(r=>`<tr data-st="${esc(r.status||"")}"><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
|
||||
<td class="dim" style="max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.query||"")}</td>
|
||||
<td class="right mono">${r.got??""}</td><td class="right mono">${r.kept??""}</td><td class="right mono dim">${r.bytes?num(r.bytes):""}</td>
|
||||
<td><span class="pill ${r.status==="ok"?"ok":"no"}"></span>${esc(r.status||"")}</td></tr>`).join("")}</tbody></table></div></div>`;
|
||||
$("#r-q").addEventListener("input",debounce(e=>{
|
||||
const v=e.target.value.toLowerCase();
|
||||
$$("#r-tbl tbody tr").forEach(tr=>tr.style.display=tr.textContent.toLowerCase().includes(v)?"":"none");
|
||||
},120));
|
||||
S.runQ=""; S.runF="all";
|
||||
const applyRuns=()=>{const v=(S.runQ||"").toLowerCase();
|
||||
$$("#r-tbl tbody tr").forEach(tr=>{
|
||||
const okT=!v||tr.textContent.toLowerCase().includes(v);
|
||||
const okS=S.runF==="all"||(S.runF==="ok"?tr.dataset.st==="ok":tr.dataset.st!=="ok");
|
||||
tr.style.display=(okT&&okS)?"":"none";});};
|
||||
$("#r-q").addEventListener("input",debounce(e=>{S.runQ=e.target.value;applyRuns()},120));
|
||||
$$("#view [data-rf]").forEach(c=>c.addEventListener("click",()=>{
|
||||
$$("#view [data-rf]").forEach(x=>x.classList.remove("on"));c.classList.add("on");S.runF=c.dataset.rf;applyRuns();}));
|
||||
};
|
||||
|
||||
/* ---------- telegram ---------- */
|
||||
@@ -749,7 +784,9 @@ V.watch=async()=>{
|
||||
<div class="card" style="margin-bottom:14px"><div class="row">
|
||||
<input id="w-q" placeholder="например: CVE-2026, firecrawl, приватка" style="min-width:320px">
|
||||
<select id="w-type"><option value="all">любой тип</option>${(S.types||[]).map(t=>`<option value="${esc(t.key)}">${esc(t.label)}</option>`).join("")}</select>
|
||||
<button class="btn primary" data-act="watch-add">Добавить правило</button></div></div>
|
||||
<button class="btn primary" data-act="watch-add">Добавить правило</button></div>
|
||||
<div class="row" style="margin-top:10px"><span class="dim">быстро:</span>
|
||||
${["CVE-2026","ключ","firecrawl","слив","промо","52pojie"].map(x=>`<span class="chip click" data-watch-q="${esc(x)}">${esc(x)}</span>`).join("")}</div></div>
|
||||
<div class="grid g2">${(d.rules||[]).map(r=>`<div class="card hov">
|
||||
<div class="row"><b>${esc(r.q)}</b><span class="chip">${esc(r.type)}</span><span class="spacer"></span>
|
||||
<span class="chip on">${num(r.count)}</span><button class="ico" data-watch-del="${esc(r.id)}">✕</button></div>
|
||||
@@ -865,6 +902,8 @@ document.addEventListener("click",async e=>{
|
||||
const it=t.closest("[data-id]"); if(it){e.preventDefault();openItem(+it.dataset.id);return}
|
||||
const wd=t.closest("[data-watch-del]"); if(wd){e.preventDefault();await post("/api/watch/remove",{id:wd.dataset.watchDel});toast("правило удалено");render();return}
|
||||
const wt=t.closest("[data-watch]"); if(wt){e.preventDefault();await post("/api/watch/add",{q:wt.dataset.watch,type:"all"});toast("в watchlist");return}
|
||||
const wq=t.closest("[data-watch-q]"); if(wq){e.preventDefault();
|
||||
post("/api/watch/add",{q:wq.dataset.watchQ,type:"all"}).then(()=>{toast("правило добавлено: "+wq.dataset.watchQ);render()});return}
|
||||
const po=t.closest("[data-pull-one]"); if(po){e.preventDefault();tgPull([po.dataset.pullOne]);return}
|
||||
const ts=t.closest("[data-toggle-src]"); if(ts){e.preventDefault();
|
||||
await post("/api/sources/toggle",{name:ts.dataset.toggleSrc,enabled:ts.dataset.on==="1"});toast("переключено");srcDetail(ts.dataset.toggleSrc);return}
|
||||
|
||||
Reference in New Issue
Block a user