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", r"redeem\s?(code|код)", r"invite\s?code", r"реф(еральн|код)", r"бонус\s+на\s+сч[её]т", r"кэшб[еэ]к|cashback",
]), ]),
("trial", "триалы", 24, [ ("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"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, [ ("leak", "сливы", 26, [
r"слив(?!\s*(сок|ки|очн|тест|трафик))", r"leaked\s+(database|db|dump|combo)", r"db\s?dump", 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"скрипт", r"авторег", r"autoreg", r"panel", r"панел[ьи]\s", r"рассылк", r"sender", r"скрипт",
]), ]),
("resale", "продажа", 14, [ ("resale", "продажа", 14, [
r"продаю", r"продам", r"куплю\s+аккаунт", r"for\s+sale", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)", r"продаю", r"продам", r"куплю\s+аккаунт", r"\bfor\s+sale\b", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)",
r"реселлер", r"resell", 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, [ ("credits", "кредиты/квоты", 22, [
r"free\s+(?:api\s+)?credits?", r"\b\d+\s?(?:k|m)?\s?tokens?\s+(?:free|бесплатн)", r"free\s+quota", 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] 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} 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 ABUSE_SRC_SC = 14
# категории-источники, которые вообще имеют смысл для абузов # категории-источники, которые вообще имеют смысл для абузов
@@ -159,8 +169,8 @@ def _qualifies(source, title, text, tags):
return 0, [] return 0, []
# панели new-api, github/hn и abuse-источники считаем абузными по факту источника # панели new-api, github/hn и abuse-источники считаем абузными по факту источника
if cat == "abuse" or known_prefix: if cat == "abuse" or known_prefix:
if sc >= ABUSE_SRC_SC or strong or len(hits) >= 2: if sc >= ABUSE_SRC_SC and (strong or len(hits) >= 2):
return sc or 1, hits return sc, hits
if strong and sc >= 16: if strong and sc >= 16:
return sc, hits return sc, hits
if len(hits) >= 2 and sc >= 30: if len(hits) >= 2 and sc >= 30:

View File

@@ -1,5 +1,7 @@
"""Local UI dependencies are served without exposing project data.""" """Local UI dependencies are served without exposing project data."""
import http.client import http.client
import pathlib
import re
import threading import threading
from http.server import ThreadingHTTPServer from http.server import ThreadingHTTPServer
@@ -75,3 +77,47 @@ def test_index_still_served(static_server):
status, headers, body = static_server("/") status, headers, body = static_server("/")
assert status == 200 and body == b"<html>panel</html>" assert status == 200 and body == b"<html>panel</html>"
assert headers["Content-Type"].startswith("text/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: async with async_playwright() as playwright:
browser = await playwright.chromium.launch() browser = await playwright.chromium.launch()
page = await browser.new_page(viewport={"width": 1440, "height": 960}) 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("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e))) page.on("pageerror", lambda e: errors.append(str(e)))
@@ -33,8 +35,16 @@ async def run(url, out):
page.on("response", response_seen) page.on("response", response_seen)
def step(name):
report["stage"] = name
print("[smoke]", name, flush=True)
async def settled(): 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): async def navigate(view):
nav = page.locator(f'[data-nav="{view}"]').first 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})") size = await page.evaluate("({scroll:document.documentElement.scrollWidth, width:innerWidth})")
assert size["scroll"] <= size["width"] + 1, f"Horizontal overflow at {label}: {size}" 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): async def assert_dark_theme(label):
colors = await page.evaluate("""() => { colors = await page.evaluate("""() => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -83,7 +129,9 @@ async def run(url, out):
report["navigation_routes"] = await page.locator("[data-nav]").count() report["navigation_routes"] = await page.locator("[data-nav]").count()
assert report["navigation_routes"] == 9 assert report["navigation_routes"] == 9
await assert_no_overflow("desktop") await assert_no_overflow("desktop")
await assert_no_text_collision("feed 1440")
await assert_dark_theme("feed") await assert_dark_theme("feed")
step("feed")
await page.screenshot(path=str(out / "black_feed.png")) await page.screenshot(path=str(out / "black_feed.png"))
# A real search must update both results and the total, not just the input. # 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() await settled()
assert (await page.locator("#d-title").inner_text()).strip() assert (await page.locator("#d-title").inner_text()).strip()
await assert_dark_theme("reader") await assert_dark_theme("reader")
step("reader")
await page.screenshot(path=str(out / "black_reader.png")) await page.screenshot(path=str(out / "black_reader.png"))
focusables = page.locator('#drawer a[href], #drawer button:not([disabled]), #drawer input:not([disabled])') focusables = page.locator('#drawer a[href], #drawer button:not([disabled]), #drawer input:not([disabled])')
if await focusables.count(): 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.wait_for_function("!document.querySelector('#drawer').classList.contains('show')")
await page.locator("#drawer").wait_for(state="hidden") await page.locator("#drawer").wait_for(state="hidden")
assert await opener.evaluate("el => document.activeElement === el"), "Reader did not restore focus" assert await opener.evaluate("el => document.activeElement === el"), "Reader did not restore focus"
step("reader-keys")
await page.locator('[data-act="help"]').first.click() await page.locator('[data-act="help"]').first.click()
step("help-clicked")
await page.wait_for_selector("#modal.show") await page.wait_for_selector("#modal.show")
step("help-shown")
await settled() await settled()
step("help-settled")
await assert_dark_theme("help modal") await assert_dark_theme("help modal")
step("help-dark")
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
step("help-escape")
await page.locator("#modal").wait_for(state="hidden") await page.locator("#modal").wait_for(state="hidden")
step("help-hidden")
report["reader_keyboard"] = True report["reader_keyboard"] = True
# Source filtering is a real API query, not a dead text field. # Source filtering is a real API query, not a dead text field.
step("help-modal")
await navigate("sources") await navigate("sources")
await page.wait_for_selector("#src-tbl") 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: 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 report["source_search"] = True
await page.locator("#s-q").fill("") await page.locator("#s-q").fill("")
await settled() await settled()
await assert_no_text_collision("sources 1440")
await assert_dark_theme("sources") await assert_dark_theme("sources")
await page.screenshot(path=str(out / "black_sources.png")) await page.screenshot(path=str(out / "black_sources.png"))
step("sources")
await navigate("abuse") await navigate("abuse")
report["find_rows"] = await page.locator("#ab-list .item").count() report["find_rows"] = await page.locator("#ab-list .item").count()
await assert_no_text_collision("abuse 1440")
await assert_dark_theme("abuse") await assert_dark_theme("abuse")
await page.screenshot(path=str(out / "black_finds.png")) 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: 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 assert_dark_theme(view)
await page.screenshot(path=str(out / f"black_{view}.png")) await page.screenshot(path=str(out / f"black_{view}.png"))
report[view] = "rendered" report[view] = "rendered"
step("routes")
await navigate("digest") await navigate("digest")
async with page.expect_response(lambda r: "/api/digest?" in r.url) as digest_pending: async with page.expect_response(lambda r: "/api/digest?" in r.url) as digest_pending:
await page.locator('[data-act="digest"]').click() await page.locator('[data-act="digest"]').click()
@@ -176,6 +237,7 @@ async def run(url, out):
await assert_dark_theme("digest") await assert_dark_theme("digest")
await page.screenshot(path=str(out / "black_digest.png")) await page.screenshot(path=str(out / "black_digest.png"))
step("digest")
# Deep-link source filters and both exported formats must stay in sync. # 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() sample = await (await page.request.get(url.rstrip("/") + "/api/items?limit=1")).json()
source = sample["items"][0]["source"] 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"]) assert data["count"] and all(item["source"] == source for item in data["items"])
report["filtered_exports"] = True report["filtered_exports"] = True
step("exports")
# Mobile navigation has labels, a real toggle, and no page overflow. # Mobile navigation has labels, a real toggle, and no page overflow.
await page.set_viewport_size({"width": 390, "height": 844}) await page.set_viewport_size({"width": 390, "height": 844})
await page.locator("#sidebar-toggle").click() 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.screenshot(path=str(out / "black_mobile.png"))
await page.set_viewport_size({"width": 768, "height": 1024}) await page.set_viewport_size({"width": 768, "height": 1024})
await assert_no_overflow("tablet 768") 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.css") == 200
assert assets.get("/assets/vendor/tabler/tabler.min.js") == 200 assert assets.get("/assets/vendor/tabler/tabler.min.js") == 200
@@ -222,7 +290,10 @@ async def run(url, out):
report["console_errors"] = errors report["console_errors"] = errors
(out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (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)) 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 return 0

View File

@@ -10,16 +10,16 @@
<style> <style>
/* Self-hosted Inter 5.3.0 variable (OFL-1.1), Latin + Cyrillic subsets. */ /* 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; @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; } 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; @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; } 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; @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; } 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; @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; } 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 /* 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. */ Vendored Tabler remains the layout and behavior foundation; no React/Radix runtime. */
@@ -52,7 +52,7 @@
--omni-space-4: 1rem; --omni-space-4: 1rem;
--omni-space-6: 1.5rem; --omni-space-6: 1.5rem;
--omni-space-8: 2rem; --omni-space-8: 2rem;
--omni-text-caption: .6875rem; --omni-text-caption: .75rem;
--omni-text-small: .75rem; --omni-text-small: .75rem;
--omni-text-base: .875rem; --omni-text-base: .875rem;
--omni-text-title: 1rem; --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 { background: var(--omni-background); }
.page-wrapper .container-xl { width: 100%; max-width: none; padding-inline: var(--omni-space-8); } .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-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-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-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; } #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 > .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); } .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; } .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-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-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: 400; 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%; } #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 { --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); } .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); } .sidebar-footer { padding: var(--omni-space-4) var(--omni-space-3); }
#health { line-height: 1.6; } #health { line-height: 1.6; }
.sidebar-footer .btn { padding-inline: 0; white-space: normal; text-align: start; } .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 .navbar-toggler-icon { width: var(--omni-space-4); height: var(--omni-space-4); }
#sidebar-toggle .ms-2 { margin-inline-start: 0 !important; } #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; } .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 { 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: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); } .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: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-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; } .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); } .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 { 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); } .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; } .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-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; } #list .feed-preview { -webkit-line-clamp: 1; }
.feed-original { color: var(--omni-muted-foreground); } .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 > .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); } #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-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-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); } .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> </style>
</head> </head>
<body> <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"> <div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg bg-surface" aria-label="Главная навигация"> <aside class="navbar navbar-vertical navbar-expand-lg bg-surface" aria-label="Главная навигация">
<div class="container-fluid"> <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>')}`} 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>`}} 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=>{ 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); 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);