This commit is contained in:
Andrey Sazonov 2025-10-02 20:35:37 +03:00
commit b6d046c5e2
6 changed files with 354 additions and 0 deletions

26
Dockerfile Normal file
View File

@ -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"]

78
README.md Normal file
View File

@ -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.
---

124
bot.py Normal file
View File

@ -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,
)

8
config.py Normal file
View File

@ -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&regionCode=31&districtCode={districtCode}&locality=Белгород&localityCode={localityCode}'
url_avar = f'{main_url}4&regionCode=31&districtCode={districtCode}&locality=Белгород&localityCode={localityCode}&Street=-'
url_plan = f'{main_url}2&regionCode=31&districtCode={districtCode}&locality=Белгород&localityCode={localityCode}'
token = ''
URL_APP = ''

102
main.py Normal file
View File

@ -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()

16
requirements.txt Normal file
View File

@ -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