abuse: теги accs + продажа подписок, строже порог; panels: реальные free-имена и 130/175 с бесплатными
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

This commit is contained in:
Vlad Gothbreach
2026-10-27 03:28:44 +03:00
parent cad71c065c
commit 8d4a4c14b4
5 changed files with 275 additions and 27 deletions

View File

@@ -10,16 +10,16 @@
<style>
/* Self-hosted Inter 5.3.0 variable (OFL-1.1), Latin + Cyrillic subsets. */
@font-face { font-family: "Inter Variable"; font-style: normal; font-display: swap; font-weight: 100 900;
src: url("/assets/vendor/inter/files/inter-latin-wght-normal.woff2") format("woff2-variations");
src: url("/assets/vendor/inter/files/inter-latin-wght-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: "Inter Variable"; font-style: normal; font-display: swap; font-weight: 100 900;
src: url("/assets/vendor/inter/files/inter-latin-ext-wght-normal.woff2") format("woff2-variations");
src: url("/assets/vendor/inter/files/inter-latin-ext-wght-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: "Inter Variable"; font-style: normal; font-display: swap; font-weight: 100 900;
src: url("/assets/vendor/inter/files/inter-cyrillic-wght-normal.woff2") format("woff2-variations");
src: url("/assets/vendor/inter/files/inter-cyrillic-wght-normal.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
@font-face { font-family: "Inter Variable"; font-style: normal; font-display: swap; font-weight: 100 900;
src: url("/assets/vendor/inter/files/inter-cyrillic-ext-wght-normal.woff2") format("woff2-variations");
src: url("/assets/vendor/inter/files/inter-cyrillic-ext-wght-normal.woff2") format("woff2");
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
/* shadcn/ui neutral dark tokens adapted for vanilla OMNI: https://ui.shadcn.com/docs/theming
Vendored Tabler remains the layout and behavior foundation; no React/Radix runtime. */
@@ -52,7 +52,7 @@
--omni-space-4: 1rem;
--omni-space-6: 1.5rem;
--omni-space-8: 2rem;
--omni-text-caption: .6875rem;
--omni-text-caption: .75rem;
--omni-text-small: .75rem;
--omni-text-base: .875rem;
--omni-text-title: 1rem;
@@ -142,7 +142,7 @@ body { font-family: var(--tblr-font-sans-serif); font-size: var(--omni-text-base
.page-wrapper { background: var(--omni-background); }
.page-wrapper .container-xl { width: 100%; max-width: none; padding-inline: var(--omni-space-8); }
.page-header { margin: 0; padding-block: var(--omni-space-6); }
.page-pretitle, .subheader { font-size: var(--omni-text-caption); font-weight: 500; letter-spacing: .08em; color: var(--omni-muted-foreground); }
.page-pretitle, .subheader { font-size: var(--omni-text-caption); font-weight: 500; letter-spacing: .06em; color: var(--omni-muted-foreground); }
.page-pretitle { margin-bottom: var(--omni-space-2); }
.page-title { font-size: var(--omni-text-heading); line-height: 1.3333; font-weight: 600; letter-spacing: -.025em; }
#page-description { max-width: 64rem; line-height: 1.5; }
@@ -156,9 +156,10 @@ a:hover { color: var(--omni-foreground); }
.navbar.navbar-vertical > .container-fluid { min-width: 0; gap: 0; }
.navbar.navbar-vertical .navbar-brand { position: static; inset: auto; transform: none; margin: 0; min-width: 0; white-space: normal; color: var(--omni-foreground); }
.brand-lockup { display: flex; align-items: center; gap: var(--omni-space-3); text-decoration: none; }
.skip-link { position: absolute; z-index: 1090; }
.brand-mark { display: grid; place-items: center; flex: 0 0 var(--omni-space-8); width: var(--omni-space-8); height: var(--omni-space-8); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); background: var(--omni-card); }
.brand-name { display: block; font-size: var(--omni-text-title); font-weight: 600; line-height: 1.25; letter-spacing: .08em; }
.brand-caption { display: block; margin-top: var(--omni-space-1); color: var(--omni-muted-foreground); font-size: var(--omni-text-caption); font-weight: 400; letter-spacing: .04em; }
.brand-name { display: block; font-size: var(--omni-text-title); font-weight: 600; line-height: 1.3; letter-spacing: .04em; }
.brand-caption { display: block; margin-top: var(--omni-space-1); color: var(--omni-muted-foreground); font-size: var(--omni-text-caption); font-weight: 500; line-height: 1.45; letter-spacing: .06em; }
#sidebar-menu { min-width: 0; width: 100%; }
.navbar.navbar-vertical .navbar-nav { --tblr-sidebar-nav-padding-y: var(--omni-space-2); --tblr-sidebar-nav-padding-x: var(--omni-space-3); gap: var(--omni-space-1); padding-inline: var(--omni-space-2); }
.navbar.navbar-vertical .navbar-nav .nav-link { gap: var(--omni-space-3); min-height: var(--omni-control-height); padding: var(--omni-space-2) var(--omni-space-3); margin: 0; border-radius: var(--omni-radius); color: var(--omni-muted-foreground); font-size: var(--omni-text-base); line-height: 1.4; transition: background-color var(--omni-transition), color var(--omni-transition); }
@@ -172,7 +173,7 @@ a:hover { color: var(--omni-foreground); }
.sidebar-footer { padding: var(--omni-space-4) var(--omni-space-3); }
#health { line-height: 1.6; }
.sidebar-footer .btn { padding-inline: 0; white-space: normal; text-align: start; }
#sidebar-toggle { position: static; inset: auto; transform: none; width: auto; min-width: 5.5rem; height: var(--omni-control-height); flex: 0 0 auto; gap: var(--omni-space-2); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); color: var(--omni-foreground); font-size: var(--omni-text-base); background: var(--omni-background); box-shadow: none; }
#sidebar-toggle { position: static; inset: auto; transform: none; width: auto; min-width: 5.5rem; height: auto; min-height: var(--omni-control-height); flex: 0 0 auto; gap: var(--omni-space-2); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); color: var(--omni-foreground); font-size: var(--omni-text-base); background: var(--omni-background); box-shadow: none; }
#sidebar-toggle .navbar-toggler-icon { width: var(--omni-space-4); height: var(--omni-space-4); }
#sidebar-toggle .ms-2 { margin-inline-start: 0 !important; }
.card { --tblr-card-spacer-x: var(--omni-space-4); --tblr-card-spacer-y: var(--omni-space-4); --tblr-card-cap-bg: var(--omni-background); --tblr-card-bg: var(--omni-background); --tblr-card-border-color: var(--omni-border); --tblr-card-border-radius: var(--omni-radius); --tblr-card-box-shadow: none; min-width: 0; background: var(--omni-background); border-color: var(--omni-border); box-shadow: none; }
@@ -186,7 +187,7 @@ a:hover { color: var(--omni-foreground); }
.nav-tabs .nav-link { margin: 0; padding: var(--omni-space-3) 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--omni-muted-foreground); font-weight: 500; }
.nav-tabs .nav-link:hover { color: var(--omni-foreground); border-color: var(--omni-border); }
.nav-tabs .nav-link.active { color: var(--omni-foreground); background: transparent; border-color: var(--omni-foreground); }
.btn { --tblr-btn-color: var(--omni-foreground); --tblr-btn-bg: var(--omni-background); --tblr-btn-border-color: var(--omni-border); --tblr-btn-hover-color: var(--omni-foreground); --tblr-btn-hover-bg: var(--omni-muted); --tblr-btn-hover-border-color: var(--omni-border); --tblr-btn-active-color: var(--omni-foreground); --tblr-btn-active-bg: var(--omni-muted); --tblr-btn-active-border-color: var(--omni-ring); --tblr-btn-disabled-color: var(--omni-muted-foreground); --tblr-btn-disabled-bg: var(--omni-muted); --tblr-btn-disabled-border-color: var(--omni-border); --tblr-btn-box-shadow: none; min-height: var(--omni-control-height); padding: var(--omni-space-2) var(--omni-space-3); font-size: var(--omni-text-base); font-weight: 500; line-height: 1.25; border-radius: var(--omni-radius); background-image: none; box-shadow: none; transition: background-color var(--omni-transition), color var(--omni-transition), border-color var(--omni-transition); }
.btn { --tblr-btn-color: var(--omni-foreground); --tblr-btn-bg: var(--omni-background); --tblr-btn-border-color: var(--omni-border); --tblr-btn-hover-color: var(--omni-foreground); --tblr-btn-hover-bg: var(--omni-muted); --tblr-btn-hover-border-color: var(--omni-border); --tblr-btn-active-color: var(--omni-foreground); --tblr-btn-active-bg: var(--omni-muted); --tblr-btn-active-border-color: var(--omni-ring); --tblr-btn-disabled-color: var(--omni-muted-foreground); --tblr-btn-disabled-bg: var(--omni-muted); --tblr-btn-disabled-border-color: var(--omni-border); --tblr-btn-box-shadow: none; min-height: var(--omni-control-height); padding: var(--omni-space-2) var(--omni-space-3); font-size: var(--omni-text-base); font-weight: 500; line-height: 1.4; border-radius: var(--omni-radius); background-image: none; box-shadow: none; transition: background-color var(--omni-transition), color var(--omni-transition), border-color var(--omni-transition); }
.btn:hover, .btn:active, .btn:focus-visible { background-image: none; box-shadow: none; }
.btn-primary { --tblr-btn-color: var(--omni-background); --tblr-btn-bg: var(--omni-foreground); --tblr-btn-border-color: var(--omni-foreground); --tblr-btn-hover-color: var(--omni-background); --tblr-btn-hover-bg: var(--omni-primary-hover); --tblr-btn-hover-border-color: var(--omni-primary-hover); --tblr-btn-active-color: var(--omni-background); --tblr-btn-active-bg: var(--omni-primary-hover); --tblr-btn-active-border-color: var(--omni-primary-hover); }
.btn-ghost-secondary, .btn-link { --tblr-btn-color: var(--omni-muted-foreground); --tblr-btn-bg: transparent; --tblr-btn-border-color: transparent; --tblr-btn-hover-color: var(--omni-foreground); --tblr-btn-hover-bg: var(--omni-muted); --tblr-btn-hover-border-color: transparent; }
@@ -227,15 +228,15 @@ option, optgroup { color: var(--omni-foreground); background: var(--omni-backgro
.item:hover, .item:focus-within { background: var(--omni-card); }
.reader-open { display: block; width: 100%; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; text-align: start; }
.reader-open:hover .feed-title, .reader-open:focus-visible .feed-title { color: var(--omni-foreground); text-decoration: underline; text-underline-offset: var(--omni-space-1); }
.feed-title { font-size: var(--omni-text-title); line-height: 1.4; letter-spacing: -.01em; }
.feed-title { font-size: var(--omni-text-title); line-height: 1.45; letter-spacing: 0; }
.line-clamp-2 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; }
.feed-content { flex: 1; min-width: 0; }
.feed-preview { font-size: var(--omni-text-base); line-height: 1.5; }
.feed-preview { font-size: var(--omni-text-base); line-height: 1.55; }
#list .feed-preview { -webkit-line-clamp: 1; }
.feed-original { color: var(--omni-muted-foreground); }
#feed-form > .row { --tblr-gutter-x: var(--omni-space-3); --tblr-gutter-y: var(--omni-space-3); }
#feed-form summary { cursor: pointer; font-size: var(--omni-text-small); }
.badge { padding: var(--omni-space-1) var(--omni-space-2); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); font-size: var(--omni-text-caption); line-height: 1; font-weight: 500; letter-spacing: 0; }
.badge { padding: 2px var(--omni-space-2); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); font-size: var(--omni-text-caption); line-height: 1.4; font-weight: 500; letter-spacing: 0; }
.bg-blue-lt, .bg-primary-lt, .bg-secondary-lt { color: var(--omni-secondary) !important; background-color: var(--omni-muted) !important; border-color: var(--omni-border); }
.bg-green-lt { color: var(--omni-success) !important; background-color: var(--omni-success-bg) !important; border-color: var(--omni-success-border); }
.bg-red-lt { color: var(--omni-danger) !important; background-color: var(--omni-danger-bg) !important; border-color: var(--omni-danger-border); }
@@ -306,7 +307,7 @@ pre { padding: var(--omni-space-4); }
</style>
</head>
<body>
<a class="visually-hidden-focusable btn btn-primary position-absolute m-3" href="#main-content" data-skip-main>Перейти к содержимому</a>
<a class="visually-hidden-focusable skip-link btn btn-primary m-3" href="#main-content" data-skip-main>Перейти к содержимому</a>
<div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg bg-surface" aria-label="Главная навигация">
<div class="container-fluid">
@@ -506,7 +507,7 @@ V.abuse=async ctx=>{
};
async function loadPanels(ctx=activeContext){const scope=requestScope("panels",ctx),box=$("#pn-box");if(!box)return;box.innerHTML=loading("Загрузка API-панелей…");try{const d=await api("/api/panels",{signal:scope.signal});if(!scope.valid())return;const all=(d.panels||[]).filter(p=>Number(p.free_count||p.free||0)>0),rows=all.slice(0,40);box.innerHTML=`<div class="card-body border-bottom"><h2 class="card-title">Панели с бесплатными моделями</h2><p class="text-secondary mb-0">${num(all.length)} с бесплатными моделями из ${num(d.total)} в реестре · показано ${num(rows.length)}. Доступность и условия проверяйте на сайте панели. Учётные данные здесь не запрашиваются.</p></div>${rows.length?`<div class="table-responsive"><table class="table table-vcenter mb-0" id="panels-table"><thead><tr><th scope="col">Панель</th><th scope="col">Бесплатных моделей</th><th scope="col">Всего моделей</th><th scope="col">Действие</th></tr></thead><tbody>${rows.map(p=>{const raw=String(p.host||p.url||""),url=safeUrl(/^https?:\/\//i.test(raw)?raw:"https://"+raw),host=url?new URL(url).hostname:"Адрес недоступен",models=Array.isArray(p.models)?p.models.length:(p.models||p.model_count||0);return `<tr><td class="fw-medium text-break">${esc(host)}</td><td>${num(p.free_count||p.free||0)}</td><td>${num(models)}</td><td>${external(url,"Открыть панель","btn btn-sm")}</td></tr>`}).join("")}</tbody></table></div>`:empty("Бесплатных панелей пока нет","В текущем реестре нет панелей с бесплатными моделями. Обновите список позже.",'<button class="btn" data-act="panels-retry" type="button">Обновить список</button>')}`}
catch(error){if(scope.valid()&&error.name!=="AbortError")box.innerHTML=`<div class="p-3">${failure(error,()=>loadPanels(ctx))}</div>`}}
async function summary(){const ctx=requestScope("summary"),box=$("#sum-box");if(!box)return;box.innerHTML=`<div class="card">${loading("Подготовка сводки…")}</div>`;try{const d=await api("/api/summary?limit=14"+(S.tag?"&tag="+encodeURIComponent(S.tag):""),{signal:ctx.signal});if(!ctx.valid())return;box.innerHTML=`<section class="card card-body"><h2 class="card-title">Сводка по ${num(d.n)} находкам</h2><div class="reader-markdown">${md(d.summary||"Сводка не сформирована. Проверьте настройку ИИ на сервере.")}</div></section>`}catch(error){if(ctx.valid()&&error.name!=="AbortError")box.innerHTML=`<div class="card card-body">${failure(error,summary)}</div>`}}
async function summary(){const ctx=requestScope("summary"),box=$("#sum-box");if(!box)return;box.innerHTML=`<div class="card">${loading("Подготовка сводки…")}</div>`;try{const d=await api("/api/summary?limit=14"+(S.tag?"&tag="+encodeURIComponent(S.tag):""),{signal:ctx.signal});if(!ctx.valid())return;box.innerHTML=`<section class="card card-body"><h2 class="card-title">Сводка по ${num(d.n)} находкам</h2><div class="reader-markdown">${md(d.summary||"Сводка не сформирована. Проверьте настройку ИИ на сервере.")}</div></section>`}catch(error){if(ctx.valid()&&error.name!=="AbortError"){const reason=String(error.message||"");const hint=reason.includes("ai_not_configured")?"ИИ не настроен. Добавьте ключ в разделе «Ключи» и повторите сводку.":"";box.innerHTML=`<div class="card card-body">${hint?`<p class="mb-2">${esc(hint)}</p>`:""}${failure(error,summary)}</div>`}}}
V.sources=async ctx=>{
ctx.mount(`<div class="card"><div class="card-body border-bottom"><div class="row g-3 align-items-end"><div class="col-sm-6 col-xl-4"><label class="form-label" for="s-q">Поиск источника</label><input class="form-control" id="s-q" type="search" value="${esc(S.srcQ)}" placeholder="Название или адрес сайта"></div><div class="col-sm-6 col-xl-3"><label class="form-label" for="s-cat">Категория</label><select class="form-select" id="s-cat">${categoryOptions(S.srcCat)}</select></div><div class="col-6 col-xl-3"><label class="form-label" for="s-state">Результат проверки</label><select class="form-select" id="s-state">${[["","Любой результат"],["ok","Успешно"],["empty","Нет записей"],["err","Ошибка"],["none","Не проверен"]].map(([v,l])=>`<option value="${v}" ${S.srcState===v?"selected":""}>${l}</option>`).join("")}</select></div><div class="col-6 col-xl-2"><label class="form-label" for="s-enabled">Участие в сборе</label><select class="form-select" id="s-enabled">${[["","Все"],["on","Включены"],["off","Выключены"]].map(([v,l])=>`<option value="${v}" ${S.srcEnabled===v?"selected":""}>${l}</option>`).join("")}</select></div></div><div class="d-flex flex-wrap align-items-center gap-3 mt-3"><button class="btn btn-sm" type="button" data-act="check-all" data-lock="source-check">Проверить категорию</button><button class="btn btn-link btn-sm p-0" data-act="source-reset" type="button">Сбросить фильтры</button><details class="ms-sm-auto"><summary class="text-secondary small">Массовое включение</summary><div class="d-flex flex-wrap gap-2 mt-2"><button class="btn btn-sm" data-act="toggle-all" data-on="1" data-lock="source-write" type="button">Включить категорию</button><button class="btn btn-sm" data-act="toggle-all" data-on="0" data-lock="source-write" type="button">Выключить категорию</button></div></details></div><p class="text-secondary small mt-3 mb-0">«Включён» — участвует в сборе. «Проверен» — есть результат запроса; включение само по себе не считается проверкой. «В базе» — уже сохранённые записи.</p></div><div class="card-body py-3 text-secondary" id="src-summary" aria-live="polite">Загрузка реестра…</div><div id="src-bulk" class="card-body bg-primary-lt border-top border-bottom" hidden><div class="d-flex flex-wrap gap-2 align-items-center"><span id="src-sel" class="me-auto">Выбрано: 0</span><button class="btn btn-sm" data-act="bulk-on" data-lock="source-write" type="button">Включить</button><button class="btn btn-sm" data-act="bulk-off" data-lock="source-write" type="button">Выключить</button><button class="btn btn-sm" data-act="bulk-check" data-lock="source-check" type="button">Проверить выбранные</button></div></div><div class="table-responsive"><table class="table table-vcenter mb-0" id="src-tbl"><thead><tr><th scope="col"><input class="form-check-input m-0" type="checkbox" id="src-all" aria-label="Выбрать все показанные источники"></th><th scope="col">Источник</th><th scope="col">Категория</th><th scope="col">Результат</th><th scope="col" class="text-end">В базе</th><th scope="col">Последняя проверка</th><th scope="col">Действия</th></tr></thead><tbody><tr><td colspan="7">${loading("Загрузка источников…",5)}</td></tr></tbody></table></div></div>`);await loadSources(ctx);