init
This commit is contained in:
56
.env.example
Normal file
56
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
*.session
|
||||||
|
*.session-journal
|
||||||
|
digests/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
digest.log
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
104
README.md
Normal 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
136
collector.py
Normal 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
62
config.py
Normal 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
109
delivery.py
Normal 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
14
docker-compose.yml
Normal 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
100
main.py
Normal 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
5
requirements.txt
Normal 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
147
summarizer.py
Normal 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"]
|
||||||
Reference in New Issue
Block a user