#!/usr/bin/env python3 """ watcher.py — Hlídač nových webinářů na praktickylekar.online ============================================================ Co dělá při každém spuštění (cíleno na 1× denně v 8:00 přes Plánovač úloh): 1. Stáhne hlavní stránku a najde banner s nadcházejícím webinářem (odkaz `webinar.php?idwebinar=`). 2. Porovná ID s posledním zpracovaným (uloženo ve `state.json`). 3. Pokud je webinář NOVÝ: a) projde "bránu" (potvrzení zdravotnického odborníka, POST /check2.php) — teprve potom se na stránce webináře objeví registrační formulář, b) z formuláře ŽIVĚ přečte skrytá pole `webid` a `cislo` (cislo = PL + DDMMRRRR, mění se podle data — NIKDY se nehádá), c) přes Telegram se ZEPTÁ, jestli má osoby z config.json přihlásit, d) po potvrzení ("ano") odešle registraci za každou osobu, e) výsledek potvrdí přes Telegram. 4. Pokud nový webinář NENÍ a POSILATINFOPOKAZDEKONTROLE=True, pošle ráno informaci "zkontrolováno, nic nového". Po přihlášení chodí potvrzovací e-mail automaticky z webu — e-mail tedy neřešíme, notifikace jdou jen přes Telegram. CLI: python watcher.py # ostrý denní běh python watcher.py --test # test: ignoruje state, VŽDY dry-run (nic neodešle) python watcher.py --reset # smaže state.json (zapomene poslední webinář) """ import json import logging import os import re import sys from pathlib import Path from urllib.parse import urljoin import requests from bs4 import BeautifulSoup # ── Telegram: lokálně sdílená knihovna z kořene, na serveru přibalená kopie ── ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) try: # lokálně (Windows): kořen projektu má balík Knihovny + Medevio/.env from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram # noqa: E402 except ModuleNotFoundError: # server (python-runner): Knihovny tu není → přibalená kopie + lokální .env from telegram_notify import posli_telegram, zeptej_se_telegram # noqa: E402 # ════════════════════════════════════════════════════════════════════════════ # PŘEPÍNAČE # ════════════════════════════════════════════════════════════════════════════ # True = po KAŽDÉ ranní kontrole pošli na Telegram zprávu "zkontrolováno" # (i když není nic nového) — užitečné při zaběhávání, ať víš, že to jede. # False = ozvi se jen když je NOVÝ webinář. (Nastav, až bude vše ověřené.) POSILATINFOPOKAZDEKONTROLE = True # True = NIC se reálně neodešle (registrace se jen "nasucho" simuluje a vypíše). # Telegram dotaz/potvrzení proběhne normálně. Pro bezpečné otestování. # False = ostrý režim — po potvrzení "ano" na Telegramu se reálně přihlásí. DRY_RUN = False # Jak dlouho (s) čekat ráno na odpověď ano/ne na Telegramu, než to vzdá. ASK_TIMEOUT = 1800 # 30 minut # ════════════════════════════════════════════════════════════════════════════ HERE = Path(__file__).resolve().parent CONFIG_PATH = HERE / "config.json" STATE_PATH = HERE / "state.json" LOG_PATH = HERE / "watcher.log" HEADERS = {"User-Agent": "Mozilla/5.0 (webinar-watcher; osobni pouziti)"} TIMEOUT = 30 logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler(LOG_PATH, encoding="utf-8"), logging.StreamHandler(sys.stdout), ], ) log = logging.getLogger("watcher") # ── pomocné I/O ────────────────────────────────────────────────────────────── def load_json(path: Path, default=None): if not path.exists(): return default return json.loads(path.read_text(encoding="utf-8")) def save_json(path: Path, data): path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") # ── krok 1: najdi nadcházející webinář na hlavní stránce ───────────────────── def find_upcoming_webinar(session, watch_url): """Vrátí (id, text_banneru, absolutni_url) nebo None.""" r = session.get(watch_url, headers=HEADERS, timeout=TIMEOUT) r.raise_for_status() soup = BeautifulSoup(r.text, "html.parser") # Zakomentované bannery jsou HTML komentáře → BeautifulSoup je nebere jako . odkazy = soup.select('a[href*="webinar.php?idwebinar="]') if not odkazy: return None if len(odkazy) > 1: log.warning("Na stránce je víc odkazů na webinář (%d), beru první.", len(odkazy)) a = odkazy[0] href = a.get("href", "") m = re.search(r"idwebinar=(\d+)", href) if not m: return None wid = m.group(1) text = " ".join(a.get_text().split()) return wid, text, urljoin(watch_url, href) # ── krok 2: projdi bránu (potvrzení zdravotnického odborníka) ──────────────── def projdi_branu(session, base_url, reg_url): """ POST /check2.php se dvěma checkboxy → nastaví cookie souhlas=1, díky které se na stránce webináře objeví registrační formulář. Vrací True/False. """ data = {"zdravotnicky-pracovnik": "on", "laicka-verejnost": "on"} r = session.post( urljoin(base_url, "/check2.php"), data=data, headers={**HEADERS, "Referer": reg_url}, timeout=TIMEOUT, ) r.raise_for_status() ok = session.cookies.get("souhlas") == "1" log.info("Brána check2.php: %s (cookies=%s)", "OK" if ok else "?", session.cookies.get_dict()) return ok # ── krok 3: přečti registrační formulář a jeho skrytá pole ─────────────────── def parse_registration_form(session, reg_url): """ Načte stránku webináře (už po projití brány) a vrátí (action_url, hidden_fields_dict). Skrytá pole (webid, cislo) se ČTOU, nehádají. Hledá konkrétně formulář mířící na 'registrovat'. """ r = session.get(reg_url, headers={**HEADERS, "Referer": reg_url}, timeout=TIMEOUT) r.raise_for_status() soup = BeautifulSoup(r.text, "html.parser") form = None for f in soup.find_all("form"): if "registrovat" in (f.get("action") or "").lower(): form = f break if form is None: raise RuntimeError( "Registrační formulář nenalezen (brána neprošla, nebo se změnila struktura webu)." ) action = urljoin(reg_url, form.get("action", "")) hidden = {} for inp in form.find_all("input", attrs={"type": "hidden"}): name = inp.get("name") if name: hidden[name] = inp.get("value", "") return action, hidden # ── krok 4: sestav a odešli registraci ─────────────────────────────────────── def build_payload(person, hidden): payload = { "email": person["email"], "clen": person.get("clen", "1"), "prukaz": person.get("prukaz", ""), "clk": person.get("clk", ""), "titul1": person.get("titul1", ""), "jmeno": person.get("jmeno", ""), "prijmeni": person.get("prijmeni", ""), "pracoviste": person.get("pracoviste", ""), "mesto": person.get("mesto", ""), "souhlas": "on", # souhlas se zpracováním osobních údajů (nutné pro odeslání) } payload.update(hidden) # webid, cislo, … (živě z formuláře) return payload def register_person(session, action_url, reg_url, person, hidden): """Vrátí (ok: bool, info: str).""" payload = build_payload(person, hidden) cele_jmeno = f"{person['jmeno']} {person['prijmeni']}" if DRY_RUN: log.info("DRY_RUN – NEodesílám. Payload pro %s: %s", cele_jmeno, payload) return True, "DRY-RUN (nic neodesláno)" r = session.post( action_url, data=payload, headers={**HEADERS, "Referer": reg_url}, timeout=TIMEOUT, ) r.raise_for_status() txt_low = r.text.lower() ok = any(k in txt_low for k in ("úspěš", "uspes", "zaregistr", "děkuj", "dekuj")) # snippet pro případnou ruční kontrolu snippet = " ".join(BeautifulSoup(r.text, "html.parser").get_text().split())[:200] return ok, f"HTTP {r.status_code} | {snippet}" # ── Telegram dotaz ano/ne ──────────────────────────────────────────────────── def je_souhlas(odpoved: str | None) -> bool: if not odpoved: return False return odpoved.strip().lower() in ("ano", "a", "yes", "y", "jo", "ok") # ── hlavní logika ──────────────────────────────────────────────────────────── def main(): args = sys.argv[1:] test_mode = "--test" in args if "--reset" in args: if STATE_PATH.exists(): STATE_PATH.unlink() log.info("state.json smazán.") return cfg = load_json(CONFIG_PATH) if not cfg: log.error("Chybí config.json"); sys.exit(1) dry = DRY_RUN or test_mode # --test vždy jen nasucho globals()["DRY_RUN"] = dry state = load_json(STATE_PATH, default={"last_id": None}) session = requests.Session() session.get(cfg["watch_url"], headers=HEADERS, timeout=TIMEOUT) # init PHPSESSID found = find_upcoming_webinar(session, cfg["watch_url"]) if not found: log.info("Žádný nadcházející webinář na stránce nenalezen.") if POSILATINFOPOKAZDEKONTROLE: posli_telegram("🔎 Webináře: zkontrolováno, žádný nadcházející webinář na stránce.") return wid, banner, reg_url = found banner_clean = banner.replace("\n", " ") log.info("Nadcházející webinář: id=%s | %s | %s", wid, banner_clean, reg_url) je_novy = test_mode or state.get("last_id") != wid if not je_novy: log.info("Beze změny (id=%s už zpracováno).", wid) if POSILATINFOPOKAZDEKONTROLE: posli_telegram( f"✅ Webináře: zkontrolováno v 8:00, nic nového.\n" f"Aktuální (už řešený): {banner_clean}" ) return # ── NOVÝ webinář ───────────────────────────────────────────────────────── log.info("NOVÝ webinář! id=%s", wid) try: if not projdi_branu(session, cfg["base_url"], reg_url): log.warning("Bránu se nepodařilo projít – zkouším formulář i tak.") action_url, hidden = parse_registration_form(session, reg_url) except Exception as e: log.exception("Chyba při čtení formuláře.") posli_telegram(f"⚠️ Webináře: nový webinář {banner_clean}, ale NEPODAŘILO se přečíst formulář:\n{e}") return log.info("Formulář action=%s, skrytá pole=%s", action_url, hidden) jmena = ", ".join(f"{p['jmeno']} {p['prijmeni']}" for p in cfg["registrants"]) # ── Telegram: zeptej se na souhlas s přihlášením ───────────────────────── otazka = ( f"🆕 NOVÝ webinář na praktickylekar.online!\n\n" f"{banner_clean}\n{reg_url}\n" f"(webid={hidden.get('webid','?')}, cislo={hidden.get('cislo','?')})\n\n" f"Mám přihlásit: {jmena}?\n" f"{'[TEST – nic se reálně neodešle] ' if dry else ''}" f"Odpověz ANO / NE." ) odpoved = zeptej_se_telegram(otazka, timeout=ASK_TIMEOUT) if odpoved is None: log.info("Bez odpovědi (timeout) – state NEukládám, zeptám se zítra znovu.") return if not je_souhlas(odpoved): log.info("Odpověď '%s' → NEpřihlašuji.", odpoved) state["last_id"] = wid # rozhodnuto (ne) → příště se neptat znovu save_json(STATE_PATH, state) posli_telegram(f"👌 OK, webinář {banner_clean} nechávám bez přihlášení.") return # ── přihlášení ─────────────────────────────────────────────────────────── vysledky = [] for p in cfg["registrants"]: cele = f"{p['jmeno']} {p['prijmeni']}" try: ok, info = register_person(session, action_url, reg_url, p, hidden) vysledky.append(f"{'✅' if ok else '❓'} {cele}: {'OK' if ok else 'NEJISTÉ – zkontroluj'}") log.info("Registrace %s: %s | %s", cele, ok, info) except Exception as e: vysledky.append(f"❌ {cele}: CHYBA {e}") log.exception("Chyba při registraci %s", p["email"]) # state ukládáme až po pokusu o registraci state["last_id"] = wid save_json(STATE_PATH, state) shrnuti = ( f"{'🧪 TEST (nic neodesláno) – ' if dry else '📨 '}Přihlášení na webinář:\n" f"{banner_clean}\n\n" + "\n".join(vysledky) + ("\n\n(Po reálném přihlášení dorazí potvrzovací e-mail z webu.)" if not dry else "") ) posli_telegram(shrnuti) log.info("Hotovo (last_id=%s).", wid) if __name__ == "__main__": main()