INIT
This commit is contained in:
commit
b6d046c5e2
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
78
README.md
Normal 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
124
bot.py
Normal 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
8
config.py
Normal 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®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 = ''
|
||||||
102
main.py
Normal file
102
main.py
Normal 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
16
requirements.txt
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user