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