bot: inline keyboard, channel posting, format switch, abuse subscribe; sources: own TG channels; hide keys tag

This commit is contained in:
ENI
2026-10-26 13:26:41 +03:00
parent e49f711b18
commit 6d65812314
3 changed files with 305 additions and 8 deletions

View File

@@ -1,3 +1,21 @@
### Telegram-бот: кнопки, канал, формат
```bash
set OMNI_BOT_TOKEN=<токен BotFather> # .bot_token, в git не попадает
set OMNI_BOT_OWNERS=<твой numeric id> # кому отвечает, можно несколько через запятую
python -m omni.bot
```
Экран бота — инлайн-кнопки: `Лента`, `Абузы`, `Источники`, `Цифры`, теги `Промо / Триалы / Сливы`, `Подписка: вкл/выкл`, `Формат: кратко/полно`, `Пост в канал`.
Команды: `/new [N]`, `/abuse [тег]`, `/find <слово>`, `/src <слово>`, `/run <категория>`, `/add <url>`, `/auto [N]`, `/stats`, `/sub [@канал]`, `/unsub [@канал]`, `/subs`, `/post [@канал]`.
**Постинг в канал.** Добавь бота админом канала с правом публикации и отправь `/sub @твойканал` — бот каждые 5 минут проверяет новые абузы и публикует только те, что появились после подписки. `/post @твойканал` — опубликовать подборку сразу, `/unsub @твойканал` — остановить. Дальше можно подключить свой бот: код один, токен свой, база общая.
**Формат текста.** `кратко` — заголовок, источник, тег. `полно` — плюс ссылка на запись. Переключается кнопкой и хранится на чат/канал: канал можно держать в одном формате, личку — в другом.
**Про ключи не публикуем.** Тег `keys` отфильтрован на уровне бота (`HIDE_TAGS` в `omni/bot.py`): записи, у которых после фильтра не осталось тегов, не попадают ни в `/abuse`, ни в автопостинг.
# omni-parse # omni-parse
Один парсер под любую тему: Reddit, форумы, поисковики, X/Twitter, карточки товаров и платные search-API — в одну SQLite-базу с FTS5, дедупом и рейтингом. Один парсер под любую тему: Reddit, форумы, поисковики, X/Twitter, карточки товаров и платные search-API — в одну SQLite-базу с FTS5, дедупом и рейтингом.
@@ -176,7 +194,7 @@ MIT.
## Абузы, авто-добавление и Telegram-бот ## Абузы, авто-добавление и Telegram-бот
- **абузы**: категория `abuse` — 31 живых площадок (XenForo/Discourse/Telegram/RSS), классификатор `omni/abuse.py` раскладывает записи по тегам `ключи / промо / триалы / сливы / софт / продажа / новости`. В панели — отдельная вкладка (`?view=abuse`), в боте — `/abuse [тег]`. - **абузы**: категория `abuse` — 31 живая площадка (XenForo/Discourse/Telegram/RSS), включая свои каналы, классификатор `omni/abuse.py` раскладывает записи по тегам `ключи / промо / триалы / сливы / софт / продажа / новости`. В панели — отдельная вкладка (`?view=abuse`), в боте — `/abuse [тег]`.
- **авто-добавление**: `omni/discover.py` ищет фиды на уже подключённых хостах и каталогах, проверяет их теми же адаптерами и сохраняет только те, что реально отдали записи. В панели — кнопка «авто-добавление», в API — `POST /api/discover?hosts=40&add=25`. - **авто-добавление**: `omni/discover.py` ищет фиды на уже подключённых хостах и каталогах, проверяет их теми же адаптерами и сохраняет только те, что реально отдали записи. В панели — кнопка «авто-добавление», в API — `POST /api/discover?hosts=40&add=25`.
- **новости**: любые RSS/Atom/JSON-ленты (редакции, релизы, сообщества) подключаются как обычный источник или через `/add <url>` в боте — отдельной схемы не требуется. - **новости**: любые RSS/Atom/JSON-ленты (редакции, релизы, сообщества) подключаются как обычный источник или через `/add <url>` в боте — отдельной схемы не требуется.
@@ -190,3 +208,23 @@ python -m omni.bot
Команды: `/new [N]`, `/abuse [тег]`, `/find <слово>`, `/src <слово>`, `/run <категория>`, `/add <url>`, `/stats`. Бот работает на той же базе и том же реестре, что панель; отвечает только id из `OMNI_BOT_ALLOW`. Команды: `/new [N]`, `/abuse [тег]`, `/find <слово>`, `/src <слово>`, `/run <категория>`, `/add <url>`, `/stats`. Бот работает на той же базе и том же реестре, что панель; отвечает только id из `OMNI_BOT_ALLOW`.
## Что нужно подключить
1. **Python 3.11+** и зависимости: `pip install -r requirements.txt` (httpx, curl_cffi, bs4, lxml). Больше ничего не обязательно — база создаётся сама в `data/omni.sqlite3`.
2. **Ни одного ключа не нужно.** Reddit (архив), форумы (Discourse/XenForo/веб-Telegram), HN, arXiv, GitHub, HF, поисковики работают без регистрации. Платные search-API (`TAVILY_API_KEY`, `EXA_API_KEY`, `YOU_API_KEY`, `FIRECRAWL_API_KEY`) подхватываются из env, если они есть: с ними больше покрытие, без них проект полностью рабочий.
3. **Свои источники**: кнопка «авто-добавление» в панели, `POST /api/discover`, `/auto` в боте или `python -m omni add <url>` — адрес проверяется реальным запросом и сохраняется только если отдал записи.
4. **Свой бот**: токен BotFather в `OMNI_BOT_TOKEN`, свой numeric id в `OMNI_BOT_OWNERS`, бота админом канала и `/sub @канал`.
## Проект обновляется
Проект живой: реестр источников, адаптеры и классификатор абузов правятся по мере того, как площадки ломают разметку. Обновление:
```bash
git pull
pip install -r requirements.txt
python -m omni serve --port 8077 # панель
python -m omni.bot # бот, если нужен
```
База в `data/` не перезаписывается — источники, здоровье и подписки остаются на месте. Ближайшие шаги: отдельная новостная лента с группировкой по сюжетам, скоринг абузов по свежести и частоте площадки, экспорт подборки в канал по расписанию.

