feat(tg): реальные абузы из Telegram без сессии — адаптер tg_web переписан (служебное/пустыши отсекаются, заголовок из тела, порог 12/8 для leak-каналов), 19 живых каналов из 87 проверенных, сигнал абуза в score (+до 1.8), tools/tg_discover.py, tools/fixtg.py, tools/purge_tg_junk.py, tools/rescore.py; реестр 869, база 7295 (ab_ 3135, tg_ 374); тесты 19
This commit is contained in:
33
README.md
33
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/<handle>/statuses`: полный текст, метрики (лайки, репосты, просмотры), дата, язык.
|
||||
|
||||
18
REPORT.md
18
REPORT.md
@@ -1,19 +1,19 @@
|
||||
# omni-parse · автоотчёт
|
||||
|
||||
<!-- STATS:START -->
|
||||
_обновлено 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
|
||||
<!-- STATS:END -->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
20
omni/rank.py
20
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
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
49
omni/sources_tg_abuse.py
Normal file
49
omni/sources_tg_abuse.py
Normal file
@@ -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},
|
||||
]
|
||||
@@ -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
|
||||
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
|
||||
|
||||
34
tools/fixtg.py
Normal file
34
tools/fixtg.py
Normal file
@@ -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()
|
||||
58
tools/purge_tg_junk.py
Normal file
58
tools/purge_tg_junk.py
Normal file
@@ -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()
|
||||
44
tools/rescore.py
Normal file
44
tools/rescore.py
Normal file
@@ -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()
|
||||
78
tools/tg_discover.py
Normal file
78
tools/tg_discover.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user