324 lines
14 KiB
Python
324 lines
14 KiB
Python
#!/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()
|