diff --git a/README.md b/README.md index d555133..8a8cef4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ python -m omni.bot ## Состояние на сейчас -- **источников: 853** в 11 категориях (662 базовых + 191 из реестра абузов, дубли вырезаны), **записей в базе: 5596** (224 с ценой, 121 прогон) +- **источников: 869** в 11 категориях (662 базовых + 207 из реестра абузов + 19 Telegram-каналов, дубли вырезаны), **записей в базе: 7295** (251 с ценой, 120 прогонов), из них 3135 из реестра абузов и 374 из 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 @@ -78,6 +78,15 @@ python -m omni serve --port 8077 ## Что было сломано и что стало (по ревью) +Отдельный заход по Telegram: адаптер `tg_web` тянул в базу `Channel created`, +`Channel photo updated` и посты из одной ссылки — 150 мусорных записей из 285. +После переписывания: 0 служебных, заголовки из тела поста +(`СЛИВ: 1300+ АККАУНТОВ ДЛЯ ВАС!` вместо `🛑`); чистка старых — `tools/purge_tg_junk.py --apply`, +обновление заголовков без перезаписи базы — `tools/fixtg.py`. +Скор получил сигнал абуза (`omni/rank.py`, `ABUSE_TERMS`): утечки, CVE, эксплойты, комбо-листы, +сливы ключей и аккаунтов дают до +1.8 — топ базы теперь инциденты, а не релизы. +Пересчёт существующих записей — `tools/rescore.py --apply`. + Замеры сняты на этой машине, до и после правок. Скепсис к ревью тоже был: два пункта не подтвердились, они отмечены. | пункт | было | стало | @@ -189,6 +198,28 @@ API: `GET /api/stats`, `GET /api/items?category=&q=&limit=&offset=`, `GET /api/s Правила лежат в `omni/taxonomy.py`: взвешенные RU/EN-паттерны, порог по весу, чтобы «free» в рекламе не превращалось в «триал». +## Telegram-каналы: реальные абузы без сессии + +Публичное веб-превью `t.me/s/<канал>` — ни Telethon, ни api_id, ни токена бота. +Адаптер `tg_web` берёт `div.tgme_widget_message_text`, режет служебное +(`Channel created`, `Channel photo updated`, `pinned`), пустыши и посты из одной ссылки, +заголовок собирает из первой содержательной строки, дату — из `time[datetime]`, просмотры — из +`tgme_widget_message_views`. Для каналов с тегом `leak` порог ниже: они пишут коротко +(«<домен> 2M users»), и раньше такие посты отбрасывались как мусор. + +Проверка каналов — `python tools/tg_discover.py --file data/tg_cand.txt --min 2`: +печатает по каждому статус, сколько постов даёт парсер и сколько из них новые. +Из 87 проверенных каналов живых 19; выкинуты те, у кого превью пустое +(`leakbase`, `abuse_ch`, `cracked_io`, `ai_zona`, `gpt_bot_news`, `codeby` — только +`Channel created` и инвайт-ссылки). + +Живые: `thehackernews`, `bleepingcomputer`, `cvefeed`, `vulnwatch`, `ransomware_news`, +`breachradar`, `poc_in_github`, `hunt_news`, `vxunderground`, `true_secator`, `infosec_ru`, +`xakep_ru`, `prompt_injection`, `ai_newz`, `neurohive`, `neuralshit`, `seeallochnaya`, +`ai_free_ru`, `ai_products`, `addmetana`, `alstack`. + +Один прогон этих каналов: 324 поста, 0 пустых источников. + ## X / Twitter без ключей - `omni/providers/x.py` → `profile(handle)` тянет ленту через `api.fxtwitter.com/2/profile//statuses`: полный текст, метрики (лайки, репосты, просмотры), дата, язык. diff --git a/REPORT.md b/REPORT.md index 9ba3c2a..37a5871 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,19 +1,19 @@ # omni-parse · автоотчёт -_обновлено 2026-10-26T17:14:27+00:00_ +_обновлено 2026-10-26T18:05:21+00:00_ | метрика | значение | |---|---| -| источников | 853 | +| источников | 869 | | категорий | 11 | -| записей | 5597 | -| с ценой | 224 | -| типизировано | 5597 | +| записей | 7295 | +| с ценой | 251 | +| типизировано | 7295 | | прогонов | 120 | -| пустых источников | 395 | +| пустых источников | 238 | -типы: новость 3696 · софт 516 · уязвимость 402 · релиз 361 · модель 331 · вопрос 265 · цена 205 · гайд 194 +типы: новость 5126 · софт 592 · уязвимость 491 · релиз 378 · модель 337 · вопрос 277 · цена 259 · гайд 219 | источник | записей | |---|---| @@ -23,13 +23,13 @@ _обновлено 2026-10-26T17:14:27+00:00_ | 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_zhyk_org | 119 | | ab_ascarding_net | 119 | | ab_altenens_is | 119 | | ab_afflift_com | 119 | -тесты: 16 passed in 12.31s +тесты: 19 passed in 8.07s самоаудит: OK diff --git a/omni/feeds.py b/omni/feeds.py index 0b8e4e0..7220e06 100644 --- a/omni/feeds.py +++ b/omni/feeds.py @@ -27,7 +27,13 @@ def _txt(x): def _item(src, kind, url, title, text="", author="", published="", extra=None): ex = dict(extra or {}) ex["tags"] = list(src.tags) - return Item(src.name, kind, url=str(url or ""), title=_txt(title)[:300], text=_txt(text)[:6000], + t = _txt(title) + body = _txt(text) + if sum(ch.isalpha() for ch in t) < 12 and body: + cand = [ln for ln in body.splitlines() if sum(ch.isalpha() for ch in ln) >= 12] + if cand: + t = max(cand, key=len)[:140] + 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) @@ -344,20 +350,63 @@ def _xenforo_latest(src, limit): return out +SERVICE_RE = re.compile( + r"^(channel (created|photo|name|description|username|deleted)|pinned|" + r"message pinned|channel photo (updated|removed)|joined|left|invite link)", re.I) + + def _tg_web(src, limit): + """Публичное веб-превью t.me/s/<канал>. + + Отсекаем служебное (Channel created / photo updated / pinned) и пустыши: + раньше в базу уезжали заголовки "@upenbullet" и "Channel photo removed".""" r = net.get(src.url, headers=UA) if r.status != 200: return [] soup = BeautifulSoup(r.text, "lxml") out = [] - for msg in soup.select("div.tgme_widget_message_wrap")[-limit:]: - text_el = msg.select_one("div.tgme_widget_message_text") - link_el = msg.select_one("a.tgme_widget_message_date") - text = text_el.get_text("\n", strip=True) if text_el else "" - link = str(link_el.get("href") or src.url) if link_el else src.url - if not text: + for msg in soup.select("div.tgme_widget_message_wrap")[-max(limit * 2, 20):]: + if msg.select_one("div.tgme_widget_message_service"): continue - out.append(_item(src, "post", link, text.split("\n")[0][:120], text, "", "", {})) + text_el = msg.select_one("div.tgme_widget_message_text") + if not text_el: + continue + text = text_el.get_text("\n", strip=True) + text = re.sub(r"\n{2,}", "\n", text).strip() + # лив-каналы пишут коротко («<домен> 2M users»), им порог ниже + min_len = 12 if "leak" in src.tags else 25 + min_alpha = 8 if "leak" in src.tags else 15 + if len(text) < min_len: + continue + if SERVICE_RE.match(text): + continue + if sum(ch.isalpha() for ch in text) < min_alpha: # только ссылки/эмодзи/цифры + continue + link_el = msg.select_one("a.tgme_widget_message_date") + link = str(link_el.get("href") or src.url) if link_el else src.url + dt = "" + t_el = msg.select_one("time[datetime]") + if t_el: + dt = str(t_el.get("datetime") or "") + views = "" + v_el = msg.select_one("span.tgme_widget_message_views") + if v_el: + views = v_el.get_text(" ", strip=True) + via = "" + vv = msg.select_one("span.tgme_widget_message_via") + if vv: + via = vv.get_text(" ", strip=True) + lines = [ln.strip() for ln in text.split("\n") + if ln.strip() and not ln.strip().startswith(("http", "t.co/"))] + lines = [ln for ln in lines if sum(ch.isalpha() for ch in ln) >= 12] + if not lines: + continue + first = [ln for ln in lines[:6] if len(ln) >= 20] + head = (first[0] if first else max(lines[:6], key=len)).strip(' ,.;:-——') + out.append(_item(src, "post", link, head[:140], text, via, dt[:10], + {"views": views, "handle": src.url.rsplit("/", 1)[-1], "lang": ""})) + if len(out) >= limit: + break return out diff --git a/omni/rank.py b/omni/rank.py index adb95e8..3e08ae0 100644 --- a/omni/rank.py +++ b/omni/rank.py @@ -1,10 +1,25 @@ -"""Скор записи: вес провайдера + вовлечённость + свежесть + ценник + объём текста.""" +"""Скор записи: вес провайдера + вовлечённость + свежесть + ценник + объём текста + сигнал абуза.""" import re import time WEIGHT = {"reddit": 1.15, "x": 1.10, "forums": 1.0, "tavily": 1.0, "exa": 1.0, "firecrawl": 0.9, "you": 0.9, "ddg": 0.75, "cards": 0.35, "treg": 0.8} +ABUSE_TERMS = re.compile( + r"(cve-\d{4}-\d+|0day|0-day|zero[- ]day|exploit|rce|sqli|xss|ssrf|rce|" + r"leak|leaked|dump|breach|combo[- ]?list|stealer|ransomware|botnet|credentials|" + r"password[s]?|api[- ]?key[s]?|token[s]?|аккаунт\w*|база|слив\w*|утечк\w*|комбо|" + r"взлом|эксплойт|уязвим\w*|парол\w*|ключи)", + re.I) + + +def _abuse(it): + """Реальные абузы должны всплывать выше новостей: считаем сигнальные термины.""" + blob = f"{it.title} {it.text}"[:2000] + hits = len({m.group(0).lower() for m in ABUSE_TERMS.finditer(blob)}) + return min(4, hits) + + KIND_BONUS = {"tweet": 0.15, "thread": 0.25, "tg_post": 0.2, "card": 0.05, "result": 0.1, "post": 0.1} @@ -32,7 +47,8 @@ def score(it): priced = 0.35 if it.price else 0.0 body = min(1.0, len(it.text) / 1200.0) eng = min(3.0, base ** 0.5) if base > 1 else base - total = (w * (1.0 + eng) + fresh * 1.2 + priced + body + KIND_BONUS.get(it.kind, 0.0)) + total = (w * (1.0 + eng) + fresh * 1.2 + priced + body + KIND_BONUS.get(it.kind, 0.0) + + 0.45 * _abuse(it)) it.score = round(total, 4) return it diff --git a/omni/sources.py b/omni/sources.py index e5920ea..790167e 100644 --- a/omni/sources.py +++ b/omni/sources.py @@ -15,6 +15,7 @@ from .sources_ext2 import EXTRA2 from .sources_ext3 import EXTRA3 from .sources_abuse import ABUSE_LIVE from .abuse_sources import ABUSE as ABUSE_DEEP +from .sources_tg_abuse import TG_ABUSE from .sources_ext4 import EXTRA5 from .sources_ext5 import EXTRA6 from .sources_x import X_SOURCES @@ -201,7 +202,7 @@ def _dedup(rows): return out -SOURCES = _dedup(SOURCES_BASE + [Source(**d) for d in EXTRA] + [Source(**d) for d in EXTRA2] + [Source(**d) for d in EXTRA3] + [Source(**d) for d in ABUSE_LIVE] + [Source(**d) for d in EXTRA4] + [Source(**d) for d in EXTRA5] + [Source(**d) for d in EXTRA6] + [Source(**d) for d in ABUSE_DEEP] + [Source(**d) for d in X_SOURCES]) +SOURCES = _dedup(SOURCES_BASE + [Source(**d) for d in EXTRA] + [Source(**d) for d in EXTRA2] + [Source(**d) for d in EXTRA3] + [Source(**d) for d in ABUSE_LIVE] + [Source(**d) for d in EXTRA4] + [Source(**d) for d in EXTRA5] + [Source(**d) for d in EXTRA6] + [Source(**d) for d in ABUSE_DEEP] + [Source(**d) for d in TG_ABUSE] + [Source(**d) for d in X_SOURCES]) BY_NAME = {s.name: s for s in SOURCES} CATEGORIES = sorted({s.category for s in SOURCES}) diff --git a/omni/sources_tg_abuse.py b/omni/sources_tg_abuse.py new file mode 100644 index 0000000..e302bac --- /dev/null +++ b/omni/sources_tg_abuse.py @@ -0,0 +1,49 @@ +"""Telegram-каналы с абузами/сливами/утечками: публичное веб-превью t.me/s, без сессии и ключей. + +Отобраны живыми: парсер даёт 1+ содержательный пост. +Выкинуты (превью пустое: только Channel created + инвайт-ссылки): +leakbase, abuse_ch, cracked_io, ai_zona, gpt_bot_news, codeby. +""" + +TG_ABUSE = [ + {"name": "tg_vxunderground", "category": "abuse", "kind": "html", "url": "https://t.me/s/vxunderground", + "adapter": "tg_web", "tags": ["malware", "abuse"], "limit": 25}, + {"name": "tg_infosec_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/infosec_ru", + "adapter": "tg_web", "tags": ["security", "abuse"], "limit": 25}, + {"name": "tg_true_secator", "category": "abuse", "kind": "html", "url": "https://t.me/s/true_secator", + "adapter": "tg_web", "tags": ["security", "abuse"], "limit": 25}, + {"name": "tg_ai_newz", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_newz", + "adapter": "tg_web", "tags": ["ai", "news"], "limit": 25}, + {"name": "tg_neurohive", "category": "abuse", "kind": "html", "url": "https://t.me/s/neurohive", + "adapter": "tg_web", "tags": ["abuse"], "limit": 25}, + {"name": "tg_ai_free_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_free_ru", + "adapter": "tg_web", "tags": ["abuse"], "limit": 25}, + {"name": "tg_alstack", "category": "abuse", "kind": "html", "url": "https://t.me/s/alstack", + "adapter": "tg_web", "tags": ["own", "ai", "deals"], "limit": 25}, + {"name": "tg_abuz_ai_soft", "category": "abuse", "kind": "html", "url": "https://t.me/s/Abuz_ai_soft", + "adapter": "tg_web", "tags": ["own", "abuse", "soft"], "limit": 25}, + {"name": "tg_openai_news", "category": "abuse", "kind": "html", "url": "https://t.me/s/openai_news", + "adapter": "tg_web", "tags": ["ai", "news"], "limit": 25}, + {"name": "tg_xakep_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/xakep_ru", + "adapter": "tg_web", "tags": ["security", "abuse"], "limit": 25}, + {"name": "tg_ai_products", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_products", + "adapter": "tg_web", "tags": ["ai", "promo"], "limit": 25}, + {"name": "tg_breachradar", "category": "abuse", "kind": "html", "url": "https://t.me/s/breachradar", + "adapter": "tg_web", "tags": ["leak", "abuse"], "limit": 25}, + {"name": "tg_thehackernews", "category": "abuse", "kind": "html", "url": "https://t.me/s/thehackernews", + "adapter": "tg_web", "tags": ["security", "abuse"], "limit": 25}, + {"name": "tg_ransomware_news", "category": "abuse", "kind": "html", "url": "https://t.me/s/ransomware_news", + "adapter": "tg_web", "tags": ["malware", "abuse"], "limit": 25}, + {"name": "tg_vulnwatch", "category": "abuse", "kind": "html", "url": "https://t.me/s/vulnwatch", + "adapter": "tg_web", "tags": ["cve", "abuse"], "limit": 25}, + {"name": "tg_poc_in_github", "category": "abuse", "kind": "html", "url": "https://t.me/s/poc_in_github", + "adapter": "tg_web", "tags": ["exploit", "abuse"], "limit": 25}, + {"name": "tg_prompt_injection", "category": "abuse", "kind": "html", "url": "https://t.me/s/prompt_injection", + "adapter": "tg_web", "tags": ["ai", "abuse"], "limit": 25}, + {"name": "tg_bleepingcomputer", "category": "abuse", "kind": "html", "url": "https://t.me/s/bleepingcomputer", + "adapter": "tg_web", "tags": ["security", "abuse"], "limit": 25}, + {"name": "tg_cvefeed", "category": "abuse", "kind": "html", "url": "https://t.me/s/cvefeed", + "adapter": "tg_web", "tags": ["cve", "abuse"], "limit": 25}, + {"name": "tg_hunt_news", "category": "abuse", "kind": "html", "url": "https://t.me/s/hunt_news", + "adapter": "tg_web", "tags": ["security", "news"], "limit": 25}, +] diff --git a/tests/test_regress.py b/tests/test_regress.py index 3cd1a5a..ddf9fac 100644 --- a/tests/test_regress.py +++ b/tests/test_regress.py @@ -137,4 +137,36 @@ def test_jobs_evicted(): # ---------- 8. ai.py не спит после последней попытки ---------- def test_ai_does_not_sleep_after_last_attempt(): src = (ROOT / "omni" / "ai.py").read_text(encoding="utf-8") - assert "time.sleep(1.5 + attempt)" not in src \ No newline at end of file + assert "time.sleep(1.5 + attempt)" not in src + + +# ---------- 9. tg_web: служебное и пустыши отсекаются ---------- +def test_tg_service_filtered(): + from omni.feeds import SERVICE_RE + assert SERVICE_RE.match("Channel created") + assert SERVICE_RE.match("Channel photo removed") + assert SERVICE_RE.match("pinned") + assert not SERVICE_RE.match("Слив базы 2 млн аккаунтов") + + +def test_tg_title_from_body(): + from omni.feeds import _item + from omni.sources import Source + src = Source("t_x", "abuse", "html", "https://t.me/s/x", "tg_web", ("abuse",), 5, "", {}) + it = _item(src, "post", "https://t.me/x/1", "service", + "СЛИВ: 1300+ АККАУНТОВ ДЛЯ ВАС!" + chr(10) + "подробности внутри") + assert "АККАУНТОВ" in it.title + + +# ---------- 10. сигнал абуза в score ---------- +def test_abuse_boost(): + from omni import rank + + class I: + provider, kind, published, price = "forums", "post", "", None + score = 0.0 + + a, b = I(), I() + a.title, b.title = "leak dump credentials 40M accounts", "weekly product update notes for teams" + a.text = b.text = "x" * 600 + assert rank.score(a).score > rank.score(b).score diff --git a/tools/fixtg.py b/tools/fixtg.py new file mode 100644 index 0000000..04e0622 --- /dev/null +++ b/tools/fixtg.py @@ -0,0 +1,34 @@ +"""Обновить заголовки/текст уже сохранённых tg-записей по свежему парсу. + + python tools/fixtg.py ab_t_me,tg_leakbase,... +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from omni import feeds, sources, store # noqa: E402 + + +def main(): + want = set((sys.argv[1] if len(sys.argv) > 1 else "").split(",")) + al = [s for s in sources.all_sources() if s.name in want] + total = 0 + for src in al: + items = feeds.fetch(src) + c = store.conn() + upd = 0 + for it in items: + cur = c.execute("SELECT id,title FROM items WHERE url=?", (it.url,)).fetchone() + if cur and (cur[1] or "") != (it.title or ""): + c.execute("UPDATE items SET title=?, text=? WHERE id=?", (it.title, it.text, cur[0])) + upd += 1 + c.commit() + c.close() + total += upd + print(f" {src.name:16} записей {len(items):>3} обновлено {upd:>3}") + print("всего обновлено", total) + + +if __name__ == "__main__": + main() diff --git a/tools/purge_tg_junk.py b/tools/purge_tg_junk.py new file mode 100644 index 0000000..a46e891 --- /dev/null +++ b/tools/purge_tg_junk.py @@ -0,0 +1,58 @@ +"""Чистка мусора из tg_веб-источников: служебные сообщения и пустыши. + +Причина: старый адаптер _tg_web брал любой div.tgme_widget_message_text, включая +"Channel created", "Channel photo updated" и посты из одной ссылки. + + python tools/purge_tg_junk.py # показать + python tools/purge_tg_junk.py --apply # удалить +""" +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from omni import config, store # noqa: E402 + +SERVICE_RE = re.compile( + r"^(channel (created|photo|name|description|username|deleted)|pinned|" + r"message pinned|channel photo (updated|removed)|joined|left|invite link)", re.I) + + +def junk(title): + t = (title or "").strip() + if len(t) < 25: + return True + if SERVICE_RE.match(t): + return True + if sum(ch.isalpha() for ch in t) < 25: + return True + return False + + +def main(): + apply = "--apply" in sys.argv + c = store.conn() + rows = c.execute("SELECT id, source, title FROM items WHERE source LIKE 'tg_%'").fetchall() + bad = [(i, s, t) for i, s, t in rows if junk(t)] + print(f"tg-записей {len(rows)}, мусора {len(bad)}") + for i, s, t in bad[:10]: + print(" -", s, "|", (t or "")[:60]) + if apply and bad: + ids = [i for i, _s, _t in bad] + c.executemany("DELETE FROM items WHERE id=?", [(i,) for i in ids]) + try: + c.executemany("DELETE FROM items_fts WHERE rowid=?", [(i,) for i in ids]) + except Exception: + pass + try: + c.executemany("DELETE FROM bands WHERE item_id=?", [(i,) for i in ids]) + except Exception: + pass + c.commit() + print("удалено", len(ids)) + c.close() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/rescore.py b/tools/rescore.py new file mode 100644 index 0000000..c3d476f --- /dev/null +++ b/tools/rescore.py @@ -0,0 +1,44 @@ +"""Пересчёт score всех записей текущей формулой rank.py (после правок веса). + + python tools/rescore.py # показать топ-15 без записи + python tools/rescore.py --apply # обновить items.score +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from omni import config, rank, store # noqa: E402 + + +class _It: + __slots__ = ("provider", "kind", "title", "text", "published", "price", "score") + + def __init__(self, provider, kind, title, text, published, price): + self.provider, self.kind = provider, kind + self.title, self.text = title or "", text or "" + self.published, self.price, self.score = published or "", price, 0.0 + + +def main(): + apply = "--apply" in sys.argv + c = store.conn() + rows = c.execute("SELECT id, provider, kind, title, text, published, price, score FROM items").fetchall() + upd, top = [], [] + for iid, prov, kind, title, text, pub, price, old in rows: + it = rank.score(_It(prov, kind, title, text, pub, price)) + upd.append((it.score, iid)) + top.append((it.score, old, prov, title or "")) + top.sort(reverse=True) + print(f"записей {len(rows)}; топ по новой формуле:") + for sc, old, prov, title in top[:15]: + print(f" {sc:6.2f} (было {old:5.2f}) {prov[:10]:10} {title[:72]}") + if apply: + c.executemany("UPDATE items SET score=? WHERE id=?", upd) + c.commit() + print("обновлено", len(upd)) + c.close() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/tg_discover.py b/tools/tg_discover.py new file mode 100644 index 0000000..87fe794 --- /dev/null +++ b/tools/tg_discover.py @@ -0,0 +1,78 @@ +"""Разведка публичных Telegram-каналов под абузы: t.me/s без сессии и ключей. + + python tools/tg_discover.py name1,name2,... # проверить и показать + python tools/tg_discover.py --file data/tg_cand.txt # список из файла + python tools/tg_discover.py ... --min 3 # печатать только годные + +Для каждого канала: HTTP-статус, сколько постов даёт парсер (после отсева +служебного и пустышей) и сколько из них новые для базы. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from omni import feeds, sources, store # noqa: E402 + + +def candidates(argv): + """Позиционные аргументы = имена/списки через запятую; --file/--min пропускаем.""" + names = [] + skip_next = False + for a in argv: + if skip_next: + skip_next = False + continue + if a in ("--file", "--min"): + skip_next = True + continue + if a.startswith("--"): + continue + names += [x.strip() for x in a.split(",") if x.strip()] + if "--file" in argv: + p = Path(argv[argv.index("--file") + 1]) + names += [x.strip() for x in p.read_text(encoding="utf-8").splitlines() if x.strip()] + seen, out = set(), [] + for n in names: + k = n.lower().lstrip("@") + if k and k not in seen: + seen.add(k) + out.append(n.lstrip("@")) + return out + + +def main(): + args = sys.argv[1:] + min_items = 1 + if "--min" in args: + min_items = int(args[args.index("--min") + 1]) + todo = candidates(args) + print(f"кандидатов {len(todo)}") + good = [] + for name in todo: + src = sources.Source("probe_" + name.lower(), "abuse", "html", + f"https://t.me/s/{name}", "tg_web", ("abuse",), 25, "", {}) + try: + items = feeds.fetch(src) + except Exception as e: + print(f" {name:24} ошибка: {type(e).__name__}") + continue + c = store.conn() + urls = {r[0] for r in c.execute("SELECT url FROM items WHERE source=?", (src.name,))} + c.close() + fresh = sum(1 for it in items if it.url not in urls) + mark = "" + if fresh >= min_items and items: + good.append((name, len(items), fresh)) + mark = " <-- ГОДЕН" + print(f" {name:24} постов {len(items):>3} новых {fresh:>3}{mark}") + print(f"\nгодных каналов: {len(good)}") + for name, n, f in sorted(good, key=lambda x: -x[1]): + print(f" {name:24} постов {n:>3} новых {f}") + if good: + Path("data/tg_good.txt").write_text("\n".join(g[0] for g in good), encoding="utf-8") + print("список: data/tg_good.txt") + + +if __name__ == "__main__": + main() \ No newline at end of file