diff --git a/README.md b/README.md index 5b3ef95..61dc6be 100644 --- a/README.md +++ b/README.md @@ -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 `, `/auto [N]`, `/stats`, `/sub [@канал]`, `/unsub [@канал]`, `/subs`, `/post [@канал]`. + +**Постинг в канал.** Добавь бота админом канала с правом публикации и отправь `/sub @твойканал` — бот каждые 5 минут проверяет новые абузы и публикует только те, что появились после подписки. `/post @твойканал` — опубликовать подборку сразу, `/unsub @твойканал` — остановить. Дальше можно подключить свой бот: код один, токен свой, база общая. + +**Формат текста.** `кратко` — заголовок, источник, тег. `полно` — плюс ссылка на запись. Переключается кнопкой и хранится на чат/канал: канал можно держать в одном формате, личку — в другом. + +**Про ключи не публикуем.** Тег `keys` отфильтрован на уровне бота (`HIDE_TAGS` в `omni/bot.py`): записи, у которых после фильтра не осталось тегов, не попадают ни в `/abuse`, ни в автопостинг. + # omni-parse Один парсер под любую тему: Reddit, форумы, поисковики, X/Twitter, карточки товаров и платные search-API — в одну SQLite-базу с FTS5, дедупом и рейтингом. @@ -176,7 +194,7 @@ MIT. ## Абузы, авто-добавление и 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`. - **новости**: любые RSS/Atom/JSON-ленты (редакции, релизы, сообщества) подключаются как обычный источник или через `/add ` в боте — отдельной схемы не требуется. @@ -190,3 +208,23 @@ python -m omni.bot Команды: `/new [N]`, `/abuse [тег]`, `/find <слово>`, `/src <слово>`, `/run <категория>`, `/add `, `/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 ` — адрес проверяется реальным запросом и сохраняется только если отдал записи. +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/` не перезаписывается — источники, здоровье и подписки остаются на месте. Ближайшие шаги: отдельная новостная лента с группировкой по сюжетам, скоринг абузов по свежести и частоте площадки, экспорт подборки в канал по расписанию. diff --git a/omni/bot.py b/omni/bot.py index 38458f1..6f01b7b 100644 --- a/omni/bot.py +++ b/omni/bot.py @@ -12,6 +12,9 @@ /add добавить источник (сначала реальная проверка фида) /run <категория> прогнать категорию /auto [n] автодобавление новых фидов + /sub [@канал] подписать чат/канал на новые абузы + /unsub [@канал] отписаться + /post [@канал] опубликовать абузы сейчас /stats цифры """ from __future__ import annotations @@ -44,13 +47,16 @@ def _api(method, **params): 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] if os.environ.get("OMNI_BOT_DRY"): print("[DRY] " + text.replace("", "").replace("", "")[:1200]) 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", - disable_web_page_preview="false" if preview else "true") + disable_web_page_preview="false" if preview else "true", **kw) def _esc(s): @@ -76,7 +82,8 @@ def cmd_start(chat_id, arg): "/add <url> — подключить фид\n" "/run abuse — прогнать категорию\n" "/auto 10 — автодобавление\n" - "/stats — цифры") + "/stats — цифры\n/sub @канал — постить новые абузы в канал\n/post @канал — опубликовать сейчас", + reply_markup=KB_MAIN) 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"))) +# --- подписка, формат, канал ------------------------------------------------- +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 = "%s\n%s · %s" % (title, src, tags) + if fmt == "full": + url = it.get("url") or "" + if url: + head += ' · открыть' % _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 "%s" % 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("%s · id %s" % (c, i) for c, i in rows[:20]) + _send(chat_id, "подписки (%d)\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 = ("новые абузы · %d\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, "/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): @@ -198,21 +409,62 @@ def handle(update): _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(): if not TOKEN: raise SystemExit("нужен OMNI_BOT_TOKEN") offset = 0 + import threading + threading.Thread(target=pusher, daemon=True).start() print("bot up", flush=True) while True: try: - r = _api("getUpdates", offset=offset, timeout=30) + r = _api("getUpdates", offset=offset, timeout=25, allowed_updates='["message","callback_query"]') except Exception as e: print("poll fail", e, flush=True) time.sleep(5) continue for up in r.get("result", []): offset = up["update_id"] + 1 - handle(up) + if up.get("callback_query"): + try: + handle_cb(up["callback_query"]) + except Exception as e: + print("cb fail", e, flush=True) + else: + handle(up) time.sleep(0.3) diff --git a/omni/sources.py b/omni/sources.py index d3e88b8..08ae791 100644 --- a/omni/sources.py +++ b/omni/sources.py @@ -179,6 +179,13 @@ SOURCES_BASE = [ 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",)), ] +# ---------- свои каналы (проверены 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): seen, out = set(), [] @@ -190,7 +197,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]) +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} CATEGORIES = sorted({s.category for s in SOURCES})