From ea286e117d0f2e333ee90573a15effb504e03e2f Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 27 May 2026 19:39:45 +0200 Subject: [PATCH] feat: add diary (/diary) command with calendar parsing via Playwright - Parse school diary calendar HTML table for daily/weekly/monthly views - Cache diary data with 5-minute TTL - Inline keyboard for today/tomorrow/week/month selection - Ukrainian month/weekday names and formatting --- edu_master/webinar-checker/checker.py | 275 +++++++++++++++++++++++++- 1 file changed, 274 insertions(+), 1 deletion(-) diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index 65043cf..2daeedc 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -1,7 +1,10 @@ import os +import re import logging import redis import json +import time +from datetime import datetime, timedelta from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatMember from telegram.constants import ChatType @@ -30,6 +33,7 @@ def _env(key, default=None): EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua') EDU_WEBINAR_PATH = _env('EDU_URL_WEBINAR', '/webinar/useractive') WEBINAR_URL = f"{EDU_BASE.rstrip('/')}/{EDU_WEBINAR_PATH.lstrip('/')}" +DIARY_URL = f"{EDU_BASE.rstrip('/')}/user/diary" WEBINAR_CHECK_INTERVAL = int(_env('WEBINAR_CHECK_INTERVAL', 60)) REDIS_HOST = _env('REDIS_HOST', 'redis') @@ -252,6 +256,205 @@ def get_admin_keyboard(user_id: int): ] return InlineKeyboardMarkup(keyboard) +# --- Diary Functions --- + +DIARY_MONTH_NAMES = ['', 'Січня', 'Лютого', 'Березня', 'Квітня', 'Травня', 'Червня', + 'Липня', 'Серпня', 'Вересня', 'Жовтня', 'Листопада', 'Грудня'] + +DIARY_WEEKDAYS_SHORT = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд'] + +def get_diary_keyboard(): + today = datetime.now() + keyboard = [ + [ + InlineKeyboardButton(f"📌 Сьогодні ({today.day}.{today.month:02d})", callback_data="diary_today"), + InlineKeyboardButton("📌 Завтра", callback_data="diary_tomorrow"), + ], + [ + InlineKeyboardButton("📅 Цей тиждень", callback_data="diary_week"), + InlineKeyboardButton("📅 Весь місяць", callback_data="diary_month"), + ], + ] + return InlineKeyboardMarkup(keyboard) + +def _parse_calendar_html(table_html: str) -> tuple: + """Parse calendar HTML table into (month_text, {day_num: {weekday, events}}).""" + days = {} + weekdays = [] + rows = re.findall(r']*>(.*?)', table_html, re.DOTALL) + + month_text = '' + for r_idx, row in enumerate(rows): + cells = re.findall(r']*>(.*?)', row, re.DOTALL) + + if r_idx == 0: + # Month navigation row: extract "Травень 2026" from nav text + raw = re.sub(r'<[^>]+>', ' ', row).strip() + raw = re.sub(r'\s+', ' ', raw) + m = re.search(r'([А-Яа-яіїєґ\']+\s*:?\s*\d{4})', raw) + if m: + month_text = m.group(1).replace(' : ', ' ').strip() + else: + month_text = raw + elif r_idx == 1: + # Day names row + for cell in cells: + name = re.sub(r'<[^>]+>', '', cell).strip() + if name: + weekdays.append(name) + else: + # Data rows: each cell = a day + for col_idx, cell in enumerate(cells): + # Extract day number — first number in the cell text + text = re.sub(r'<[^>]+>', ' ', cell).strip() + text = re.sub(r'\s+', ' ', text) + dm = re.match(r'(\d+)', text) + if not dm: + continue + day_num = dm.group(1) + + # Extract events: title attribute (full name) of ALL tags inside the cell + events = [] + for a_match in re.finditer(r']*>(.*?)', cell, re.DOTALL): + a_tag = a_match.group(0) + # Prefer the title attribute (contains full name, not truncated) + title_m = re.search(r'title\s*=\s*"([^"]*)"', a_tag) + if title_m: + et = title_m.group(1).strip() + else: + et = re.sub(r'<[^>]+>', '', a_match.group(1)).strip() + if et: + events.append(et) + + weekday = weekdays[col_idx] if col_idx < len(weekdays) else '' + days[day_num] = {'weekday': weekday, 'events': events} + + return month_text, days + + +async def fetch_diary_data(phpsessid: str) -> dict | None: + logger.info("Fetching diary data via Playwright...") + try: + async with async_playwright() as p: + browser = await p.chromium.connect(PLAYWRIGHT_WS) + try: + context_browser = await browser.new_context(user_agent=USER_AGENT) + await context_browser.add_cookies([{ + 'name': 'PHPSESSID', + 'value': phpsessid, + 'domain': 'edu.edu.vn.ua', + 'path': '/' + }]) + page = await context_browser.new_page() + + try: + await page.goto(DIARY_URL, wait_until='domcontentloaded') + await page.wait_for_selector('table.calendar', timeout=10000) + await page.wait_for_timeout(1500) + + table_html = await page.evaluate(""" + () => { + const t = document.querySelector('table.calendar'); + return t ? t.outerHTML : null; + } + """) + if not table_html: + logger.error("table.calendar not found in DOM") + return None + + # Debug: save HTML for troubleshooting + try: + with open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f: + f.write(table_html) + except Exception: + pass + + month_text, days = _parse_calendar_html(table_html) + logger.info(f"Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d['events'])}/{len(days)}") + + return {'monthFullText': month_text, 'days': days} + + except Exception as e: + logger.error(f"Error parsing diary: {e}") + return None + finally: + await page.close() + await context_browser.close() + finally: + await browser.close() + except Exception as e: + logger.error(f"Playwright error in diary fetch: {e}") + return None + +def _parse_diary_month(text: str) -> str: + match = re.search(r'([А-Яа-яіїєґ\']+\s*:\s*\d{4})', text) + if match: + return match.group(1).replace(' : ', ' ').strip() + return text.strip() + +def format_diary_day(data: dict, day_num: int) -> str: + days = data.get('days', {}) + month_str = _parse_diary_month(data.get('monthFullText', '')) + day_data = days.get(str(day_num)) + lines = [f"📅 {day_num} {month_str}", "─" * 18] + if not day_data or not day_data.get('events'): + lines.append("Немає подій") + else: + for e in day_data['events']: + lines.append(f"📌 {e}") + lines.append(f"\n🔗 {DIARY_URL}") + return "\n".join(lines) + +def format_diary_week(data: dict, today: datetime) -> str: + days = data.get('days', {}) + month_str = _parse_diary_month(data.get('monthFullText', '')) + monday = today - timedelta(days=today.weekday()) + sunday = monday + timedelta(days=6) + lines = [f"📅 Тиждень {monday.day}.{monday.month} – {sunday.day}.{sunday.month}\n"] + for i in range(7): + d = monday + timedelta(days=i) + day_data = days.get(str(d.day)) + lines.append(f"─ {DIARY_WEEKDAYS_SHORT[i]} {d.day}.{d.month} ─") + if not day_data or not day_data.get('events'): + lines.append("Немає подій\n") + else: + for e in day_data['events']: + lines.append(f"📌 {e}") + lines.append("") + lines.append(f"🔗 {DIARY_URL}") + return "\n".join(lines) + +def format_diary_month(data: dict) -> str: + days = data.get('days', {}) + month_str = _parse_diary_month(data.get('monthFullText', '')) + lines = [f"📅 {month_str}\n"] + for day_num in sorted(days.keys(), key=int): + day_data = days[day_num] + events = day_data.get('events', []) + weekday = day_data.get('weekday', '') + lines.append(f"─ {weekday} {day_num} ─") + if not events: + lines.append("Немає подій\n") + else: + for e in events: + lines.append(f"📌 {e}") + lines.append("") + lines.append(f"🔗 {DIARY_URL}") + return "\n".join(lines) + +async def _get_diary_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None: + cached = context.user_data.get('diary_cache') + now_ts = time.time() + if cached and (now_ts - cached.get('timestamp', 0)) < 300: + return cached['data'] + phpsessid = redis_client.get(KEY_PHPSESSID) + if not phpsessid: + return None + data = await fetch_diary_data(phpsessid) + if data: + context.user_data['diary_cache'] = {'data': data, 'timestamp': now_ts} + return data + # --- Command Handlers --- async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -376,6 +579,74 @@ async def clear_history(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.error(f"Failed to clear history: {e}") await update.message.reply_text(t(admin_id, 'history_clear_failed')) +async def diary_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + chat = update.effective_chat + if not is_whitelisted(user.id): + await update.message.reply_text(t(user.id, 'access_denied')) + return + await update.message.reply_text( + "📅 Щоденник — виберіть період:", + parse_mode='HTML', + reply_markup=get_diary_keyboard() + ) + +async def diary_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + user_id = query.from_user.id + await query.answer() + + if user_id != ADMIN_ID: + if not is_whitelisted(user_id): + await query.edit_message_text("⛔ Доступ заборонено.") + return + + data = query.data + if data == "diary_refresh": + context.user_data.pop('diary_cache', None) + await query.edit_message_text("🔄 Завантажую щоденник...") + diary_data = await _get_diary_data(context) + if not diary_data: + await query.edit_message_text("❌ Не вдалося завантажити щоденник. Немає сесії або помилка.") + return + await query.edit_message_text( + "📅 Щоденник — виберіть період:", + parse_mode='HTML', + reply_markup=get_diary_keyboard() + ) + return + + await query.edit_message_text("🔄 Завантажую щоденник...") + diary_data = await _get_diary_data(context) + if not diary_data: + await query.edit_message_text("❌ Не вдалося завантажити щоденник.") + return + + today = datetime.now() + if data == "diary_today": + text = format_diary_day(diary_data, today.day) + elif data == "diary_tomorrow": + tomorrow = today + timedelta(days=1) + if tomorrow.day < today.day: + text = "❌ Дані за наступний місяць недоступні. Перейдіть на сайт." + else: + text = format_diary_day(diary_data, tomorrow.day) + elif data == "diary_week": + text = format_diary_week(diary_data, today) + elif data == "diary_month": + text = format_diary_month(diary_data) + else: + return + + if len(text) > 4096: + text = text[:4090] + "\n\n✂️ ...(обрізано)" + + await query.edit_message_text( + text, + parse_mode='HTML', + reply_markup=get_diary_keyboard() + ) + # --- Admin Callbacks --- async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -649,8 +920,10 @@ def main(): app.add_handler(CommandHandler("adduser", add_user)) app.add_handler(CommandHandler("removeuser", remove_user)) app.add_handler(CommandHandler("clearhistory", clear_history)) + app.add_handler(CommandHandler("diary", diary_command)) - # Callback handlers - language selection first, then admin panel + # Callback handlers - diary first, then language selection, then admin panel + app.add_handler(CallbackQueryHandler(diary_callback, pattern="^diary_")) app.add_handler(CallbackQueryHandler(language_callback, pattern="^lang_")) app.add_handler(CallbackQueryHandler(admin_callback))