This commit is contained in:
Тимур Абайдулин
2026-02-07 14:46:15 +03:00
commit 9fd7d42c6a
8 changed files with 698 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
config.yaml
*.session
*.session-journal
digests/
__pycache__/
*.pyc
.env
digest.log

75
README.md Normal file
View File

@@ -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<TOKEN>/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
```

143
collector.py Normal file
View File

@@ -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

75
config.example.yaml Normal file
View File

@@ -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

118
delivery.py Normal file
View File

@@ -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}")

124
main.py Normal file
View File

@@ -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()

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
telethon>=1.36
pyyaml>=6.0
anthropic>=0.40
openai>=1.50
httpx>=0.27

150
summarizer.py Normal file
View File

@@ -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"]