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

@@ -23,8 +23,10 @@ RULES = [
r"redeem\s?(code|код)", r"invite\s?code", r"реф(еральн|код)", r"бонус\s+на\s+сч[её]т", r"кэшб[еэ]к|cashback",
]),
("trial", "триалы", 24, [
r"trial", r"триал", r"free\s+credits?", r"бесплатн\w*\s+(кредит|токен|баланс|доступ|период)",
r"\btrial\b", r"триал", r"free\s+credits?", r"бесплатн\w*\s+(кредит|токен|баланс|доступ|период)",
r"free\s+tier", r"\$\d+\s+free", r"试用", r"免费", r"пробн\w+\s+период", r"без\s+депозита", r"no\s+deposit",
r"\bfree\b\s*[-–—:]\s*\S", r"\b\d+\s+enrolls?\s+left\b", r"\bfree\s+enroll\w*", r"\bfree\s+access\b",
r"\b100%\s*(?:free|off)\b", r"\bfree\s+forever\b", r"\b\d+\s*(?:mo|мес)\w*\s+free\b",
]),
("leak", "сливы", 26, [
r"слив(?!\s*(сок|ки|очн|тест|трафик))", r"leaked\s+(database|db|dump|combo)", r"db\s?dump",
@@ -36,8 +38,16 @@ RULES = [
r"авторег", r"autoreg", r"panel", r"панел[ьи]\s", r"рассылк", r"sender", r"скрипт",
]),
("resale", "продажа", 14, [
r"продаю", r"продам", r"куплю\s+аккаунт", r"for\s+sale", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)",
r"реселлер", r"resell",
r"продаю", r"продам", r"куплю\s+аккаунт", r"\bfor\s+sale\b", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)",
r"реселлер", r"resell", r"\bnew\s+stock\b", r"\bin\s+stock\b", r"\bв\s+наличии\b", r"\bоптом\b",
r"\b(?:доступ|подписк\w*|аккаунт\w*)\s+на\s+\d+\s*(?:дн|мес)", r"\b\d{1,3}\s?d\b(?=[^a-z]|$)",
]),
("accs", "аккаунты/подписки", 22, [
r"(chatgpt|claude|cursor|midjourney|perplexity|grok|copilot|gemini|netflix|spotify|youtube\s?premium|notion|figma|canva|devin|windsurf)\b[^\n.]{0,40}\b(plus|pro|max|premium|team|business|ultra|30d|60d|90d|1\s?мес|12\s?мес|год)\b",
r"\b(plus|pro|premium|ultra|team)\b[^\n.]{0,25}\b(chatgpt|claude|cursor|midjourney|perplexity|grok|copilot|gemini|notion|figma|canva)\b",
r"\b(аккаунт|акк|подписк|доступ)\w*\s+(chatgpt|claude|cursor|midjourney|perplexity|grok|gemini|notion)\b",
r"\b(shared|share|private|приватн\w*|общ\w*)\s+(account|аккаунт|подписк)",
r"\b\d{1,3}\s*(?:d|дн)\s*(?:new\s+)?(?:stock|аккаунт|подписк)",
]),
("credits", "кредиты/квоты", 22, [
r"free\s+(?:api\s+)?credits?", r"\b\d+\s?(?:k|m)?\s?tokens?\s+(?:free|бесплатн)", r"free\s+quota",
@@ -87,7 +97,7 @@ RULES = [
COMPILED = [(tag, label, w, [re.compile(p, re.I) for p in pats]) for tag, label, w, pats in RULES]
TAG_LABEL = {t: l for t, l, _, _ in RULES}
STRONG = {"keys", "promo", "trial", "resale", "credits", "freeapi", "ltd", "combo"}
STRONG = {"keys", "promo", "trial", "resale", "accs", "credits", "freeapi", "ltd", "combo"}
# на специализированных абуз-площадках хватает одного признака: сам источник тематический
ABUSE_SRC_SC = 14
# категории-источники, которые вообще имеют смысл для абузов
@@ -159,8 +169,8 @@ def _qualifies(source, title, text, tags):
return 0, []
# панели new-api, github/hn и abuse-источники считаем абузными по факту источника
if cat == "abuse" or known_prefix:
if sc >= ABUSE_SRC_SC or strong or len(hits) >= 2:
return sc or 1, hits
if sc >= ABUSE_SRC_SC and (strong or len(hits) >= 2):
return sc, hits
if strong and sc >= 16:
return sc, hits
if len(hits) >= 2 and sc >= 30:

View File

@@ -1,5 +1,7 @@
"""Local UI dependencies are served without exposing project data."""
import http.client
import pathlib
import re
import threading
from http.server import ThreadingHTTPServer
@@ -75,3 +77,47 @@ def test_index_still_served(static_server):
status, headers, body = static_server("/")
assert status == 200 and body == b"<html>panel</html>"
assert headers["Content-Type"].startswith("text/html")
WEB_ROOT = pathlib.Path(__file__).resolve().parents[1] / "web"
def test_panel_html_is_self_contained_black():
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
# Black theme is declared three ways so first paint cannot flash white.
assert 'data-bs-theme="dark"' in html
assert "color-scheme: dark" in html
assert "--omni-background: #09090b" in html
# No remote dependencies: every src/href must stay inside /assets/ or be a data URL.
for attr in re.findall(r'(?:src|href)="([^"]+)"', html):
assert not attr.startswith(("http://", "https://", "//")), attr
assert "/assets/vendor/tabler/tabler.min.css" in html
assert "/assets/vendor/tabler/tabler.min.js" in html
def test_panel_self_hosts_inter_for_latin_and_cyrillic():
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
assert html.count('font-family: "Inter Variable"') == 4
assert 'format("woff2-variations")' not in html # rejected by Firefox/Safari
assert html.count('format("woff2")') == 4
assert html.count("font-display: swap") == 4
assert "--tblr-font-sans-serif: \"Inter Variable\"" in html
files = WEB_ROOT / "vendor" / "inter" / "files"
for subset in ("latin", "latin-ext", "cyrillic", "cyrillic-ext"):
face = files / f"inter-{subset}-wght-normal.woff2"
assert face.is_file() and face.stat().st_size > 2000, face
assert f"/assets/vendor/inter/files/{face.name}" in html
licence = (WEB_ROOT / "vendor" / "inter" / "LICENSE").read_text(encoding="utf-8")
assert "SIL OPEN FONT LICENSE" in licence.upper()
def test_panel_typography_does_not_clip_cyrillic():
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
badge = re.search(r"\.badge \{[^}]*\}", html)
assert badge and "line-height: 1.4" in badge.group(0)
assert "letter-spacing: -.01em" not in html # negative tracking on Cyrillic titles
for selector in (".brand-caption", ".feed-title", ".feed-preview"):
rule = re.search(r"(?m)^" + re.escape(selector) + r" \{[^}]*\}", html)
assert rule, selector
line_height = re.search(r"line-height: ([\d.]+)", rule.group(0))
assert line_height and float(line_height.group(1)) >= 1.4, rule.group(0)

View File

@@ -0,0 +1,120 @@
"""Перепроверка реестра new-api панелей: жив ли /api/pricing и сколько моделей бесплатных.
Запуск: python tools/refresh_panels_free.py [--workers 12] [--timeout 15] [--write]
Пишет data/panels.json (+ .bak) только с --write. В выводе — сводка и топ панелей.
Секретов не касается: только публичные /api/pricing и /api/models.
"""
from __future__ import annotations
import argparse
import json
import shutil
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PANELS = ROOT / "data" / "panels.json"
UA = "curl/8.5.0"
def _get(url: str, timeout: int):
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.getcode(), r.read()
def probe(entry: dict, timeout: int) -> dict:
host = str(entry.get("host") or entry.get("url") or "").strip()
host = host.replace("https://", "").replace("http://", "").strip("/")
out = dict(entry)
out["host"] = host
out["checked"] = True
if not host:
out.update(alive=False, free_count=0, models=0, why="no-host")
return out
base = "https://" + host
for path in ("/api/pricing", "/api/models"):
try:
code, body = _get(base + path, timeout)
except Exception as e:
out.update(alive=False, why=type(e).__name__)
continue
if code != 200:
out.update(alive=False, why="http %s" % code)
continue
try:
js = json.loads(body.decode("utf-8", "replace"))
except Exception:
out.update(alive=False, why="bad-json")
continue
if path == "/api/models":
rows = js.get("data") if isinstance(js, dict) else js
if isinstance(rows, list):
out.update(alive=True, why="models-only", models=len(rows), free_count=int(out.get("free_count") or 0))
return out
rows = js.get("data") if isinstance(js, dict) else None
if not isinstance(rows, list):
out.update(alive=False, why="no-data")
continue
free = 0
names: list[str] = []
for m in rows:
if not isinstance(m, dict):
continue
ratio = m.get("model_ratio")
quota = m.get("quota_type")
try:
freeish = (ratio is not None and float(ratio) == 0.0) or (quota == 0)
except Exception:
freeish = False
if freeish:
free += 1
nm = m.get("model_name") or m.get("model")
if isinstance(nm, str) and len(names) < 12:
names.append(nm)
out.update(alive=True, why="pricing", models=len(rows), free_count=free, free=names)
return out
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--workers", type=int, default=12)
ap.add_argument("--timeout", type=int, default=15)
ap.add_argument("--write", action="store_true")
a = ap.parse_args()
raw = json.loads(PANELS.read_text(encoding="utf-8"))
rows = raw["panels"] if isinstance(raw, dict) else raw
print("в реестре:", len(rows))
with ThreadPoolExecutor(max_workers=a.workers) as ex:
res = list(ex.map(lambda e: probe(e, a.timeout), rows))
alive = [r for r in res if r.get("alive")]
with_free = [r for r in alive if int(r.get("free_count") or 0) > 0]
total_free = sum(int(r.get("free_count") or 0) for r in with_free)
print("живых /api/pricing:", len(alive), "| с бесплатными:", len(with_free), "| free-моделей:", total_free)
print("было: с бесплатными %d" % sum(1 for r in rows if int(r.get("free_count") or 0) > 0))
with_free.sort(key=lambda r: -int(r.get("free_count") or 0))
for r in with_free[:10]:
print(" %-28s free %-4s всего %s" % (r["host"], r.get("free_count"), r.get("models")))
from collections import Counter
print("причины отказов:", Counter(r.get("why") for r in res if not r.get("alive")).most_common(6))
if a.write:
if PANELS.exists():
shutil.copy2(PANELS, PANELS.with_suffix(".json.bak"))
payload = dict(raw) if isinstance(raw, dict) else {"panels": res}
payload["panels"] = res
PANELS.write_text(json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8")
print("записано:", PANELS)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -21,6 +21,8 @@ async def run(url, out):
async with async_playwright() as playwright:
browser = await playwright.chromium.launch()
page = await browser.new_page(viewport={"width": 1440, "height": 960})
page.set_default_timeout(15000)
page.set_default_navigation_timeout(30000)
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
@@ -33,8 +35,16 @@ async def run(url, out):
page.on("response", response_seen)
def step(name):
report["stage"] = name
print("[smoke]", name, flush=True)
async def settled():
await page.wait_for_load_state("networkidle", timeout=25000)
# The panel polls /api/health, so "networkidle" is a best-effort hint, never a gate.
try:
await page.wait_for_load_state("networkidle", timeout=5000)
except Exception:
pass
async def navigate(view):
nav = page.locator(f'[data-nav="{view}"]').first
@@ -50,6 +60,42 @@ async def run(url, out):
size = await page.evaluate("({scroll:document.documentElement.scrollWidth, width:innerWidth})")
assert size["scroll"] <= size["width"] + 1, f"Horizontal overflow at {label}: {size}"
async def assert_no_text_collision(label):
"""Only counts overlaps where both text nodes are actually painted on top of each other."""
hits = await page.evaluate("""() => {
const painted = (el, x, y) => { const top = document.elementFromPoint(x, y); return !!top && (top === el || el.contains(top) || top.contains(el)); };
const boxes = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (!node.textContent.trim()) continue;
const el = node.parentElement;
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
if (el.closest('#sug:not(.show), #drawer:not(.show), #modal:not(.show), .visually-hidden-focusable:not(:focus)')) continue;
const range = document.createRange();
range.selectNodeContents(node);
for (const rect of range.getClientRects()) {
if (rect.width < 4 || rect.height < 4) continue;
boxes.push({el, rect, text: node.textContent.trim().slice(0, 28)});
}
}
const found = [];
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i], b = boxes[j];
if (a.el === b.el || a.el.contains(b.el) || b.el.contains(a.el)) continue;
const overlapX = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left);
const overlapY = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top);
if (overlapX <= 3 || overlapY <= 3) continue;
const cx = (Math.max(a.rect.left, b.rect.left) + Math.min(a.rect.right, b.rect.right)) / 2;
const cy = (Math.max(a.rect.top, b.rect.top) + Math.min(a.rect.bottom, b.rect.bottom)) / 2;
if (painted(a.el, cx, cy) && painted(b.el, cx, cy)) found.push(a.text + ' <> ' + b.text);
}
return found.slice(0, 5);
}""")
assert not hits, f"Text painted on text at {label}: {hits}"
report.setdefault("no_text_collision", []).append(label)
async def assert_dark_theme(label):
colors = await page.evaluate("""() => {
const canvas = document.createElement('canvas');
@@ -83,7 +129,9 @@ async def run(url, out):
report["navigation_routes"] = await page.locator("[data-nav]").count()
assert report["navigation_routes"] == 9
await assert_no_overflow("desktop")
await assert_no_text_collision("feed 1440")
await assert_dark_theme("feed")
step("feed")
await page.screenshot(path=str(out / "black_feed.png"))
# A real search must update both results and the total, not just the input.
@@ -112,6 +160,7 @@ async def run(url, out):
await settled()
assert (await page.locator("#d-title").inner_text()).strip()
await assert_dark_theme("reader")
step("reader")
await page.screenshot(path=str(out / "black_reader.png"))
focusables = page.locator('#drawer a[href], #drawer button:not([disabled]), #drawer input:not([disabled])')
if await focusables.count():
@@ -125,15 +174,23 @@ async def run(url, out):
await page.wait_for_function("!document.querySelector('#drawer').classList.contains('show')")
await page.locator("#drawer").wait_for(state="hidden")
assert await opener.evaluate("el => document.activeElement === el"), "Reader did not restore focus"
step("reader-keys")
await page.locator('[data-act="help"]').first.click()
step("help-clicked")
await page.wait_for_selector("#modal.show")
step("help-shown")
await settled()
step("help-settled")
await assert_dark_theme("help modal")
step("help-dark")
await page.keyboard.press("Escape")
step("help-escape")
await page.locator("#modal").wait_for(state="hidden")
step("help-hidden")
report["reader_keyboard"] = True
# Source filtering is a real API query, not a dead text field.
step("help-modal")
await navigate("sources")
await page.wait_for_selector("#src-tbl")
async with page.expect_response(lambda r: "/api/sources?" in r.url and missing in r.url) as src_pending:
@@ -144,11 +201,14 @@ async def run(url, out):
report["source_search"] = True
await page.locator("#s-q").fill("")
await settled()
await assert_no_text_collision("sources 1440")
await assert_dark_theme("sources")
await page.screenshot(path=str(out / "black_sources.png"))
step("sources")
await navigate("abuse")
report["find_rows"] = await page.locator("#ab-list .item").count()
await assert_no_text_collision("abuse 1440")
await assert_dark_theme("abuse")
await page.screenshot(path=str(out / "black_finds.png"))
async with page.expect_response(lambda r: urlsplit(r.url).path == "/api/panels") as panels_pending:
@@ -165,6 +225,7 @@ async def run(url, out):
await assert_dark_theme(view)
await page.screenshot(path=str(out / f"black_{view}.png"))
report[view] = "rendered"
step("routes")
await navigate("digest")
async with page.expect_response(lambda r: "/api/digest?" in r.url) as digest_pending:
await page.locator('[data-act="digest"]').click()
@@ -176,6 +237,7 @@ async def run(url, out):
await assert_dark_theme("digest")
await page.screenshot(path=str(out / "black_digest.png"))
step("digest")
# Deep-link source filters and both exported formats must stay in sync.
sample = await (await page.request.get(url.rstrip("/") + "/api/items?limit=1")).json()
source = sample["items"][0]["source"]
@@ -196,6 +258,7 @@ async def run(url, out):
assert data["count"] and all(item["source"] == source for item in data["items"])
report["filtered_exports"] = True
step("exports")
# Mobile navigation has labels, a real toggle, and no page overflow.
await page.set_viewport_size({"width": 390, "height": 844})
await page.locator("#sidebar-toggle").click()
@@ -210,7 +273,12 @@ async def run(url, out):
await page.screenshot(path=str(out / "black_mobile.png"))
await page.set_viewport_size({"width": 768, "height": 1024})
await assert_no_overflow("tablet 768")
report["responsive"] = [390, 768, 1440]
await assert_no_text_collision("tablet 768")
for width in (1100, 1024):
await page.set_viewport_size({"width": width, "height": 900})
await assert_no_overflow(f"desktop {width}")
await assert_no_text_collision(f"desktop {width}")
report["responsive"] = [390, 768, 1024, 1100, 1440]
assert assets.get("/assets/vendor/tabler/tabler.min.css") == 200
assert assets.get("/assets/vendor/tabler/tabler.min.js") == 200
@@ -222,7 +290,10 @@ async def run(url, out):
report["console_errors"] = errors
(out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
await browser.close()
try:
await asyncio.wait_for(browser.close(), timeout=20)
except Exception:
pass
return 0

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);