commit b6d046c5e237158a7fb1cde0050378943d25198f Author: pikus Date: Thu Oct 2 20:35:37 2025 +0300 INIT diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b281290 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +ENV PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=100 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --upgrade pip && \ + pip install --no-cache-dir --disable-pip-version-check -r requirements.txt + +COPY . . + +RUN mkdir -p logs + +CMD ["python", "bot.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f391ce --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ + +--- + +# 🚨 Бот отключений электроэнергии (МРСК Белгород) + +Telegram-бот, который автоматически парсит и отображает актуальную информацию об **плановых**, **внерегламентных** и **аварийных** отключениях электроэнергии в г. Белгород. + +Данные берутся с официального сайта [МРСК Центра и Приволжья](https://www.mrsk-1.ru/). + +--- + +## Возможности + +- Просмотр трёх типов отключений +- Автоматическое обновление данных каждые **5 минут** +- Удобный интерфейс через Telegram +- Подробное логирование запросов и ошибок + +--- + +## 🛠 Технологии + +- Python 3.11 +- [aiogram](https://docs.aiogram.dev/) (v2) +- requests, pytz +- Docker (для развёртывания) + +--- + +## Запуск + +### 1. Клонируйте репозиторий + +### 2. Настройте конфигурацию +Отредактируйте файл `config.py`: +```python +token = 'ВАШ_TELEGRAM_BOT_TOKEN' +URL_APP = 'https://ваш-домен.ru' +``` + +### 3. Соберите и запустите через Docker +```bash +docker build -t power-outage-bot . +docker run -d \ + --name power-bot \ + -p 5000:5000 \ + -v $(pwd)/logs:/app/logs \ + power-outage-bot +``` + +> Убедитесь, что ваш сервер доступен по `URL_APP` и настроен reverse proxy (например, Nginx) на порт 5000. + +--- + +## 📁 Структура проекта + +``` +. +├── bot.py # Основной файл бота +├── main.py # Парсер данных с сайта МРСК +├── config.py # Конфигурация (токен, URL, API-эндпоинты) +├── requirements.txt # Зависимости Python +├── Dockerfile # Для сборки образа +├── logs/ # Директория для логов (бот.log, parser.log, users.log) +└── README.md +``` + +--- + +## Обратная связь + +Если у вас есть вопросы, предложения или вы нашли баг — пишите: [@pikusQQ](https://t.me/pikusQQ) + +--- + +> ⚠️ **Важно**: Бот использует **webhook**, поэтому должен быть развёрнут на сервере с HTTPS. + +--- diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..610cb1d --- /dev/null +++ b/bot.py @@ -0,0 +1,124 @@ +import asyncio +import logging +import pytz +from datetime import datetime +from pathlib import Path +from aiogram import Bot, Dispatcher, executor, types +from aiogram.dispatcher.filters import Text +import config +from main import start_parser + +logging.basicConfig( + level=logging.INFO, + filename="logs/bot.log", + format="%(asctime)s - %(module)s - %(levelname)s - %(funcName)s: %(lineno)d - %(message)s", + datefmt='%H:%M:%S', +) +logger = logging.getLogger(__name__) + +TZ = pytz.timezone('Europe/Moscow') +USER_LOG = Path("logs/users.log") +UPDATE_INTERVAL = 300 # 5 минут в секундах + +bot = Bot(token=config.token) +dp = Dispatcher(bot) + +async def periodic_update(): + while True: + logger.info("Запуск автоматического обновления данных...") + try: + start_parser() + logger.info("Автоматическое обновление завершено") + except Exception as e: + logger.error(f"Ошибка при автоматическом обновлении: {e}") + await asyncio.sleep(UPDATE_INTERVAL) + +@dp.message_handler(commands='start') +async def cmd_start(message: types.Message): + buttons = ['Плановые', 'Внерегламентные', 'Аварийные', 'Информация'] + keyboard = types.ReplyKeyboardMarkup(resize_keyboard=True) + keyboard.add(*buttons) + await message.answer('Выберите тип отключений:', reply_markup=keyboard) + +# Утилита для отправки файла +async def send_file_content(message: types.Message, file_path: Path, empty_msg: str, log_msg: str): + await message.answer("Обновляю...") + + if not file_path.exists() or file_path.stat().st_size == 0: + await message.answer(empty_msg) + else: + text = file_path.read_text(encoding='utf-8') + if len(text) > 4095: + for i in range(0, len(text), 4095): + await message.answer(text[i:i + 4095]) + else: + await message.answer(text) + + now = datetime.now(TZ).strftime('%d/%m/%Y %H:%M') + username = message.from_user.username or f"id{message.from_user.id}" + log_entry = f"{now} t.me/{username} получил инфу по {log_msg}.\n" + USER_LOG.parent.mkdir(exist_ok=True) + with USER_LOG.open('a', encoding='utf-8') as f: + f.write(log_entry) + logger.info(f"Пользователь {username} запросил {log_msg}") + +@dp.message_handler(Text(equals='Плановые')) +async def plan(message: types.Message): + await send_file_content( + message, + Path("plan.txt"), + "[Нет плановых отключений]", + "плановым отключениям" + ) + +@dp.message_handler(Text(equals='Внерегламентные')) +async def vnereglament(message: types.Message): + await send_file_content( + message, + Path("vnereglament.txt"), + "[Нет внерегламентных отключений]", + "внерегламентным отключениям" + ) + +@dp.message_handler(Text(equals='Аварийные')) +async def avar(message: types.Message): + await send_file_content( + message, + Path("avar.txt"), + "[Нет аварийных отключений]", + "аварийным отключениям" + ) + +@dp.message_handler(Text(equals='Информация')) +async def info(message: types.Message): + await message.answer( + "ℹ️ Информация МРСК Белгород по отключениям эл-ва (v2)\n\n" + "✅ Данные обновляются автоматически каждые 5 минут.\n" + "📩 Вопросы и предложения: @pikusQQ" + ) + +@dp.message_handler() +async def fallback(message: types.Message): + await message.answer("Нажмите /start для начала работы.") + +# Webhook handlers +async def on_startup(dp): + await bot.set_webhook(config.URL_APP) + # Запуск фоновой задачи после старта + asyncio.create_task(periodic_update()) + logger.info("Webhook установлен. Фоновое обновление запущено.") + +async def on_shutdown(dp): + await bot.delete_webhook() + logger.info("Webhook удалён") + +if __name__ == '__main__': + executor.start_webhook( + dispatcher=dp, + webhook_path='', + on_startup=on_startup, + on_shutdown=on_shutdown, + skip_updates=True, + host='0.0.0.0', + port=5000, + ) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..c339d38 --- /dev/null +++ b/config.py @@ -0,0 +1,8 @@ +main_url = 'https://www.mrsk-1.ru/customers/customer-service/power-outage/ajax.php?request=' +districtCode = '0ECC8970-2702-4C14-9E4D-956606EFFEE3' +localityCode = '02E9C019-AB4D-4FA0-928E-D6C0A41DC256' +url_vnereglament = f'{main_url}3®ionCode=31&districtCode={districtCode}&locality=Белгород&localityCode={localityCode}' +url_avar = f'{main_url}4®ionCode=31&districtCode={districtCode}&locality=Белгород&localityCode={localityCode}&Street=-' +url_plan = f'{main_url}2®ionCode=31&districtCode={districtCode}&locality=Белгород&localityCode={localityCode}' +token = '' +URL_APP = '' diff --git a/main.py b/main.py new file mode 100644 index 0000000..8ed56c8 --- /dev/null +++ b/main.py @@ -0,0 +1,102 @@ +import json +import logging +import requests +from pathlib import Path +from urllib3.exceptions import InsecureRequestWarning +import urllib3 + +import config + +urllib3.disable_warnings(InsecureRequestWarning) + +logging.basicConfig( + level=logging.INFO, + filename="logs/parser.log", + format="%(asctime)s - %(module)s - %(levelname)s - %(funcName)s: %(lineno)d - %(message)s", + datefmt='%H:%M:%S', +) + +logger = logging.getLogger(__name__) + +PLAN_FILE = Path("plan.txt") +VN_FILE = Path("vnereglament.txt") +AVAR_FILE = Path("avar.txt") + +def clean_street(street: str) -> str: + """Очищает и форматирует строку с адресами.""" + street = street.replace('Белгород г; ', '').replace('\n\n', '\n').strip() + lines = [line.strip() for line in street.split('\n') if line.strip()] + return '\n'.join(lines) + +def format_time(dt_str: str) -> str: + """Преобразует '2025-10-02T09:00:00' → '02.10.2025 09:00'.""" + try: + return dt_str.replace('T', ' ') + except: + return dt_str + +def parse_and_save(data, file_path: Path, mode: str): + """ + mode: 'plan', 'vnereglament', 'avar' + """ + if not data or data == 0: + file_path.write_text("[Нет отключений]", encoding='utf-8') + return + + records = data if isinstance(data, list) else [data] + lines = [] + + for item in records: + if mode in ('plan', 'vnereglament'): + street = clean_street(item.get("DisconnectionObject", "")) + time_down = format_time(item.get("DisconnectionDateTime", "")) + time_up = format_time(item.get("EnergyOnPlanningDateTime", "")) + lines.append(f"{street}\nОтключение: {time_down} — {time_up}\n{'─' * 30}") + elif mode == 'avar': + street = clean_street(item.get("StreetHome", "")) + time_up = format_time(item.get("ScheduledTimeRemoval", "")) + lines.append(f"{street}\nВосстановление: ~{time_up}\n{'─' * 30}") + + file_path.write_text('\n\n'.join(lines), encoding='utf-8') + +def get_plan(): + try: + r = requests.get(url=config.url_plan, verify=False, timeout=10) + r.raise_for_status() + data = r.json() + parse_and_save(data, PLAN_FILE, 'plan') + logger.info("[Плановые отключения] обновлены") + except Exception as e: + logger.error(f"Ошибка при получении плановых отключений: {e}") + PLAN_FILE.write_text("[Ошибка загрузки данных]", encoding='utf-8') + +def get_vnereglament(): + try: + r = requests.get(url=config.url_vnereglament, verify=False, timeout=10) + r.raise_for_status() + data = r.json() + parse_and_save(data, VN_FILE, 'vnereglament') + logger.info("[Внерегламентные отключения] обновлены") + except Exception as e: + logger.error(f"Ошибка при получении внерегламентных отключений: {e}") + VN_FILE.write_text("[Ошибка загрузки данных]", encoding='utf-8') + +def get_avar(): + try: + r = requests.get(url=config.url_avar, verify=False, timeout=10) + r.raise_for_status() + data = r.json() + parse_and_save(data, AVAR_FILE, 'avar') + logger.info("[Аварийные отключения] обновлены") + except Exception as e: + logger.error(f"Ошибка при получении аварийных отключений: {e}") + AVAR_FILE.write_text("[Ошибка загрузки данных]", encoding='utf-8') + +def start_parser(): + get_plan() + get_vnereglament() + get_avar() + logger.info("Парсер отработал") + +if __name__ == '__main__': + start_parser() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..349d982 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +aiogram==2.25.2 +aiohttp==3.8.6 +aiosignal==1.3.1 +async-timeout==4.0.3 +attrs==23.2.0 +Babel==2.9.1 +certifi==2024.6.2 +charset-normalizer==3.3.2 +frozenlist==1.4.1 +idna==3.7 +magic-filter==1.0.12 +multidict==6.0.5 +pytz==2024.1 +requests==2.32.3 +urllib3==2.2.1 +yarl==1.9.4