commit ba1245a06c2b4fbd9557845a828cc1b8f0e81fc5 Author: Тимур Абайдулин Date: Sat Feb 7 16:31:46 2026 +0300 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ca1be7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f82920 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +*.session +*.session-journal +digests/ +__pycache__/ +*.pyc +digest.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b4f633e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f660bf1 --- /dev/null +++ b/README.md @@ -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 # Отправка дайджеста +``` diff --git a/collector.py b/collector.py new file mode 100644 index 0000000..f88b578 --- /dev/null +++ b/collector.py @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..4171b7b --- /dev/null +++ b/config.py @@ -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") + ), + }, + } diff --git a/delivery.py b/delivery.py new file mode 100644 index 0000000..77cf2f2 --- /dev/null +++ b/delivery.py @@ -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}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ff4c295 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/main.py b/main.py new file mode 100644 index 0000000..1deed67 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..67b07e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +telethon>=1.36 +python-dotenv>=1.0 +httpx>=0.27 +anthropic>=0.40 +openai>=1.50 diff --git a/summarizer.py b/summarizer.py new file mode 100644 index 0000000..9b8f2a2 --- /dev/null +++ b/summarizer.py @@ -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"]