From aa417d635b402e1b833c6200a9589eb98c364c5c Mon Sep 17 00:00:00 2001 From: Vlad Gothbreach Date: Mon, 26 Oct 2026 22:43:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(tg):=20=D1=81=D0=B2=D0=BE=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D1=8F=20Telethon=20=E2=80=94=20omni/tg?= =?UTF-8?q?sess.py=20+=20omni=20pump=20(=D0=B3=D0=BB=D1=83=D0=B1=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=20200=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=2020,=2020=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2=20->=202832=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0,=202592=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85),=20CVE-I?= =?UTF-8?q?D=20=D0=B2=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B5=20(471),=20tg=5Falive=5Faudit=20=D0=B8=D0=B7=20856=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B9=20=D0=BD=D0=B0=D1=88=D1=91?= =?UTF-8?q?=D0=BB=20121=20=D0=B6=D0=B8=D0=B2=D1=83=D1=8E;=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=2022?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 24 ++++++++++++- REPORT.md | 32 ++++++++--------- omni/cli.py | 36 +++++++++++++++++++ omni/feeds.py | 5 +++ omni/tgsess.py | 82 ++++++++++++++++++++++++++++++++++++++++++ tests/test_regress.py | 20 +++++++++++ tools/tgpump.py | 84 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 266 insertions(+), 17 deletions(-) create mode 100644 omni/tgsess.py create mode 100644 tools/tgpump.py diff --git a/README.md b/README.md index 8a8cef4..3505106 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ python -m omni.bot ## Состояние на сейчас -- **источников: 869** в 11 категориях (662 базовых + 207 из реестра абузов + 19 Telegram-каналов, дубли вырезаны), **записей в базе: 7295** (251 с ценой, 120 прогонов), из них 3135 из реестра абузов и 374 из Telegram-каналов +- **источников: 869** в 11 категориях (662 базовых + 207 из реестра абузов + 19 Telegram-каналов, дубли вырезаны), **записей в базе: 9889** (421 с ценой, 120 прогонов), из них 3135 из реестра абузов и 2966 из Telegram (превью + своя сессия) - **типы записей: 14** — каждая запись размечена по смыслу: новость 3693 · софт 516 · уязвимость 402 · релиз 361 · модель 331 · вопрос 265 · цена 205 · гайд 194 · датасет 126 · слив 90 · триал 41 · промо 34 · вакансия 21 · перепродажа 10 - **X/Twitter: 30 аккаунтов** читаются без API-ключей через `api.fxtwitter.com` (313 твитов в базе) - вкладки панели: `Лента` · `Источники` · `Прогоны` · `Абузы`; полоса чипов «тип», фильтр «только с ценой», сортировка `score/fresh/price`, экспорт CSV @@ -220,6 +220,28 @@ API: `GET /api/stats`, `GET /api/items?category=&q=&limit=&offset=`, `GET /api/s Один прогон этих каналов: 324 поста, 0 пустых источников. +### Своя сессия: глубина вместо 20 постов + +Веб-превью отдаёт ~20 последних постов. Свой аккаунт (Telethon) по тем же каналам +берёт 200: прогон 20 каналов дал **2832 поста, 2592 новых записи** (остальное дубли). +Каналы с выключенным превью тоже читаются, но пустые не оживают: у `leakbase` +и через сессию 4 поста — только инвайты. + +Секретов в репозитории нет, всё через окружение: + +```bash +export OMNI_TG_SESSION=/path/to/account # .session без расширения +export OMNI_TG_API_ID=... OMNI_TG_API_HASH=... +python -m omni pump thehackernews,cvefeed --limit 200 --apply +``` + +Без env команда честно говорит, что сессии нет, и парсер продолжает работать +на веб-превью. Аудит пула сессий — `tg_alive_audit.py`: из 856 файлов живыми +оказались 121 (остальные unauthorized/битые), их и берём. + +Ещё по ходу: у CVE-каналов тело advisory без идентификатора в заголовке — +теперь `CVE-2026-97254 · Improper Neutralization...`, таких заголовков 471. + ## X / Twitter без ключей - `omni/providers/x.py` → `profile(handle)` тянет ленту через `api.fxtwitter.com/2/profile//statuses`: полный текст, метрики (лайки, репосты, просмотры), дата, язык. diff --git a/REPORT.md b/REPORT.md index 37a5871..376254a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,35 +1,35 @@ # omni-parse · автоотчёт -_обновлено 2026-10-26T18:05:21+00:00_ +_обновлено 2026-10-26T19:42:10+00:00_ | метрика | значение | |---|---| | источников | 869 | | категорий | 11 | -| записей | 7295 | -| с ценой | 251 | -| типизировано | 7295 | +| записей | 9889 | +| с ценой | 421 | +| типизировано | 9889 | | прогонов | 120 | -| пустых источников | 238 | +| пустых источников | 237 | -типы: новость 5126 · софт 592 · уязвимость 491 · релиз 378 · модель 337 · вопрос 277 · цена 259 · гайд 219 +типы: новость 6223 · уязвимость 1354 · софт 921 · релиз 563 · цена 437 · модель 399 · вопрос 347 · гайд 301 | источник | записей | |---|---| | arxiv_cs_ai | 280 | +| tg_vulnwatch | 220 | +| tg_bleepingcomputer | 220 | +| tg_ransomware_news | 218 | | community | 215 | | reddit_machinelearning | 208 | -| hf_trending_models | 156 | -| ab_niflheim_world | 123 | -| ab_cpamafia_pro | 121 | -| ab_zhyk_org | 120 | -| ab_hardforum_com | 120 | -| ab_carders_biz | 120 | -| ab_ascarding_net | 119 | -| ab_altenens_is | 119 | -| ab_afflift_com | 119 | +| tg_breachradar | 202 | +| tg_thehackernews | 200 | +| tg_poc_in_github | 200 | +| tg_cvefeed | 200 | +| tg_xakep_ru | 199 | +| tg_alstack | 198 | -тесты: 19 passed in 8.07s +тесты: 20 passed in 2.30s самоаудит: OK diff --git a/omni/cli.py b/omni/cli.py index cc0fba2..1b8346f 100644 --- a/omni/cli.py +++ b/omni/cli.py @@ -8,6 +8,7 @@ import sys from . import config, rank, store from . import feeds, sources from . import filter as flt +from .model import Item from .providers import FUNCS, MODULES, get, status ALL_FREE = ["reddit", "x", "forums", "ddg", "mojeek", "bing"] @@ -268,6 +269,35 @@ def cmd_dedupe(a): return 0 +def cmd_pump(a): + """История Telegram-каналов через свою сессию (нужны OMNI_TG_SESSION/API_ID/API_HASH).""" + from . import tgsess + if not tgsess.enabled(): + print("нет сессии: задай OMNI_TG_SESSION, OMNI_TG_API_ID, OMNI_TG_API_HASH") + return 2 + handles = [h.strip() for h in a.channels.split(",") if h.strip()] + recs = tgsess.fetch(handles, a.limit) + if not recs: + print("пусто") + return 1 + items = [] + for r in recs: + for post in r.get("posts") or []: + if len(post["text"]) < 12: + continue + items.append(Item("tg_" + r["handle"], "post", url=post["url"], title=post["title"], + text=post["text"], author="@" + r["handle"], + published=post["date"], source="tg_" + r["handle"], + extra={"views": post.get("views"), "via": "session"})) + print("каналов %d, постов %d" % (len(recs), len(items))) + if not a.apply: + print("dry-run: добавь --apply") + return 0 + new, dup = store.add(items) + print("новых %d, дублей %d" % (new, dup)) + return 0 + + def main(argv=None): try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") @@ -369,6 +399,12 @@ def main(argv=None): sp.add_argument("--apply", action="store_true", help="реально удалить лишние строки") sp.set_defaults(fn=cmd_dedupe) + sp = sub.add_parser("pump", help="история Telegram-каналов через свою сессию") + sp.add_argument("channels", help="каналы через запятую") + sp.add_argument("--limit", type=int, default=100) + sp.add_argument("--apply", action="store_true") + sp.set_defaults(fn=cmd_pump) + args = p.parse_args(argv) return args.fn(args) diff --git a/omni/feeds.py b/omni/feeds.py index 7220e06..f7cc570 100644 --- a/omni/feeds.py +++ b/omni/feeds.py @@ -33,6 +33,11 @@ def _item(src, kind, url, title, text="", author="", published="", extra=None): cand = [ln for ln in body.splitlines() if sum(ch.isalpha() for ch in ln) >= 12] if cand: t = max(cand, key=len)[:140] + # CVE-каналы постят тело advisory без идентификатора в заголовке — выносим его вперёд + if "CVE-" not in t: + m = re.search(r"CVE-\d{4}-\d{4,7}", body or "") + if m: + t = "%s · %s" % (m.group(0), t[:120]) return Item(src.name, kind, url=str(url or ""), title=t[:300], text=body[:6000], author=_txt(author)[:80], published=str(published or ""), source=src.name, extra=ex) diff --git a/omni/tgsess.py b/omni/tgsess.py new file mode 100644 index 0000000..2e19c33 --- /dev/null +++ b/omni/tgsess.py @@ -0,0 +1,82 @@ +"""Telegram через свою сессию (Telethon): глубина и закрытые каналы. + +Зачем, если есть веб-превью t.me/s: превью отдаёт ~20 последних постов и ничего +не отдаёт для каналов с выключенным превью. Сессия тянет 200+ постов и читает +каналы/группы, куда аккаунт уже вступил. + +Конфиг только через env (секретов в репозитории нет): + OMNI_TG_SESSION путь к .session без расширения + OMNI_TG_API_ID api_id (my.telegram.org) + OMNI_TG_API_HASH api_hash + OMNI_TG_LIMIT сколько постов тянуть на канал (по умолчанию 100) + +Если env не задан — модуль молча выключен, парсер работает на веб-превью. +""" +import asyncio +import os +import re +from datetime import datetime, timezone + +SERVICE_RE = re.compile( + r"^(channel (created|photo|name|description|username|deleted)|pinned|message pinned|" + r"channel photo (updated|removed)|joined|left|invite link)", re.I) + + +def enabled(): + return bool(os.environ.get("OMNI_TG_SESSION") and os.environ.get("OMNI_TG_API_ID") + and os.environ.get("OMNI_TG_API_HASH")) + + +def _key(handle): + return handle.strip().strip("/").split("/")[-1].lstrip("@") + + +async def _pull(handles, limit, session, api_id, api_hash, timeout=40): + from telethon import TelegramClient + + client = TelegramClient(session, api_id, api_hash, connection_retries=1, timeout=timeout / 2) + out = [] + await asyncio.wait_for(client.connect(), timeout) + try: + for h in handles: + hh = _key(h) + rec = {"handle": hh, "posts": []} + try: + ent = await asyncio.wait_for(client.get_entity(hh), timeout) + msgs = await asyncio.wait_for(client.get_messages(ent, limit=limit), timeout) + rec["title"] = getattr(ent, "title", "") or hh + rec["id"] = getattr(ent, "id", None) + for m in msgs: + text = (getattr(m, "message", "") or "").strip() + if not text or SERVICE_RE.match(text): + continue + rec["posts"].append({ + "id": getattr(m, "id", None), + "url": "https://t.me/%s/%s" % (hh, getattr(m, "id", "")), + "title": text.splitlines()[0][:300], + "text": text[:6000], + "views": getattr(m, "views", None), + "date": (m.date.astimezone(timezone.utc).isoformat() + if isinstance(getattr(m, "date", None), datetime) else ""), + "channel": hh, + }) + except Exception as e: + rec["error"] = type(e).__name__ + out.append(rec) + finally: + try: + await asyncio.wait_for(client.disconnect(), 10) + except Exception: + pass + return out + + +def fetch(handles, limit=None): + """Список словарей {handle, title, id, posts[]} по каналам. Пусто, если нет env.""" + if not enabled(): + return [] + session = os.environ["OMNI_TG_SESSION"] + api_id = int(os.environ["OMNI_TG_API_ID"]) + api_hash = os.environ["OMNI_TG_API_HASH"] + lim = int(limit or os.environ.get("OMNI_TG_LIMIT") or 100) + return asyncio.run(_pull(list(handles), lim, session, api_id, api_hash)) \ No newline at end of file diff --git a/tests/test_regress.py b/tests/test_regress.py index 7ef3885..0bf96f9 100644 --- a/tests/test_regress.py +++ b/tests/test_regress.py @@ -182,3 +182,23 @@ def test_tag_command_readonly(monkeypatch): bot.handle({"message": {"chat": {"id": 1}, "from": {"id": 1}, "text": "/tag"}}) assert sent and "типы записей" in sent[0], sent[:1] assert "/tag" in bot.CMDS + + + +# ---------- 12. сессия Telegram: выключена без env, CVE в заголовке ---------- +def test_tgsess_disabled_without_env(monkeypatch): + from omni import tgsess + for k in ("OMNI_TG_SESSION", "OMNI_TG_API_ID", "OMNI_TG_API_HASH"): + monkeypatch.delenv(k, raising=False) + assert tgsess.enabled() is False + assert tgsess.fetch(["thehackernews"], 5) == [] + assert tgsess._key("@SomeChan/") == "SomeChan" + + +def test_item_cve_prefix(): + from types import SimpleNamespace + from omni.feeds import _item + src = SimpleNamespace(name="tg_cvefeed", tags=["cve"]) + it = _item(src, "post", "https://t.me/cvefeed/1", "Improper neutralization of input", + "Details: CVE-2026-12345 affects the parser badly.") + assert it.title.startswith("CVE-2026-12345"), it.title diff --git a/tools/tgpump.py b/tools/tgpump.py new file mode 100644 index 0000000..6af3539 --- /dev/null +++ b/tools/tgpump.py @@ -0,0 +1,84 @@ +"""Заливка истории Telegram-каналов через свою сессию (Telethon) в базу omni. + +Веб-превью t.me/s даёт ~20 постов, сессия — 200+. Скрипт берёт живую сессию из +пула аудита, тянет каналы и складывает посты как обычные записи (source = tg_<канал>), +чтобы они получили типы, скор и попали в панель/бот. + +Запуск: + python tools/tgpump.py --pool /c/Users/User/tmp/tg_chat_grow/alive_orca.json \ + --channels thehackernews,cvefeed,true_secator --limit 200 [--apply] + +Без --apply только считает (dry-run). Секретов в коде нет: api_id/hash из env +TG_API_ID/TG_API_HASH (по умолчанию официальные desktop-значения), путь к сессии — +из запуска. +""" +import argparse +import json +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from omni import store, tgsess # noqa: E402 +from omni.feeds import _item # noqa: E402 + + +def pick_session(pool_path, skip=()): + rows = [r for r in json.loads(Path(pool_path).read_text(encoding="utf-8")) + if r.get("state") == "alive" and r["session"] not in skip] + if not rows: + raise SystemExit("в пуле нет живых сессий") + return rows[0]["session"] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--pool", default="/c/Users/User/tmp/tg_chat_grow/alive_orca.json") + ap.add_argument("--sessions-root", default=str(Path.home() / ".orca" / "sessions")) + ap.add_argument("--channels", required=True, help="через запятую или @файл") + ap.add_argument("--limit", type=int, default=200) + ap.add_argument("--apply", action="store_true") + a = ap.parse_args() + + chans = a.channels + if chans.startswith("@"): + chans = Path(chans[1:]).read_text(encoding="utf-8").replace("\n", ",") + handles = [c.strip() for c in chans.split(",") if c.strip()] + + sess = pick_session(a.pool) + os.environ["OMNI_TG_SESSION"] = str(Path(a.sessions_root) / sess)[:-8] + os.environ.setdefault("OMNI_TG_API_ID", os.environ.get("TG_API_ID", "2040")) + os.environ.setdefault("OMNI_TG_API_HASH", + os.environ.get("TG_API_HASH", "b18441a1ff607e10a989891a5462e627")) + os.environ["OMNI_TG_LIMIT"] = str(a.limit) + + print(f"сессия {sess} | каналов {len(handles)} | лимит {a.limit}") + res = tgsess.fetch(handles, a.limit) + total_posts, items, per = 0, [], [] + for rec in res: + h = rec["handle"] + posts = rec.get("posts") or [] + total_posts += len(posts) + per.append((h, len(posts), rec.get("error", ""))) + src = SimpleNamespace(name="tg_" + h.replace("-", "_"), tags=["abuse", "tg"]) + for p in posts: + if len(p["text"]) < 12: + continue + items.append(_item(src, "post", p["url"], p["title"], p["text"], + author="@" + h, published=p["date"], + extra={"views": p["views"], "handle": h, "via": "session"})) + print("постов получено:", total_posts, "| к записи:", len(items)) + for h, n, err in per: + print(f" {h:24} {n:4} {err}") + + if not a.apply: + print("dry-run. добавь --apply чтобы записать в базу") + return + new, dup = store.add(items) + print(f"записано новых: {new}, дублей: {dup}") + + +if __name__ == "__main__": + main() \ No newline at end of file