This commit is contained in:
2026-06-17 11:53:54 +02:00
parent 9edfddae95
commit dc07e19179
20 changed files with 2294 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.env
*.log
__pycache__/
_*.html
+90
View File
@@ -0,0 +1,90 @@
# Webináře — hlídač nových webinářů (praktickylekar.online)
## Účel
Jednou denně (8:00, Plánovač úloh) zkontroluje [praktickylekar.online](https://www.praktickylekar.online/),
zda přibyl nový webinář. Když ano → přes **Telegram** se zeptá, jestli má přihlásit
osoby z `config.json` (Michaela + Vladimír Buzalkovi), po potvrzení je přihlásí a
výsledek pošle zpět na Telegram. Po přihlášení chodí potvrzovací e-mail automaticky z webu.
## Soubory
| Soubor | Popis |
|--------|-------|
| `watcher.py` | hlavní skript |
| `config.json` | URL + údaje přihlašovaných osob |
| `state.json` | vytvoří se sám; pamatuje poslední zpracované `idwebinar` |
| `watcher.log` | log běhů |
## Přepínače v `watcher.py` (nahoře)
- `POSILATINFOPOKAZDEKONTROLE``True` = pošle Telegram zprávu po **každé** ranní
kontrole (i když nic nového; vhodné při zaběhávání). `True` je teď nastaveno.
Až bude vše ověřené → přepnout na `False` (ozve se jen při novém webináři).
- `DRY_RUN``True` = nic se reálně neodešle (registrace se jen simuluje), Telegram
dotaz proběhne. `False` = ostrý režim (reálné přihlášení po potvrzení „ano").
- `ASK_TIMEOUT` — kolik sekund ráno čekat na odpověď ano/ne (default 1800 = 30 min).
## CLI
```
python watcher.py # ostrý denní běh
python watcher.py --test # ignoruje state + VŽDY dry-run (otestuje plumbing)
python watcher.py --reset # smaže state.json
```
## Ověřená struktura webu (k 2026-06-17)
1. **Banner** na hlavní stránce: `<a href="/webinar.php?idwebinar=560">` → z něj se čte ID.
2. **Brána** `POST /check2.php` s `zdravotnicky-pracovnik=on` & `laicka-verejnost=on`
→ nastaví cookie `souhlas=1`. **Bez ní se registrační formulář vůbec nezobrazí.**
3. **Registrace** `POST /registrovat4.php`, pole:
- `email` (povinné)
- `clen` = `1` (člen SVL Ano) / `2` (Ne) → Buzalkovi `1`
- `prukaz` = číslo průkazu SVL (povinné když clen=1)
- `clk` = evidenční číslo ČLK, **přesně 10 znaků** (`pattern=.{10,10}`)
- `titul1, jmeno, prijmeni, pracoviste, mesto` — jen pro nečleny (clen=2)
- `souhlas` = `on` (souhlas se zpracováním OÚ, povinné)
- **skrytá** `webid` (= idwebinar) a `cislo` (= `PL` + DDMMRRRR, dle data webináře)
**čtou se živě z formuláře, nehádají se.**
> Pokud provozovatel změní názvy polí / strukturu, skript loguje, co našel
> (`watcher.log`) — podle toho se selektory upraví.
## Nasazení na tower (PRODUKCE) — Unraid, python-runner
Běží na **toweru** (Unraid, 192.168.1.76) v kontejneru **`python-runner`**,
plánováno přes **User Scripts plugin** na **8:00 denně**.
- Soubory: `/mnt/user/Scripts/Webinare/` → v kontejneru `/scripts/Webinare/`
- Telegram: na serveru **není** `Knihovny/` ani `Medevio/.env`, proto je přibalená
kopie `telegram_notify.py` + lokální `/scripts/Webinare/.env`
(jen `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID`, práva 600).
- Wrapper: `/boot/config/plugins/user.scripts/scripts/WebinarWatcher/script`
(`flock` + `docker exec`, log `/mnt/user/Scripts/logs/webinar_watcher.log`).
- Rozvrh: záznam v `schedule.json` (`custom: 0 8 * * *`) + řádek v
`customSchedule.cron``update_cron``/etc/cron.d/root`.
- `state.json` na serveru seedován na `560` (na ten jste registrovaní).
### Nasazení / správa z Windows — `deploy_tower.py`
Heslo NIKDY v souboru, bere se z env `TOWER_PW`:
```bash
TOWER_PW=... python deploy_tower.py recon # zmapuje server (jen čte)
TOWER_PW=... python deploy_tower.py deploy # nahraje soubory (+ seed state.json)
TOWER_PW=... python deploy_tower.py env # naplní serverový .env z Medevio/.env
TOWER_PW=... python deploy_tower.py smoke # test: telegram .env + detekce (neodesílá)
TOWER_PW=... python deploy_tower.py schedule # založí/aktualizuje rozvrh 8:00
TOWER_PW=... python deploy_tower.py prodrun # ruční spuštění ostrého běhu
```
Po změně `watcher.py`/`config.json` lokálně → `deploy` znovu (idempotentní,
`state.json` ani `.env` nepřepisuje).
### Heartbeat → tichý režim
Server běží s `POSILATINFOPOKAZDEKONTROLE=True` (ranní „zkontrolováno"). Až bude
ověřeno, v lokálním `watcher.py` přepnout na `False` a `deploy` znovu.
## Alternativa — Plánovač úloh (Windows), pokud poběží lokálně
```powershell
schtasks /Create /TN "WebinarWatcher" /SC DAILY /ST 08:00 ^
/TR "python \"U:\ordinaceprojekt\Webináře\watcher.py\"" /F
```
## Notifikace
Přes sdílenou knihovnu `Knihovny/telegram_notify.py`
(`posli_telegram`, `zeptej_se_telegram`), bot **@Vlado_Claude_Bot**,
token/chat_id z `Medevio/.env`.
+28
View File
@@ -0,0 +1,28 @@
{
"watch_url": "https://www.praktickylekar.online/",
"base_url": "https://www.praktickylekar.online",
"registrants": [
{
"jmeno": "Michaela",
"prijmeni": "Buzalková",
"titul1": "",
"email": "michaela.buzalkova@buzalka.cz",
"clen": "1",
"prukaz": "761790",
"clk": "5141811171",
"pracoviste": "",
"mesto": ""
},
{
"jmeno": "Vladimír",
"prijmeni": "Buzalka",
"titul1": "",
"email": "vladimir.buzalka@buzalka.cz",
"clen": "1",
"prukaz": "761791",
"clk": "1143687173",
"pracoviste": "",
"mesto": ""
}
]
}
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
deploy_tower.py — nasazení webinar-watcheru na tower (Unraid, python-runner).
Heslo se NIKDY neukládá do souboru — bere se z proměnné prostředí TOWER_PW:
TOWER_PW=... python deploy_tower.py recon
TOWER_PW=... python deploy_tower.py deploy
TOWER_PW=... python deploy_tower.py schedule
TOWER_PW=... python deploy_tower.py smoke # rychlý test (neblokuje na Telegramu)
Vzor převzat z EmailAgent / MedicusFirebird:
- skripty v /mnt/user/Scripts/<Název>/ → v kontejneru /scripts/<Název>/
- spouští se: docker exec python-runner python3 /scripts/Webinare/watcher.py
- plánování přes Unraid User Scripts plugin (wrapper + schedule.json cron)
"""
import os
import sys
import json
import posixpath
import paramiko
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HOST = "192.168.1.76"
USER = "root"
CONTAINER = "python-runner"
LOCAL_DIR = os.path.dirname(os.path.abspath(__file__))
HOST_DIR = "/mnt/user/Scripts/Webinare" # na hostiteli (Unraid)
CONT_DIR = "/scripts/Webinare" # uvnitř kontejneru
PLUGIN_DIR = "/boot/config/plugins/user.scripts"
USERSCRIPTS = PLUGIN_DIR + "/scripts"
SCHEDULE_JSON = PLUGIN_DIR + "/schedule.json"
CUSTOM_CRON = PLUGIN_DIR + "/customSchedule.cron"
US_NAME = "WebinarWatcher"
CRON_EXPR = "0 8 * * *"
# soubory, které kopírujeme na server (telegram_notify.py = přibalená kopie,
# protože /scripts/Knihovny na serveru není)
FILES = ["watcher.py", "telegram_notify.py", "config.json", "requirements.txt", "NOTES.md"]
def connect():
pw = os.environ.get("TOWER_PW")
if not pw:
sys.exit("Chybí TOWER_PW v prostředí.")
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=pw, timeout=20, allow_agent=False, look_for_keys=False)
return c
def run(c, cmd, timeout=180):
_in, out, err = c.exec_command(cmd, timeout=timeout)
o = out.read().decode("utf-8", "replace")
e = err.read().decode("utf-8", "replace")
rc = out.channel.recv_exit_status()
return rc, o, e
def show(c, cmd, timeout=180):
rc, o, e = run(c, cmd, timeout)
print(f"$ {cmd}")
body = (o + (("\n[stderr] " + e) if e.strip() else "")).rstrip()
print(body if body else "(prázdné)")
print(f" rc={rc}\n")
return rc, o, e
# ── RECON ────────────────────────────────────────────────────────────────────
def recon(c):
show(c, "hostname; uname -r")
show(c, "docker ps --format '{{.Names}}' | sort")
show(c, "ls -la /mnt/user/Scripts/ | head -50")
show(c, f"docker exec {CONTAINER} ls /scripts/ | head -50")
show(c, f"docker exec {CONTAINER} ls -la /scripts/Knihovny/telegram_notify.py")
show(c, f"docker exec {CONTAINER} sh -lc 'test -f /scripts/Medevio/.env && grep -oE \"^(TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID)=\" /scripts/Medevio/.env || echo NENI_ENV'")
show(c, f"docker exec {CONTAINER} python3 -c \"import requests,bs4;print('deps_ok requests',requests.__version__,'bs4',bs4.__version__)\"")
show(c, f"ls -la {USERSCRIPTS}/ | head -50")
# vzor existujícího wrapperu + rozvrhu (StahovaniFaktur)
show(c, f"cat {USERSCRIPTS}/StahovaniFaktur/script 2>/dev/null")
show(c, f"cat {USERSCRIPTS}/StahovaniFaktur/schedule.json 2>/dev/null")
show(c, "grep -n 'Scripts' /etc/cron.d/root 2>/dev/null | head")
# ── DEPLOY (kopie souborů) ───────────────────────────────────────────────────
def deploy(c):
run(c, f"mkdir -p {HOST_DIR} /mnt/user/Scripts/logs")
sftp = c.open_sftp()
for f in FILES:
lp = os.path.join(LOCAL_DIR, f)
if not os.path.exists(lp):
print(f" přeskakuji (není lokálně): {f}")
continue
rp = posixpath.join(HOST_DIR, f)
sftp.put(lp, rp)
print(f"{f}{rp}")
# seed state.json jen když na serveru ještě není (ať se nepřemazává běhový stav)
rp_state = posixpath.join(HOST_DIR, "state.json")
rc, _o, _e = run(c, f"test -f {rp_state}")
if rc != 0:
lp_state = os.path.join(LOCAL_DIR, "state.json")
if os.path.exists(lp_state):
sftp.put(lp_state, rp_state)
print(f" ↑ state.json (seed) → {rp_state}")
else:
with sftp.open(rp_state, "w") as fh:
fh.write('{"last_id": null}\n')
print(" ↑ state.json (prázdný)")
else:
print(" state.json na serveru už existuje — neměním.")
sftp.close()
show(c, f"ls -la {HOST_DIR}/")
# ── ENV (naplní /scripts/Webinare/.env Telegram klíči z lokálního Medevio/.env) ─
def env(c):
src = os.path.join(os.path.dirname(LOCAL_DIR), "Medevio", ".env")
if not os.path.exists(src):
sys.exit("Lokální Medevio/.env nenalezen.")
chteji = ("TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID")
radky = []
with open(src, encoding="utf-8") as fh:
for line in fh:
s = line.strip()
if "=" in s and not s.startswith("#"):
k = s.split("=", 1)[0].strip()
if k in chteji:
radky.append(s)
keys = [r.split("=", 1)[0] for r in radky]
if not all(k in keys for k in chteji):
sys.exit(f"V Medevio/.env chybí některý z klíčů: {chteji}")
run(c, f"mkdir -p {HOST_DIR}")
sftp = c.open_sftp()
with sftp.open(posixpath.join(HOST_DIR, ".env"), "w") as fh:
fh.write("\n".join(radky) + "\n")
sftp.chmod(posixpath.join(HOST_DIR, ".env"), 0o600)
sftp.close()
print(f" .env zapsán na server ({', '.join(keys)}) — hodnoty se nevypisují.")
show(c, f"docker exec {CONTAINER} sh -lc 'grep -oE \"^(TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID)=\" {CONT_DIR}/.env'")
# ── CRON RECON (zjistí, jak User Scripts ukládá rozvrh) ──────────────────────
def cron(c):
show(c, "ls -la /boot/config/plugins/user.scripts/scripts/StahovaniFaktur/")
show(c, "ls -la /boot/config/plugins/user.scripts/scripts/MedicusFirebirdRestore/")
show(c, "cat /boot/config/plugins/user.scripts/scripts/MedicusFirebirdRestore/schedule.json 2>/dev/null || echo bez_schedule_json")
show(c, "ls -la /etc/cron.d/")
show(c, "cat /etc/cron.d/root 2>/dev/null")
show(c, "crontab -l 2>/dev/null | tail -40")
# ── CRONSTORE RECON (kam plugin persistuje rozvrh přes reboot) ───────────────
def cronstore(c):
show(c, "ls -la /boot/config/plugins/user.scripts/")
show(c, "find /boot/config/plugins/user.scripts/ -maxdepth 1 -type f -exec ls -la {} +")
show(c, "grep -rsl 'StahovaniFaktur' /boot/config/ 2>/dev/null | grep -v '/scripts/StahovaniFaktur/'")
show(c, "grep -rsn '6,18\\|cron\\|schedule' /boot/config/plugins/user.scripts/ --include='*.json' --include='*.cfg' --include='*.dat' --include='*.php' 2>/dev/null | head -40")
# ── CRONFILES (dump přesného formátu schedule.json + customSchedule.cron) ────
def cronfiles(c):
show(c, "sed -n '185,210p' /boot/config/plugins/user.scripts/schedule.json")
show(c, "head -8 /boot/config/plugins/user.scripts/schedule.json")
show(c, "tail -8 /boot/config/plugins/user.scripts/schedule.json")
show(c, "cat /boot/config/plugins/user.scripts/customSchedule.cron")
show(c, "ls -la /usr/local/sbin/update_cron /usr/local/emhttp/plugins/user.scripts/startCustom.php 2>&1")
# ── SMOKE TEST (neblokuje na Telegramu) ──────────────────────────────────────
def smoke(c):
# ověří přibalený telegram modul + načtení .env (jen délky, ne hodnoty)
# + detekci webináře na webu. NEodesílá Telegram ani registraci.
py = (
"import sys; sys.path.insert(0,'/scripts/Webinare');"
"import telegram_notify as t;"
"print('telegram .env OK: token_len',len(t._token()),'chat_id_set',bool(t._resolve_chat_id(None)));"
"import json,requests,re;"
"from bs4 import BeautifulSoup;"
"cfg=json.load(open('/scripts/Webinare/config.json',encoding='utf-8'));"
"s=requests.Session(); s.get(cfg['watch_url'],headers={'User-Agent':'Mozilla/5.0'},timeout=30);"
"r=s.get(cfg['watch_url'],headers={'User-Agent':'Mozilla/5.0'},timeout=30);"
"a=BeautifulSoup(r.text,'html.parser').select('a[href*=\\\"webinar.php?idwebinar=\\\"]')[0];"
"print('detekce OK webinar=',re.search(r'idwebinar=(\\\\d+)',a['href']).group(1))"
)
show(c, f"docker exec {CONTAINER} python3 -c \"{py}\"", timeout=90)
# ── SCHEDULE (User Scripts plugin, denně 8:00) ───────────────────────────────
def schedule(c):
d = f"{USERSCRIPTS}/{US_NAME}"
script_path = f"{d}/script"
cron_line = (f"{CRON_EXPR} /usr/local/emhttp/plugins/user.scripts/startCustom.php "
f"{script_path} > /dev/null 2>&1")
# wrapper (styl převzat z StahovaniFaktur: flock + docker exec + log s datem/rc)
wrapper = (
"#!/bin/bash\n"
"# WebinarWatcher - denne 8:00, hlidac webinaru praktickylekar.online. flock proti prekryvu.\n"
"LOG=/mnt/user/Scripts/logs/webinar_watcher.log\n"
"mkdir -p /mnt/user/Scripts/logs\n"
"exec 9>/tmp/webinar_watcher.lock\n"
"flock -n 9 || exit 0\n"
"OUT=$(docker exec -e PYTHONIOENCODING=utf-8 -e TZ=Europe/Prague " + CONTAINER + " python3 " + CONT_DIR + "/watcher.py 2>&1)\n"
"RC=$?\n"
"{ echo \"===== $(date '+%F %T') (rc=$RC) =====\"; echo \"$OUT\"; } >> \"$LOG\"\n"
)
run(c, f"mkdir -p {d}")
sftp = c.open_sftp()
with sftp.open(script_path, "w") as fh:
fh.write(wrapper)
with sftp.open(f"{d}/name", "w") as fh:
fh.write(US_NAME)
with sftp.open(f"{d}/description", "w") as fh:
fh.write("Hlidac webinaru praktickylekar.online, denne 8:00")
# ── schedule.json: přidej/aktualizuj záznam (se zálohou) ──
run(c, f"cp -a {SCHEDULE_JSON} {SCHEDULE_JSON}.bak_webinar")
with sftp.open(SCHEDULE_JSON, "r") as fh:
data = json.loads(fh.read().decode("utf-8"))
data[script_path] = {
"script": script_path,
"frequency": "custom",
"id": "schedule" + US_NAME,
"custom": CRON_EXPR,
}
with sftp.open(SCHEDULE_JSON, "w") as fh:
fh.write(json.dumps(data, indent=2))
# ── customSchedule.cron: přidej řádek (se zálohou), pokud chybí ──
with sftp.open(CUSTOM_CRON, "r") as fh:
cron_txt = fh.read().decode("utf-8")
if script_path not in cron_txt:
run(c, f"cp -a {CUSTOM_CRON} {CUSTOM_CRON}.bak_webinar")
with sftp.open(CUSTOM_CRON, "w") as fh:
fh.write(cron_txt.rstrip() + "\n\n" + cron_line + "\n")
sftp.close()
run(c, f"chmod +x {script_path}")
# ── regeneruj systémový cron + ověř ──
show(c, "/usr/local/sbin/update_cron")
print("── OVĚŘENÍ ──")
show(c, f"ls -la {d}/")
show(c, f"grep -n '{US_NAME}' {CUSTOM_CRON}")
show(c, f"grep -n '{US_NAME}' /etc/cron.d/root")
show(c, f"grep -n '{US_NAME}' {SCHEDULE_JSON}")
# ── PRODRUN (spustí přesně to, co pustí cron — pro ruční test/trigger) ────────
def prodrun(c):
show(c, f"docker exec -e PYTHONIOENCODING=utf-8 {CONTAINER} python3 {CONT_DIR}/watcher.py",
timeout=200)
MODES = {"recon": recon, "deploy": deploy, "env": env, "cron": cron,
"cronstore": cronstore, "cronfiles": cronfiles, "smoke": smoke,
"schedule": schedule, "prodrun": prodrun}
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "recon"
if mode not in MODES:
sys.exit(f"Neznámý režim '{mode}'. Použij: {', '.join(MODES)}")
c = connect()
try:
print(f"=== {mode.upper()} na {USER}@{HOST} ===\n")
MODES[mode](c)
finally:
c.close()
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
requests
beautifulsoup4
+3
View File
@@ -0,0 +1,3 @@
{
"last_id": "560"
}
+115
View File
@@ -0,0 +1,115 @@
"""
telegram_notify.py — PŘIBALENÁ kopie pro běh na serveru (python-runner)
=======================================================================
Na toweru není balík `Knihovny/` ani `Medevio/.env`, proto má watcher tuto
soběstačnou kopii. Funkce jsou shodné s `Knihovny/telegram_notify.py`.
Token a chat_id se hledají v `.env` na víc místech (první nalezené vyhrává):
1) `.env` ve stejném adresáři jako tento soubor (server: /scripts/Webinare/.env)
2) `../Medevio/.env` (lokální vývoj)
3) `../../Medevio/.env` (kořen projektu)
TELEGRAM_BOT_TOKEN=123456789:AAE...
TELEGRAM_CHAT_ID=6639316354
"""
import os
import sys
import time
from pathlib import Path
import requests
def _load_env():
here = Path(__file__).resolve().parent
kandidati = [
here / ".env",
here.parent / "Medevio" / ".env",
here.parent.parent / "Medevio" / ".env",
]
for env_path in kandidati:
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
_load_env()
API_BASE = "https://api.telegram.org/bot{token}/{method}"
def _token() -> str:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError("Chybí TELEGRAM_BOT_TOKEN (.env)")
return token
def _resolve_chat_id(chat_id):
chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
if not chat_id:
raise RuntimeError("Chybí TELEGRAM_CHAT_ID (zadej argumentem nebo v .env)")
return str(chat_id)
def _call(method, *, http_timeout=15, **params):
url = API_BASE.format(token=_token(), method=method)
r = requests.post(url, json=params, timeout=http_timeout)
data = r.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram {method} selhal [{r.status_code}]: {data}")
return data["result"]
def posli_telegram(text, *, chat_id=None, parse_mode=None, disable_notification=False):
params = {
"chat_id": _resolve_chat_id(chat_id),
"text": text,
"disable_notification": disable_notification,
}
if parse_mode:
params["parse_mode"] = parse_mode
return _call("sendMessage", **params)
def zeptej_se_telegram(otazka, *, chat_id=None, timeout=300, poll_timeout=30, parse_mode=None):
cid = _resolve_chat_id(chat_id)
existujici = _call("getUpdates", http_timeout=15)
offset = (existujici[-1]["update_id"] + 1) if existujici else 0
posli_telegram(otazka, chat_id=cid, parse_mode=parse_mode)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
zbyva = int(deadline - time.monotonic())
if zbyva <= 0:
break
lp = max(1, min(poll_timeout, zbyva))
updates = _call("getUpdates", http_timeout=lp + 10, offset=offset, timeout=lp)
for u in updates:
offset = u["update_id"] + 1
msg = u.get("message") or {}
if str(msg.get("chat", {}).get("id")) != cid:
continue
text = msg.get("text")
if text:
return text
return None
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
args = sys.argv[1:]
if args and args[0] == "--ask":
print(zeptej_se_telegram(" ".join(args[1:]) or "?", timeout=240) or "(bez odpovědi)")
elif args:
posli_telegram(" ".join(args))
print("Odesláno OK")
else:
print('Použití: python telegram_notify.py "text" | --ask "otázka?"')
+323
View File
@@ -0,0 +1,323 @@
#!/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()