This commit is contained in:
Тимур Абайдулин
2026-02-07 16:31:46 +03:00
commit ba1245a06c
11 changed files with 754 additions and 0 deletions

56
.env.example Normal file
View File

@@ -0,0 +1,56 @@
# ============================================
# Telegram News Digest — Конфигурация
# ============================================
# --- Telegram API ---
TG_API_ID=12345678
TG_API_HASH=your_api_hash_here
TG_SESSION_NAME=digest_session
# Список каналов через запятую
TG_CHANNELS=@rbc_news,@medabordarossa,@breakingmash,@rian_ru
# Сколько часов назад собирать
TG_HOURS_BACK=12
# Максимум сообщений с одного канала
TG_MAX_MESSAGES_PER_CHANNEL=50
# Пауза между каналами (секунды)
TG_DELAY_BETWEEN_CHANNELS=2
# Минимальная длина сообщения (символов)
TG_MIN_MESSAGE_LENGTH=50
# --- LLM ---
# Провайдер: anthropic / openai / ollama
LLM_PROVIDER=ollama
# Anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-20250514
# OpenAI
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini
# Ollama (host.docker.internal — доступ к хосту из Docker)
OLLAMA_URL=http://host.docker.internal:11434
OLLAMA_MODEL=gemma3:12b
# Максимум токенов в ответе
LLM_MAX_TOKENS=4096
# Язык дайджеста
LLM_LANGUAGE=русский
# --- Доставка ---
# Метод: saved_messages / bot / file
DELIVERY_METHOD=saved_messages
# Через бота
BOT_TOKEN=
BOT_CHAT_ID=
# Максимальная длина сообщения Telegram
DELIVERY_MAX_MESSAGE_LENGTH=4000

7
.gitignore vendored Normal file
View File

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

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
# Зависимости отдельным слоем для кэширования
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py .
# Директория для сессий и дайджестов (монтируется как volume)
RUN mkdir -p /data
ENTRYPOINT ["python", "main.py"]

104
README.md Normal file
View File

@@ -0,0 +1,104 @@
# 🤖 tg-digest-docker
Автоматический сбор новостей из Telegram-каналов с AI-суммаризацией и доставкой дайджеста.
## Архитектура
```
┌─────────────────────────────────────────────┐
│ macOS (M4 Pro) │
│ │
│ ┌───────────┐ ┌──────────────────────┐ │
│ │ Ollama │◄───│ Docker: tg-digest │ │
│ │ (native) │ │ ┌────────────────┐ │ │
│ │ GPU ✓ │ │ │ collector.py │ │ │
│ └───────────┘ │ │ summarizer.py │ │ │
│ localhost:11434 │ │ delivery.py │ │ │
│ │ └────────────────┘ │ │
│ │ volume: /data │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────┘
```
- **Ollama** работает нативно на macOS → полный доступ к Apple GPU
- **Python-приложение** в Docker → изолированные зависимости
- Связь через `host.docker.internal:11434`
## Быстрый старт
### 1. Ollama (на хосте)
```bash
# Установи Ollama: https://ollama.com
ollama pull gemma3:12b
ollama serve # если не запущен как сервис
```
### 2. Telegram API
Получи `api_id` и `api_hash` на https://my.telegram.org
### 3. Конфигурация
```bash
cp .env.example .env
# Заполни .env: TG_API_ID, TG_API_HASH, список каналов
```
### 4. Сборка и авторизация
```bash
docker compose build
docker compose run --rm app --auth
# Введи номер телефона и код из Telegram
```
### 5. Запуск
```bash
# Тест (вывод в консоль, без отправки)
docker compose run --rm app --dry-run
# Боевой запуск
docker compose run --rm app
# Только сбор (без суммаризации)
docker compose run --rm app --collect
```
### Автозапуск (cron на маке)
```bash
crontab -e
# Каждый день в 8:00 и 20:00
0 8,20 * * * cd /path/to/tg-digest && docker compose run --rm app >> /tmp/tg-digest.log 2>&1
```
## Конфигурация (.env)
| Переменная | Описание | По умолчанию |
|---|---|---|
| `TG_API_ID` | Telegram API ID | — |
| `TG_API_HASH` | Telegram API Hash | — |
| `TG_CHANNELS` | Каналы через запятую | — |
| `TG_HOURS_BACK` | За сколько часов собирать | `12` |
| `LLM_PROVIDER` | `ollama` / `anthropic` / `openai` | `ollama` |
| `OLLAMA_MODEL` | Модель Ollama | `gemma3:12b` |
| `DELIVERY_METHOD` | `saved_messages` / `bot` / `file` | `saved_messages` |
Полный список — в `.env.example`.
## Структура
```
tg-digest/
├── .env.example # Шаблон конфигурации
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── config.py # Загрузка конфига из env
├── main.py # Точка входа + CLI
├── collector.py # Сбор из Telegram
├── summarizer.py # LLM-суммаризация
└── delivery.py # Отправка дайджеста
```

136
collector.py Normal file
View File

