diff --git a/README.md b/README.md index 5452751..7fe1e2a 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ python -m omni serve --port 8077 ### Бесплатные API-панели -Отдельная вкладка в «Находках»: new-api шлюзы, у которых открыт бесплатный доступ к моделям. 175 панелей в реестре, 131 с бесплатными моделями, проверка идёт через публичный `/api/pricing` — учётные данные не запрашиваются. +Отдельная вкладка в «Находках»: new-api шлюзы, у которых открыт бесплатный доступ к моделям. 175 панелей в реестре, 90 с полностью бесплатными моделями (prompt и completion = 0), проверка идёт через публичный `/api/pricing` — учётные данные не запрашиваются. -![Бесплатные API-панели — 131 из 175 панелей отдают модели без оплаты](docs/screenshots/panels.png) +![Бесплатные API-панели — 90 из 175 панелей отдают модели без оплаты](docs/screenshots/panels.png) ### Источники diff --git a/docs/screenshots/abuse.png b/docs/screenshots/abuse.png index ceb13a5..2361420 100644 Binary files a/docs/screenshots/abuse.png and b/docs/screenshots/abuse.png differ diff --git a/docs/screenshots/panels.png b/docs/screenshots/panels.png index 2fa0907..a2025b7 100644 Binary files a/docs/screenshots/panels.png and b/docs/screenshots/panels.png differ diff --git a/tools/push_top.py b/tools/push_top.py index ebeb8eb..943dc8b 100644 --- a/tools/push_top.py +++ b/tools/push_top.py @@ -10,6 +10,7 @@ import argparse import html import json import os +import re import sqlite3 import sys import urllib.parse @@ -24,7 +25,50 @@ from omni import abuse # noqa: E402 TAG_RU = {"promo": "промо", "trial": "триал", "keys": "ключи", "leak": "слив", "resale": "перепродажа", "accs": "аккаунты", "credits": "кредиты", "invite": "инвайты", "ltd": "lifetime", "freeapi": "free-API", - "combo": "комбо", "tool": "тулза"} + "combo": "комбо", "tool": "тулза", "coupon": "купон", "nokyc": "без KYC", + "api": "API", "sub": "подписка", "unlimited": "безлимит", "referral": "реф"} + + +NOISE = ("навигация по каналу", "навигация", "закреп", "правила канала", "прайс-лист", + "реклама в канале", "меню канала", "список каналов") +EMOJI = re.compile("[🀀-🫿☀-➿⬀-⯿️⁠​‍

]") + + +def clean(txt: str, n: int = 110) -> str: + txt = EMOJI.sub(" ", txt or "") + txt = re.sub(r"\s+", " ", txt).strip() + if len(txt) <= n: + return txt + cut = txt[:n] + if " " in cut[40:]: + cut = cut[:cut.rfind(" ")] + return cut.rstrip(" ,.;:-") + "…" + + +def is_noise(title: str) -> bool: + low = (title or "").lower() + return any(k in low for k in NOISE) + + +def panel_block(limit: int = 4) -> list[str]: + import json + from pathlib import Path + pj = ROOT / "data" / "panels.json" + if not pj.exists(): + return [] + rows = json.load(pj.open(encoding="utf-8"))["panels"] + rows = [x for x in rows if int(x.get("free_count") or 0) > 0] + rows.sort(key=lambda x: -int(x.get("free_count") or 0)) + if not rows: + return [] + out = ["ПАНЕЛИ С БЕСПЛАТНЫМИ МОДЕЛЯМИ"] + for x in rows[:limit]: + names = ", ".join(str(n) for n in (x.get("free") or [])[:3]) + out.append("%s — %d free из %d%s" % ( + html.escape(str(x.get("host"))), int(x["free_count"]), int(x.get("models") or 0), + (" · " + html.escape(names)) if names else "")) + out.append("") + return out def token() -> str: @@ -48,7 +92,7 @@ def build(limit: int) -> str: picks, seen = [], set() for it in res["items"]: key = it.get("sig") or it["id"] - if key in seen: + if key in seen or is_noise(it.get("title") or ""): continue seen.add(key) picks.append(it) @@ -62,7 +106,7 @@ def build(limit: int) -> str: raw = it.get("tags") or [] raw = raw.split(",") if isinstance(raw, str) else list(raw) tags = ", ".join(TAG_RU.get(t, t) for t in raw if t) - title = html.escape((it.get("title") or it.get("text") or "")[:110].strip()) + title = html.escape(clean(it.get("title") or it.get("text") or "")) tags_s = f" · {html.escape(tags)}" if tags else "" dup = f" · копий {it['dups']}" if it.get("dups") else "" link = it.get("url") or "" @@ -72,6 +116,7 @@ def build(limit: int) -> str: if link: out.append(f'{html.escape(link[:78])}') out.append("") + out.extend(panel_block()) return "\n".join(out)[:3900] diff --git a/tools/refresh_panels_free.py b/tools/refresh_panels_free.py index cf58e2a..8d9df7c 100644 --- a/tools/refresh_panels_free.py +++ b/tools/refresh_panels_free.py @@ -64,9 +64,16 @@ def probe(entry: dict, timeout: int) -> dict: if not isinstance(m, dict): continue ratio = m.get("model_ratio") + cratio = m.get("completion_ratio") quota = m.get("quota_type") try: - freeish = (ratio is not None and float(ratio) == 0.0) or (quota == 0) + # Строго как адаптер newapi_pricing: модель бесплатна только если + # нулевые И prompt, И completion. Мягкое «ratio==0 или quota==0» + # завышало счётчик в разы (760 моделей на панели с 1 бесплатной). + if ratio is None and cratio is None: + freeish = (quota == 0) + else: + freeish = float(ratio or 0) == 0.0 and float(cratio or 0) == 0.0 except Exception: freeish = False if freeish: diff --git a/tools/shots.py b/tools/shots.py index d54f9d2..9ee49e2 100644 --- a/tools/shots.py +++ b/tools/shots.py @@ -26,8 +26,11 @@ SECRET = re.compile(r"\b(sk-|pk-|ek_live_|ghp_|xoxb-)[A-Za-z0-9_\-]{12,}") def main(): bad = [] + only = set(sys.argv[1].split(",")) if len(sys.argv) > 1 else None with Camoufox(headless=True, geoip=False, humanize=True) as b: for route, name in SHOTS: + if only and name not in only: + continue p = b.new_page(viewport={"width": 1500, "height": 1350}) p.goto(BASE + route, wait_until="domcontentloaded", timeout=90000) n = 0 diff --git a/tools/sync_panel_items.py b/tools/sync_panel_items.py new file mode 100644 index 0000000..a41318a --- /dev/null +++ b/tools/sync_panel_items.py @@ -0,0 +1,63 @@ +"""Синхронизировать тексты pn_*-записей с data/panels.json. + +Панель переотдаёт тот же url, поэтому store писал её один раз и число +бесплатных моделей в выдаче застывало. Скрипт переписывает title/text/score +по актуальному free_count (строгое правило: prompt и completion = 0). + +Запуск: python tools/sync_panel_items.py [--dry] +""" +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def plural(n: int) -> str: + if n % 10 == 1 and n % 100 != 11: + return "модель" + if n % 10 in (2, 3, 4) and n % 100 not in (12, 13, 14): + return "модели" + return "моделей" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--dry", action="store_true") + a = ap.parse_args() + rows = json.load((ROOT / "data" / "panels.json").open(encoding="utf-8"))["panels"] + by = {} + for x in rows: + host = str(x.get("host") or "").strip() + if not host: + continue + by["pn_" + host.replace(".", "_").replace("-", "_")] = ( + host, int(x.get("free_count") or 0), int(x.get("models") or 0), x.get("free") or []) + c = sqlite3.connect(str(ROOT / "data" / "omni.sqlite3")) + c.execute("PRAGMA busy_timeout=60000") + n = 0 + for src, (host, free, total, names) in by.items(): + item = c.execute("SELECT id FROM items WHERE source=? LIMIT 1", (src,)).fetchone() + if not item: + continue + title = "%s: %d бесплатных %s из %d" % (host, free, plural(free), total) + text = title + ("\n" + ", ".join(str(x) for x in names[:12]) if names else "") + if a.dry: + n += 1 + continue + c.execute("UPDATE items SET title=?, text=? WHERE id=?", (title, text, item[0])) + c.execute("UPDATE items_fts SET title=?, text=? WHERE rowid=?", (title, text, item[0])) + c.execute("UPDATE abuse_idx SET score=? WHERE id=?", (70 + min(free // 3, 110), item[0])) + n += 1 + if not a.dry: + c.commit() + print("синхронизировано панелей:", n, "| с free:", sum(1 for v in by.values() if v[1] > 0)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())