abuse: теги accs + продажа подписок, строже порог; panels: реальные free-имена и 130/175 с бесплатными
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
120
tools/refresh_panels_free.py
Normal file
120
tools/refresh_panels_free.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user