diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index df23329..80fbddc 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -115,6 +115,7 @@ TRANSLATIONS = { 'month': '📅 Весь месяц', 'no_events': 'Нет событий', 'no_lessons': 'Нет уроков', + 'time_unknown': 'неизвестно', 'free_period': 'Свободно', 'unsubscribed': '🔕 Вы отписались от уведомлений.', 'diary_title': '📅 Дневник — выберите период:', @@ -186,6 +187,7 @@ TRANSLATIONS = { 'month': '📅 Весь місяць', 'no_events': 'Немає подій', 'no_lessons': 'Немає уроків', + 'time_unknown': 'невідомо', 'free_period': 'Вільно', 'unsubscribed': '🔕 Ви відписалися від сповіщень.', 'diary_title': '📅 Щоденник — виберіть період:', @@ -257,6 +259,7 @@ TRANSLATIONS = { 'month': '📅 Whole month', 'no_events': 'No events', 'no_lessons': 'No lessons', + 'time_unknown': 'unknown', 'free_period': 'Free', 'unsubscribed': '🔕 You have unsubscribed from notifications.', 'diary_title': '📅 Diary — choose a period:', @@ -490,7 +493,10 @@ def get_diary_keyboard(user_id: int | None = None): def _parse_calendar_html(table_html: str) -> tuple: - """Parse calendar HTML table into (month_text, {day_num: {weekday, events}}).""" + """Parse calendar HTML table into (month_text, {day_num: {weekday, events}}). + + Each event is a dict: {'title': str, 'id': site event id | None, 'time': 'HH:MM' | None}. + """ days = {} weekdays = [] rows = re.findall(r']*>(.*?)', table_html, re.DOTALL) @@ -522,22 +528,101 @@ def _parse_calendar_html(table_html: str) -> tuple: continue day_num = dm.group(1) - # Extract events: title attribute (full name) of ALL tags inside the cell + # Extract events: title attribute (full name) of ALL tags inside the cell. + # Each event keeps its site data-event-id and a 'time' slot (filled later + # from the AJAX popup by fetch_diary_data). 'time' is 'HH:MM' or None. 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) et = title_m.group(1).strip() if title_m else re.sub(r'<[^>]+>', '', a_match.group(1)).strip() - if et: - events.append(et) + if not et: + continue + id_m = re.search(r'data-event-id\s*=\s*"?(\d+)"?', a_tag) + events.append( + { + 'title': et, + 'id': id_m.group(1) if id_m else None, + 'time': None, + } + ) weekday = weekdays[col_idx] if col_idx < len(weekdays) else '' - days[day_num] = {'weekday': weekday, 'events': events} + days[day_num] = {'weekday': weekday, 'weekday_idx': col_idx, 'events': events} return month_text, days +async def _collect_event_times(page) -> dict: + """Click every calendar `a.event-link` and read the time from the AJAX popup. + + The site shows event details only after a click fills the fancybox container + (`div.event-full-info span.data`). Returns {event_id: 'HH:MM'} for events + that have a date; events without one are simply skipped. A failed click on a + single event never breaks the whole diary parse. + """ + times_by_id: dict[str, str] = {} + try: + links = await page.query_selector_all('a.event-link') + except Exception as e: + logger.warning(f'Failed to list diary event links: {e}') + return times_by_id + + for link in links: + event_id = None + event_deadline = time.monotonic() + 10 + try: + event_id = await link.get_attribute('data-event-id') + if not event_id or event_id in times_by_id: + continue + await link.evaluate('(el) => el.click()') + await page.wait_for_selector( + f'div.event-full-info[data-event-full-info-id="{event_id}"] span.data', + timeout=3000, + state='visible', + ) + if time.monotonic() > event_deadline: + break + time_text = await page.evaluate( + """(id) => { + const nodes = document.querySelectorAll( + 'div.event-full-info[data-event-full-info-id="' + id + '"] span.data' + ); + for (const el of nodes) { + if (el.getClientRects().length > 0 || el.offsetParent !== null) { + return el.textContent.trim(); + } + } + return null; + }""", + event_id, + ) + if time_text: + m = re.search(r'(\d{1,2}:\d{2})', time_text) + if m: + times_by_id[event_id] = m.group(1) + except Exception as e: + logger.debug(f'Failed to read time for diary event {event_id}: {e}') + if time.monotonic() > event_deadline: + break + finally: + # Close the fancybox popup (click the close button, else Escape). + closed = False + with contextlib.suppress(Exception): + close_btn = await page.query_selector('#fancybox-close') + if close_btn is not None: + await close_btn.click(timeout=2000) + closed = True + if not closed: + with contextlib.suppress(Exception): + await page.keyboard.press('Escape') + await page.wait_for_timeout(150) + + logger.info(f'Diary event times collected for {len(times_by_id)} events') + return times_by_id + + async def fetch_diary_data(phpsessid: str) -> dict | None: logger.info('Fetching diary data via Playwright...') try: @@ -570,6 +655,16 @@ async def fetch_diary_data(phpsessid: str) -> dict | None: f.write(table_html) month_text, days = _parse_calendar_html(table_html) + + # Read event times by opening each event's AJAX popup. + times_by_id = await _collect_event_times(page) + if times_by_id: + for day_data in days.values(): + for ev in day_data.get('events', []): + eid = ev.get('id') + if eid and eid in times_by_id: + ev['time'] = times_by_id[eid] + logger.info( f'Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d["events"])}/{len(days)}' ) @@ -596,6 +691,25 @@ def _parse_diary_month(text: str) -> str: return text.strip() +def _format_event_line(event, lang: str) -> str: + """Render one diary event line: 📌 title, with time in parens when known. + + '08:00' is the site's placeholder for "no time specified" — show a localized + 'time_unknown' mark instead. Unknown/absent time renders as before, no parens. + """ + if isinstance(event, dict): + title = escape(str(event.get('title') or '')) + tme = event.get('time') + else: + title = escape(str(event)) + tme = None + if tme == '08:00': + return f'📌 {title} ({_tr(lang, "time_unknown")})' + if tme: + return f'📌 {title} ({escape(str(tme))})' + return f'📌 {title}' + + def format_diary_day(data: dict, day_num: int, lang: str = 'en') -> str: days = data.get('days', {}) month_str = _parse_diary_month(data.get('monthFullText', '')) @@ -605,7 +719,7 @@ def format_diary_day(data: dict, day_num: int, lang: str = 'en') -> str: lines.append(_tr(lang, 'no_events')) else: for e in day_data['events']: - lines.append(f'📌 {escape(e)}') + lines.append(_format_event_line(e, lang)) lines.append(f'\n🔗 {DIARY_URL}') return '\n'.join(lines) @@ -614,12 +728,12 @@ def format_diary_week(data: dict, today: datetime, lang: str = 'en') -> str: days = data.get('days', {}) _parse_diary_month(data.get('monthFullText', '')) monday = today - timedelta(days=today.weekday()) - sunday = monday + timedelta(days=6) + friday = monday + timedelta(days=4) start = f'{monday.day}.{monday.month}' - end = f'{sunday.day}.{sunday.month}' + end = f'{friday.day}.{friday.month}' short = DIARY_WEEKDAYS_SHORT.get(lang, DIARY_WEEKDAYS_SHORT['en']) lines = [_tr(lang, 'diary_week_title', start=start, end=end) + '\n'] - for i in range(7): + for i in range(5): d = monday + timedelta(days=i) day_data = days.get(str(d.day)) lines.append(f'─ {short[i]} {d.day}.{d.month} ─') @@ -627,7 +741,7 @@ def format_diary_week(data: dict, today: datetime, lang: str = 'en') -> str: lines.append(_tr(lang, 'no_events') + '\n') else: for e in day_data['events']: - lines.append(f'📌 {escape(e)}') + lines.append(_format_event_line(e, lang)) lines.append('') lines.append(f'🔗 {DIARY_URL}') return '\n'.join(lines) @@ -639,6 +753,17 @@ def format_diary_month(data: dict, lang: str = 'en') -> str: lines = [f'📅 {month_str}\n'] for day_num in sorted(days.keys(), key=int): day_data = days[day_num] + if day_data.get('weekday_idx', 0) >= 5: + continue + if day_data.get('weekday', '').strip().lower() in ( + 'субота', + 'суббота', + 'saturday', + 'неділя', + 'воскресенье', + 'sunday', + ): + continue events = day_data.get('events', []) weekday = day_data.get('weekday', '') lines.append(f'─ {weekday} {day_num} ─') @@ -646,7 +771,7 @@ def format_diary_month(data: dict, lang: str = 'en') -> str: lines.append(_tr(lang, 'no_events') + '\n') else: for e in events: - lines.append(f'📌 {escape(e)}') + lines.append(_format_event_line(e, lang)) lines.append('') lines.append(f'🔗 {DIARY_URL}') return '\n'.join(lines) @@ -676,7 +801,7 @@ def get_schedule_day_keyboard(user_id: int | None = None): tomorrow = today + timedelta(days=1) today_label = _tr(lang, 'today') tomorrow_label = _tr(lang, 'tomorrow') - short_keys = ['day_mon', 'day_tue', 'day_wed', 'day_thu', 'day_fri', 'day_sat', 'day_sun'] + short_keys = ['day_mon', 'day_tue', 'day_wed', 'day_thu', 'day_fri'] keyboard = [ [ InlineKeyboardButton( @@ -706,14 +831,6 @@ def get_schedule_day_keyboard(user_id: int | None = None): InlineKeyboardButton( _tr(lang, short_keys[4]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[4]}' ), - InlineKeyboardButton( - _tr(lang, short_keys[5]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[5]}' - ), - ], - [ - InlineKeyboardButton( - _tr(lang, short_keys[6]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[6]}' - ), ], ] return InlineKeyboardMarkup(keyboard)