From 9fd7d42c6aa8b9e9c446f6a07890a2d9fd17b347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=90=D0=B1=D0=B0=D0=B9?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D0=B8=D0=BD?= Date: Sat, 7 Feb 2026 14:46:15 +0300 Subject: [PATCH] init --- .gitignore | 8 +++ README.md | 75 ++++++++++++++++++++++ collector.py | 143 +++++++++++++++++++++++++++++++++++++++++ config.example.yaml | 75 ++++++++++++++++++++++ delivery.py | 118 ++++++++++++++++++++++++++++++++++ main.py | 124 ++++++++++++++++++++++++++++++++++++ requirements.txt | 5 ++ summarizer.py | 150 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 698 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 collector.py create mode 100644 config.example.yaml create mode 100644 delivery.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 summarizer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31eb4ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +config.yaml +*.session +*.session-journal +digests/ +__pycache__/ +*.pyc +.env +digest.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a4d771 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# 📰 Telegram News Digest + +Автоматический сбор новостей из Telegram-каналов с AI-суммаризацией и доставкой дайджеста. + +## Возможности + +- Сбор сообщений из списка Telegram-каналов (публичных и приватных) +- Фильтрация по времени (за последние N часов) +- Суммаризация через LLM (Anthropic Claude API / OpenAI / Ollama) +- Группировка новостей по темам +- Отправка дайджеста в Telegram (Избранное или бот) +- Готов к запуску через cron / Airflow + +## Быстрый старт + +### 1. Получи Telegram API credentials + +- Зайди на https://my.telegram.org +- Создай приложение → получи `api_id` и `api_hash` + +### 2. (Опционально) Создай Telegram-бота для доставки + +- Напиши @BotFather → `/newbot` +- Получи токен бота +- Напиши боту любое сообщение, затем узнай свой `chat_id`: + ``` + curl https://api.telegram.org/bot/getUpdates + ``` + +### 3. Установи зависимости + +```bash +pip install -r requirements.txt +``` + +### 4. Настрой конфиг + +```bash +cp config.example.yaml config.yaml +# Отредактируй config.yaml — впиши свои ключи и список каналов +``` + +### 5. Первый запуск (авторизация) + +```bash +python main.py --auth +``` +Введи номер телефона и код из Telegram. Сессия сохранится в файл. + +### 6. Запуск сбора дайджеста + +```bash +python main.py +``` + +### Автоматический запуск (cron) + +```bash +# Каждый день в 8:00 и 20:00 +0 8,20 * * * cd /path/to/tg-digest && python main.py >> digest.log 2>&1 +``` + +## Структура проекта + +``` +tg-digest/ +├── config.example.yaml # Пример конфигурации +├── config.yaml # Твой конфиг (не коммитить!) +├── main.py # Точка входа +├── collector.py # Сбор сообщений из каналов +├── summarizer.py # Суммаризация через LLM +├── delivery.py # Отправка дайджеста +├── requirements.txt # Зависимости +└── README.md +``` diff --git a/collector.py b/collector.py new file mode 100644 index 0000000..33dfb5e --- /dev/null +++ b/collector.py @@ -0,0 +1,143 @@ +""" +Сбор сообщений из Telegram-каналов через Telethon. +""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from dataclasses import dataclass, field + +from telethon import TelegramClient +from telethon.errors import ( + FloodWaitError, + ChannelPrivateError, + UsernameNotOccupiedError, + UsernameInvalidError, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class Message: + """Одно сообщение из канала.""" + channel: str + channel_title: str + text: str + date: datetime + views: int = 0 + url: str = "" + + +@dataclass +class CollectorResult: + """Результат сбора.""" + messages: list[Message] = field(default_factory=list) + errors: dict[str, str] = field(default_factory=dict) + + +class MessageCollector: + def __init__(self, config: dict): + tg = config["telegram"] + self.client = TelegramClient( + tg["session_name"], + tg["api_id"], + tg["api_hash"], + ) + self.channels: list[str] = tg["channels"] + self.hours_back: int = tg.get("hours_back", 12) + self.max_per_channel: int = tg.get("max_messages_per_channel", 50) + self.delay: float = tg.get("delay_between_channels", 2) + self.min_length: int = tg.get("min_message_length", 50) + + async def auth(self): + """Интерактивная авторизация (первый запуск).""" + await self.client.start() + me = await self.client.get_me() + logger.info(f"Авторизован как: {me.first_name} ({me.phone})") + await self.client.disconnect() + + async def collect(self) -> CollectorResult: + """Собрать сообщения из всех каналов.""" + result = CollectorResult() + cutoff = datetime.now(timezone.utc) - timedelta(hours=self.hours_back) + + await self.client.connect() + if not await self.client.is_user_authorized(): + raise RuntimeError( + "Сессия не авторизована. Запусти: python main.py --auth" + ) + + for channel in self.channels: + try: + messages = await self._collect_channel(channel, cutoff) + result.messages.extend(messages) + logger.info(f" ✓ {channel}: {len(messages)} сообщений") + except ChannelPrivateError: + err = "Канал приватный или вы не подписаны" + result.errors[channel] = err + logger.warning(f" ✗ {channel}: {err}") + except (UsernameNotOccupiedError, UsernameInvalidError): + err = "Канал не найден" + result.errors[channel] = err + logger.warning(f" ✗ {channel}: {err}") + except FloodWaitError as e: + logger.warning(f" ⏳ FloodWait: ждём {e.seconds}с") + await asyncio.sleep(e.seconds) + # Повторяем попытку + try: + messages = await self._collect_channel(channel, cutoff) + result.messages.extend(messages) + except Exception as retry_err: + result.errors[channel] = str(retry_err) + except Exception as e: + result.errors[channel] = str(e) + logger.warning(f" ✗ {channel}: {e}") + + await asyncio.sleep(self.delay) + + await self.client.disconnect() + + # Сортировка по дате (новые первые) + result.messages.sort(key=lambda m: m.date, reverse=True) + logger.info( + f"Итого: {len(result.messages)} сообщений из " + f"{len(self.channels) - len(result.errors)} каналов" + ) + return result + + async def _collect_channel( + self, channel: str, cutoff: datetime + ) -> list[Message]: + """Собрать сообщения из одного канала.""" + entity = await self.client.get_entity(channel) + channel_title = getattr(entity, "title", channel) + messages = [] + + async for msg in self.client.iter_messages( + entity, limit=self.max_per_channel + ): + # Пропускаем старые + if msg.date < cutoff: + break + + # Пропускаем без текста и короткие + if not msg.text or len(msg.text.strip()) < self.min_length: + continue + + # Формируем ссылку + username = getattr(entity, "username", None) + url = f"https://t.me/{username}/{msg.id}" if username else "" + + messages.append( + Message( + channel=channel, + channel_title=channel_title, + text=msg.text.strip(), + date=msg.date, + views=msg.views or 0, + url=url, + ) + ) + + return messages diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..677800e --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,75 @@ +# ============================================ +# Telegram News Digest — Конфигурация +# ============================================ + +telegram: + api_id: 12345678 # Получить на https://my.telegram.org + api_hash: "your_api_hash_here" + session_name: "digest_session" # Имя файла сессии + + # Список каналов для парсинга + # Можно указывать: @username, username, или числовой ID канала + channels: + - "@rbc_news" + - "@medabordarossa" + - "@breakingmash" + - "@rian_ru" + # Добавь свои каналы... + + # Сколько часов назад собирать сообщения + hours_back: 12 + + # Максимум сообщений с одного канала + max_messages_per_channel: 50 + + # Пауза между каналами (секунды) — защита от FloodWait + delay_between_channels: 2 + + # Минимальная длина сообщения (символов) — фильтрует мусор + min_message_length: 50 + +# ============================================ +# LLM для суммаризации +# ============================================ + +llm: + # Провайдер: "anthropic", "openai", "ollama" + provider: "anthropic" + + # --- Anthropic --- + anthropic_api_key: "sk-ant-..." + anthropic_model: "claude-sonnet-4-20250514" + + # --- OpenAI --- + # openai_api_key: "sk-..." + # openai_model: "gpt-4o-mini" + + # --- Ollama (локальный) --- + # ollama_url: "http://localhost:11434" + # ollama_model: "llama3.1:8b" + + # Максимум токенов в ответе + max_tokens: 4096 + + # Язык дайджеста + language: "русский" + +# ============================================ +# Доставка дайджеста +# ============================================ + +delivery: + # Метод: "saved_messages", "bot", "file" + method: "saved_messages" # Отправка в «Избранное» + + # --- Через бота --- + # method: "bot" + # bot_token: "123456:ABC-DEF..." + # chat_id: 123456789 + + # --- В файл --- + # method: "file" + # output_dir: "./digests" + + # Максимальная длина одного сообщения Telegram (символов) + max_message_length: 4000 diff --git a/delivery.py b/delivery.py new file mode 100644 index 0000000..f5650c7 --- /dev/null +++ b/delivery.py @@ -0,0 +1,118 @@ +""" +Доставка дайджеста: Telegram (Избранное / бот) или файл. +""" + +import logging +import os +from datetime import datetime +from pathlib import Path + +import httpx +from telethon import TelegramClient + +logger = logging.getLogger(__name__) + + +def _split_message(text: str, max_length: int = 4000) -> list[str]: + """Разбивает длинное сообщение на части по границам строк.""" + if len(text) <= max_length: + return [text] + + parts = [] + current = "" + for line in text.split("\n"): + if len(current) + len(line) + 1 > max_length: + if current: + parts.append(current.strip()) + current = line + "\n" + else: + current += line + "\n" + if current.strip(): + parts.append(current.strip()) + + return parts + + +class DeliveryManager: + def __init__(self, config: dict): + self.cfg = config["delivery"] + self.tg_cfg = config["telegram"] + self.method = self.cfg["method"] + self.max_length = self.cfg.get("max_message_length", 4000) + + async def deliver(self, digest: str) -> None: + """Отправить дайджест выбранным способом.""" + now = datetime.now().strftime("%d.%m.%Y %H:%M") + header = f"📰 **Новостной дайджест** — {now}\n\n" + full_text = header + digest + + if self.method == "saved_messages": + await self._send_saved_messages(full_text) + elif self.method == "bot": + await self._send_via_bot(full_text) + elif self.method == "file": + self._save_to_file(full_text) + else: + raise ValueError(f"Неизвестный метод доставки: {self.method}") + + async def _send_saved_messages(self, text: str): + """Отправить в «Избранное» через Telethon.""" + client = TelegramClient( + self.tg_cfg["session_name"], + self.tg_cfg["api_id"], + self.tg_cfg["api_hash"], + ) + await client.connect() + + if not await client.is_user_authorized(): + raise RuntimeError("Сессия не авторизована") + + parts = _split_message(text, self.max_length) + me = await client.get_me() + for i, part in enumerate(parts, 1): + await client.send_message(me, part, parse_mode="md") + logger.info(f"Отправлена часть {i}/{len(parts)} в Избранное") + + await client.disconnect() + logger.info("✓ Дайджест отправлен в Избранное") + + async def _send_via_bot(self, text: str): + """Отправить через Telegram Bot API.""" + token = self.cfg["bot_token"] + chat_id = self.cfg["chat_id"] + url = f"https://api.telegram.org/bot{token}/sendMessage" + + parts = _split_message(text, self.max_length) + + async with httpx.AsyncClient(timeout=30) as client: + for i, part in enumerate(parts, 1): + resp = await client.post( + url, + json={ + "chat_id": chat_id, + "text": part, + "parse_mode": "Markdown", + "disable_web_page_preview": True, + }, + ) + if resp.status_code != 200: + logger.error( + f"Ошибка Bot API: {resp.status_code} {resp.text}" + ) + else: + logger.info( + f"Отправлена часть {i}/{len(parts)} через бота" + ) + + logger.info("✓ Дайджест отправлен через бота") + + def _save_to_file(self, text: str): + """Сохранить дайджест в файл.""" + output_dir = Path(self.cfg.get("output_dir", "./digests")) + output_dir.mkdir(parents=True, exist_ok=True) + + filename = datetime.now().strftime("digest_%Y%m%d_%H%M.md") + filepath = output_dir / filename + + filepath.write_text(text, encoding="utf-8") + logger.info(f"✓ Дайджест сохранён: {filepath}") diff --git a/main.py b/main.py new file mode 100644 index 0000000..93efa9d --- /dev/null +++ b/main.py @@ -0,0 +1,124 @@ +""" +Telegram News Digest — точка входа. + +Использование: + python main.py # Собрать и отправить дайджест + python main.py --auth # Авторизация (первый запуск) + python main.py --collect # Только собрать (без суммаризации) + python main.py --dry-run # Собрать + суммаризировать, но не отправлять +""" + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + +import yaml + +from collector import MessageCollector +from summarizer import Summarizer +from delivery import DeliveryManager + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger(__name__) + + +def load_config(path: str = "config.yaml") -> dict: + """Загрузить конфигурацию.""" + config_path = Path(path) + if not config_path.exists(): + logger.error( + f"Конфиг не найден: {path}\n" + f"Скопируй config.example.yaml → config.yaml и заполни." + ) + sys.exit(1) + + with open(config_path, encoding="utf-8") as f: + return yaml.safe_load(f) + + +async def run_auth(config: dict): + """Авторизация в Telegram.""" + logger.info("🔑 Авторизация в Telegram...") + collector = MessageCollector(config) + await collector.auth() + logger.info("✓ Авторизация завершена. Сессия сохранена.") + + +async def run_digest(config: dict, collect_only: bool = False, dry_run: bool = False): + """Основной пайплайн: сбор → суммаризация → доставка.""" + + # 1. Сбор сообщений + logger.info("📥 Сбор сообщений из каналов...") + collector = MessageCollector(config) + result = await collector.collect() + + if result.errors: + logger.warning(f"Ошибки в {len(result.errors)} каналах:") + for ch, err in result.errors.items(): + logger.warning(f" {ch}: {err}") + + if not result.messages: + logger.info("📭 Нет новых сообщений. Выход.") + return + + if collect_only: + # Вывести сырые сообщения + for msg in result.messages[:20]: + print(f"\n[{msg.channel_title}] {msg.date.strftime('%H:%M')}") + print(msg.text[:200] + ("..." if len(msg.text) > 200 else "")) + return + + # 2. Суммаризация + logger.info("🤖 Суммаризация через LLM...") + summarizer = Summarizer(config) + digest = await summarizer.summarize(result.messages) + + if dry_run: + print("\n" + "=" * 60) + print(digest) + print("=" * 60) + logger.info("(dry-run: дайджест не отправлен)") + return + + # 3. Доставка + logger.info("📤 Отправка дайджеста...") + delivery = DeliveryManager(config) + await delivery.deliver(digest) + + logger.info("✅ Готово!") + + +def main(): + parser = argparse.ArgumentParser(description="Telegram News Digest") + parser.add_argument( + "--auth", action="store_true", help="Авторизация в Telegram" + ) + parser.add_argument( + "--collect", action="store_true", help="Только сбор (без суммаризации)" + ) + parser.add_argument( + "--dry-run", action="store_true", help="Без отправки (вывод в консоль)" + ) + parser.add_argument( + "--config", default="config.yaml", help="Путь к конфигу" + ) + args = parser.parse_args() + + config = load_config(args.config) + + if args.auth: + asyncio.run(run_auth(config)) + else: + asyncio.run( + run_digest(config, collect_only=args.collect, dry_run=args.dry_run) + ) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..854329a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +telethon>=1.36 +pyyaml>=6.0 +anthropic>=0.40 +openai>=1.50 +httpx>=0.27 diff --git a/summarizer.py b/summarizer.py new file mode 100644 index 0000000..5250961 --- /dev/null +++ b/summarizer.py @@ -0,0 +1,150 @@ +""" +Суммаризация новостей через LLM. +Поддержка: Anthropic Claude, OpenAI, Ollama (локальный). +""" + +import logging +from datetime import datetime, timezone + +import httpx + +logger = logging.getLogger(__name__) + +SYSTEM_PROMPT = """Ты — опытный редактор новостного дайджеста. Твоя задача: + +1. Прочитай все собранные сообщения из Telegram-каналов. +2. Сгруппируй новости по темам (политика, экономика, технологии, общество и т.д.). +3. Для каждой темы напиши краткое резюме ключевых событий. +4. Если несколько каналов пишут об одном событии — объедини в одну новость, не дублируй. +5. Выдели 3-5 главных новостей дня отдельным блоком в начале. +6. Для каждой новости укажи источник (название канала). + +Формат ответа: + +📌 **ГЛАВНОЕ** +• [краткая новость] — _Источник_ +... + +📂 **[ТЕМА]** +• [краткая новость] — _Источник_ +... + +Правила: +- Пиши кратко и по делу, без воды +- Язык: {language} +- Не выдумывай информацию — только то, что есть в сообщениях +- Если сообщение — реклама или спам, пропусти его +- Сохраняй нейтральный тон +""" + + +def _build_messages_block(messages: list) -> str: + """Форматирует сообщения в текстовый блок для LLM.""" + parts = [] + for i, msg in enumerate(messages, 1): + date_str = msg.date.strftime("%Y-%m-%d %H:%M") + part = ( + f"--- Сообщение {i} ---\n" + f"Канал: {msg.channel_title}\n" + f"Дата: {date_str}\n" + f"Просмотры: {msg.views}\n" + f"Текст:\n{msg.text}\n" + ) + if msg.url: + part += f"Ссылка: {msg.url}\n" + parts.append(part) + return "\n".join(parts) + + +def _truncate_to_fit(text: str, max_chars: int = 180_000) -> str: + """Обрезает текст, чтобы влезть в контекстное окно.""" + if len(text) <= max_chars: + return text + logger.warning( + f"Текст обрезан: {len(text)} → {max_chars} символов" + ) + return text[:max_chars] + "\n\n[...текст обрезан из-за лимита контекста]" + + +class Summarizer: + def __init__(self, config: dict): + self.cfg = config["llm"] + self.provider = self.cfg["provider"] + self.language = self.cfg.get("language", "русский") + self.max_tokens = self.cfg.get("max_tokens", 4096) + + async def summarize(self, messages: list) -> str: + """Суммаризация списка сообщений.""" + if not messages: + return "📭 Нет новых сообщений за указанный период." + + messages_block = _build_messages_block(messages) + messages_block = _truncate_to_fit(messages_block) + + user_prompt = ( + f"Вот {len(messages)} сообщений из Telegram-каналов. " + f"Составь новостной дайджест.\n\n{messages_block}" + ) + + system = SYSTEM_PROMPT.format(language=self.language) + + logger.info( + f"Суммаризация через {self.provider} " + f"({len(messages)} сообщений, ~{len(messages_block)} символов)" + ) + + if self.provider == "anthropic": + return await self._anthropic(system, user_prompt) + elif self.provider == "openai": + return await self._openai(system, user_prompt) + elif self.provider == "ollama": + return await self._ollama(system, user_prompt) + else: + raise ValueError(f"Неизвестный провайдер: {self.provider}") + + async def _anthropic(self, system: str, user_prompt: str) -> str: + import anthropic + + client = anthropic.AsyncAnthropic( + api_key=self.cfg["anthropic_api_key"] + ) + response = await client.messages.create( + model=self.cfg.get("anthropic_model", "claude-sonnet-4-20250514"), + max_tokens=self.max_tokens, + system=system, + messages=[{"role": "user", "content": user_prompt}], + ) + return response.content[0].text + + async def _openai(self, system: str, user_prompt: str) -> str: + from openai import AsyncOpenAI + + client = AsyncOpenAI(api_key=self.cfg["openai_api_key"]) + response = await client.chat.completions.create( + model=self.cfg.get("openai_model", "gpt-4o-mini"), + max_tokens=self.max_tokens, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user_prompt}, + ], + ) + return response.choices[0].message.content + + async def _ollama(self, system: str, user_prompt: str) -> str: + url = self.cfg.get("ollama_url", "http://localhost:11434") + model = self.cfg.get("ollama_model", "llama3.1:8b") + + async with httpx.AsyncClient(timeout=300) as client: + response = await client.post( + f"{url}/api/chat", + json={ + "model": model, + "stream": False, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user_prompt}, + ], + }, + ) + response.raise_for_status() + return response.json()["message"]["content"]