@@ -0,0 +1,136 @@
"""
Сбор сообщений из Telegram-каналов через Telethon.
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass, field
from pathlib import Path
from telethon import TelegramClient
from telethon.errors import (
FloodWaitError,
ChannelPrivateError,
UsernameNotOccupiedError,
UsernameInvalidError,
)
logger = logging.getLogger(__name__)
SESSION_DIR = Path("/data")
@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"]
session_path = SESSION_DIR / tg["session_name"]
self.client = TelegramClient(
str(session_path), 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(
"Сессия не авторизована. Запусти:\n"
"docker compose run --rm app 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

62
config.py Normal file
View File

@@ -0,0 +1,62 @@
"""
Загрузка конфигурации из переменных окружения.
"""
import os
from pathlib import Path
from dotenv import load_dotenv
# Загружаем .env если есть (в Docker пробрасывается через env_file)
env_path = Path(__file__).parent / ".env"
if env_path.exists():
load_dotenv(env_path)
def get_config() -> dict:
"""Собрать конфиг из переменных окружения."""
return {
"telegram": {
"api_id": int(os.environ["TG_API_ID"]),
"api_hash": os.environ["TG_API_HASH"],
"session_name": os.environ.get("TG_SESSION_NAME", "digest_session"),
"channels": [
ch.strip()
for ch in os.environ.get("TG_CHANNELS", "").split(",")
if ch.strip()
],
"hours_back": int(os.environ.get("TG_HOURS_BACK", "12")),
"max_messages_per_channel": int(
os.environ.get("TG_MAX_MESSAGES_PER_CHANNEL", "50")
),
"delay_between_channels": float(
os.environ.get("TG_DELAY_BETWEEN_CHANNELS", "2")
),
"min_message_length": int(
os.environ.get("TG_MIN_MESSAGE_LENGTH", "50")
),
},
"llm": {
"provider": os.environ.get("LLM_PROVIDER", "ollama"),
"anthropic_api_key": os.environ.get("ANTHROPIC_API_KEY", ""),
"anthropic_model": os.environ.get(
"ANTHROPIC_MODEL", "claude-sonnet-4-20250514"
),
"openai_api_key": os.environ.get("OPENAI_API_KEY", ""),
"openai_model": os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
"ollama_url": os.environ.get(
"OLLAMA_URL", "http://host.docker.internal:11434"
),
"ollama_model": os.environ.get("OLLAMA_MODEL", "gemma3:12b"),
"max_tokens": int(os.environ.get("LLM_MAX_TOKENS", "4096")),
"language": os.environ.get("LLM_LANGUAGE", "русский"),
},
"delivery": {
"method": os.environ.get("DELIVERY_METHOD", "saved_messages"),
"bot_token": os.environ.get("BOT_TOKEN", ""),
"chat_id": os.environ.get("BOT_CHAT_ID", ""),
"max_message_length": int(
os.environ.get("DELIVERY_MAX_MESSAGE_LENGTH", "4000")
),
},
}

109
delivery.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Доставка дайджеста: Telegram (Избранное / бот) или файл.
"""
import logging
from datetime import datetime
from pathlib import Path
import httpx
from telethon import TelegramClient
logger = logging.getLogger(__name__)
SESSION_DIR = Path("/data")
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):
session_path = SESSION_DIR / self.tg_cfg["session_name"]
client = TelegramClient(
str(session_path),
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):
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("/data/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}")

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
app:
build: .
env_file: .env
volumes:
# Persistent: сессия Telethon + сохранённые дайджесты
- tg-data:/data
# host.docker.internal доступен по умолчанию на Docker Desktop (macOS)
# Для Linux раскомментируй:
# extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
tg-data:

100
main.py Normal file
View File

@@ -0,0 +1,100 @@
"""
Telegram News Digest — точка входа.
Использование (через Docker):
docker compose run --rm app python main.py --auth # Авторизация
docker compose run --rm app python main.py --dry-run # Тест (без отправки)
docker compose run --rm app python main.py # Боевой запуск
"""
import argparse
import asyncio
import logging
import sys
from config import get_config
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__)
async def run_auth(config: dict):
logger.info("🔑 Авторизация в Telegram...")
collector = MessageCollector(config)
await collector.auth()
logger.info("✓ Авторизация завершена. Сессия сохранена в /data/")
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")
parser.add_argument("--collect", action="store_true")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
try:
config = get_config()
except (KeyError, ValueError) as e:
logger.error(f"Ошибка конфигурации: {e}")
logger.error("Проверь .env файл (см. .env.example)")
sys.exit(1)
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
python-dotenv>=1.0
httpx>=0.27
anthropic>=0.40
openai>=1.50

147
summarizer.py Normal file
View File

@@ -0,0 +1,147 @@
"""
Суммаризация новостей через LLM.
Поддержка: Anthropic Claude, OpenAI, Ollama (нативный на хосте).
"""
import logging
import httpx
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """ВАЖНО: Весь ответ ДОЛЖЕН быть написан ТОЛЬКО на {language} языке. Никакого английского.
Ты — опытный редактор новостного дайджеста. Твоя задача:
1. Прочитай все собранные сообщения из Telegram-каналов.
2. Сгруппируй новости по темам (политика, экономика, технологии, общество и т.д.).
3. Для каждой темы напиши краткое резюме ключевых событий.
4. Если несколько каналов пишут об одном событии — объедини в одну новость, не дублируй.
5. Выдели 3-5 главных новостей дня отдельным блоком в начале.
6. Для каждой новости укажи источник (название канала).
Формат ответа:
📌 **ГЛАВНОЕ**
• [краткая новость] — _Источник_
...
📂 **[ТЕМА]**
• [краткая новость] — _Источник_
...
Правила:
- Пиши кратко и по делу, без воды
- Не выдумывай информацию — только то, что есть в сообщениях
- Если сообщение — реклама или спам, пропусти его
- Сохраняй нейтральный тон
- Названия тем, заголовки и весь текст — ТОЛЬКО на {language} языке
"""
def _build_messages_block(messages: list) -> str:
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"Составь новостной дайджест на {self.language} языке. "
f"Весь текст ответа должен быть на {self.language} языке.\n\n"
f"{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://host.docker.internal:11434")
model = self.cfg.get("ollama_model", "gemma3:12b")
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"]