View File

@@ -12,6 +12,9 @@
/add <url> добавить источник (сначала реальная проверка фида) /add <url> добавить источник (сначала реальная проверка фида)
/run <категория> прогнать категорию /run <категория> прогнать категорию
/auto [n] автодобавление новых фидов /auto [n] автодобавление новых фидов
/sub [@канал] подписать чат/канал на новые абузы
/unsub [@канал] отписаться
/post [@канал] опубликовать абузы сейчас
/stats цифры /stats цифры
""" """
from __future__ import annotations from __future__ import annotations
@@ -44,13 +47,16 @@ def _api(method, **params):
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
def _send(chat_id, text, preview=True): def _send(chat_id, text, preview=True, reply_markup=None):
text = text[:MAX_LEN] text = text[:MAX_LEN]
if os.environ.get("OMNI_BOT_DRY"): if os.environ.get("OMNI_BOT_DRY"):
print("[DRY] " + text.replace("</b>", "").replace("<b>", "")[:1200]) print("[DRY] " + text.replace("</b>", "").replace("<b>", "")[:1200])
return {"ok": True} return {"ok": True}
kw = {}
if reply_markup:
kw["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
return _api("sendMessage", chat_id=chat_id, text=text, parse_mode="HTML", return _api("sendMessage", chat_id=chat_id, text=text, parse_mode="HTML",
disable_web_page_preview="false" if preview else "true") disable_web_page_preview="false" if preview else "true", **kw)
def _esc(s): def _esc(s):
@@ -76,7 +82,8 @@ def cmd_start(chat_id, arg):
"/add &lt;url&gt; — подключить фид\n" "/add &lt;url&gt; — подключить фид\n"
"/run abuse — прогнать категорию\n" "/run abuse — прогнать категорию\n"
"/auto 10 — автодобавление\n" "/auto 10 — автодобавление\n"
"/stats — цифры") "/stats — цифры\n/sub @канал — постить новые абузы в канал\n/post @канал — опубликовать сейчас",
reply_markup=KB_MAIN)
def cmd_new(chat_id, arg): def cmd_new(chat_id, arg):
@@ -169,9 +176,213 @@ def cmd_stats(chat_id, arg):
s.get("items"), len(a), alive, len(sources.CATEGORIES), s.get("runs"), s.get("priced"))) s.get("items"), len(a), alive, len(sources.CATEGORIES), s.get("runs"), s.get("priced")))
# --- подписка, формат, канал -------------------------------------------------
HIDE_TAGS = {"keys"} # про ключи не постим и не показываем
SUBS_SQL = ("CREATE TABLE IF NOT EXISTS abuse_subs (chat_id INTEGER PRIMARY KEY, "
"ts INTEGER DEFAULT 0, last_id INTEGER DEFAULT 0)")
KB_MAIN = {"inline_keyboard": [
[{"text": "Лента", "callback_data": "m:new"}, {"text": "Абузы", "callback_data": "m:abuse"}],
[{"text": "Источники", "callback_data": "m:src"}, {"text": "Цифры", "callback_data": "m:stats"}],
[{"text": "Промо", "callback_data": "t:promo"}, {"text": "Триалы", "callback_data": "t:trial"},
{"text": "Сливы", "callback_data": "t:leak"}],
[{"text": "Подписка: вкл", "callback_data": "sub:on"}, {"text": "выкл", "callback_data": "sub:off"}],
[{"text": "Формат: кратко", "callback_data": "fmt:short"},
{"text": "полно", "callback_data": "fmt:full"}],
[{"text": "Пост в канал", "callback_data": "post:now"}],
]}
PREFS_SQL = "CREATE TABLE IF NOT EXISTS bot_prefs (chat_id INTEGER PRIMARY KEY, fmt TEXT DEFAULT 'short')"
def _conn():
import sqlite3
from .config import DB
c = sqlite3.connect(str(DB), timeout=30)
c.execute(SUBS_SQL)
c.execute(PREFS_SQL)
c.commit()
return c
def prefs_get(chat_id):
c = _conn()
r = c.execute("SELECT fmt FROM bot_prefs WHERE chat_id=?", (chat_id,)).fetchone()
c.close()
return (r[0] if r else "short")
def prefs_set(chat_id, fmt):
c = _conn()
c.execute("INSERT INTO bot_prefs(chat_id, fmt) VALUES(?,?) "
"ON CONFLICT(chat_id) DO UPDATE SET fmt=excluded.fmt", (chat_id, fmt))
c.commit()
c.close()
def subs_add(target, last_id=0):
c = _conn()
if not last_id:
last_id = c.execute("SELECT COALESCE(MAX(id),0) FROM items").fetchone()[0]
c.execute("INSERT INTO abuse_subs(chat_id, ts, last_id) VALUES(?,?,?) "
"ON CONFLICT(chat_id) DO UPDATE SET last_id=excluded.last_id",
(target, int(time.time()), last_id))
c.commit()
c.close()
return last_id
def subs_del(target):
c = _conn()
n = c.execute("DELETE FROM abuse_subs WHERE chat_id=?", (target,)).rowcount
c.commit()
c.close()
return n
def subs_all():
c = _conn()
rows = c.execute("SELECT chat_id, last_id FROM abuse_subs ORDER BY ts DESC").fetchall()
c.close()
return rows
def _resolve_chat(arg, chat_id):
a = (arg or "").strip()
if not a:
return chat_id
if a.lstrip("-").isdigit():
return int(a)
try:
r = _api("getChat", chat_id=a)
return (r.get("result") or {}).get("id")
except Exception:
return None
def _scan_safe(limit=10, tag=None, window=6000):
d = abuse_mod.scan(limit=limit * 3, tag=tag, window=window)
out = []
for it in d.get("items", []):
tags = [t for t in (it.get("tags") or []) if t not in HIDE_TAGS]
if not tags:
continue
it = dict(it)
it["tags"] = tags
out.append(it)
d["items"] = out[:limit]
d["hidden_keys"] = len(d.get("items", [])) - len(out)
return d
def _line(it, fmt="short"):
title = _esc((it.get("title") or "")[:150])
src = _esc(it.get("source") or "")
tags = _esc(",".join(it.get("tags") or []))
head = "<b>%s</b>\n<code>%s</code> · %s" % (title, src, tags)
if fmt == "full":
url = it.get("url") or ""
if url:
head += ' · <a href="%s">открыть</a>' % _esc(url)
return head
def abuse_text(tag=None, limit=6, fmt="short"):
d = _scan_safe(limit=limit, tag=tag)
if not d["items"]:
return None
head = "абузы · %s · %d" % (tag or "все", d["total"])
return head + "\n\n" + "\n\n".join(_line(i, fmt) for i in d["items"])
def cmd_abuse(chat_id, arg):
tag = arg.strip().split()[0] if arg.strip() else None
txt = abuse_text(tag, fmt=prefs_get(chat_id))
_send(chat_id, txt or "абузов не видно", reply_markup=KB_MAIN)
def cmd_sub(chat_id, arg):
target = _resolve_chat(arg, chat_id)
if not target:
return _send(chat_id, "не вижу такой чат: добавь бота админом и повтори /sub @канал", reply_markup=KB_MAIN)
last = subs_add(target)
where = "сюда" if target == chat_id else "<code>%s</code>" % target
_send(chat_id, "подписан: %s, шлю только новые абузы (после id %d). /unsub — отписаться" % (where, last),
reply_markup=KB_MAIN)
def cmd_unsub(chat_id, arg):
target = _resolve_chat(arg, chat_id) or chat_id
n = subs_del(target)
_send(chat_id, "отписал" if n else "подписки не было", reply_markup=KB_MAIN)
def cmd_subs(chat_id, arg):
rows = subs_all()
if not rows:
return _send(chat_id, "подписок нет", reply_markup=KB_MAIN)
body = "\n".join("<code>%s</code> · id %s" % (c, i) for c, i in rows[:20])
_send(chat_id, "<b>подписки (%d)</b>\n%s" % (len(rows), body), reply_markup=KB_MAIN)
def publish(target, tag=None, limit=5):
"""Один пост с абузами в указанный чат/канал."""
fmt = prefs_get(target) if isinstance(target, int) else "short"
txt = abuse_text(tag, limit=limit, fmt=fmt)
if not txt:
return False
_send(target, txt)
return True
def cmd_test(chat_id, arg):
target = _resolve_chat(arg, chat_id)
if not target:
return _send(chat_id, "куда постить? /post @канал", reply_markup=KB_MAIN)
ok = publish(target)
_send(chat_id, "опубликовал" if ok else "нечего публиковать", reply_markup=KB_MAIN)
def push_new(per_chat=5, window=2000):
subs = subs_all()
if not subs:
return 0
d = _scan_safe(limit=400, window=window)
items = sorted(d["items"], key=lambda x: x.get("id") or 0)
sent = 0
for target, last in subs:
fresh = [i for i in items if (i.get("id") or 0) > (last or 0)]
if not fresh:
continue
chunk = fresh[-per_chat:]
fmt = prefs_get(target) if isinstance(target, int) else "short"
text = ("<b>новые абузы · %d</b>\n\n" % len(fresh)) + "\n\n".join(_line(i, fmt) for i in chunk)
try:
_send(target, text[:MAX_LEN])
sent += 1
except Exception as e:
print("push fail", target, e, flush=True)
continue
subs_add(target, last_id=chunk[-1].get("id") or last)
return sent
def pusher(every=300):
while True:
time.sleep(every)
try:
n = push_new()
if n:
print("pushed", n, flush=True)
except Exception as e:
print("pusher fail", e, flush=True)
CMDS = {"/start": cmd_start, "/help": cmd_start, "/new": cmd_new, "/abuse": cmd_abuse, CMDS = {"/start": cmd_start, "/help": cmd_start, "/new": cmd_new, "/abuse": cmd_abuse,
"/find": cmd_find, "/src": cmd_src, "/add": cmd_add, "/run": cmd_run, "/find": cmd_find, "/src": cmd_src, "/add": cmd_add, "/run": cmd_run,
"/auto": cmd_auto, "/stats": cmd_stats} "/auto": cmd_auto, "/stats": cmd_stats,
"/sub": cmd_sub, "/unsub": cmd_unsub, "/subs": cmd_subs,
"/post": cmd_test}
def handle(update): def handle(update):
@@ -198,20 +409,61 @@ def handle(update):
_send(chat_id, "ошибка: %s" % _esc(type(e).__name__ + ": " + str(e)[:160])) _send(chat_id, "ошибка: %s" % _esc(type(e).__name__ + ": " + str(e)[:160]))
def handle_cb(cb):
cb_id = cb.get("id")
data = cb.get("data") or ""
msg = cb.get("message") or {}
chat_id = (msg.get("chat") or {}).get("id")
try:
_api("answerCallbackQuery", callback_query_id=cb_id)
except Exception:
pass
if not chat_id or (OWNERS and (cb.get("from") or {}).get("id") not in OWNERS):
return
fmt = prefs_get(chat_id)
if data == "m:new":
return cmd_new(chat_id, "5")
if data == "m:abuse":
return cmd_abuse(chat_id, "")
if data == "m:src":
return cmd_src(chat_id, "")
if data == "m:stats":
return cmd_stats(chat_id, "")
if data.startswith("t:"):
return cmd_abuse(chat_id, data[2:])
if data == "sub:on":
return cmd_sub(chat_id, "")
if data == "sub:off":
return cmd_unsub(chat_id, "")
if data.startswith("fmt:"):
prefs_set(chat_id, data[4:])
return _send(chat_id, "формат: %s" % ("кратко" if data[4:] == "short" else "полно"), reply_markup=KB_MAIN)
if data == "post:now":
return cmd_test(chat_id, "")
def run_forever(): def run_forever():
if not TOKEN: if not TOKEN:
raise SystemExit("нужен OMNI_BOT_TOKEN") raise SystemExit("нужен OMNI_BOT_TOKEN")
offset = 0 offset = 0
import threading
threading.Thread(target=pusher, daemon=True).start()
print("bot up", flush=True) print("bot up", flush=True)
while True: while True:
try: try:
r = _api("getUpdates", offset=offset, timeout=30) r = _api("getUpdates", offset=offset, timeout=25, allowed_updates='["message","callback_query"]')
except Exception as e: except Exception as e:
print("poll fail", e, flush=True) print("poll fail", e, flush=True)
time.sleep(5) time.sleep(5)
continue continue
for up in r.get("result", []): for up in r.get("result", []):
offset = up["update_id"] + 1 offset = up["update_id"] + 1
if up.get("callback_query"):
try:
handle_cb(up["callback_query"])
except Exception as e:
print("cb fail", e, flush=True)
else:
handle(up) handle(up)
time.sleep(0.3) time.sleep(0.3)

