diff --git a/omni/abuse.py b/omni/abuse.py index 45aa275..510b47f 100644 --- a/omni/abuse.py +++ b/omni/abuse.py @@ -23,9 +23,11 @@ RULES = [ r"redeem\s?(code|код)", r"invite\s?code", r"реф(еральн|код)", r"бонус\s+на\s+сч[её]т", r"кэшб[еэ]к|cashback", ]), ("trial", "триалы", 24, [ - r"trial", r"триал", r"free\s+credits?", r"бесплатн\w*\s+(кредит|токен|баланс|доступ|период)", - r"free\s+tier", r"\$\d+\s+free", r"试用", r"免费", r"пробн\w+\s+период", r"без\s+депозита", r"no\s+deposit", - ]), + r"\btrial\b", r"триал", r"free\s+credits?", r"бесплатн\w*\s+(кредит|токен|баланс|доступ|период)", + r"free\s+tier", r"\$\d+\s+free", r"试用", r"免费", r"пробн\w+\s+период", r"без\s+депозита", r"no\s+deposit", + r"\bfree\b\s*[-–—:]\s*\S", r"\b\d+\s+enrolls?\s+left\b", r"\bfree\s+enroll\w*", r"\bfree\s+access\b", + r"\b100%\s*(?:free|off)\b", r"\bfree\s+forever\b", r"\b\d+\s*(?:mo|мес)\w*\s+free\b", + ]), ("leak", "сливы", 26, [ r"слив(?!\s*(сок|ки|очн|тест|трафик))", r"leaked\s+(database|db|dump|combo)", r"db\s?dump", r"combo[\s-]?list", r"stealer\s?log", r"утечк\w*\s+баз", r"база\s+(пассов|почт|данных)", @@ -36,9 +38,17 @@ RULES = [ r"авторег", r"autoreg", r"panel", r"панел[ьи]\s", r"рассылк", r"sender", r"скрипт", ]), ("resale", "продажа", 14, [ - r"продаю", r"продам", r"куплю\s+аккаунт", r"for\s+sale", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)", - r"реселлер", r"resell", - ]), + r"продаю", r"продам", r"куплю\s+аккаунт", r"\bfor\s+sale\b", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)", + r"реселлер", r"resell", r"\bnew\s+stock\b", r"\bin\s+stock\b", r"\bв\s+наличии\b", r"\bоптом\b", + r"\b(?:доступ|подписк\w*|аккаунт\w*)\s+на\s+\d+\s*(?:дн|мес)", r"\b\d{1,3}\s?d\b(?=[^a-z]|$)", + ]), + ("accs", "аккаунты/подписки", 22, [ + r"(chatgpt|claude|cursor|midjourney|perplexity|grok|copilot|gemini|netflix|spotify|youtube\s?premium|notion|figma|canva|devin|windsurf)\b[^\n.]{0,40}\b(plus|pro|max|premium|team|business|ultra|30d|60d|90d|1\s?мес|12\s?мес|год)\b", + r"\b(plus|pro|premium|ultra|team)\b[^\n.]{0,25}\b(chatgpt|claude|cursor|midjourney|perplexity|grok|copilot|gemini|notion|figma|canva)\b", + r"\b(аккаунт|акк|подписк|доступ)\w*\s+(chatgpt|claude|cursor|midjourney|perplexity|grok|gemini|notion)\b", + r"\b(shared|share|private|приватн\w*|общ\w*)\s+(account|аккаунт|подписк)", + r"\b\d{1,3}\s*(?:d|дн)\s*(?:new\s+)?(?:stock|аккаунт|подписк)", + ]), ("credits", "кредиты/квоты", 22, [ r"free\s+(?:api\s+)?credits?", r"\b\d+\s?(?:k|m)?\s?tokens?\s+(?:free|бесплатн)", r"free\s+quota", r"бесплатн\w*\s+(?:лимит|квот|токен)", r"кредит\w*\s+(?:бесплатн|в\s+подарок)", r"赠金", r"额度", @@ -87,7 +97,7 @@ RULES = [ COMPILED = [(tag, label, w, [re.compile(p, re.I) for p in pats]) for tag, label, w, pats in RULES] TAG_LABEL = {t: l for t, l, _, _ in RULES} -STRONG = {"keys", "promo", "trial", "resale", "credits", "freeapi", "ltd", "combo"} +STRONG = {"keys", "promo", "trial", "resale", "accs", "credits", "freeapi", "ltd", "combo"} # на специализированных абуз-площадках хватает одного признака: сам источник тематический ABUSE_SRC_SC = 14 # категории-источники, которые вообще имеют смысл для абузов @@ -159,8 +169,8 @@ def _qualifies(source, title, text, tags): return 0, [] # панели new-api, github/hn и abuse-источники считаем абузными по факту источника if cat == "abuse" or known_prefix: - if sc >= ABUSE_SRC_SC or strong or len(hits) >= 2: - return sc or 1, hits + if sc >= ABUSE_SRC_SC and (strong or len(hits) >= 2): + return sc, hits if strong and sc >= 16: return sc, hits if len(hits) >= 2 and sc >= 30: diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index edfc212..ab0502b 100644 --- a/tests/test_static_assets.py +++ b/tests/test_static_assets.py @@ -1,5 +1,7 @@ """Local UI dependencies are served without exposing project data.""" import http.client +import pathlib +import re import threading from http.server import ThreadingHTTPServer @@ -75,3 +77,47 @@ def test_index_still_served(static_server): status, headers, body = static_server("/") assert status == 200 and body == b"panel" 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) diff --git a/tools/refresh_panels_free.py b/tools/refresh_panels_free.py new file mode 100644 index 0000000..cf58e2a --- /dev/null +++ b/tools/refresh_panels_free.py @@ -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()) \ No newline at end of file diff --git a/tools/ui_smoke.py b/tools/ui_smoke.py index e1535bc..ada3157 100644 --- a/tools/ui_smoke.py +++ b/tools/ui_smoke.py @@ -21,6 +21,8 @@ async def run(url, out): async with async_playwright() as playwright: browser = await playwright.chromium.launch() page = await browser.new_page(viewport={"width": 1440, "height": 960}) + page.set_default_timeout(15000) + page.set_default_navigation_timeout(30000) page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None) page.on("pageerror", lambda e: errors.append(str(e))) @@ -33,8 +35,16 @@ async def run(url, out): page.on("response", response_seen) + def step(name): + report["stage"] = name + print("[smoke]", name, flush=True) + async def settled(): - await page.wait_for_load_state("networkidle", timeout=25000) + # The panel polls /api/health, so "networkidle" is a best-effort hint, never a gate. + try: + await page.wait_for_load_state("networkidle", timeout=5000) + except Exception: + pass async def navigate(view): nav = page.locator(f'[data-nav="{view}"]').first @@ -50,6 +60,42 @@ async def run(url, out): size = await page.evaluate("({scroll:document.documentElement.scrollWidth, width:innerWidth})") assert size["scroll"] <= size["width"] + 1, f"Horizontal overflow at {label}: {size}" + async def assert_no_text_collision(label): + """Only counts overlaps where both text nodes are actually painted on top of each other.""" + hits = await page.evaluate("""() => { + const painted = (el, x, y) => { const top = document.elementFromPoint(x, y); return !!top && (top === el || el.contains(top) || top.contains(el)); }; + const boxes = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + if (!node.textContent.trim()) continue; + const el = node.parentElement; + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue; + if (el.closest('#sug:not(.show), #drawer:not(.show), #modal:not(.show), .visually-hidden-focusable:not(:focus)')) continue; + const range = document.createRange(); + range.selectNodeContents(node); + for (const rect of range.getClientRects()) { + if (rect.width < 4 || rect.height < 4) continue; + boxes.push({el, rect, text: node.textContent.trim().slice(0, 28)}); + } + } + const found = []; + for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i], b = boxes[j]; + if (a.el === b.el || a.el.contains(b.el) || b.el.contains(a.el)) continue; + const overlapX = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left); + const overlapY = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top); + if (overlapX <= 3 || overlapY <= 3) continue; + const cx = (Math.max(a.rect.left, b.rect.left) + Math.min(a.rect.right, b.rect.right)) / 2; + const cy = (Math.max(a.rect.top, b.rect.top) + Math.min(a.rect.bottom, b.rect.bottom)) / 2; + if (painted(a.el, cx, cy) && painted(b.el, cx, cy)) found.push(a.text + ' <> ' + b.text); + } + return found.slice(0, 5); + }""") + assert not hits, f"Text painted on text at {label}: {hits}" + report.setdefault("no_text_collision", []).append(label) + async def assert_dark_theme(label): colors = await page.evaluate("""() => { const canvas = document.createElement('canvas'); @@ -83,7 +129,9 @@ async def run(url, out): report["navigation_routes"] = await page.locator("[data-nav]").count() assert report["navigation_routes"] == 9 await assert_no_overflow("desktop") + await assert_no_text_collision("feed 1440") await assert_dark_theme("feed") + step("feed") await page.screenshot(path=str(out / "black_feed.png")) # A real search must update both results and the total, not just the input. @@ -112,6 +160,7 @@ async def run(url, out): await settled() assert (await page.locator("#d-title").inner_text()).strip() await assert_dark_theme("reader") + step("reader") await page.screenshot(path=str(out / "black_reader.png")) focusables = page.locator('#drawer a[href], #drawer button:not([disabled]), #drawer input:not([disabled])') if await focusables.count(): @@ -125,15 +174,23 @@ async def run(url, out): await page.wait_for_function("!document.querySelector('#drawer').classList.contains('show')") await page.locator("#drawer").wait_for(state="hidden") assert await opener.evaluate("el => document.activeElement === el"), "Reader did not restore focus" + step("reader-keys") await page.locator('[data-act="help"]').first.click() + step("help-clicked") await page.wait_for_selector("#modal.show") + step("help-shown") await settled() + step("help-settled") await assert_dark_theme("help modal") + step("help-dark") await page.keyboard.press("Escape") + step("help-escape") await page.locator("#modal").wait_for(state="hidden") + step("help-hidden") report["reader_keyboard"] = True # Source filtering is a real API query, not a dead text field. + step("help-modal") await navigate("sources") await page.wait_for_selector("#src-tbl") async with page.expect_response(lambda r: "/api/sources?" in r.url and missing in r.url) as src_pending: @@ -144,11 +201,14 @@ async def run(url, out): report["source_search"] = True await page.locator("#s-q").fill("") await settled() + await assert_no_text_collision("sources 1440") await assert_dark_theme("sources") await page.screenshot(path=str(out / "black_sources.png")) + step("sources") await navigate("abuse") report["find_rows"] = await page.locator("#ab-list .item").count() + await assert_no_text_collision("abuse 1440") await assert_dark_theme("abuse") await page.screenshot(path=str(out / "black_finds.png")) async with page.expect_response(lambda r: urlsplit(r.url).path == "/api/panels") as panels_pending: @@ -165,6 +225,7 @@ async def run(url, out): await assert_dark_theme(view) await page.screenshot(path=str(out / f"black_{view}.png")) report[view] = "rendered" + step("routes") await navigate("digest") async with page.expect_response(lambda r: "/api/digest?" in r.url) as digest_pending: await page.locator('[data-act="digest"]').click() @@ -176,6 +237,7 @@ async def run(url, out): await assert_dark_theme("digest") await page.screenshot(path=str(out / "black_digest.png")) + step("digest") # Deep-link source filters and both exported formats must stay in sync. sample = await (await page.request.get(url.rstrip("/") + "/api/items?limit=1")).json() source = sample["items"][0]["source"] @@ -196,6 +258,7 @@ async def run(url, out): assert data["count"] and all(item["source"] == source for item in data["items"]) report["filtered_exports"] = True + step("exports") # Mobile navigation has labels, a real toggle, and no page overflow. await page.set_viewport_size({"width": 390, "height": 844}) await page.locator("#sidebar-toggle").click() @@ -210,7 +273,12 @@ async def run(url, out): await page.screenshot(path=str(out / "black_mobile.png")) await page.set_viewport_size({"width": 768, "height": 1024}) await assert_no_overflow("tablet 768") - report["responsive"] = [390, 768, 1440] + await assert_no_text_collision("tablet 768") + for width in (1100, 1024): + await page.set_viewport_size({"width": width, "height": 900}) + await assert_no_overflow(f"desktop {width}") + await assert_no_text_collision(f"desktop {width}") + report["responsive"] = [390, 768, 1024, 1100, 1440] assert assets.get("/assets/vendor/tabler/tabler.min.css") == 200 assert assets.get("/assets/vendor/tabler/tabler.min.js") == 200 @@ -222,7 +290,10 @@ async def run(url, out): report["console_errors"] = errors (out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, ensure_ascii=False, indent=2)) - await browser.close() + try: + await asyncio.wait_for(browser.close(), timeout=20) + except Exception: + pass return 0 diff --git a/web/index.html b/web/index.html index 00bdea9..9b8af49 100644 --- a/web/index.html +++ b/web/index.html @@ -10,16 +10,16 @@
-Перейти к содержимому +Перейти к содержимому