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