View File

@@ -179,6 +179,13 @@ SOURCES_BASE = [
S("tg_openai", "forum", "html", "https://t.me/s/openai", "tg_web", ("telegram",)), S("tg_openai", "forum", "html", "https://t.me/s/openai", "tg_web", ("telegram",)),
S("tg_huggingface", "forum", "html", "https://t.me/s/huggingface", "tg_web", ("telegram",)), S("tg_huggingface", "forum", "html", "https://t.me/s/huggingface", "tg_web", ("telegram",)),
] ]
# ---------- свои каналы (проверены 26.10.26) ----------
EXTRA4 = [
{"name": "tg_alstack", "category": "community", "kind": "html",
"url": "https://t.me/s/AlStack", "adapter": "tg_web", "tags": ["telegram", "own"], "limit": 20},
{"name": "tg_abuz_ai_soft_posts", "category": "abuse", "kind": "html",
"url": "https://t.me/s/Abuz_ai_soft", "adapter": "tg_web", "tags": ["telegram", "abuse", "own"], "limit": 20},
]
def _dedup(rows): def _dedup(rows):
seen, out = set(), [] seen, out = set(), []
@@ -190,7 +197,7 @@ def _dedup(rows):
return out 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]) 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])
BY_NAME = {s.name: s for s in SOURCES} BY_NAME = {s.name: s for s in SOURCES}
CATEGORIES = sorted({s.category for s in SOURCES}) CATEGORIES = sorted({s.category for s in SOURCES})