commit 9fd7d42c6aa8b9e9c446f6a07890a2d9fd17b347 Author: Тимур Абайдулин Date: Sat Feb 7 14:46:15 2026 +0300 init 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"]