319 lines
12 KiB
Python
319 lines
12 KiB
Python
import requests
|
|
import json
|
|
import asyncio
|
|
from bs4 import BeautifulSoup
|
|
from datetime import datetime, timedelta
|
|
from aiogram import Bot, Dispatcher, types
|
|
from aiogram.filters import Command
|
|
from aiogram.types import Message
|
|
import logging
|
|
|
|
# Настройки
|
|
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "YOUR_TOKEN_HERE")
|
|
ALLOWED_CHAT_IDS = list(map(int, os.getenv("ALLOWED_CHAT_IDS", "").split(","))) if os.getenv("ALLOWED_CHAT_IDS") else []
|
|
CHECK_INTERVAL = 120 # 2 минуты
|
|
|
|
# Настройка логирования
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Глобальные переменные
|
|
bot = Bot(token=TELEGRAM_TOKEN)
|
|
dp = Dispatcher()
|
|
last_schedule = None
|
|
|
|
|
|
def parse_html(html: str):
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
cells = soup.select(".disconnection-detailed-table-cell.cell")
|
|
|
|
schedule = []
|
|
current_hour = 0
|
|
current_day = 0
|
|
|
|
for cell in cells:
|
|
if 'legend' in cell.get('class', []) or 'head' in cell.get('class', []):
|
|
continue
|
|
|
|
cell_classes = cell.get('class', [])
|
|
full_hour_off = 'has_disconnection' in cell_classes and 'full_hour' in cell_classes
|
|
|
|
hour_block = cell.select_one(".hour_block")
|
|
if not hour_block:
|
|
continue
|
|
|
|
left = hour_block.select_one(".half.left")
|
|
right = hour_block.select_one(".half.right")
|
|
|
|
def parse_half(half, force_off=False):
|
|
if not half:
|
|
return {"status": "unknown", "queue": None, "confirmed": None}
|
|
|
|
if force_off:
|
|
status = "off"
|
|
else:
|
|
classes = half.get("class", [])
|
|
if "has_disconnection" in classes:
|
|
status = "off"
|
|
elif "no_disconnection" in classes:
|
|
status = "on"
|
|
else:
|
|
status = "unknown"
|
|
|
|
queue = None
|
|
confirmed = None
|
|
disconnection_div = half.select_one(".disconnection")
|
|
if disconnection_div and disconnection_div.has_attr("title"):
|
|
title = disconnection_div["title"]
|
|
if "Номер черги" in title:
|
|
queue = title.split(":")[-1].strip()
|
|
|
|
disconnection_classes = disconnection_div.get("class", [])
|
|
if "disconnection_confirm_1" in disconnection_classes:
|
|
confirmed = True
|
|
elif "disconnection_confirm_0" in disconnection_classes:
|
|
confirmed = False
|
|
|
|
if force_off and not confirmed and not queue:
|
|
if 'confirm_1' in cell_classes:
|
|
confirmed = True
|
|
elif 'confirm_0' in cell_classes:
|
|
confirmed = False
|
|
|
|
return {"status": status, "queue": queue, "confirmed": confirmed}
|
|
|
|
left_data = parse_half(left, force_off=full_hour_off)
|
|
right_data = parse_half(right, force_off=full_hour_off)
|
|
|
|
schedule.append({
|
|
"hour": current_hour,
|
|
"day": current_day,
|
|
"first_half": left_data,
|
|
"second_half": right_data,
|
|
})
|
|
|
|
current_hour += 1
|
|
if current_hour >= 24:
|
|
current_hour = 0
|
|
current_day += 1
|
|
|
|
return schedule
|
|
|
|
|
|
def get_voe_html(city_id, street_id, house_id):
|
|
url = "https://www.voe.com.ua/disconnection/detailed?ajax_form=1&_wrapper_format=drupal_ajax"
|
|
headers = {
|
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"User-Agent": "Mozilla/5.0 (compatible; PowerBot/1.0)"
|
|
}
|
|
data = {
|
|
"search_type": 0,
|
|
"city_id": city_id,
|
|
"street_id": street_id,
|
|
"house_id": house_id,
|
|
"form_build_id": "form-Irv5aHw1R2FT_Ik2apyHOZ47hTH5xPNH_LQnBrmpSTc",
|
|
"form_id": "disconnection_detailed_search_form",
|
|
"_triggering_element_name": "search",
|
|
"_triggering_element_value": "Показати",
|
|
"_drupal_ajax": 1,
|
|
}
|
|
r = requests.post(url, headers=headers, data=data)
|
|
r.raise_for_status()
|
|
resp_json = json.loads(r.text)
|
|
insert_html = next((i["data"] for i in resp_json if i.get("command") == "insert"), None)
|
|
if not insert_html:
|
|
raise ValueError("HTML не найден в ответе")
|
|
return insert_html
|
|
|
|
|
|
def format_schedule_message(schedule, days_to_show=2):
|
|
"""Форматирует расписание для отправки в Telegram"""
|
|
message = f"⚡️ <b>Графік відключень світла</b>\n"
|
|
message += f"🕐 Оновлено: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n\n"
|
|
|
|
start_date = datetime.now()
|
|
|
|
for day in range(days_to_show):
|
|
day_blocks = [b for b in schedule if b["day"] == day]
|
|
if not day_blocks:
|
|
continue
|
|
|
|
date_str = (start_date + timedelta(days=day)).strftime('%d.%m')
|
|
day_name = "Сьогодні" if day == 0 else "Завтра"
|
|
|
|
message += f"📅 <b>{day_name} ({date_str})</b>\n"
|
|
message += "━━━━━━━━━━━━━━━━━━━━\n"
|
|
|
|
for block in day_blocks:
|
|
first = block["first_half"]
|
|
second = block["second_half"]
|
|
|
|
def get_icon(half_data):
|
|
if half_data["status"] == "off":
|
|
if half_data["confirmed"] is True:
|
|
return "🔴" # Точно відключено
|
|
elif half_data["confirmed"] is False:
|
|
return "🟠" # Можливо відключено
|
|
return "🔴"
|
|
elif half_data["status"] == "on":
|
|
return "🟢"
|
|
return "⚪️"
|
|
|
|
first_icon = get_icon(first)
|
|
second_icon = get_icon(second)
|
|
|
|
# Форматируем время
|
|
hour_start = f"{block['hour']:02d}:00"
|
|
hour_mid = f"{block['hour']:02d}:30"
|
|
hour_end = f"{block['hour']+1:02d}:00"
|
|
|
|
# Информация о черге
|
|
queue_info = ""
|
|
if first["queue"] or second["queue"]:
|
|
queue = first["queue"] or second["queue"]
|
|
queue_info = f" | Черга {queue}"
|
|
|
|
message += f"{hour_start}-{hour_mid} {first_icon} {hour_mid}-{hour_end} {second_icon}{queue_info}\n"
|
|
|
|
message += "\n"
|
|
|
|
message += "🟢 - Світло є\n"
|
|
message += "🔴 - Відключення підтверджено\n"
|
|
message += "🟠 - Можливе відключення\n"
|
|
message += "⚪️ - Інформація відсутня"
|
|
|
|
return message
|
|
|
|
|
|
def schedules_differ(old_schedule, new_schedule):
|
|
"""Проверяет, отличаются ли графики"""
|
|
if old_schedule is None or new_schedule is None:
|
|
return True
|
|
|
|
if len(old_schedule) != len(new_schedule):
|
|
return True
|
|
|
|
for old, new in zip(old_schedule, new_schedule):
|
|
# Сравниваем только первые 2 дня
|
|
if old["day"] >= 2:
|
|
break
|
|
|
|
if (old["first_half"] != new["first_half"] or
|
|
old["second_half"] != new["second_half"]):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
async def send_to_all_users(message_text: str):
|
|
"""Отправляет сообщение всем пользователям"""
|
|
for chat_id in ALLOWED_CHAT_IDS:
|
|
try:
|
|
await bot.send_message(chat_id, message_text, parse_mode="HTML")
|
|
logger.info(f"Сообщение отправлено пользователю {chat_id}")
|
|
except Exception as e:
|
|
logger.error(f"Ошибка отправки пользователю {chat_id}: {e}")
|
|
|
|
|
|
async def check_schedule():
|
|
"""Проверяет график и отправляет уведомления при изменениях"""
|
|
global last_schedule
|
|
|
|
try:
|
|
logger.info("Проверка графика...")
|
|
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
|
|
new_schedule = parse_html(html)
|
|
|
|
if schedules_differ(last_schedule, new_schedule):
|
|
logger.info("Обнаружены изменения в графике!")
|
|
message = format_schedule_message(new_schedule, days_to_show=2)
|
|
|
|
if last_schedule is None:
|
|
# Первый запуск
|
|
await send_to_all_users(f"✨ <b>Бот запущено!</b>\n\n{message}")
|
|
else:
|
|
# Изменения в графике
|
|
await send_to_all_users(f"🔄 <b>Графік оновлено!</b>\n\n{message}")
|
|
|
|
last_schedule = new_schedule
|
|
else:
|
|
logger.info("Изменений нет")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Ошибка при проверке графика: {e}")
|
|
await send_to_all_users(f"❌ <b>Помилка!</b>\n\nНе вдалося отримати графік: {str(e)}")
|
|
|
|
|
|
async def monitoring_loop():
|
|
"""Основной цикл мониторинга"""
|
|
# Первая проверка сразу при запуске
|
|
await check_schedule()
|
|
|
|
# Затем проверяем каждые 2 минуты
|
|
while True:
|
|
await asyncio.sleep(CHECK_INTERVAL)
|
|
await check_schedule()
|
|
|
|
|
|
@dp.message(Command("start"))
|
|
async def cmd_start(message: Message):
|
|
"""Обработчик команды /start"""
|
|
if message.chat.id not in ALLOWED_CHAT_IDS:
|
|
await message.answer("❌ У вас немає доступу до цього бота.")
|
|
return
|
|
|
|
await message.answer(
|
|
"👋 <b>Вітаю!</b>\n\n"
|
|
"Я буду відстежувати зміни в графіку відключень світла.\n\n"
|
|
"Доступні команди:\n"
|
|
"/schedule - Показати поточний графік\n"
|
|
"/check - Перевірити графік зараз",
|
|
parse_mode="HTML"
|
|
)
|
|
|
|
|
|
@dp.message(Command("schedule"))
|
|
async def cmd_schedule(message: Message):
|
|
"""Показывает текущий график"""
|
|
if message.chat.id not in ALLOWED_CHAT_IDS:
|
|
await message.answer("❌ У вас немає доступу до цього бота.")
|
|
return
|
|
|
|
try:
|
|
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
|
|
schedule = parse_html(html)
|
|
text = format_schedule_message(schedule, days_to_show=2)
|
|
await message.answer(text, parse_mode="HTML")
|
|
except Exception as e:
|
|
await message.answer(f"❌ Помилка: {str(e)}")
|
|
|
|
|
|
@dp.message(Command("check"))
|
|
async def cmd_check(message: Message):
|
|
"""Принудительная проверка графика"""
|
|
if message.chat.id not in ALLOWED_CHAT_IDS:
|
|
await message.answer("❌ У вас немає доступу до цього бота.")
|
|
return
|
|
|
|
await message.answer("🔄 Перевіряю графік...")
|
|
await check_schedule()
|
|
|
|
|
|
async def main():
|
|
"""Главная функция"""
|
|
logger.info("Запуск бота...")
|
|
|
|
# Запускаем мониторинг в фоне
|
|
monitoring_task = asyncio.create_task(monitoring_loop())
|
|
|
|
# Запускаем бота
|
|
try:
|
|
await dp.start_polling(bot)
|
|
finally:
|
|
monitoring_task.cancel()
|
|
await bot.session.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main()) |