Files
ordinaceprojekt/Webináře/watcher.py
T
2026-06-17 11:53:54 +02:00

324 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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=<ID>`).
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 <a>.
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()