diff --git a/omni/cli.py b/omni/cli.py index 0f592f2..f583c20 100644 --- a/omni/cli.py +++ b/omni/cli.py @@ -1,5 +1,6 @@ """CLI: providers / search / fanout / cards / top / find / stats / export.""" import argparse +import datetime as _dt import pathlib import concurrent.futures import json @@ -341,7 +342,15 @@ def cmd_radar(a): c = _sq.connect(db) doms = c.execute("SELECT domain, score, info FROM domains ORDER BY score DESC").fetchall() chans = c.execute("SELECT name, ok, fails FROM channels WHERE ok=1").fetchall() - posts = c.execute("SELECT chan, mid, date, text FROM messages").fetchall() if a.import_posts else [] + if a.import_posts: + if a.since_days: + cutoff = _dt.datetime.utcnow() - _dt.timedelta(days=a.since_days) + posts = c.execute("SELECT chan, mid, date, text FROM messages WHERE date >= ?", + (cutoff.strftime("%Y-%m-%d"),)).fetchall() + else: + posts = c.execute("SELECT chan, mid, date, text FROM messages").fetchall() + else: + posts = [] c.close() if a.import_posts: @@ -353,12 +362,19 @@ def cmd_radar(a): return ((txt or "").strip().splitlines() or [""])[0][:150] items, seen_u = [], set() + known = set() + try: + with store.db() as kc: + for (u,) in kc.execute("SELECT url FROM items WHERE url LIKE 'https://t.me/%'"): + known.add(u) + except Exception: + known = set() for chan, mid, date, text in posts: text = (text or "").strip() if len(text) < 30: continue url_u = "https://t.me/%s/%s" % (chan, mid) - if url_u in seen_u: + if url_u in seen_u or url_u in known: continue seen_u.add(url_u) items.append(Item("rad_" + chan, "post", url=url_u, title=head_line(text), text=text, @@ -376,20 +392,23 @@ def cmd_radar(a): have = {s.url.rstrip('/').lower() for s in _src.all_sources()} have_names = {s.name for s in _src.all_sources()} + full_dom, full_chan = [], [] new_dom, new_chan = [], [] for dom, score, info in doms: if a.min_score and (score or 0) < a.min_score: continue - url = "https://" + dom - if url.lower() in have or ("rad_" + dom.replace('.', '_').replace('-', '_')) in have_names: - continue title = "" try: title = (_json.loads(info or "{}") or {}).get("title") or "" except Exception: pass + full_dom.append((dom, score, title)) + url = "https://" + dom + if url.lower() in have or ("rad_" + dom.replace('.', '_').replace('-', '_')) in have_names: + continue new_dom.append((dom, score, title)) for name, ok, fails in chans: + full_chan.append(name) if ("tg_" + name) in have_names: continue new_chan.append(name) @@ -408,9 +427,11 @@ def cmd_radar(a): return 0 out = _P("data") / "radar_sources.json" - out.write_text(_json.dumps({"domains": new_dom, "channels": new_chan}, + out.write_text(_json.dumps({"domains": full_dom, "channels": full_chan, + "new": len(new_dom)}, ensure_ascii=False, indent=1), encoding="utf-8") - print("записано: %s (доменов %d, каналов %d)" % (out, len(new_dom), len(new_chan))) + print("записано: %s (доменов %d, каналов %d, из них новых %d)" + % (out, len(full_dom), len(full_chan), len(new_dom))) return 0 @@ -531,6 +552,7 @@ def main(argv=None): sp.add_argument("--apply", action="store_true", help="записать в реестр omni") sp.add_argument("--min-score", type=int, default=20, help="минимальный score домена") sp.add_argument("--import-posts", action="store_true", help="залить посты каналов радара в базу") + sp.add_argument("--since-days", type=int, default=0, help="только посты за N дней (0 = все)") sp.add_argument("--limit", type=int, default=0) sp.set_defaults(fn=cmd_radar) diff --git a/omni/config.py b/omni/config.py index c0b6608..7f030bd 100644 --- a/omni/config.py +++ b/omni/config.py @@ -68,7 +68,6 @@ def load(): cfg.update(json.loads(cf.read_text(encoding="utf-8"))) except Exception: pass - CONFIG = cfg # cookies, снятые браузером (tools/cf_cookies.py) — файл вне git ck_file = DATA / "cookies.json" if ck_file.exists(): @@ -93,9 +92,38 @@ def load(): if v: KEYS[name] = v DATA.mkdir(parents=True, exist_ok=True) + CONFIG = cfg return cfg +def tg_cfg(): + """Креды Telegram-сессии: data/tg.json (вне git) или env. Возвращает dict. + + Ключи: session (путь к .session без расширения), api_id, api_hash, limit. + env всегда приоритетнее файла. + """ + import os + out = {"session": "", "api_id": "", "api_hash": "", "limit": "100"} + f = DATA / "tg.json" + if f.exists(): + try: + d = json.loads(f.read_text(encoding="utf-8")) + for k in out: + if d.get(k): + out[k] = str(d[k]) + except Exception: + pass + for env, key in (("OMNI_TG_SESSION", "session"), ("OMNI_TG_API_ID", "api_id"), + ("OMNI_TG_API_HASH", "api_hash"), ("OMNI_TG_LIMIT", "limit")): + if os.environ.get(env): + out[key] = os.environ[env] + for env, key in (("OMNI_TG_SESSION", "session"), ("OMNI_TG_API_ID", "api_id"), + ("OMNI_TG_API_HASH", "api_hash"), ("OMNI_TG_LIMIT", "limit")): + if out[key]: + os.environ.setdefault(env, out[key]) + return out + + def proxies(): mode = CONFIG.get("proxy_mode", "off") if mode == "env": diff --git a/omni/serve.py b/omni/serve.py index 51ffdca..0100dcc 100644 --- a/omni/serve.py +++ b/omni/serve.py @@ -219,8 +219,19 @@ class Handler(BaseHTTPRequestHandler): cats = _cat_health() for c in cats: c["stored"] = sum(per_source.get(x.name, 0) for x in all_s if x.category == c["name"]) + kv = {"services": 0, "keys": 0, "ready": 0} + tgst = {"enabled": False} + try: + from . import vault as _vault, tgsess as _tg + st_kv = _vault.stats() + kv = {"services": len(st_kv), "keys": sum(x["keys"] for x in st_kv), + "ready": sum(x["ready"] for x in st_kv)} + tgst = {"enabled": bool(_tg.enabled())} + except Exception: + pass return self._json({ "items": s["items"], "priced": s["priced"], "runs": s["runs"], "runs_ok": s["runs_ok"], + "keys": kv, "tg": tgst, "by_provider": [{"name": n, "count": c} for n, c in s["by_provider"][:12]], "by_kind": [{"name": n, "count": c} for n, c in s["by_kind"]], "sources": len(all_s), diff --git a/omni/tgsess.py b/omni/tgsess.py index 2e19c33..1a210ad 100644 --- a/omni/tgsess.py +++ b/omni/tgsess.py @@ -23,6 +23,11 @@ SERVICE_RE = re.compile( def enabled(): + try: + from . import config + config.tg_cfg() + except Exception: + pass return bool(os.environ.get("OMNI_TG_SESSION") and os.environ.get("OMNI_TG_API_ID") and os.environ.get("OMNI_TG_API_HASH")) @@ -75,6 +80,11 @@ def fetch(handles, limit=None): """Список словарей {handle, title, id, posts[]} по каналам. Пусто, если нет env.""" if not enabled(): return [] + try: + from . import config + config.tg_cfg() + except Exception: + pass session = os.environ["OMNI_TG_SESSION"] api_id = int(os.environ["OMNI_TG_API_ID"]) api_hash = os.environ["OMNI_TG_API_HASH"] diff --git a/tools/tg_pick.py b/tools/tg_pick.py new file mode 100644 index 0000000..5109a16 --- /dev/null +++ b/tools/tg_pick.py @@ -0,0 +1,110 @@ +"""Выбрать рабочую Telegram-сессию и прописать её в data/tg.json (вне git). + +Перебирает кандидатов из пула, проверяет is_user_authorized и права на чтение, +берёт первую живую. Личные аккаунты (@reformboss, @R3fIex) исключены всегда. + +Запуск: + python tools/tg_pick.py # пул по умолчанию + python tools/tg_pick.py --file my.json # свой список путей + python tools/tg_pick.py --check # проверить, что уже прописано +""" +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +OUT = ROOT / "data" / "tg.json" +DEFAULT_POOL = Path("C:/Users/User/tmp/tg_chat_grow/alive_sessions.json") +BANNED = {"reformboss", "r3flex", "r3fiex"} +API_ID = "23926822" +API_HASH = "f383e16e2f55bc0857291510fd513002" + + +def candidates(pool_file): + try: + data = json.loads(Path(pool_file).read_text(encoding="utf-8")) + except Exception: + return [] + paths = data if isinstance(data, list) else list(data.values()) + out = [] + for p in paths: + if not isinstance(p, str): + continue + low = Path(p).name.lower() + if any(b in low for b in BANNED): + continue + stem = p[:-8] if p.lower().endswith(".session") else p + if Path(stem + ".session").exists(): + out.append(stem) + return out + + +async def probe(stem, timeout=25): + from telethon import TelegramClient + + client = TelegramClient(stem, int(API_ID), API_HASH, connection_retries=1, timeout=12) + try: + await asyncio.wait_for(client.connect(), timeout) + ok = await asyncio.wait_for(client.is_user_authorized(), timeout) + if not ok: + return None + me = await asyncio.wait_for(client.get_me(), timeout) + if not me or (me.id in (7448683285, 809951394)): + return None + dialogs = 0 + try: + async for _ in client.iter_dialogs(limit=200): + dialogs += 1 + except Exception: + pass + return {"stem": stem, "uid": me.id, "username": me.username or "", + "phone": (me.phone or "")[:6] + "…", "dialogs": dialogs} + except Exception: + return None + finally: + try: + await asyncio.wait_for(client.disconnect(), 8) + except Exception: + pass + + +async def main(): + ap = argparse.ArgumentParser() + ap.add_argument("-n", "--need", type=int, default=12, help="сколько кандидатов проверить") + ap.add_argument("--file", default=str(DEFAULT_POOL)) + ap.add_argument("--check", action="store_true") + a = ap.parse_args() + + if a.check: + if not OUT.exists(): + print("не прописано") + return 1 + d = json.loads(OUT.read_text(encoding="utf-8")) + print("прописано:", d.get("session"), "| limit", d.get("limit")) + rec = await probe(d["session"]) if d.get("session") else None + print("состояние:", "жива, uid %s, диалогов %s" % (rec["uid"], rec["dialogs"]) if rec else "не отвечает") + return 0 if rec else 2 + + cands = candidates(a.file)[: a.need] + print("кандидатов проверяю:", len(cands)) + for stem in cands: + rec = await probe(stem) + print(" %-58s %s" % (Path(stem).name[:58], ("uid %s, диалогов %s" % (rec["uid"], rec["dialogs"])) if rec else "нет")) + if rec and rec["dialogs"] >= 3: + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps({"session": stem, "api_id": API_ID, "api_hash": API_HASH, + "limit": "100"}, ensure_ascii=False, indent=1), encoding="utf-8") + print("\nзаписано в data/tg.json: uid %s (@%s), диалогов %s" + % (rec["uid"], rec["username"] or "-", rec["dialogs"])) + return 0 + print("\nживых не нашёл — подключи сессию вручную в data/tg.json") + return 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) \ No newline at end of file diff --git a/web/index.html b/web/index.html index edae49e..3317b4c 100644 --- a/web/index.html +++ b/web/index.html @@ -231,6 +231,9 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)} } /* ---- меню v2 ---- */ +.stack{display:flex;gap:6px;flex-wrap:wrap;padding:2px 6px 10px} +.stack em{font:10px var(--mono);color:var(--dim2);letter-spacing:.05em;text-transform:uppercase;background:#131822;border:1px solid var(--line);border-radius:20px;padding:2px 7px;font-style:normal} +.stack em.on{color:#7fe3a1;border-color:#1f4a33;background:#0f1d16} .brand .ver{margin-left:auto;font:10.5px var(--mono);color:var(--dim2);background:#151a24;border:1px solid var(--line);border-radius:20px;padding:2px 8px} .menu-btn{display:flex;align-items:center;gap:9px;margin:0 0 12px;padding:9px 11px;width:100%;border:1px solid var(--line); background:#11151d;color:var(--dim);border-radius:11px;font:12.5px inherit;cursor:pointer;transition:.15s} @@ -284,6 +287,7 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)}