Compare commits
6 Commits
45c32a37c4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e981659621 | |||
| e5315b821e | |||
| 19036b58cc | |||
| 0beaffec45 | |||
| 26e44fc721 | |||
| dc07e19179 |
@@ -50,3 +50,6 @@ Import vždy přes `sys.path` na kořen projektu nebo přímou cestou.
|
|||||||
|--------|---------|-------|
|
|--------|---------|-------|
|
||||||
| `stahni_str8ts.py` | `SběrDatRůzné/DailyStr8ts/` | Stahuje daily Str8ts puzzle jako PDF, odesílá emailem — viz [NOTES.md](SběrDatRůzné/DailyStr8ts/NOTES.md) |
|
| `stahni_str8ts.py` | `SběrDatRůzné/DailyStr8ts/` | Stahuje daily Str8ts puzzle jako PDF, odesílá emailem — viz [NOTES.md](SběrDatRůzné/DailyStr8ts/NOTES.md) |
|
||||||
| `10_StahnoutXML.py`, `11_ParseXML.py` | `Recepty/NačteníPředpisuWithClaude/` | Pipeline pro stahování detailů receptů z eRecept SÚKL — viz [NacistPredpis_DOKUMENTACE.md](Recepty/NačteníPředpisuWithClaude/NacistPredpis_DOKUMENTACE.md) |
|
| `10_StahnoutXML.py`, `11_ParseXML.py` | `Recepty/NačteníPředpisuWithClaude/` | Pipeline pro stahování detailů receptů z eRecept SÚKL — viz [NacistPredpis_DOKUMENTACE.md](Recepty/NačteníPředpisuWithClaude/NacistPredpis_DOKUMENTACE.md) |
|
||||||
|
| `watcher.py` | `Webináře/` | Hlídá nové webináře na praktickylekar.online, přes Telegram potvrdí a přihlásí Buzalkovi — viz [NOTES.md](Webináře/NOTES.md) |
|
||||||
|
| `stahni_video.py` | `Video/` | Stahuje videa (Vimeo, YouTube…) přes yt-dlp; soukromá/nedostupná sám přeskočí — viz [NOTES.md](Video/NOTES.md) |
|
||||||
|
| `euni_stahni.py`, `euni_db.py`, `euni_report.py` | `Euni/` | Stahování kurzů z euni.cz (PDF + videa) s trackingem v MongoDB EUNI (idempotentní) — viz [NOTES.md](Euni/NOTES.md) |
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Přihlašovací údaje k euni.cz — zkopíruj do souboru .env a vyplň.
|
||||||
|
# (.env je v .gitignore, do gitu se nedostane.)
|
||||||
|
EUNI_USERNAME=tvoje_prihlasovaci_jmeno
|
||||||
|
EUNI_PASSWORD=tvoje_heslo
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# stažený obsah a inventura — do gitu nepatří
|
||||||
|
stazeno/
|
||||||
|
euni_kurzy.json
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
# Euni — stahování a tracking kurzů z euni.cz
|
||||||
|
|
||||||
|
Přihlásí se na euni.cz, projde kurzy, vytěží odkazy + metadata a stahuje obsah
|
||||||
|
(PDF/prezentace a videa Vimeo/YouTube). Vše se trackuje v **MongoDB EUNI**, takže
|
||||||
|
stahování je idempotentní — skript ví, co už má, a netahá dvakrát.
|
||||||
|
|
||||||
|
## Soubory
|
||||||
|
|
||||||
|
| Soubor | Popis |
|
||||||
|
|--------|-------|
|
||||||
|
| `euni_stahni.py` | hlavní pipeline: login → scrape → ingest do Mongo → stahování → záloha do SeaweedFS |
|
||||||
|
| `euni_db.py` | připojení a operace nad MongoDB EUNI (kolekce, indexy, upserty) |
|
||||||
|
| `euni_seaweed.py` | nahrávání/stahování souborů do SeaweedFS (filer HTTP API) |
|
||||||
|
| `euni_restore.py` | obnova všech souborů ze SeaweedFS na disk (na jakémkoli PC) |
|
||||||
|
| `euni_report.py` | dashboard: přehled stavu (kolik staženo/čeká/přeskočeno) |
|
||||||
|
| `.env` | `EUNI_USERNAME`, `EUNI_PASSWORD` (v .gitignore) |
|
||||||
|
| `euni_kurzy.json` | poslední inventura (záloha; primární zdroj je Mongo) |
|
||||||
|
| `stazeno/` | stažený obsah, `stazeno/<id>-<slug>/{dokumenty,videa}/` |
|
||||||
|
|
||||||
|
## Závislosti
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python -m pip install -U requests beautifulsoup4 python-dotenv yt-dlp static-ffmpeg pymongo
|
||||||
|
```
|
||||||
|
|
||||||
|
Video stahuje sdílený modul `../Video/stahni_video.py` (yt-dlp + static-ffmpeg,
|
||||||
|
soukromá videa sám přeskočí).
|
||||||
|
|
||||||
|
## MongoDB EUNI
|
||||||
|
|
||||||
|
Server `mongodb://192.168.1.76:27017` (bez hesla), DB `EUNI`. Lze přepsat env
|
||||||
|
proměnnou `EUNI_MONGO_URI`.
|
||||||
|
|
||||||
|
### Kolekce `kurzy` (1 dokument na kurz)
|
||||||
|
`_id` = euni ID kurzu. Pole: `slug, nazev, url, profese[], autor,
|
||||||
|
autor_medailonek_url, datum_publikace, revidovano, akreditace, kredity,
|
||||||
|
pocet_videi, pocet_dokumentu, first_seen, updated_at`.
|
||||||
|
|
||||||
|
### Kolekce `materialy` (1 dokument na soubor)
|
||||||
|
Unikátní index `{kurz_id, klic}`. Pole: `kurz_id, kurz_nazev, druh
|
||||||
|
(video|dokument), platforma (vimeo|youtube), klic (vimeo:ID / youtube:ID /
|
||||||
|
doc:hash), zdroj_url, watch_url, popis, pripona, stav, duvod, soubor,
|
||||||
|
velikost_b, pokusy, posledni_chyba, first_seen, updated_at, stazeno_at`.
|
||||||
|
|
||||||
|
**Stavy:** `ceka` → `stazeno` / `preskoceno` (soukromé video) / `chyba`.
|
||||||
|
|
||||||
|
**SeaweedFS reference** (po nahrání kopie): `seaweed_path` (cesta ve filer =
|
||||||
|
identifikátor pro vyžádání, např. `euni/5618-.../dokumenty/x.pdf`),
|
||||||
|
`seaweed_fids` (fid chunků = čísla souborů v SeaweedFS), `seaweed_md5`,
|
||||||
|
`seaweed_size`, `seaweed_at`.
|
||||||
|
|
||||||
|
## SeaweedFS záloha + obnova
|
||||||
|
|
||||||
|
Každý stažený soubor se nahraje do **SeaweedFS** (filer na Unraidu,
|
||||||
|
default `http://192.168.1.50:8888`, přepíše env `EUNI_FILER`). Do Mongo se k
|
||||||
|
materiálu uloží `seaweed_path` + `seaweed_fids`, takže soubor lze kdykoli vyžádat.
|
||||||
|
|
||||||
|
- Strukturu na disku zrcadlí cesta: `euni/<id>-<slug>/<typ>/<soubor>`.
|
||||||
|
- Filer metadata (mapa cesta→chunky) jsou v Mongo DB `seaweedfs` na 192.168.1.76;
|
||||||
|
bloby na poli Unraidu. (Setup: `U:\\PythonProject\\Janssen\\SeaweedFS\\`.)
|
||||||
|
- Pozn.: přímý přístup přes raw fid/volume zvenčí nefunguje (volume se uvnitř
|
||||||
|
Dockeru jmenuje `seaweed-volume`); proto se čte/zapisuje přes filer.
|
||||||
|
|
||||||
|
**Obnova kdekoliv** (stačí síť na Mongo + filer):
|
||||||
|
```bat
|
||||||
|
python euni_restore.py # vše → ./obnoveno
|
||||||
|
python euni_restore.py --out D:\Euni # jiný cíl
|
||||||
|
python euni_restore.py --kurz 5618 # jen jeden kurz
|
||||||
|
python euni_restore.py --dry-run # jen výpis
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backfill** (dohrát do SeaweedFS soubory stažené dřív):
|
||||||
|
```bat
|
||||||
|
python euni_stahni.py --seaweed-backfill --from-json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Idempotence
|
||||||
|
- Scrape dělá *upsert*: nový materiál → `ceka`; existující si **drží stav**
|
||||||
|
(nepřepíše stažené). Lze tedy bez obav scrapovat opakovaně.
|
||||||
|
- Stahování bere jen `stav: ceka` (a volitelně `chyba` pro retry).
|
||||||
|
|
||||||
|
## Použití
|
||||||
|
|
||||||
|
Nejjednodušší: **`python euni_menu.py`** — interaktivní menu s volbami 1–9
|
||||||
|
(test / dokumenty / vše / 720p / dashboard / obnova / backfill / re-scrape).
|
||||||
|
Po doběhnutí akce se vrátí do menu, `Ctrl+C` přeruší jen aktuální akci.
|
||||||
|
|
||||||
|
Ručně přes CLI:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python euni_stahni.py --scrape-only # jen inventura → Mongo + JSON
|
||||||
|
python euni_stahni.py --no-videos # scrape + stáhne jen dokumenty
|
||||||
|
python euni_stahni.py # scrape + dokumenty + videa
|
||||||
|
python euni_stahni.py --from-json --no-videos # přeskočí scrape, stáhne z Mongo/JSON
|
||||||
|
python euni_stahni.py --professions all # všechny profese (2,4,5,6,7)
|
||||||
|
python euni_stahni.py --limit 3 # jen prvních 3 kurzy (test)
|
||||||
|
python euni_stahni.py --no-mongo # bez zápisu do Mongo
|
||||||
|
python euni_stahni.py --frags 20 # víc paralelních HLS fragmentů (rychlejší)
|
||||||
|
python euni_stahni.py --video-format "bestvideo[height<=720]+bestaudio/best" # 720p
|
||||||
|
python euni_report.py # přehled stavu
|
||||||
|
python euni_report.py --soukroma # seznam přeskočených videí
|
||||||
|
```
|
||||||
|
|
||||||
|
## Jak to funguje (ověřeno)
|
||||||
|
|
||||||
|
- **Login** `/sign/` — formulář se parsuje (kopírují se skrytá Nette pole `_do`).
|
||||||
|
- **Seznam kurzů** — signál `studyAreaList-nextPage` vrací JSON snippet, stránkuje
|
||||||
|
se dokud přibývají kurzy (profese: 2=Lékař, 4=Farmaceut, 5/6=studenti, 7=NLZP).
|
||||||
|
- **Detail kurzu** — server-rendered HTML; videa z `<iframe>` (u Vimea se zachová
|
||||||
|
`?h=` hash), dokumenty z přímých odkazů i `/redirect/<base64>`.
|
||||||
|
- Metadata z bloků `lecture-info-label` → `lecture-info-mark`.
|
||||||
|
|
||||||
|
## Úskalí
|
||||||
|
|
||||||
|
- **Vimeo** dává oddělené video/audio HLS → nutný ffmpeg (řeší static-ffmpeg).
|
||||||
|
Domain-restricted videa se stahují s referer `https://www.euni.cz/`.
|
||||||
|
- **Soukromá videa** (autor je zamkl) nejdou stáhnout — skript je označí
|
||||||
|
`preskoceno` s důvodem, nepadá.
|
||||||
|
- Anotace kurzu na stránce není (jen obecný text webu) → neukládá se.
|
||||||
|
- Diakritika v názvech: v konzoli cp1250 OK; výpis má pojistku proti pádu.
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
euni_db.py — připojení a operace nad MongoDB databází EUNI.
|
||||||
|
|
||||||
|
Server: mongodb://192.168.1.76:27017 (bez hesla), databáze "EUNI".
|
||||||
|
|
||||||
|
Kolekce:
|
||||||
|
kurzy — 1 dokument na kurz (metadata + počty)
|
||||||
|
materialy — 1 dokument na stahovatelný soubor (video/dokument) + stav stahování
|
||||||
|
|
||||||
|
Idempotence: materialy mají unikátní index {kurz_id, klic}. Upsert nový soubor
|
||||||
|
založí jako "ceka"; u existujícího NEPŘEPÍŠE stav stahování (jen popisná pole).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pymongo
|
||||||
|
|
||||||
|
MONGO_URI = os.environ.get("EUNI_MONGO_URI", "mongodb://192.168.1.76:27017")
|
||||||
|
DB_NAME = "EUNI"
|
||||||
|
|
||||||
|
# stavy materiálu
|
||||||
|
CEKA = "ceka"
|
||||||
|
STAZENO = "stazeno"
|
||||||
|
PRESKOCENO = "preskoceno"
|
||||||
|
CHYBA = "chyba"
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=4000)
|
||||||
|
client.admin.command("ping")
|
||||||
|
return client[DB_NAME]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_indexes(db=None):
|
||||||
|
if db is None:
|
||||||
|
db = get_db()
|
||||||
|
db.materialy.create_index([("kurz_id", 1), ("klic", 1)], unique=True,
|
||||||
|
name="uniq_kurz_klic")
|
||||||
|
db.materialy.create_index("stav", name="stav")
|
||||||
|
db.materialy.create_index([("druh", 1), ("stav", 1)], name="druh_stav")
|
||||||
|
db.kurzy.create_index("profese", name="profese")
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- kurzy ------
|
||||||
|
def upsert_kurz(db, kurz: dict):
|
||||||
|
"""Vloží/aktualizuje kurz. Zachová first_seen, profese sjednotí."""
|
||||||
|
_id = kurz["id"]
|
||||||
|
sets = {
|
||||||
|
"slug": kurz.get("slug"),
|
||||||
|
"nazev": kurz.get("nazev") or kurz.get("title"),
|
||||||
|
"url": kurz.get("url"),
|
||||||
|
"autor": kurz.get("autor"),
|
||||||
|
"autor_medailonek_url": kurz.get("autor_medailonek_url"),
|
||||||
|
"datum_publikace": kurz.get("datum_publikace"),
|
||||||
|
"revidovano": kurz.get("revidovano"),
|
||||||
|
"akreditace": kurz.get("akreditace"),
|
||||||
|
"kredity": kurz.get("kredity"),
|
||||||
|
"pocet_videi": kurz.get("pocet_videi"),
|
||||||
|
"pocet_dokumentu": kurz.get("pocet_dokumentu"),
|
||||||
|
"updated_at": now(),
|
||||||
|
}
|
||||||
|
profese = kurz.get("profese") or []
|
||||||
|
db.kurzy.update_one(
|
||||||
|
{"_id": _id},
|
||||||
|
{
|
||||||
|
"$set": sets,
|
||||||
|
"$setOnInsert": {"first_seen": now()},
|
||||||
|
"$addToSet": {"profese": {"$each": profese}} if profese else {},
|
||||||
|
} if profese else {
|
||||||
|
"$set": sets,
|
||||||
|
"$setOnInsert": {"first_seen": now()},
|
||||||
|
},
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- materialy -----
|
||||||
|
def upsert_material(db, mat: dict):
|
||||||
|
"""Idempotentní upsert souboru. Nepřepíše stav existujícího záznamu."""
|
||||||
|
klic_filter = {"kurz_id": mat["kurz_id"], "klic": mat["klic"]}
|
||||||
|
popisne = {
|
||||||
|
"kurz_nazev": mat.get("kurz_nazev"),
|
||||||
|
"druh": mat.get("druh"),
|
||||||
|
"platforma": mat.get("platforma"),
|
||||||
|
"zdroj_url": mat.get("zdroj_url"),
|
||||||
|
"watch_url": mat.get("watch_url"),
|
||||||
|
"popis": mat.get("popis"),
|
||||||
|
"pripona": mat.get("pripona"),
|
||||||
|
"updated_at": now(),
|
||||||
|
}
|
||||||
|
db.materialy.update_one(
|
||||||
|
klic_filter,
|
||||||
|
{
|
||||||
|
"$set": popisne,
|
||||||
|
"$setOnInsert": {
|
||||||
|
"stav": CEKA,
|
||||||
|
"duvod": None,
|
||||||
|
"soubor": None,
|
||||||
|
"velikost_b": None,
|
||||||
|
"pokusy": 0,
|
||||||
|
"posledni_chyba": None,
|
||||||
|
"stazeno_at": None,
|
||||||
|
"first_seen": now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(db, kurz_id, klic, stav, soubor=None, velikost_b=None,
|
||||||
|
duvod=None, chyba=None):
|
||||||
|
"""Nastaví výsledek stahování jednoho materiálu."""
|
||||||
|
sets = {"stav": stav, "updated_at": now()}
|
||||||
|
if stav == STAZENO:
|
||||||
|
sets.update({"soubor": soubor, "velikost_b": velikost_b,
|
||||||
|
"duvod": None, "posledni_chyba": None, "stazeno_at": now()})
|
||||||
|
elif stav == PRESKOCENO:
|
||||||
|
sets.update({"duvod": duvod})
|
||||||
|
elif stav == CHYBA:
|
||||||
|
sets.update({"posledni_chyba": chyba})
|
||||||
|
upd = {"$set": sets}
|
||||||
|
if stav in (STAZENO, CHYBA):
|
||||||
|
upd["$inc"] = {"pokusy": 1}
|
||||||
|
db.materialy.update_one({"kurz_id": kurz_id, "klic": klic}, upd)
|
||||||
|
|
||||||
|
|
||||||
|
def set_seaweed(db, kurz_id, klic, path, fids=None, md5=None, size=None):
|
||||||
|
"""Uloží referenci na kopii v SeaweedFS (cesta + fid chunků)."""
|
||||||
|
db.materialy.update_one(
|
||||||
|
{"kurz_id": kurz_id, "klic": klic},
|
||||||
|
{"$set": {
|
||||||
|
"seaweed_path": path,
|
||||||
|
"seaweed_fids": fids or [],
|
||||||
|
"seaweed_md5": md5,
|
||||||
|
"seaweed_size": size,
|
||||||
|
"seaweed_at": now(),
|
||||||
|
"updated_at": now(),
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def materialy_bez_seaweed(db):
|
||||||
|
"""Stažené materiály, které ještě nemají kopii v SeaweedFS (pro backfill)."""
|
||||||
|
return list(db.materialy.find({
|
||||||
|
"stav": STAZENO,
|
||||||
|
"soubor": {"$ne": None},
|
||||||
|
"$or": [{"seaweed_path": {"$exists": False}}, {"seaweed_path": None}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def materialy_v_seaweed(db):
|
||||||
|
"""Materiály s kopií v SeaweedFS (pro restore)."""
|
||||||
|
return list(db.materialy.find({"seaweed_path": {"$exists": True, "$ne": None}}))
|
||||||
|
|
||||||
|
|
||||||
|
def cekajici_materialy(db, druh=None, vcetne_chyb=False):
|
||||||
|
"""Vrátí materiály ke stažení (stav 'ceka', volitelně i 'chyba')."""
|
||||||
|
stavy = [CEKA] + ([CHYBA] if vcetne_chyb else [])
|
||||||
|
q = {"stav": {"$in": stavy}}
|
||||||
|
if druh:
|
||||||
|
q["druh"] = druh
|
||||||
|
return list(db.materialy.find(q))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- stats ------
|
||||||
|
def stats(db=None):
|
||||||
|
if db is None:
|
||||||
|
db = get_db()
|
||||||
|
out = {"kurzy": db.kurzy.count_documents({})}
|
||||||
|
pipe = [{"$group": {"_id": {"druh": "$druh", "stav": "$stav"},
|
||||||
|
"n": {"$sum": 1}}}]
|
||||||
|
for row in db.materialy.aggregate(pipe):
|
||||||
|
d = row["_id"]["druh"]
|
||||||
|
st = row["_id"]["stav"]
|
||||||
|
out.setdefault(d, {})[st] = row["n"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
db = ensure_indexes()
|
||||||
|
print("Připojeno k EUNI na", MONGO_URI)
|
||||||
|
print(json.dumps(stats(db), ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
euni_menu.py — interaktivní menu pro stahování kurzů z euni.cz.
|
||||||
|
|
||||||
|
Spuštění:
|
||||||
|
python euni_menu.py
|
||||||
|
|
||||||
|
Jen vyber číslo a Enter. Každá volba spustí příslušný skript a po doběhnutí
|
||||||
|
se vrátíš do menu (Ctrl+C přeruší aktuální akci, ne celé menu).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
for _s in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_s.reconfigure(errors="backslashreplace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
SKRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
PY = sys.executable
|
||||||
|
|
||||||
|
# klíč -> (popis, skript, argumenty)
|
||||||
|
AKCE = {
|
||||||
|
"1": ("Test - 3 kurzy, jen dokumenty (rychle)",
|
||||||
|
"euni_stahni.py", ["--from-json", "--no-videos", "--limit", "3"]),
|
||||||
|
"2": ("Vsechny dokumenty (PDF/prezentace)",
|
||||||
|
"euni_stahni.py", ["--from-json", "--no-videos"]),
|
||||||
|
"3": ("Vse vcetne videi - nejvyssi kvalita (1080p, velke)",
|
||||||
|
"euni_stahni.py", ["--from-json"]),
|
||||||
|
"4": ("Vse vcetne videi - 720p (mensi, rychlejsi)",
|
||||||
|
"euni_stahni.py",
|
||||||
|
["--from-json", "--video-format",
|
||||||
|
"bestvideo[height<=720]+bestaudio/best"]),
|
||||||
|
"5": ("Jen videa (1080p)",
|
||||||
|
"euni_stahni.py", ["--from-json", "--no-docs"]),
|
||||||
|
"6": ("Prehled stavu (dashboard)", "euni_report.py", []),
|
||||||
|
"7": ("Obnova ze SeaweedFS na disk", "euni_restore.py", []),
|
||||||
|
"8": ("Backfill - dohrat chybejici kopie do SeaweedFS",
|
||||||
|
"euni_stahni.py", ["--seaweed-backfill", "--from-json"]),
|
||||||
|
"9": ("Aktualizovat seznam kurzu (znovu scrape do Mongo)",
|
||||||
|
"euni_stahni.py", ["--scrape-only"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vycisti_obrazovku():
|
||||||
|
os.system("cls" if os.name == "nt" else "clear")
|
||||||
|
|
||||||
|
|
||||||
|
def vypis_menu():
|
||||||
|
print("=" * 60)
|
||||||
|
print(" EUNI - stahovani kurzu z euni.cz")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
for k in sorted(AKCE):
|
||||||
|
print(f" {k}) {AKCE[k][0]}")
|
||||||
|
print()
|
||||||
|
print(" 0) Konec")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
vycisti_obrazovku()
|
||||||
|
vypis_menu()
|
||||||
|
try:
|
||||||
|
volba = input("Vyber cislo a stiskni Enter: ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
|
||||||
|
if volba in ("0", "q", "exit", "konec"):
|
||||||
|
break
|
||||||
|
akce = AKCE.get(volba)
|
||||||
|
if not akce:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_, skript, args = akce
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
subprocess.run([PY, str(SKRIPT_DIR / skript), *args],
|
||||||
|
cwd=str(SKRIPT_DIR))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nPreruseno uzivatelem.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
input("\n=== HOTOVO. Stiskni Enter pro navrat do menu ===")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
euni_report.py — přehled stavu stahování z databáze EUNI.
|
||||||
|
|
||||||
|
python euni_report.py # souhrnný přehled
|
||||||
|
python euni_report.py --chyby # vypíše materiály ve stavu chyba
|
||||||
|
python euni_report.py --soukroma # vypíše přeskočená (soukromá) videa
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
for _s in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_s.reconfigure(errors="backslashreplace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import euni_db as edb
|
||||||
|
|
||||||
|
CARA = "─" * 56
|
||||||
|
|
||||||
|
|
||||||
|
def lidsky(n):
|
||||||
|
for j, u in [(1e9, "GB"), (1e6, "MB"), (1e3, "kB")]:
|
||||||
|
if n >= j:
|
||||||
|
return f"{n/j:.1f} {u}"
|
||||||
|
return f"{n} B"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--chyby", action="store_true", help="vypiš materiály ve stavu chyba")
|
||||||
|
p.add_argument("--soukroma", action="store_true", help="vypiš přeskočená videa")
|
||||||
|
a = p.parse_args()
|
||||||
|
db = edb.get_db()
|
||||||
|
|
||||||
|
print(CARA)
|
||||||
|
print(f" EUNI — přehled ({edb.MONGO_URI})")
|
||||||
|
print(CARA)
|
||||||
|
print(f" Kurzů: {db.kurzy.count_documents({})}")
|
||||||
|
kr = db.kurzy.aggregate([{"$group": {"_id": None, "k": {"$sum": "$kredity"}}}])
|
||||||
|
kr = next(kr, {}).get("k") or 0
|
||||||
|
print(f" Kreditů celkem (akreditované kurzy): {kr}")
|
||||||
|
print(CARA)
|
||||||
|
|
||||||
|
for druh in ("video", "dokument"):
|
||||||
|
print(f" {druh.upper()}:")
|
||||||
|
pipe = [{"$match": {"druh": druh}},
|
||||||
|
{"$group": {"_id": "$stav", "n": {"$sum": 1},
|
||||||
|
"b": {"$sum": {"$ifNull": ["$velikost_b", 0]}}}}]
|
||||||
|
celkem = 0
|
||||||
|
for row in sorted(db.materialy.aggregate(pipe), key=lambda r: r["_id"]):
|
||||||
|
vel = f" ({lidsky(row['b'])})" if row["b"] else ""
|
||||||
|
print(f" {row['_id']:<11} {row['n']:>5}{vel}")
|
||||||
|
celkem += row["n"]
|
||||||
|
print(f" {'celkem':<11} {celkem:>5}")
|
||||||
|
print(CARA)
|
||||||
|
|
||||||
|
if a.chyby:
|
||||||
|
print(" CHYBY:")
|
||||||
|
for m in db.materialy.find({"stav": edb.CHYBA}):
|
||||||
|
print(f" - [{m['druh']}] {m.get('kurz_nazev','')[:40]} | "
|
||||||
|
f"{m.get('posledni_chyba','')[:60]}")
|
||||||
|
print(f" {m['zdroj_url']}")
|
||||||
|
|
||||||
|
if a.soukroma:
|
||||||
|
print(" PŘESKOČENÁ VIDEA (soukromá/nedostupná):")
|
||||||
|
for m in db.materialy.find({"stav": edb.PRESKOCENO}):
|
||||||
|
print(f" - {m.get('kurz_nazev','')[:45]} | {m.get('duvod','')}")
|
||||||
|
print(f" {m.get('watch_url') or m['zdroj_url']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
euni_restore.py — obnoví všechny stažené soubory ze SeaweedFS na disk.
|
||||||
|
|
||||||
|
Funguje na libovolném počítači: čte reference (cesty/fid) z MongoDB EUNI a každý
|
||||||
|
soubor stáhne z filer SeaweedFS zpět do souborového systému se stejnou strukturou
|
||||||
|
jako stazeno/<id>-<slug>/<typ>/<soubor>.
|
||||||
|
|
||||||
|
Potřebuje jen síťový přístup k Mongu (192.168.1.76) a filer (192.168.1.50) a:
|
||||||
|
python -m pip install pymongo requests
|
||||||
|
|
||||||
|
Použití:
|
||||||
|
python euni_restore.py # obnoví do ./obnoveno
|
||||||
|
python euni_restore.py --out D:\\Euni # jiný cílový adresář
|
||||||
|
python euni_restore.py --kurz 5618 # jen jeden kurz
|
||||||
|
python euni_restore.py --dry-run # jen vypíše, co by stáhl
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
for _s in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_s.reconfigure(errors="backslashreplace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import euni_db as edb
|
||||||
|
import euni_seaweed as sw
|
||||||
|
|
||||||
|
|
||||||
|
def lidsky(n):
|
||||||
|
n = n or 0
|
||||||
|
for j, u in [(1e9, "GB"), (1e6, "MB"), (1e3, "kB")]:
|
||||||
|
if n >= j:
|
||||||
|
return f"{n/j:.1f} {u}"
|
||||||
|
return f"{n} B"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Obnoví soubory ze SeaweedFS na disk.")
|
||||||
|
p.add_argument("--out", default="obnoveno", help="cílový adresář (výchozí ./obnoveno)")
|
||||||
|
p.add_argument("--kurz", help="obnovit jen tento kurz_id")
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="jen vypsat, nestahovat")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
out = Path(a.out)
|
||||||
|
db = edb.get_db()
|
||||||
|
if not sw.ping():
|
||||||
|
sys.exit(f"SeaweedFS filer nedostupný ({sw.FILER}).")
|
||||||
|
|
||||||
|
mats = edb.materialy_v_seaweed(db)
|
||||||
|
if a.kurz:
|
||||||
|
mats = [m for m in mats if m.get("kurz_id") == a.kurz]
|
||||||
|
print(f"Obnovuji {len(mats)} souborů z {sw.FILER} -> {out.resolve()}")
|
||||||
|
|
||||||
|
ok = preskoc = chyb = 0
|
||||||
|
bajtu = 0
|
||||||
|
for m in mats:
|
||||||
|
remote = m["seaweed_path"]
|
||||||
|
# lokální cesta: zrcadlí seaweed cestu bez prefixu 'euni/'
|
||||||
|
parts = remote.split("/")
|
||||||
|
rel = Path(*parts[1:]) if parts and parts[0] == sw.PREFIX else Path(*parts)
|
||||||
|
dest = out / rel
|
||||||
|
|
||||||
|
want = m.get("seaweed_size")
|
||||||
|
if dest.exists() and (want is None or dest.stat().st_size == want):
|
||||||
|
preskoc += 1
|
||||||
|
continue
|
||||||
|
if a.dry_run:
|
||||||
|
print(f" [BY STÁHL] {rel} ({lidsky(want)})")
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
n = sw.download(remote, dest)
|
||||||
|
bajtu += n
|
||||||
|
ok += 1
|
||||||
|
print(f" [OK] {rel} ({lidsky(n)})")
|
||||||
|
except Exception as e:
|
||||||
|
chyb += 1
|
||||||
|
print(f" [CHYBA] {rel} ({str(e)[:60]})")
|
||||||
|
|
||||||
|
print(f"\nHotovo: {ok} obnoveno, {preskoc} přeskočeno (už je), {chyb} chyb. "
|
||||||
|
f"Staženo {lidsky(bajtu)}.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
euni_seaweed.py — nahrávání/stahování souborů do SeaweedFS přes filer HTTP API.
|
||||||
|
|
||||||
|
Filer běží na Unraidu (default http://192.168.1.50:8888). Soubory se ukládají
|
||||||
|
podle cesty, která zrcadlí lokální strukturu: euni/<id>-<slug>/<typ>/<soubor>.
|
||||||
|
Filer metadata jdou do Mongo "seaweedfs" (na 192.168.1.76) — viz README v
|
||||||
|
U:\\PythonProject\\Janssen\\SeaweedFS\\.
|
||||||
|
|
||||||
|
Identifikátor pro vyžádání souboru = cesta (filer). Navíc se ukládají fid(y)
|
||||||
|
jednotlivých chunků (číslo souboru v SeaweedFS).
|
||||||
|
|
||||||
|
Přepsání endpointu: env EUNI_FILER.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
FILER = os.environ.get("EUNI_FILER", "http://192.168.1.50:8888")
|
||||||
|
PREFIX = "euni" # kořenová složka v SeaweedFS
|
||||||
|
|
||||||
|
|
||||||
|
def _url(remote_path):
|
||||||
|
return f"{FILER}/" + quote(remote_path.lstrip("/"), safe="/")
|
||||||
|
|
||||||
|
|
||||||
|
def entry_meta(remote_path, timeout=30):
|
||||||
|
"""Detailní metadata souboru (vč. chunků s fid), nebo None když neexistuje."""
|
||||||
|
try:
|
||||||
|
r = requests.get(_url(remote_path) + "?metadata=true", timeout=timeout)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def exists(remote_path):
|
||||||
|
return entry_meta(remote_path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upload(local_path, remote_path, timeout=900):
|
||||||
|
"""Nahraje soubor na filer. Vrátí dict: path, fids, size, md5."""
|
||||||
|
fname = os.path.basename(remote_path)
|
||||||
|
with open(local_path, "rb") as f:
|
||||||
|
r = requests.post(_url(remote_path), files={"file": (fname, f)},
|
||||||
|
timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
meta = entry_meta(remote_path) or {}
|
||||||
|
fids = [c.get("file_id") for c in (meta.get("chunks") or []) if c.get("file_id")]
|
||||||
|
return {
|
||||||
|
"path": remote_path,
|
||||||
|
"fids": fids,
|
||||||
|
"size": meta.get("FileSize"),
|
||||||
|
"md5": meta.get("Md5"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def download(remote_path, local_path, timeout=900):
|
||||||
|
"""Stáhne soubor z fileru na lokální cestu. Vrátí velikost v bajtech."""
|
||||||
|
r = requests.get(_url(remote_path), stream=True, timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
|
||||||
|
tmp = str(local_path) + ".part"
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=65536):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
os.replace(tmp, local_path)
|
||||||
|
return os.path.getsize(local_path)
|
||||||
|
|
||||||
|
|
||||||
|
def ping():
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{FILER}/?limit=1", headers={"Accept": "application/json"},
|
||||||
|
timeout=5)
|
||||||
|
return r.status_code == 200
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Filer:", FILER, "dostupný:" , ping())
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
euni_stahni.py — přihlásí se na euni.cz, projde kurzy a stáhne, co se stáhnout dá
|
||||||
|
(dokumenty: PDF/DOCX/PPTX/XLSX/ZIP a videa: Vimeo/YouTube).
|
||||||
|
|
||||||
|
Postup:
|
||||||
|
1) login přes /sign/ (formulář se parsuje, kopírují se i skrytá Nette pole)
|
||||||
|
2) sběr kurzů přes signál studyAreaList-nextPage (stránkování, dokud přibývají)
|
||||||
|
3) z každého kurzu se vytáhnou <iframe> videa a odkazy na dokumenty
|
||||||
|
(vč. /redirect/<base64>)
|
||||||
|
4) vše se stáhne do stazeno/<id>-<slug>/ (dokumenty/ a videa/)
|
||||||
|
|
||||||
|
Soukromá / nedostupná videa se samo přeskočí (nepadá).
|
||||||
|
|
||||||
|
Závislosti:
|
||||||
|
python -m pip install -U requests beautifulsoup4 python-dotenv yt-dlp static-ffmpeg
|
||||||
|
|
||||||
|
Údaje: Euni/.env -> EUNI_USERNAME=... EUNI_PASSWORD=...
|
||||||
|
|
||||||
|
Příklady:
|
||||||
|
python euni_stahni.py # vše: scrape + dokumenty + videa (profese Lékař)
|
||||||
|
python euni_stahni.py --scrape-only # jen inventura do euni_kurzy.json
|
||||||
|
python euni_stahni.py --from-json # přeskočí scrape, použije euni_kurzy.json
|
||||||
|
python euni_stahni.py --no-videos # jen dokumenty
|
||||||
|
python euni_stahni.py --professions 2,4 # více profesí (2=Lékař,4=Farmaceut,7=NLZP)
|
||||||
|
python euni_stahni.py --limit 3 # jen první 3 kurzy (test)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin, unquote, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# výpis ať nikdy nespadne na znaku mimo kódování konzole
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_stream.reconfigure(errors="backslashreplace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
SKRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
load_dotenv(SKRIPT_DIR / ".env")
|
||||||
|
|
||||||
|
# reuse stahovače videí z ../Video/stahni_video.py
|
||||||
|
sys.path.insert(0, str(SKRIPT_DIR.parent / "Video"))
|
||||||
|
try:
|
||||||
|
import stahni_video as sv
|
||||||
|
except Exception:
|
||||||
|
sv = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import euni_db as edb
|
||||||
|
except Exception:
|
||||||
|
edb = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import euni_seaweed as sw
|
||||||
|
except Exception:
|
||||||
|
sw = None
|
||||||
|
|
||||||
|
BASE = "https://www.euni.cz"
|
||||||
|
LOGIN_URL = f"{BASE}/sign/?bid=1"
|
||||||
|
LIST_URL = f"{BASE}/seznam-kurzu?bid=1"
|
||||||
|
NEXTPAGE = f"{BASE}/seznam-kurzu?studyAreaList-professionId={{prof}}&bid=1&do=studyAreaList-nextPage"
|
||||||
|
|
||||||
|
DOC_RE = re.compile(r"\.(pdf|docx?|pptx?|xlsx?|zip)(\?|$)", re.I)
|
||||||
|
FILE_PATH_RE = re.compile(r"fileUploader/download|files/resources", re.I)
|
||||||
|
VIDEO_RE = re.compile(r"vimeo|youtube|youtu\.be", re.I)
|
||||||
|
|
||||||
|
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/120 Safari/537.36")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- pomocné -----
|
||||||
|
def bezpecny_nazev(s: str, max_len: int = 120) -> str:
|
||||||
|
"""Očistí řetězec na bezpečný název souboru/složky pro Windows."""
|
||||||
|
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", s).strip(" .")
|
||||||
|
s = re.sub(r"\s+", " ", s)
|
||||||
|
return (s[:max_len].strip() or "bez_nazvu")
|
||||||
|
|
||||||
|
|
||||||
|
def make_session():
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update({"User-Agent": UA})
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath(p):
|
||||||
|
"""Cesta k souboru relativně k adresáři Euni (pro uložení do DB)."""
|
||||||
|
if not p:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(Path(p).resolve().relative_to(SKRIPT_DIR))
|
||||||
|
except Exception:
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _seaweed_path(dest, out_root):
|
||||||
|
"""Cesta v SeaweedFS zrcadlící lokální strukturu: euni/<id-slug>/<typ>/<soubor>."""
|
||||||
|
try:
|
||||||
|
rel = Path(dest).resolve().relative_to(Path(out_root).resolve())
|
||||||
|
except Exception:
|
||||||
|
rel = Path(dest).name
|
||||||
|
return sw.PREFIX + "/" + "/".join(Path(rel).parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _zaloh_do_seaweed(db, dest, out_root, kurz_id, klic):
|
||||||
|
"""Nahraje soubor do SeaweedFS a uloží referenci (fid) k materiálu do Mongo."""
|
||||||
|
if sw is None or not dest or not Path(dest).exists():
|
||||||
|
return None
|
||||||
|
remote = _seaweed_path(dest, out_root)
|
||||||
|
try:
|
||||||
|
meta = sw.entry_meta(remote)
|
||||||
|
if meta and meta.get("FileSize") == Path(dest).stat().st_size:
|
||||||
|
# už tam je se stejnou velikostí — jen zaznamenat referenci
|
||||||
|
info = {"path": remote,
|
||||||
|
"fids": [c.get("file_id") for c in (meta.get("chunks") or [])
|
||||||
|
if c.get("file_id")],
|
||||||
|
"size": meta.get("FileSize"), "md5": meta.get("Md5")}
|
||||||
|
else:
|
||||||
|
info = sw.upload(str(dest), remote)
|
||||||
|
if db is not None:
|
||||||
|
edb.set_seaweed(db, kurz_id, klic, info["path"],
|
||||||
|
fids=info.get("fids"), md5=info.get("md5"),
|
||||||
|
size=info.get("size"))
|
||||||
|
return info
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [SEAWEED-CHYBA] {remote} ({str(e)[:60]})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- login ------
|
||||||
|
def login(s):
|
||||||
|
r = s.get(LOGIN_URL, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
soup = BeautifulSoup(r.text, "html.parser")
|
||||||
|
|
||||||
|
form = next((f for f in soup.find_all("form")
|
||||||
|
if f.find("input", {"type": "password"})), None)
|
||||||
|
if not form:
|
||||||
|
raise RuntimeError("Přihlašovací formulář nenalezen.")
|
||||||
|
|
||||||
|
data, user_field, pass_field = {}, None, None
|
||||||
|
for inp in form.find_all("input"):
|
||||||
|
name = inp.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
itype = (inp.get("type") or "text").lower()
|
||||||
|
data[name] = inp.get("value", "") # zachová skrytá pole (_do, _token...)
|
||||||
|
if itype == "password":
|
||||||
|
pass_field = name
|
||||||
|
elif itype in ("text", "email") and user_field is None:
|
||||||
|
user_field = name
|
||||||
|
|
||||||
|
user = os.environ.get("EUNI_USERNAME")
|
||||||
|
pwd = os.environ.get("EUNI_PASSWORD")
|
||||||
|
if not user or not pwd:
|
||||||
|
sys.exit("Chybí EUNI_USERNAME / EUNI_PASSWORD. Vyplň je v Euni/.env "
|
||||||
|
"(vzor je v .env.example).")
|
||||||
|
|
||||||
|
data[user_field] = user
|
||||||
|
data[pass_field] = pwd
|
||||||
|
|
||||||
|
action = urljoin(LOGIN_URL, form.get("action") or LOGIN_URL)
|
||||||
|
r = s.post(action, data=data, headers={"Referer": LOGIN_URL}, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
if "Odhlásit" not in r.text and "odhlasit" not in r.text.lower():
|
||||||
|
raise RuntimeError("Přihlášení se nezdařilo – zkontroluj údaje v .env.")
|
||||||
|
print("✓ Přihlášeno")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- seznam kurzů ----
|
||||||
|
def get_courses_for_profession(s, profession_id):
|
||||||
|
# inicializace stránkování pro danou profesi
|
||||||
|
s.get(f"{BASE}/seznam-kurzu?studyAreaList-professionId={profession_id}&bid=1",
|
||||||
|
timeout=30)
|
||||||
|
seen, prev, guard = {}, -1, 0
|
||||||
|
while guard < 200:
|
||||||
|
guard += 1
|
||||||
|
r = s.get(NEXTPAGE.format(prof=profession_id),
|
||||||
|
headers={"X-Requested-With": "XMLHttpRequest"}, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
try:
|
||||||
|
snippet = r.json().get("snippets", {}).get(
|
||||||
|
"snippet-studyAreaList-areaList", "")
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
if not snippet:
|
||||||
|
break
|
||||||
|
soup = BeautifulSoup(snippet, "html.parser")
|
||||||
|
for a in soup.select("a.workshop"):
|
||||||
|
href = (a.get("href") or "").split("?")[0]
|
||||||
|
m = re.match(r"/lecture/(\d+)-(.+)", href)
|
||||||
|
if m:
|
||||||
|
seen[m.group(1)] = {
|
||||||
|
"id": m.group(1),
|
||||||
|
"slug": m.group(2),
|
||||||
|
"title": (a.find("h3").get_text(strip=True)
|
||||||
|
if a.find("h3") else m.group(2)),
|
||||||
|
"url": urljoin(BASE, href),
|
||||||
|
"profession": profession_id,
|
||||||
|
}
|
||||||
|
if len(seen) == prev:
|
||||||
|
break
|
||||||
|
prev = len(seen)
|
||||||
|
time.sleep(0.25)
|
||||||
|
return list(seen.values())
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_courses(s, professions):
|
||||||
|
vse = {}
|
||||||
|
for prof in professions:
|
||||||
|
kurzy = get_courses_for_profession(s, prof)
|
||||||
|
print(f" profese {prof}: {len(kurzy)} kurzů")
|
||||||
|
for k in kurzy:
|
||||||
|
vse.setdefault(k["id"], k)
|
||||||
|
return list(vse.values())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- extrakce odkazů ----
|
||||||
|
def decode_redirect(href):
|
||||||
|
m = re.search(r"/redirect/([A-Za-z0-9+/=]+)", href)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return base64.b64decode(m.group(1)).decode("utf-8", "ignore")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def watch_url(embed):
|
||||||
|
m = re.search(r"player\.vimeo\.com/video/(\d+)", embed)
|
||||||
|
if m:
|
||||||
|
return f"https://vimeo.com/{m.group(1)}"
|
||||||
|
m = re.search(r"youtube\.com/embed/([\w-]+)", embed)
|
||||||
|
if m:
|
||||||
|
return f"https://www.youtube.com/watch?v={m.group(1)}"
|
||||||
|
return embed
|
||||||
|
|
||||||
|
|
||||||
|
def _text(el):
|
||||||
|
return " ".join(el.get_text(" ", strip=True).split()) if el else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s):
|
||||||
|
m = re.search(r"(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})", s or "")
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1)))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_for_label(soup, label_text):
|
||||||
|
"""Najde hodnotu (lecture-info-mark/bold) ve stejném containeru jako daný label."""
|
||||||
|
for lab in soup.select(".lecture-info-label"):
|
||||||
|
if label_text.lower() in lab.get_text(strip=True).lower():
|
||||||
|
par = lab.parent
|
||||||
|
mark = (par.select_one(".lecture-info-mark")
|
||||||
|
or par.select_one(".lecture-info-bold"))
|
||||||
|
if mark:
|
||||||
|
return _text(mark)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_course_meta(soup):
|
||||||
|
meta = {}
|
||||||
|
autor_el = soup.select_one(".lecture-info-column-author")
|
||||||
|
if autor_el:
|
||||||
|
meta["autor"] = _text(autor_el.select_one(".lecture-info-mark"))
|
||||||
|
href = autor_el.get("href") or ""
|
||||||
|
if "vimeo" in href or "youtube" in href:
|
||||||
|
meta["autor_medailonek_url"] = href
|
||||||
|
if not meta.get("autor"):
|
||||||
|
meta["autor"] = (_mark_for_label(soup, "Autor kurzu")
|
||||||
|
or _mark_for_label(soup, "Autorka kurzu"))
|
||||||
|
meta["datum_publikace"] = _parse_date(_mark_for_label(soup, "Datum publikace"))
|
||||||
|
meta["revidovano"] = _parse_date(_mark_for_label(soup, "Revidováno"))
|
||||||
|
meta["akreditace"] = _mark_for_label(soup, "Akreditace")
|
||||||
|
m = re.search(r"(\d+)\s*kredit", soup.get_text(" "), re.I)
|
||||||
|
meta["kredity"] = int(m.group(1)) if m else None
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def material_klic(druh, item):
|
||||||
|
"""Vrátí (klic, platforma) pro deduplikaci materiálu."""
|
||||||
|
if druh == "video":
|
||||||
|
e = item["embed"]
|
||||||
|
m = re.search(r"vimeo\.com/(?:video/)?(\d+)", e)
|
||||||
|
if m:
|
||||||
|
return f"vimeo:{m.group(1)}", "vimeo"
|
||||||
|
m = (re.search(r"youtube\.com/embed/([\w-]+)", e)
|
||||||
|
or re.search(r"youtu\.be/([\w-]+)", e)
|
||||||
|
or re.search(r"[?&]v=([\w-]+)", e))
|
||||||
|
if m:
|
||||||
|
return f"youtube:{m.group(1)}", "youtube"
|
||||||
|
return "video:" + hashlib.sha1(e.encode()).hexdigest()[:16], None
|
||||||
|
return "doc:" + hashlib.sha1(item["url"].encode()).hexdigest()[:16], None
|
||||||
|
|
||||||
|
|
||||||
|
def _pripona(url):
|
||||||
|
m = re.search(r"\.([a-z0-9]{2,4})(\?|$)", url, re.I)
|
||||||
|
return m.group(1).lower() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_course_links(s, course_url):
|
||||||
|
r = s.get(course_url, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
soup = BeautifulSoup(r.text, "html.parser")
|
||||||
|
|
||||||
|
videos, vseen = [], set()
|
||||||
|
for f in soup.find_all("iframe"):
|
||||||
|
src = f.get("src") or f.get("data-src") or ""
|
||||||
|
if src.startswith("//"):
|
||||||
|
src = "https:" + src
|
||||||
|
if VIDEO_RE.search(src) and src not in vseen:
|
||||||
|
vseen.add(src)
|
||||||
|
videos.append({"embed": src, "watch": watch_url(src)})
|
||||||
|
|
||||||
|
docs, seen = [], set()
|
||||||
|
for a in soup.find_all("a", href=True):
|
||||||
|
target = decode_redirect(a["href"]) or urljoin(BASE, a["href"])
|
||||||
|
if DOC_RE.search(target) or FILE_PATH_RE.search(target):
|
||||||
|
url = unquote(target)
|
||||||
|
if url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
docs.append({
|
||||||
|
"label": " ".join(a.get_text(" ", strip=True).split())[:70],
|
||||||
|
"url": url,
|
||||||
|
})
|
||||||
|
return {"videos": videos, "documents": docs, "meta": extract_course_meta(soup)}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- stahování ------
|
||||||
|
def stahni_dokument(s, url, out_dir: Path, label=""):
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
r = s.get(url, stream=True, timeout=120)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
# jméno souboru z Content-Disposition, jinak z URL
|
||||||
|
fname = None
|
||||||
|
cd = r.headers.get("Content-Disposition", "")
|
||||||
|
m = re.search(r"filename\*?=(?:UTF-8'')?\"?([^\";]+)", cd)
|
||||||
|
if m:
|
||||||
|
fname = unquote(m.group(1))
|
||||||
|
if not fname:
|
||||||
|
fname = os.path.basename(urlparse(url).path) or "soubor"
|
||||||
|
fname = bezpecny_nazev(fname)
|
||||||
|
if "." not in fname and label:
|
||||||
|
fname = bezpecny_nazev(label)
|
||||||
|
|
||||||
|
dest = out_dir / fname
|
||||||
|
if dest.exists() and dest.stat().st_size > 0:
|
||||||
|
return ("existuje", dest.name)
|
||||||
|
|
||||||
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||||
|
with open(tmp, "wb") as fp:
|
||||||
|
for chunk in r.iter_content(chunk_size=65536):
|
||||||
|
if chunk:
|
||||||
|
fp.write(chunk)
|
||||||
|
tmp.replace(dest)
|
||||||
|
return ("staženo", dest.name)
|
||||||
|
|
||||||
|
|
||||||
|
def stahni_video(embed, out_dir: Path, referer, fmt="bestvideo*+bestaudio/best",
|
||||||
|
frags=10):
|
||||||
|
"""Stáhne video přes yt-dlp; soukromé/nedostupné přeskočí. Vrací (stav, info, fp)."""
|
||||||
|
if sv is None:
|
||||||
|
return ("chyba", "modul stahni_video není dostupný", None)
|
||||||
|
try:
|
||||||
|
import yt_dlp
|
||||||
|
from yt_dlp.utils import DownloadError
|
||||||
|
except ImportError:
|
||||||
|
return ("chyba", "yt-dlp není nainstalován", None)
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ff_dir = sv.priprav_ffmpeg()
|
||||||
|
opts = {
|
||||||
|
"outtmpl": str(out_dir / "%(title)s [%(id)s].%(ext)s"),
|
||||||
|
"format": fmt,
|
||||||
|
"concurrent_fragment_downloads": frags, # paralelní HLS fragmenty = rychlejší
|
||||||
|
"merge_output_format": "mp4",
|
||||||
|
"logger": sv._TichyLogger(),
|
||||||
|
"progress_hooks": [sv._progress_hook],
|
||||||
|
"noprogress": True,
|
||||||
|
"noplaylist": True,
|
||||||
|
"http_headers": {"Referer": referer, "User-Agent": UA},
|
||||||
|
}
|
||||||
|
if ff_dir:
|
||||||
|
opts["ffmpeg_location"] = ff_dir
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
|
info = ydl.extract_info(embed, download=True)
|
||||||
|
fp = None
|
||||||
|
rd = (info or {}).get("requested_downloads")
|
||||||
|
if rd:
|
||||||
|
fp = rd[0].get("filepath")
|
||||||
|
return ("staženo", info.get("title", embed) if info else embed, fp)
|
||||||
|
except DownloadError as e:
|
||||||
|
duvod = sv.klasifikuj_chybu(str(e))
|
||||||
|
if duvod:
|
||||||
|
return ("přeskočeno", duvod, None)
|
||||||
|
return ("chyba", str(e).split("\n")[0], None)
|
||||||
|
except Exception as e:
|
||||||
|
return ("chyba", str(e), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_course(db, c):
|
||||||
|
"""Zapíše kurz + jeho materiály do Mongo (idempotentně)."""
|
||||||
|
meta = c.get("meta") or {}
|
||||||
|
nazev = c.get("nazev") or c.get("title")
|
||||||
|
kurz = {
|
||||||
|
"id": c["id"], "slug": c.get("slug"), "nazev": nazev, "url": c.get("url"),
|
||||||
|
"profese": [c["profession"]] if c.get("profession") else c.get("profese", []),
|
||||||
|
"pocet_videi": len(c.get("videos", [])),
|
||||||
|
"pocet_dokumentu": len(c.get("documents", [])),
|
||||||
|
}
|
||||||
|
for k in ("autor", "autor_medailonek_url", "datum_publikace", "revidovano",
|
||||||
|
"akreditace", "kredity"):
|
||||||
|
kurz[k] = meta.get(k)
|
||||||
|
edb.upsert_kurz(db, kurz)
|
||||||
|
|
||||||
|
for v in c.get("videos", []):
|
||||||
|
klic, plat = material_klic("video", v)
|
||||||
|
edb.upsert_material(db, {
|
||||||
|
"kurz_id": c["id"], "kurz_nazev": nazev, "druh": "video",
|
||||||
|
"platforma": plat, "klic": klic, "zdroj_url": v["embed"],
|
||||||
|
"watch_url": v.get("watch"), "popis": None, "pripona": "mp4",
|
||||||
|
})
|
||||||
|
for d in c.get("documents", []):
|
||||||
|
klic, _ = material_klic("dokument", d)
|
||||||
|
edb.upsert_material(db, {
|
||||||
|
"kurz_id": c["id"], "kurz_nazev": nazev, "druh": "dokument",
|
||||||
|
"platforma": None, "klic": klic, "zdroj_url": d["url"],
|
||||||
|
"watch_url": None, "popis": d.get("label"), "pripona": _pripona(d["url"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- hlavní ------
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Stáhne obsah kurzů z euni.cz.")
|
||||||
|
p.add_argument("--professions", default="2",
|
||||||
|
help="ID profesí oddělené čárkou (2=Lékař,4=Farmaceut,7=NLZP), nebo 'all'")
|
||||||
|
p.add_argument("--scrape-only", action="store_true", help="jen inventura do JSON")
|
||||||
|
p.add_argument("--from-json", action="store_true",
|
||||||
|
help="přeskočí scrape, použije existující euni_kurzy.json")
|
||||||
|
p.add_argument("--no-videos", action="store_true", help="nestahovat videa")
|
||||||
|
p.add_argument("--no-docs", action="store_true", help="nestahovat dokumenty")
|
||||||
|
p.add_argument("--video-format", default="bestvideo*+bestaudio/best",
|
||||||
|
help="yt-dlp formát videa (např. \"bestvideo[height<=720]+bestaudio/best\")")
|
||||||
|
p.add_argument("--frags", type=int, default=10,
|
||||||
|
help="počet paralelně stahovaných HLS fragmentů videa (default 10)")
|
||||||
|
p.add_argument("--limit", type=int, default=0, help="jen prvních N kurzů (test)")
|
||||||
|
p.add_argument("--out", default=str(SKRIPT_DIR / "stazeno"), help="výstupní adresář")
|
||||||
|
p.add_argument("--json", default=str(SKRIPT_DIR / "euni_kurzy.json"),
|
||||||
|
help="cesta k inventurnímu JSON")
|
||||||
|
p.add_argument("--no-mongo", action="store_true",
|
||||||
|
help="nezapisovat do MongoDB (jen JSON / stahování)")
|
||||||
|
p.add_argument("--no-seaweed", action="store_true",
|
||||||
|
help="nenahrávat kopie do SeaweedFS")
|
||||||
|
p.add_argument("--seaweed-backfill", action="store_true",
|
||||||
|
help="jen dohraje do SeaweedFS stažené soubory, které tam chybí")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
json_path = Path(a.json)
|
||||||
|
out_root = Path(a.out)
|
||||||
|
|
||||||
|
s = make_session()
|
||||||
|
|
||||||
|
db = None
|
||||||
|
if not a.no_mongo:
|
||||||
|
if edb is None:
|
||||||
|
print("UPOZORNĚNÍ: modul euni_db nedostupný — pokračuji bez Mongo.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
db = edb.ensure_indexes()
|
||||||
|
print(f"✓ Mongo EUNI připojeno ({edb.MONGO_URI})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"UPOZORNĚNÍ: Mongo nedostupné ({e}) — pokračuji bez něj.")
|
||||||
|
|
||||||
|
use_seaweed = not a.no_seaweed and sw is not None
|
||||||
|
if use_seaweed:
|
||||||
|
if sw.ping():
|
||||||
|
print(f"✓ SeaweedFS filer dostupný ({sw.FILER})")
|
||||||
|
else:
|
||||||
|
print(f"UPOZORNĚNÍ: SeaweedFS filer nedostupný ({sw.FILER}) — "
|
||||||
|
f"pokračuji bez záloh.")
|
||||||
|
use_seaweed = False
|
||||||
|
|
||||||
|
# režim: jen dohrát do SeaweedFS chybějící stažené soubory
|
||||||
|
if a.seaweed_backfill:
|
||||||
|
if db is None or not use_seaweed:
|
||||||
|
sys.exit("Backfill potřebuje Mongo i SeaweedFS.")
|
||||||
|
chybi = edb.materialy_bez_seaweed(db)
|
||||||
|
print(f"Backfill do SeaweedFS: {len(chybi)} souborů")
|
||||||
|
ok = 0
|
||||||
|
for m in chybi:
|
||||||
|
dest = SKRIPT_DIR / m["soubor"]
|
||||||
|
if not dest.exists():
|
||||||
|
continue
|
||||||
|
remote = _seaweed_path(dest, out_root)
|
||||||
|
info = _zaloh_do_seaweed(db, dest, out_root, m["kurz_id"], m["klic"])
|
||||||
|
if info:
|
||||||
|
ok += 1
|
||||||
|
print(f" [SEAWEED] {remote}")
|
||||||
|
print(f"Hotovo: {ok}/{len(chybi)} nahráno.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if a.from_json:
|
||||||
|
if not json_path.exists():
|
||||||
|
sys.exit(f"JSON {json_path} neexistuje — spusť nejdřív bez --from-json.")
|
||||||
|
results = json.loads(json_path.read_text(encoding="utf-8"))
|
||||||
|
print(f"✓ Načteno z JSON: {len(results)} kurzů")
|
||||||
|
login(s) # přihlášení potřeba pro stahování dokumentů
|
||||||
|
else:
|
||||||
|
login(s)
|
||||||
|
if a.professions.lower() == "all":
|
||||||
|
profs = [2, 4, 5, 6, 7]
|
||||||
|
else:
|
||||||
|
profs = [int(x) for x in a.professions.split(",") if x.strip()]
|
||||||
|
print(f"Sbírám kurzy (profese {profs})…")
|
||||||
|
courses = get_all_courses(s, profs)
|
||||||
|
print(f"✓ Nalezeno kurzů: {len(courses)}")
|
||||||
|
if a.limit:
|
||||||
|
courses = courses[: a.limit]
|
||||||
|
print(f" (--limit: zpracuji jen prvních {len(courses)})")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, c in enumerate(courses, 1):
|
||||||
|
try:
|
||||||
|
links = extract_course_links(s, c["url"])
|
||||||
|
except Exception as e:
|
||||||
|
links = {"videos": [], "documents": [], "error": str(e)}
|
||||||
|
course = {**c, **links}
|
||||||
|
results.append(course)
|
||||||
|
if db is not None and "error" not in links:
|
||||||
|
try:
|
||||||
|
_ingest_course(db, course)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [MONGO-CHYBA] {c['id']}: {e}")
|
||||||
|
print(f"[{i}/{len(courses)}] {c['title']} → "
|
||||||
|
f"{len(links.get('videos', []))} videí, "
|
||||||
|
f"{len(links.get('documents', []))} dokumentů")
|
||||||
|
time.sleep(0.35)
|
||||||
|
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(results, ensure_ascii=False, indent=2, default=str),
|
||||||
|
encoding="utf-8")
|
||||||
|
print(f"✓ Inventura uložena: {json_path}")
|
||||||
|
|
||||||
|
# souhrn inventury
|
||||||
|
n_vid = sum(len(c.get("videos", [])) for c in results)
|
||||||
|
n_doc = sum(len(c.get("documents", [])) for c in results)
|
||||||
|
print(f"\nCelkem: {len(results)} kurzů, {n_vid} videí, {n_doc} dokumentů")
|
||||||
|
|
||||||
|
if a.scrape_only:
|
||||||
|
return
|
||||||
|
|
||||||
|
# stahování
|
||||||
|
if a.limit:
|
||||||
|
results = results[: a.limit]
|
||||||
|
stat = {"doc_ok": 0, "doc_skip": 0, "doc_err": 0,
|
||||||
|
"vid_ok": 0, "vid_skip": 0, "vid_err": 0, "sw_ok": 0}
|
||||||
|
|
||||||
|
for i, c in enumerate(results, 1):
|
||||||
|
folder = out_root / bezpecny_nazev(f"{c['id']}-{c.get('slug', '')}", 80)
|
||||||
|
print(f"\n[{i}/{len(results)}] {c.get('title', c['id'])}")
|
||||||
|
|
||||||
|
if not a.no_docs:
|
||||||
|
for d in c.get("documents", []):
|
||||||
|
klic = material_klic("dokument", d)[0]
|
||||||
|
try:
|
||||||
|
stav, name = stahni_dokument(s, d["url"], folder / "dokumenty",
|
||||||
|
d.get("label", ""))
|
||||||
|
dest = folder / "dokumenty" / name
|
||||||
|
if stav == "staženo":
|
||||||
|
stat["doc_ok"] += 1
|
||||||
|
print(f" [DOK] {name}")
|
||||||
|
else:
|
||||||
|
stat["doc_skip"] += 1
|
||||||
|
if db is not None:
|
||||||
|
sz = dest.stat().st_size if dest.exists() else None
|
||||||
|
edb.set_status(db, c["id"], klic, edb.STAZENO,
|
||||||
|
soubor=_relpath(dest), velikost_b=sz)
|
||||||
|
if use_seaweed and dest.exists():
|
||||||
|
if _zaloh_do_seaweed(db, dest, out_root, c["id"], klic):
|
||||||
|
stat["sw_ok"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
stat["doc_err"] += 1
|
||||||
|
print(f" [DOK-CHYBA] {d['url']} ({e})")
|
||||||
|
if db is not None:
|
||||||
|
edb.set_status(db, c["id"], klic, edb.CHYBA, chyba=str(e))
|
||||||
|
|
||||||
|
if not a.no_videos:
|
||||||
|
for v in c.get("videos", []):
|
||||||
|
klic = material_klic("video", v)[0]
|
||||||
|
stav, info, fp = stahni_video(v["embed"], folder / "videa", c["url"],
|
||||||
|
fmt=a.video_format, frags=a.frags)
|
||||||
|
if stav == "staženo":
|
||||||
|
stat["vid_ok"] += 1
|
||||||
|
print(f" [VIDEO] {info}")
|
||||||
|
if db is not None:
|
||||||
|
sz = (Path(fp).stat().st_size
|
||||||
|
if fp and Path(fp).exists() else None)
|
||||||
|
edb.set_status(db, c["id"], klic, edb.STAZENO,
|
||||||
|
soubor=_relpath(fp) if fp else None,
|
||||||
|
velikost_b=sz)
|
||||||
|
if use_seaweed and fp and Path(fp).exists():
|
||||||
|
if _zaloh_do_seaweed(db, fp, out_root, c["id"], klic):
|
||||||
|
stat["sw_ok"] += 1
|
||||||
|
elif stav == "přeskočeno":
|
||||||
|
stat["vid_skip"] += 1
|
||||||
|
print(f" [VIDEO-PŘESKOČENO] {info}")
|
||||||
|
if db is not None:
|
||||||
|
edb.set_status(db, c["id"], klic, edb.PRESKOCENO, duvod=info)
|
||||||
|
else:
|
||||||
|
stat["vid_err"] += 1
|
||||||
|
print(f" [VIDEO-CHYBA] {info}")
|
||||||
|
if db is not None:
|
||||||
|
edb.set_status(db, c["id"], klic, edb.CHYBA, chyba=info)
|
||||||
|
|
||||||
|
print("\n=== SOUHRN STAHOVÁNÍ ===")
|
||||||
|
print(f" dokumenty: {stat['doc_ok']} staženo, {stat['doc_skip']} přeskočeno, "
|
||||||
|
f"{stat['doc_err']} chyb")
|
||||||
|
print(f" videa: {stat['vid_ok']} staženo, {stat['vid_skip']} přeskočeno "
|
||||||
|
f"(soukromá/nedostupná), {stat['vid_err']} chyb")
|
||||||
|
if not a.no_seaweed:
|
||||||
|
print(f" SeaweedFS: {stat['sw_ok']} souborů zazálohováno")
|
||||||
|
print(f" výstup: {out_root}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# VZP (111) — Stahování seznamu registrovaných pojištěnců
|
||||||
|
|
||||||
|
## Co skript dělá
|
||||||
|
|
||||||
|
`StahniSeznamPojistencuVZP.py` (Playwright + Chrome):
|
||||||
|
|
||||||
|
1. **Přihlásí se** certifikátem na VZP Point (auto-výběr cert z Windows store)
|
||||||
|
2. Projde **ODESLANÁ PODÁNÍ** (řazeno od nejnovějšího) a najde podání typu
|
||||||
|
„Seznam registrovaných pojištěnců"
|
||||||
|
3. Stahuje **přiložené datové dávky** `F111MMRR.nnn` (CP852) do
|
||||||
|
`…\Zúčtovací zprávy\SeznamyPojištěnců\` od nejnovějšího a **zastaví se na první
|
||||||
|
už stažené dávce** (inkrementálně — starší jsou stažené, nejde hluboko do minulosti).
|
||||||
|
4. **Podá novou žádost** o výpis (datové rozhraní) za nejnovější dostupné období
|
||||||
|
(zjištěno z configu) — výsledek dorazí do ODESLANÝCH PODÁNÍ a stáhne se příště.
|
||||||
|
|
||||||
|
Dávky pak zpracovává `Insurance/SeznamPojistencu/01_parse_seznam_dg_tool.py`.
|
||||||
|
|
||||||
|
## Platforma — ODLIŠNÁ
|
||||||
|
|
||||||
|
VZP běží na **point.vzp.cz** (VZP Point), NE portalzp.cz ani eforms. Login je
|
||||||
|
certifikátem přes Chrome — politika `AutoSelectCertificateForUrls` vybere cert
|
||||||
|
automaticky (issuer `I.CA Public CA/RSA 06/2022`), bez NMSigneru. Plně Playwright.
|
||||||
|
|
||||||
|
## Jak se seznam získává
|
||||||
|
|
||||||
|
VZP seznam **není** samočinná zpráva — musí se **požádat podáním**:
|
||||||
|
- NOVÉ PODÁNÍ → „Seznam registrovaných pojištěnců ke dni"
|
||||||
|
- **Formát výstupu = „Datové rozhraní"** (NE „PDF"!) + období (měsíc/rok)
|
||||||
|
- VZP požadavek zpracuje (~minuty) a výsledek = datová dávka III-1.1.2,
|
||||||
|
stažitelná z detailu zpracovaného podání (sloupec „Přiložený soubor").
|
||||||
|
|
||||||
|
> Pozn.: pokud se zvolí formát „PDF", výsledkem je PDF (p…pdf), které parser neumí.
|
||||||
|
> Vždy volit „Datové rozhraní".
|
||||||
|
|
||||||
|
## Formát dávky (III-1.1.2)
|
||||||
|
|
||||||
|
Soubor `F111MMRR.nnn`, pevná šířka, **CP852**. Hlavička typ H:
|
||||||
|
`H09305001` (IČP) + počet + RRMMDD. Věty typu I: příjmení, jméno, číslo poj.,
|
||||||
|
datum registrace, kód pojišťovny. (Detaily v `SeznamPojistencu/01_parse_seznam_dg_tool.py`.)
|
||||||
|
|
||||||
|
## Stažení dávky z detailu podání
|
||||||
|
|
||||||
|
Detail `/Desk/Form/Detail/{id}` → záložka „Výsledky zpracování" → odkaz s názvem
|
||||||
|
`F111MMRR.nnn` (href="#", JS handler). Stahuje se Playwright klikem
|
||||||
|
(`expect_download` + `dispatch_event('click')`) — žádná přímá URL.
|
||||||
|
|
||||||
|
## Podání žádosti (REST API — bez podpisu!)
|
||||||
|
|
||||||
|
Podání jde čistě přes REST API Pointu (Bearer token z inline `"bearerToken"` na dashboardu),
|
||||||
|
**žádný elektronický podpis** — autentizace stačí přes session + token. Tři kroky:
|
||||||
|
|
||||||
|
1. **Config** (zjištění období): `GET /api/desk/draft/form65/config`
|
||||||
|
→ `periodLimits {from, until}` + `defaultModel.period {month, year}`.
|
||||||
|
Podává se za **nejnovější dostupné období** (`until` / `defaultModel`), ne za kalendářní
|
||||||
|
měsíc (ten portál odmítne — HTTP 400 při publish).
|
||||||
|
2. **Vytvoř koncept**: `POST /api/desk/draft/form65/{partnerId}`
|
||||||
|
body `{"outputFormat":"Text","period":{"month":M,"year":Y}}` → `{"draftId":"...","state":"Verified"}`
|
||||||
|
- `outputFormat:"Text"` = **Datové rozhraní** (NE "Pdf"!)
|
||||||
|
- partnerId = `3197807` (subjekt MUDr. Buzalková)
|
||||||
|
3. **Publikuj**: `POST /api/desk/draft/form65/{draftId}/publish` (prázdné tělo)
|
||||||
|
→ `{"formId": <id odeslaného podání>}`
|
||||||
|
|
||||||
|
Token se čte stejně jako v `StahováníZpráv/111 VZP/stahovanipodani.py`.
|
||||||
|
|
||||||
|
### Jak bylo zjištěno
|
||||||
|
|
||||||
|
Formulář Form65 je React SPA s custom comboboxem, který nešel proklikat headless ani
|
||||||
|
naslepo. Odchyceno tak, že uživatel podal jedno podání ručně a do stránky byl vložen
|
||||||
|
háček ukládající fetch/XHR do `localStorage` (přežije přesměrování) — z toho se vyčetly
|
||||||
|
přesné endpointy a payloady.
|
||||||
|
|
||||||
|
## Soubory
|
||||||
|
|
||||||
|
| Soubor | Popis |
|
||||||
|
|--------|-------|
|
||||||
|
| `StahniSeznamPojistencuVZP.py` | Login + stažení datových dávek z podání |
|
||||||
|
|
||||||
|
## Parametry
|
||||||
|
|
||||||
|
- **IČP**: 09305001, **IČZ**: 09305000 (MUDr. Michaela Buzalková)
|
||||||
|
- **Login**: certifikát ve Windows store (sdílený profil `StahováníZpráv/111 VZP/chrome_profile`)
|
||||||
|
|
||||||
|
## Stav
|
||||||
|
|
||||||
|
Hotovo a otestováno (17.06.2026): login ✓, backfill 23 dávek `F111….0NN` (všechny `H09305001`),
|
||||||
|
inkrementální běh zastaví na první už stažené dávce ✓, **podání žádosti přes REST API ✓**
|
||||||
|
(auto období z configu = 04/2026, create+publish → formId). Download i podání plně automatické.
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Stahování seznamu registrovaných pojištěnců VZP (111) — VZP Point (Playwright).
|
||||||
|
|
||||||
|
VZP běží na ODLIŠNÉ platformě (point.vzp.cz) — ne portalzp.cz, ne eforms:
|
||||||
|
- login: certifikát přes Chrome (auto-výběr z Windows store, politika
|
||||||
|
AutoSelectCertificateForUrls), Playwright. Bez NMSigneru.
|
||||||
|
- seznam: požaduje se podáním "Seznam registrovaných pojištěnců" s formátem
|
||||||
|
výstupu "Datové rozhraní". Výsledek = datová dávka III-1.1.2
|
||||||
|
(soubor F111MMRR.nnn, CP852, hlavička H09305001), stažitelná
|
||||||
|
z detailu zpracovaného podání.
|
||||||
|
|
||||||
|
Tento skript STAHUJE výsledky už zpracovaných podání "Seznam registrovaných
|
||||||
|
pojištěnců" (datová dávka) do složky SeznamyPojištěnců.
|
||||||
|
Podání žádosti (NOVÉ PODÁNÍ) zatím dělá uživatel ručně na portálu — viz NOTES.md.
|
||||||
|
|
||||||
|
Soubory dávek pak zpracovává Insurance/SeznamPojistencu/01_parse_seznam_dg_tool.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import winreg
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
|
||||||
|
from Knihovny.najdi_dropbox import get_dropbox_root
|
||||||
|
|
||||||
|
POINT_URL = "https://point.vzp.cz"
|
||||||
|
DASHBOARD_URL = f"{POINT_URL}/Desk/FormDashboard"
|
||||||
|
INBOX_URL = f"{POINT_URL}/Inbox/Message"
|
||||||
|
|
||||||
|
# Sdílené s VZP skriptem pro stahování zpráv
|
||||||
|
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "111 VZP"))
|
||||||
|
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
|
||||||
|
COOKIES_FILE = os.path.join(STAHUJ_DIR, "vzp_cookies.json")
|
||||||
|
|
||||||
|
DEST_DIR = os.path.join(
|
||||||
|
get_dropbox_root(),
|
||||||
|
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
|
||||||
|
)
|
||||||
|
|
||||||
|
CERT_ISSUER_CN = "I.CA Public CA/RSA 06/2022"
|
||||||
|
|
||||||
|
# Název podání i přílohy
|
||||||
|
PODANI_NAZEV = "Seznam registrovaných pojištěnců"
|
||||||
|
DAVKA_RE = re.compile(r"^F\d{7}\.\d+$") # F111MMRR.nnn
|
||||||
|
|
||||||
|
# Podání žádosti (REST API, ověřeno odchytem)
|
||||||
|
PARTNER_ID = "3197807" # subjekt MUDr. Buzalková (partnerId z formuláře Form65)
|
||||||
|
OUTPUT_FORMAT = "Text" # "Text" = Datové rozhraní (NE "Pdf"!)
|
||||||
|
|
||||||
|
# Období podávané žádosti se zjistí automaticky z configu (nejnovější dostupné, viz
|
||||||
|
# config.defaultModel / periodLimits.until). Pro ruční přepsání nastav OVERRIDE_OBDOBI
|
||||||
|
# na (měsíc, rok), jinak ponech None.
|
||||||
|
OVERRIDE_OBDOBI: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
# Kolikrát max. kliknout 'Načíst další' při hledání podání (dashboard míchá typy).
|
||||||
|
# Stahování se stejně zastaví na první už stažené dávce, takže do minulosti nejde hluboko.
|
||||||
|
MAX_LOADS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _set_chrome_cert_policy() -> None:
|
||||||
|
policy = json.dumps({"pattern": "https://[*.]vzp.cz",
|
||||||
|
"filter": {"ISSUER": {"CN": CERT_ISSUER_CN}}})
|
||||||
|
try:
|
||||||
|
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER,
|
||||||
|
r"SOFTWARE\Policies\Google\Chrome\AutoSelectCertificateForUrls")
|
||||||
|
winreg.SetValueEx(key, "1", 0, winreg.REG_SZ, policy)
|
||||||
|
winreg.CloseKey(key)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Varování: nelze nastavit Chrome politiku: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cookies(context) -> int:
|
||||||
|
if not os.path.exists(COOKIES_FILE):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
with open(COOKIES_FILE, encoding="utf-8") as f:
|
||||||
|
context.add_cookies(json.load(f))
|
||||||
|
return 1
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _save_cookies(context) -> None:
|
||||||
|
try:
|
||||||
|
vzp = [c for c in context.cookies() if "vzp.cz" in c.get("domain", "")]
|
||||||
|
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(vzp, f, indent=2, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def prihlaseni(context):
|
||||||
|
"""Zajistí přihlášení na VZP Point. Vrátí přihlášenou page."""
|
||||||
|
_load_cookies(context)
|
||||||
|
page = context.new_page()
|
||||||
|
page.goto(DASHBOARD_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||||
|
|
||||||
|
if page.url.startswith("https://auth.vzp.cz/signin"):
|
||||||
|
print("Přihlašuji certifikátem...")
|
||||||
|
cert_btn = page.locator("a, button").filter(has_text=re.compile(r"certifikát", re.I)).first
|
||||||
|
cert_btn.wait_for(state="visible", timeout=10_000)
|
||||||
|
cert_btn.click(no_wait_after=True)
|
||||||
|
try:
|
||||||
|
page.wait_for_url("https://point.vzp.cz/**", timeout=60_000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not page.url.startswith(POINT_URL):
|
||||||
|
raise RuntimeError(f"Přihlášení selhalo. URL: {page.url}")
|
||||||
|
|
||||||
|
print("Přihlášení OK.")
|
||||||
|
_save_cookies(context)
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer_token(page) -> str:
|
||||||
|
"""Vytáhne Bearer token z inline <script> na stránce VZP Point."""
|
||||||
|
scripts = page.evaluate(
|
||||||
|
"() => Array.from(document.querySelectorAll('script:not([src])')).map(s => s.textContent)"
|
||||||
|
)
|
||||||
|
for text in scripts:
|
||||||
|
m = re.search(r'"bearerToken"\s*:\s*"([^"]+)"', text)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
raise RuntimeError("bearerToken nenalezen na stránce")
|
||||||
|
|
||||||
|
|
||||||
|
def zjisti_obdobi(page) -> tuple[int, int]:
|
||||||
|
"""Vrátí nejnovější dostupné období (měsíc, rok) z configu formuláře Form65."""
|
||||||
|
token = _bearer_token(page)
|
||||||
|
cfg = page.evaluate(
|
||||||
|
"""async (token) => {
|
||||||
|
const r = await fetch('/api/desk/draft/form65/config',
|
||||||
|
{headers:{'Authorization':'Bearer '+token, 'Accept':'application/json'}});
|
||||||
|
return await r.json();
|
||||||
|
}""",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
period = (cfg.get("defaultModel") or {}).get("period") \
|
||||||
|
or (cfg.get("periodLimits") or {}).get("until") or {}
|
||||||
|
return int(period["month"]), int(period["year"])
|
||||||
|
|
||||||
|
|
||||||
|
def podej_zadost(page, mesic: int, rok: int) -> int | None:
|
||||||
|
"""Podá žádost 'Seznam registrovaných pojištěnců' (datové rozhraní) za období mesic/rok.
|
||||||
|
|
||||||
|
Vytvoří koncept (POST .../form65/{partnerId}) a publikuje ho
|
||||||
|
(POST .../form65/{draftId}/publish). Vrátí formId odeslaného podání nebo None.
|
||||||
|
"""
|
||||||
|
token = _bearer_token(page)
|
||||||
|
res = page.evaluate(
|
||||||
|
"""async ({token, partner, fmt, mesic, rok}) => {
|
||||||
|
const h = {'Authorization':'Bearer '+token,
|
||||||
|
'Content-Type':'application/json', 'Accept':'application/json'};
|
||||||
|
const r1 = await fetch('/api/desk/draft/form65/'+partner, {
|
||||||
|
method:'POST', headers:h,
|
||||||
|
body: JSON.stringify({outputFormat: fmt, period: {month: mesic, year: rok}})});
|
||||||
|
let j1=null; try { j1 = await r1.json(); } catch(e){}
|
||||||
|
if (!r1.ok || !j1 || !j1.draftId)
|
||||||
|
return {ok:false, step:'create', status:r1.status, body: JSON.stringify(j1)};
|
||||||
|
const r2 = await fetch('/api/desk/draft/form65/'+j1.draftId+'/publish', {
|
||||||
|
method:'POST', headers:h});
|
||||||
|
let j2=null; try { j2 = await r2.json(); } catch(e){}
|
||||||
|
return {ok: r2.ok, step:'publish', status:r2.status,
|
||||||
|
formId: j2 && j2.formId, state: j1.state};
|
||||||
|
}""",
|
||||||
|
{"token": token, "partner": PARTNER_ID, "fmt": OUTPUT_FORMAT, "mesic": mesic, "rok": rok},
|
||||||
|
)
|
||||||
|
if res.get("ok"):
|
||||||
|
print(f" OK — podání odesláno, formId: {res.get('formId')} (stav konceptu: {res.get('state')})")
|
||||||
|
return res.get("formId")
|
||||||
|
print(f" Podání selhalo ({res.get('step')}, HTTP {res.get('status')}): {res.get('body','')[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _seznam_podani_v_dom(page) -> list[dict]:
|
||||||
|
"""Vrátí podání 'Seznam registrovaných pojištěnců' aktuálně načtená v DOMu (pořadí = nejnovější první)."""
|
||||||
|
podani = page.evaluate(r"""() => {
|
||||||
|
return Array.from(document.querySelectorAll('a[href*="/Desk/Form/Detail/"]'))
|
||||||
|
.map(a => ({ text: (a.innerText || a.title || '').replace(/\s+/g, ' ').trim(),
|
||||||
|
href: a.getAttribute('href') }))
|
||||||
|
.filter(x => /Seznam registrovaných pojištěnců/i.test(x.text));
|
||||||
|
}""")
|
||||||
|
seen, out = set(), []
|
||||||
|
for p in podani:
|
||||||
|
if p["href"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(p["href"])
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _nacti_dalsi(page) -> bool:
|
||||||
|
"""Klikne 'Načíst další záznamy'. Vrátí True pokud tlačítko existovalo."""
|
||||||
|
clicked = page.evaluate("""() => {
|
||||||
|
const a = Array.from(document.querySelectorAll('a,button'))
|
||||||
|
.find(e => /Načíst další/i.test(e.innerText || ''));
|
||||||
|
if (a) { a.scrollIntoView(); a.click(); return true; }
|
||||||
|
return false;
|
||||||
|
}""")
|
||||||
|
if clicked:
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
return clicked
|
||||||
|
|
||||||
|
|
||||||
|
def stahni_davku_z_podani(page, href: str, already: set) -> tuple[int, bool]:
|
||||||
|
"""Otevře detail podání a stáhne přiloženou datovou dávku (F...).
|
||||||
|
|
||||||
|
Vrátí (počet_stažených, narazil_na_uz_stazenou). Druhý příznak je True, pokud
|
||||||
|
má podání dávku, kterou už máme v archivu — signál, že jsme dorazili do už
|
||||||
|
stažené minulosti a stahování lze ukončit.
|
||||||
|
"""
|
||||||
|
page.goto(POINT_URL + href, wait_until="networkidle", timeout=40_000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
fnames = page.evaluate(r"""() => Array.from(document.querySelectorAll('a'))
|
||||||
|
.map(a => (a.innerText || '').trim())
|
||||||
|
.filter(t => /^F\d{7}\.\d+$/.test(t))""")
|
||||||
|
fnames = list(dict.fromkeys(fnames))
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
hit_existing = False
|
||||||
|
for fname in fnames:
|
||||||
|
if fname in already or os.path.exists(os.path.join(DEST_DIR, fname)):
|
||||||
|
print(f" [stop] dávka už stažena: {fname}")
|
||||||
|
hit_existing = True
|
||||||
|
continue
|
||||||
|
link = page.locator("a", has_text=fname).first
|
||||||
|
try:
|
||||||
|
with page.expect_download(timeout=30_000) as di:
|
||||||
|
link.dispatch_event("click")
|
||||||
|
body = di.value
|
||||||
|
target = os.path.join(DEST_DIR, fname)
|
||||||
|
body.save_as(target)
|
||||||
|
with open(target, "rb") as fh:
|
||||||
|
head = fh.read(9)
|
||||||
|
if not head.decode("cp852", errors="ignore").startswith("H09305001"):
|
||||||
|
print(f" POZOR: {fname} nemá hlavičku H09305001 (přesto uloženo)")
|
||||||
|
print(f" OK: {fname}")
|
||||||
|
already.add(fname)
|
||||||
|
downloaded += 1
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Chyba při stahování {fname}: {e}")
|
||||||
|
return downloaded, hit_existing
|
||||||
|
|
||||||
|
|
||||||
|
def hlavni() -> None:
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
print("Chybí playwright: pip install playwright && playwright install chrome")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.makedirs(DEST_DIR, exist_ok=True)
|
||||||
|
_set_chrome_cert_policy()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
context = p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir=CHROME_PROFILE,
|
||||||
|
channel="chrome",
|
||||||
|
headless=False,
|
||||||
|
slow_mo=100,
|
||||||
|
ignore_https_errors=True,
|
||||||
|
accept_downloads=True,
|
||||||
|
args=["--force-renderer-accessibility"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
page = prihlaseni(context)
|
||||||
|
|
||||||
|
already = set(os.listdir(DEST_DIR))
|
||||||
|
print(f"V archivu: {len(already)} souborů.\n")
|
||||||
|
|
||||||
|
# Nasbírej podání 'Seznam...' — ODESLANÁ PODÁNÍ řadí od nejnovějšího.
|
||||||
|
# Dashboard míchá typy podání, proto je potřeba pár 'Načíst další'.
|
||||||
|
page.goto(DASHBOARD_URL, wait_until="networkidle", timeout=40_000)
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
for _ in range(MAX_LOADS):
|
||||||
|
if not _nacti_dalsi(page):
|
||||||
|
break
|
||||||
|
podani = _seznam_podani_v_dom(page)
|
||||||
|
print(f"Nalezeno podání '{PODANI_NAZEV}': {len(podani)}\n")
|
||||||
|
|
||||||
|
# Stahuj od nejnovějšího; jakmile narazíš na už staženou dávku, skonči
|
||||||
|
# (starší jsou všechny stažené — není třeba jít hlouběji do minulosti).
|
||||||
|
celkem = 0
|
||||||
|
for pdn in podani:
|
||||||
|
print(f"Podání: {pdn['text']}")
|
||||||
|
dl, hit_existing = stahni_davku_z_podani(page, pdn["href"], already)
|
||||||
|
celkem += dl
|
||||||
|
if hit_existing:
|
||||||
|
print("Dosaženo už stažené dávky — končím (starší jsou stažené).")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\nStaženo nových dávek: {celkem}")
|
||||||
|
|
||||||
|
# Podání žádosti o nový výpis (datové rozhraní) za zvolené období.
|
||||||
|
# Výsledek dorazí do ODESLANÝCH PODÁNÍ a stáhne se při příštím spuštění.
|
||||||
|
page.goto(DASHBOARD_URL, wait_until="networkidle", timeout=40_000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
mesic, rok = OVERRIDE_OBDOBI if OVERRIDE_OBDOBI else zjisti_obdobi(page)
|
||||||
|
print(f"\n=== Podávám žádost za období {mesic:02d}/{rok} ===")
|
||||||
|
podej_zadost(page, mesic, rok)
|
||||||
|
|
||||||
|
print("\nHotovo.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
_save_cookies(context)
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
hlavni()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# VoZP (201) — Stahování seznamu registrovaných pojištěnců
|
||||||
|
|
||||||
|
## Co skript dělá
|
||||||
|
|
||||||
|
`StahniSeznamPojistencuVoZP.py` provede v jednom spuštění:
|
||||||
|
|
||||||
|
1. **Přihlásí se** certifikátem na portál VoZP (čistý Python, bez NMSigneru)
|
||||||
|
— uloží cookies do sdíleného `StahováníZpráv/201 VoZP/vozp_cookies.json`
|
||||||
|
2. **Stáhne nové výpisy** ze schránky `vypis-registrovanych-pacientu-praktickeho-lekare`
|
||||||
|
— stahuje soubory s hlavičkou `H09305001` (PDF protokoly se přeskočí)
|
||||||
|
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
|
||||||
|
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
|
||||||
|
3. **Podá žádost** o aktuální výpis (datové rozhraní)
|
||||||
|
|
||||||
|
## Platforma
|
||||||
|
|
||||||
|
VoZP běží na stejné platformě jako **ZPŠ, OZP, RBP** (portalzp.cz / json-api).
|
||||||
|
Login identický, jen `BASE_URL = https://portal.vozp.cz`.
|
||||||
|
|
||||||
|
## Schránka a stažení
|
||||||
|
|
||||||
|
Schránka má **vlastní URL** (ne `schranky-vypis-pojistencu-v-kapitaci` jako OZP/RBP):
|
||||||
|
`/app/vypis-registrovanych-pacientu-praktickeho-lekare`
|
||||||
|
|
||||||
|
Stažení přílohy: GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
|
||||||
|
`fileId` z `onclick="SchrPolOpenFile(<id>)"`. Datové soubory `f201MMRR.001`, hlavička `H09305001`.
|
||||||
|
Ve schránce bývá i PDF protokol — header checkem se přeskočí.
|
||||||
|
|
||||||
|
## Podání žádosti
|
||||||
|
|
||||||
|
Formulář `106-zadost-o-vypis` je **nejjednodušší** — jen IČZ + Třídění, žádné datum ani typ.
|
||||||
|
Výpis je aktuální snímek registrovaných pacientů. Pro datový soubor se volí třídění = `d`
|
||||||
|
(Datové rozhraní). Žádný stav.json.
|
||||||
|
|
||||||
|
POST `https://portal.vozp.cz/json-api/formular-schranky/106-zadost-o-vypis/ulozit-formular`
|
||||||
|
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
|
||||||
|
|
||||||
|
### XML žádosti (řádky `\r\n`)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">
|
||||||
|
<PolozkaFiltru Nazev="nicoz">-109305000</PolozkaFiltru>
|
||||||
|
<PolozkaFiltru Nazev="trideni">d</PolozkaFiltru>
|
||||||
|
</SchrankaZadost>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Položka | Hodnota | Význam |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| `nicoz` | `-109305000` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). Pozor: záporné! Ověřeno. |
|
||||||
|
| `trideni` | `d` | `p`=příjmení, `i`=IČP+příjmení, `r`=rodná čísla, **`d`=Datové rozhraní** (datový soubor) |
|
||||||
|
|
||||||
|
### Podpis XML
|
||||||
|
|
||||||
|
PKCS7/SHA-256, **bez** certifikátu (`NoCerts`) — stejně jako ZPŠ/OZP/RBP.
|
||||||
|
|
||||||
|
## Jak byly endpointy zjištěny
|
||||||
|
|
||||||
|
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy + odchycený XHR na
|
||||||
|
`ulozit-formular`. První ostré podání: **ref. 179776197** (17.06.2026).
|
||||||
|
|
||||||
|
## Srovnání platformy portalzp.cz
|
||||||
|
|
||||||
|
| | ZPŠ (209) | OZP (207) | RBP (213) | VoZP (201) |
|
||||||
|
|--|-----------|-----------|-----------|------------|
|
||||||
|
| Schránka | schranka-vypis-… | schranky-vypis-… | schranky-vypis-… | vypis-registrovanych-pacientu-… |
|
||||||
|
| Formulář | 29-… | 108-… | 110-… | 106-… |
|
||||||
|
| Schránka/filtr | VypisPojKap / ZZ_VYP_REG | SEZNAM_KAP | VypisPojKap / ZZ_VYP_REG | SEZNAM_KAP |
|
||||||
|
| IČZ položka | icz=25520 | nicoz=13074913 | icz=933189 | nicoz=-109305000 |
|
||||||
|
| datum | poslední den měsíce | — | Ke dni (dnešek) | — |
|
||||||
|
| typ/trideni | razeni+typ=soubor | trideni=p+typ=soubor | razeni+typ=soubor | trideni=d (Datové rozhraní) |
|
||||||
|
|
||||||
|
## Soubory
|
||||||
|
|
||||||
|
| Soubor | Popis |
|
||||||
|
|--------|-------|
|
||||||
|
| `StahniSeznamPojistencuVoZP.py` | Hlavní skript — stažení výpisů + podání žádosti |
|
||||||
|
| `log_podani.json` | Historie podání s referenčními čísly |
|
||||||
|
|
||||||
|
## Parametry
|
||||||
|
|
||||||
|
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `-109305000`
|
||||||
|
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
|
||||||
|
|
||||||
|
## Stav
|
||||||
|
|
||||||
|
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (3 datové soubory, PDF přeskočeno),
|
||||||
|
podání ✓ (ref. 179776197). Výpis z prvního podání dorazí do schránky.
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""
|
||||||
|
Stahování seznamu registrovaných pojištěnců VoZP (201) — čistý Python, bez NMSigneru.
|
||||||
|
|
||||||
|
VoZP běží na stejné platformě jako ZPŠ/OZP/RBP (portalzp.cz / json-api), s rozdíly:
|
||||||
|
- schránka: /app/vypis-registrovanych-pacientu-praktickeho-lekare
|
||||||
|
- formulář: 106-zadost-o-vypis
|
||||||
|
- filtr XML: NazevSchranky = NazevFiltru = "SEZNAM_KAP" (jako OZP)
|
||||||
|
- položky: nicoz (interní ID = -109305000), trideni (p/i/r/d)
|
||||||
|
trideni="d" = Datové rozhraní → datový soubor f201MMRR.001
|
||||||
|
- BEZ pole "datum" a BEZ pole "typ" — výpis je aktuální snímek registrovaných pacientů.
|
||||||
|
|
||||||
|
Co skript dělá v jednom spuštění:
|
||||||
|
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
|
||||||
|
2. Stáhne nové výpisy ze schránky (soubory s hlavičkou H09305001)
|
||||||
|
3. Znovu se přihlásí (Playwright invaliduje requests session)
|
||||||
|
4. Podá jednu žádost o aktuální výpis (datové rozhraní)
|
||||||
|
|
||||||
|
Log podání: log_podani.json — seznam { ref_cislo, podano_kdy }
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
|
||||||
|
from Knihovny.najdi_dropbox import get_dropbox_root
|
||||||
|
|
||||||
|
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
|
||||||
|
PFX_PASSWORD = b"Vlado7309208104++"
|
||||||
|
|
||||||
|
BASE_URL = "https://portal.vozp.cz"
|
||||||
|
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
|
||||||
|
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
|
||||||
|
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/106-zadost-o-vypis/ulozit-formular"
|
||||||
|
|
||||||
|
VYPIS_URL = f"{BASE_URL}/app/vypis-registrovanych-pacientu-praktickeho-lekare"
|
||||||
|
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
|
||||||
|
|
||||||
|
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
|
||||||
|
ICZ_INTERNAL = "-109305000" # IČZ 09305000 — interní ID položky "nicoz"
|
||||||
|
TRIDENI = "d" # p=příjmení, i=IČP+příjmení, r=rodná čísla, d=Datové rozhraní
|
||||||
|
|
||||||
|
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
|
||||||
|
HLAVICKA = "H09305001"
|
||||||
|
|
||||||
|
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
|
||||||
|
|
||||||
|
# Sdílené soubory s VoZP skriptem pro stahování zpráv
|
||||||
|
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "201 VoZP"))
|
||||||
|
COOKIES_FILE = os.path.join(STAHUJ_DIR, "vozp_cookies.json")
|
||||||
|
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
|
||||||
|
DOWNLOAD_DIR = os.path.join(
|
||||||
|
get_dropbox_root(),
|
||||||
|
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Přihlášení
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def prihlaseni() -> requests.Session:
|
||||||
|
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update({
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Origin": BASE_URL,
|
||||||
|
"Referer": BASE_URL + "/",
|
||||||
|
})
|
||||||
|
|
||||||
|
r = session.get(f"{BASE_URL}/app/prihlaseni")
|
||||||
|
r.raise_for_status()
|
||||||
|
session.cookies.set("pzp_sign", "CERT", domain="portal.vozp.cz", path="/")
|
||||||
|
|
||||||
|
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
|
||||||
|
headers={"Content-Type": "application/json; charset=UTF-8"})
|
||||||
|
r.raise_for_status()
|
||||||
|
zprava = r.json()["data"]["zprava"]
|
||||||
|
|
||||||
|
with open(PFX_PATH, "rb") as f:
|
||||||
|
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
|
||||||
|
|
||||||
|
podpis = (
|
||||||
|
pkcs7.PKCS7SignatureBuilder()
|
||||||
|
.set_data(zprava.encode("utf-8"))
|
||||||
|
.add_signer(cert, private_key, hashes.SHA256())
|
||||||
|
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
|
||||||
|
.decode("ascii").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
|
||||||
|
headers={"Content-Type": "application/json; charset=UTF-8"})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()["data"]
|
||||||
|
|
||||||
|
if not data.get("prihlasen"):
|
||||||
|
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
|
||||||
|
|
||||||
|
print("Přihlášení úspěšné!")
|
||||||
|
|
||||||
|
cookies = [
|
||||||
|
{
|
||||||
|
"name": c.name,
|
||||||
|
"value": c.value,
|
||||||
|
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
|
||||||
|
"path": c.path or "/",
|
||||||
|
"expires": int(c.expires) if c.expires else -1,
|
||||||
|
"secure": bool(c.secure),
|
||||||
|
"httpOnly": False,
|
||||||
|
"sameSite": "Lax",
|
||||||
|
}
|
||||||
|
for c in session.cookies
|
||||||
|
]
|
||||||
|
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cookies, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stahování z výpisové schránky
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def safe_filename(name: str) -> str:
|
||||||
|
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str: str) -> str:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
return "0000-00-00"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_row(cells: list) -> dict:
|
||||||
|
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
|
||||||
|
date_raw = cells[1].strip() if len(cells) > 1 else ""
|
||||||
|
desc_raw = cells[2].strip() if len(cells) > 2 else ""
|
||||||
|
fname_raw = cells[3].strip() if len(cells) > 3 else ""
|
||||||
|
|
||||||
|
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
|
||||||
|
if len(desc_lines) >= 3:
|
||||||
|
description = desc_lines[2]
|
||||||
|
elif len(desc_lines) >= 2:
|
||||||
|
description = desc_lines[1]
|
||||||
|
else:
|
||||||
|
description = desc_lines[0] if desc_lines else ""
|
||||||
|
description = description[:80]
|
||||||
|
|
||||||
|
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
|
||||||
|
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
|
||||||
|
orig_path = Path(original)
|
||||||
|
stem = orig_path.stem or "zprava"
|
||||||
|
ext = orig_path.suffix or ""
|
||||||
|
|
||||||
|
date_iso = parse_date(date_raw)
|
||||||
|
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
|
||||||
|
if len(name) > 240:
|
||||||
|
name = f"{date_iso} ({safe_filename(stem)}){ext}"
|
||||||
|
|
||||||
|
return {"date": date_iso, "desc": description, "original": original, "filename": name}
|
||||||
|
|
||||||
|
|
||||||
|
def stahni_nove_vypisy() -> int:
|
||||||
|
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
print("Chybí playwright: pip install playwright && playwright install chrome")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
with open(COOKIES_FILE, encoding="utf-8") as f:
|
||||||
|
cookies = json.load(f)
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
context = p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir=CHROME_PROFILE,
|
||||||
|
channel="chrome",
|
||||||
|
headless=False,
|
||||||
|
slow_mo=100,
|
||||||
|
ignore_https_errors=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
context.add_cookies(cookies)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
|
||||||
|
if "prihlaseni" in page.url or "login" in page.url.lower():
|
||||||
|
print("Session v prohlížeči expirovala — stahování přeskočeno")
|
||||||
|
return 0
|
||||||
|
print("Prohlížeč přihlášen OK\n")
|
||||||
|
|
||||||
|
already = set(os.listdir(DOWNLOAD_DIR))
|
||||||
|
print(f"V archivu: {len(already)} souborů.\n")
|
||||||
|
|
||||||
|
page_num = 1
|
||||||
|
seen_ids: set = set()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
url = f"{VYPIS_URL}/stranka-{page_num}"
|
||||||
|
print(f" Stránka {page_num}: {url}")
|
||||||
|
try:
|
||||||
|
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Navigace selhala: {e}")
|
||||||
|
break
|
||||||
|
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||||
|
|
||||||
|
data = page.evaluate("""() => {
|
||||||
|
const rows = [];
|
||||||
|
for (const tr of document.querySelectorAll('table tr')) {
|
||||||
|
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
|
||||||
|
if (cells.length < 4) continue;
|
||||||
|
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
|
||||||
|
if (!dlLink) continue;
|
||||||
|
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
|
||||||
|
rows.push({ cells, fileId: mFile ? mFile[0] : null });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}""")
|
||||||
|
rows = [r for r in data if r["fileId"]]
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
|
||||||
|
break
|
||||||
|
|
||||||
|
current_ids = {r["fileId"] for r in rows}
|
||||||
|
if current_ids & seen_ids:
|
||||||
|
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
|
||||||
|
break
|
||||||
|
seen_ids.update(current_ids)
|
||||||
|
print(f" Nalezeno {len(rows)} zpráv.")
|
||||||
|
|
||||||
|
stop = False
|
||||||
|
for row in rows:
|
||||||
|
info = parse_row(row["cells"])
|
||||||
|
target = os.path.join(DOWNLOAD_DIR, info["filename"])
|
||||||
|
|
||||||
|
if info["filename"] in already or os.path.exists(target):
|
||||||
|
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
|
||||||
|
stop = True
|
||||||
|
break
|
||||||
|
|
||||||
|
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
|
||||||
|
try:
|
||||||
|
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
|
||||||
|
if not r.ok:
|
||||||
|
print(f" HTTP {r.status} příloha (id={row['fileId']})")
|
||||||
|
else:
|
||||||
|
body = r.body()
|
||||||
|
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
|
||||||
|
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
|
||||||
|
else:
|
||||||
|
with open(target, "wb") as fh:
|
||||||
|
fh.write(body)
|
||||||
|
print(f" OK: {info['filename']}")
|
||||||
|
already.add(info["filename"])
|
||||||
|
downloaded += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Chyba příloha (id={row['fileId']}): {e}")
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
if stop:
|
||||||
|
break
|
||||||
|
|
||||||
|
page_num += 1
|
||||||
|
|
||||||
|
finally:
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sestavení XML a podpis žádosti
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_xml() -> str:
|
||||||
|
"""Sestaví XML žádosti o aktuální výpis registrovaných pacientů (datové rozhraní)."""
|
||||||
|
return (
|
||||||
|
f'<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">\r\n'
|
||||||
|
f'<PolozkaFiltru Nazev="nicoz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
|
||||||
|
f'<PolozkaFiltru Nazev="trideni">{TRIDENI}</PolozkaFiltru>\r\n'
|
||||||
|
f'</SchrankaZadost>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_xml(xml: str) -> str:
|
||||||
|
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
|
||||||
|
with open(PFX_PATH, "rb") as f:
|
||||||
|
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
|
||||||
|
|
||||||
|
pem = (
|
||||||
|
pkcs7.PKCS7SignatureBuilder()
|
||||||
|
.set_data(xml.encode("utf-8"))
|
||||||
|
.add_signer(cert, private_key, hashes.SHA256())
|
||||||
|
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
|
||||||
|
.decode("ascii")
|
||||||
|
)
|
||||||
|
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def odeslat_zadost(session: requests.Session) -> str | None:
|
||||||
|
"""Odešle podepsanou žádost o aktuální výpis. Vrátí referenční číslo nebo None."""
|
||||||
|
xml = build_xml()
|
||||||
|
podpis = sign_xml(xml)
|
||||||
|
|
||||||
|
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
|
||||||
|
|
||||||
|
r = session.post(SUBMIT_URL, json=payload, headers={
|
||||||
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Referer": BASE_URL + "/",
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = r.json()
|
||||||
|
except Exception:
|
||||||
|
print(f" Odpověď není JSON: {r.text[:300]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
resp_str = json.dumps(resp, ensure_ascii=False)
|
||||||
|
|
||||||
|
if resp.get("errMsg") or resp.get("error"):
|
||||||
|
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
|
||||||
|
ref = m.group(1) if m else None
|
||||||
|
|
||||||
|
if ref:
|
||||||
|
print(f" OK — ref. číslo: {ref}")
|
||||||
|
else:
|
||||||
|
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
|
||||||
|
|
||||||
|
return ref or ("OK" if r.ok else None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def uloz_log(ref_cislo: str) -> None:
|
||||||
|
log = []
|
||||||
|
if os.path.exists(LOG_FILE):
|
||||||
|
with open(LOG_FILE, encoding="utf-8") as f:
|
||||||
|
log = json.load(f)
|
||||||
|
log.append({
|
||||||
|
"ref_cislo": ref_cislo,
|
||||||
|
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
})
|
||||||
|
with open(LOG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(log, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hlavní funkce
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def hlavni() -> None:
|
||||||
|
# 1. Přihlášení — uloží cookies pro Playwright
|
||||||
|
prihlaseni()
|
||||||
|
|
||||||
|
# 2. Stažení nových výpisů z výpisové schránky
|
||||||
|
print("\n=== Stahování nových výpisů ===")
|
||||||
|
stazeno = stahni_nove_vypisy()
|
||||||
|
print(f"Staženo: {stazeno} souborů.\n")
|
||||||
|
|
||||||
|
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
|
||||||
|
print("=== Znovu přihlašuji před podáním ===")
|
||||||
|
session = prihlaseni()
|
||||||
|
|
||||||
|
# 4. Podání žádosti o aktuální výpis
|
||||||
|
print("=== Podávám žádost o aktuální výpis (datové rozhraní) ===")
|
||||||
|
ref = odeslat_zadost(session)
|
||||||
|
|
||||||
|
if ref:
|
||||||
|
uloz_log(ref)
|
||||||
|
print(f"\nHotovo — žádost podána, ref: {ref}")
|
||||||
|
else:
|
||||||
|
print("\nPodání selhalo — žádost nebyla zaevidována.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
hlavni()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"ref_cislo": "179776533",
|
||||||
|
"podano_kdy": "2026-06-17 05:48:36"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# ZPMVČR (211) — Stahování seznamu registrovaných pojištěnců
|
||||||
|
|
||||||
|
## Co skript dělá
|
||||||
|
|
||||||
|
`StahniSeznamPojistencuZPMVCR.py` (čistý Python, requests + bs4):
|
||||||
|
|
||||||
|
1. **Přihlásí se** PIN + heslem (POST formulář, bez certifikátu / NMSigneru)
|
||||||
|
2. **Projde stránkovaný přehled** všech registrací pro IČP 09305001
|
||||||
|
3. **Uloží CSV** do `…\Zúčtovací zprávy\SeznamyPojištěnců\`
|
||||||
|
|
||||||
|
## Platforma — ODLIŠNÁ od ostatních
|
||||||
|
|
||||||
|
ZPMVČR běží na **eforms.zpmvcr.cz**, NE na portalzp.cz. Žádné certifikáty, žádné schránky,
|
||||||
|
žádné datové rozhraní .001. Login je PIN + heslo.
|
||||||
|
|
||||||
|
## Zásadní rozdíl: NENÍ datový soubor
|
||||||
|
|
||||||
|
Ostatní pojišťovny dávají datový soubor (.001 / F-soubor). ZPMVČR **nemá** ekvivalent:
|
||||||
|
- EP2 sekce (`dokumenty_ke_stazeni/ep2`) je prázdná — *"nebylo stahování dokumentů nastaveno"*.
|
||||||
|
- Jediný zdroj seznamu je **HTML přehled** na stránce `registrovani_pojistenci`,
|
||||||
|
který se musí naparsovat → proto výstupem je **CSV**, ne datový soubor.
|
||||||
|
|
||||||
|
## Přihlášení
|
||||||
|
|
||||||
|
POST `https://eforms.zpmvcr.cz/eforms/ekomunikace`
|
||||||
|
Pole: `pin` (9023895287), `pin2` (prázdné), `pwd` (heslo).
|
||||||
|
|
||||||
|
## Stažení seznamu
|
||||||
|
|
||||||
|
POST `https://eforms.zpmvcr.cz/eforms/smluvni_zdravotnicke_zarizeni/registrovani_pojistenci`
|
||||||
|
|
||||||
|
| Pole | Hodnota | Význam |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `icp` | `09305001` | IČP (nebo "Vše") |
|
||||||
|
| `arztart` | `` (prázdné = Vše) | odbornost D/G/P/S |
|
||||||
|
| `mesic` / `rok` | aktuální měsíc/rok | období |
|
||||||
|
| `registrace` | `3` | 1=platné, 2=neplatné, **3=všechny** |
|
||||||
|
| `tridit` | `1` | 1=příjmení, 2=číslo pojištěnce |
|
||||||
|
| `vyhledat` | `Vyhledat` | submit |
|
||||||
|
|
||||||
|
Výsledek je **stránkovaný** (~20 řádků/strana). Další strany: POST + pole `page=N`.
|
||||||
|
Řádky v HTML: `<tr class="c1|c2">`, hodnoty za `<span class="responsiveColumn">Label:</span>`.
|
||||||
|
Hláška "Přehled ... (celkem N)" udává očekávaný počet (kontrola úplnosti).
|
||||||
|
|
||||||
|
## CSV výstup
|
||||||
|
|
||||||
|
Soubor `YYYY-MM-DD 211 ZPMVČR vsechny registrace.csv`, kódování utf-8-sig (Excel),
|
||||||
|
oddělovač `;`. Sloupce: Číslo pojištěnce; Titul; Příjmení; Jméno; Registrace od; Registrace do.
|
||||||
|
|
||||||
|
## Soubory
|
||||||
|
|
||||||
|
| Soubor | Popis |
|
||||||
|
|--------|-------|
|
||||||
|
| `StahniSeznamPojistencuZPMVCR.py` | Hlavní skript — login + scrape přehledu → CSV |
|
||||||
|
|
||||||
|
## Parametry
|
||||||
|
|
||||||
|
- **IČP**: 09305001 (MUDr. Michaela Buzalková)
|
||||||
|
- **Login**: PIN 9023895287 + heslo (v kódu, stejně jako StahováníZpráv/211)
|
||||||
|
|
||||||
|
## Stav
|
||||||
|
|
||||||
|
Hotovo a otestováno (17.06.2026): login ✓, staženo 172 registrací (9 stran, sedí s "celkem 172"),
|
||||||
|
CSV uloženo. Volba uživatele: VŠECHNY registrace (registrace=3).
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Stahování seznamu registrovaných pojištěnců ZPMVČR (211) — čistý Python (requests + bs4).
|
||||||
|
|
||||||
|
ZPMVČR běží na ODLIŠNÉ platformě (eforms.zpmvcr.cz) — ne portalzp.cz:
|
||||||
|
- login: PIN + heslo (POST formulář), bez certifikátu a bez NMSigneru
|
||||||
|
- seznam: NENÍ datový soubor jako u ostatních pojišťoven (EP2 sekce je prázdná).
|
||||||
|
Jediný zdroj je HTML "Přehled registrací" na stránce registrovani_pojistenci,
|
||||||
|
který se naparsuje a uloží jako CSV.
|
||||||
|
|
||||||
|
Co skript dělá:
|
||||||
|
1. Přihlásí se (PIN + heslo)
|
||||||
|
2. Projde stránkovaný přehled VŠECH registrací (platné i neplatné) pro IČP 09305001
|
||||||
|
3. Uloží výsledek jako CSV do složky SeznamyPojištěnců (sloupce níže)
|
||||||
|
|
||||||
|
CSV sloupce: Číslo pojištěnce; Titul; Příjmení; Jméno; Registrace od; Registrace do
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
|
||||||
|
from Knihovny.najdi_dropbox import get_dropbox_root
|
||||||
|
|
||||||
|
# ── Přihlašovací údaje ────────────────────────────────────────────────────────
|
||||||
|
PIN = "9023895287"
|
||||||
|
PIN2 = ""
|
||||||
|
HESLO = "Ax162q8+"
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BASE_URL = "https://eforms.zpmvcr.cz"
|
||||||
|
LOGIN_URL = f"{BASE_URL}/eforms/ekomunikace"
|
||||||
|
SEZNAM_URL = f"{BASE_URL}/eforms/smluvni_zdravotnicke_zarizeni/registrovani_pojistenci"
|
||||||
|
|
||||||
|
ICP = "09305001" # IČP MUDr. Michaela Buzalková
|
||||||
|
REGISTRACE = "3" # 1=platné, 2=neplatné, 3=všechny
|
||||||
|
TRIDIT = "1" # 1=příjmení, 2=číslo pojištěnce
|
||||||
|
|
||||||
|
CSV_HLAVICKA = ["Číslo pojištěnce", "Titul", "Příjmení", "Jméno", "Registrace od", "Registrace do"]
|
||||||
|
|
||||||
|
DEST_DIR = os.path.join(
|
||||||
|
get_dropbox_root(),
|
||||||
|
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prihlaseni() -> requests.Session:
|
||||||
|
"""Přihlásí se PIN + heslem, vrátí session."""
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
|
||||||
|
session.get(LOGIN_URL, timeout=15).raise_for_status()
|
||||||
|
r = session.post(LOGIN_URL, data={"pin": PIN, "pin2": PIN2, "pwd": HESLO}, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
if 'name="pin"' in r.text and "Přihlásit" in r.text:
|
||||||
|
raise RuntimeError("Přihlášení selhalo — zkontroluj PIN a heslo")
|
||||||
|
|
||||||
|
print("Přihlášení úspěšné!")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rows(html: str) -> list[list[str]]:
|
||||||
|
"""Naparsuje řádky přehledu. Vrátí seznam [číslo, titul, příjmení, jméno, reg_od, reg_do]."""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
rows = []
|
||||||
|
for tr in soup.select("tr.c1, tr.c2"):
|
||||||
|
vals = []
|
||||||
|
for td in tr.find_all("td"):
|
||||||
|
for sp in td.select("span.responsiveColumn"):
|
||||||
|
sp.extract()
|
||||||
|
vals.append(td.get_text(strip=True))
|
||||||
|
# platný datový řádek má vyplněné číslo pojištěnce v prvním sloupci
|
||||||
|
if len(vals) >= 6 and vals[0]:
|
||||||
|
rows.append(vals[:6])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def precti_celkem(html: str) -> int | None:
|
||||||
|
"""Z hlášky 'Přehled ... (celkem N)' získá očekávaný počet."""
|
||||||
|
import re
|
||||||
|
m = re.search(r"celkem\s+(\d+)", html)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def stahni_seznam(session: requests.Session) -> list[list[str]]:
|
||||||
|
"""Projde stránkovaný přehled a vrátí všechny řádky."""
|
||||||
|
base_data = {
|
||||||
|
"icp": ICP, "arztart": "",
|
||||||
|
"mesic": str(date.today().month), "rok": str(date.today().year),
|
||||||
|
"registrace": REGISTRACE, "tridit": TRIDIT, "vyhledat": "Vyhledat",
|
||||||
|
}
|
||||||
|
|
||||||
|
vsechny: list[list[str]] = []
|
||||||
|
videno: set = set()
|
||||||
|
celkem_ocekavano = None
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
while page <= 200:
|
||||||
|
data = dict(base_data)
|
||||||
|
if page > 1:
|
||||||
|
data["page"] = str(page)
|
||||||
|
r = session.post(SEZNAM_URL, data=data, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
if celkem_ocekavano is None:
|
||||||
|
celkem_ocekavano = precti_celkem(r.text)
|
||||||
|
if celkem_ocekavano is not None:
|
||||||
|
print(f"Přehled hlásí celkem {celkem_ocekavano} registrací.")
|
||||||
|
|
||||||
|
rows = parse_rows(r.text)
|
||||||
|
nove = [row for row in rows if tuple(row) not in videno]
|
||||||
|
|
||||||
|
if not nove:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in nove:
|
||||||
|
videno.add(tuple(row))
|
||||||
|
vsechny.extend(nove)
|
||||||
|
print(f" Strana {page}: +{len(nove)} (celkem {len(vsechny)})")
|
||||||
|
|
||||||
|
# poslední strana — méně řádků než plná stránka
|
||||||
|
if len(rows) < 20:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
if celkem_ocekavano is not None and len(vsechny) != celkem_ocekavano:
|
||||||
|
print(f" POZOR: staženo {len(vsechny)}, ale přehled hlásil {celkem_ocekavano}.")
|
||||||
|
|
||||||
|
return vsechny
|
||||||
|
|
||||||
|
|
||||||
|
def uloz_csv(rows: list[list[str]]) -> str:
|
||||||
|
"""Uloží řádky jako CSV (Excel-friendly: utf-8-sig, oddělovač ;). Vrátí cestu."""
|
||||||
|
os.makedirs(DEST_DIR, exist_ok=True)
|
||||||
|
dnes = date.today().strftime("%Y-%m-%d")
|
||||||
|
filename = f"{dnes} 211 ZPMVČR vsechny registrace.csv"
|
||||||
|
path = os.path.join(DEST_DIR, filename)
|
||||||
|
|
||||||
|
with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
w = csv.writer(f, delimiter=";")
|
||||||
|
w.writerow(CSV_HLAVICKA)
|
||||||
|
w.writerows(rows)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def hlavni() -> None:
|
||||||
|
session = prihlaseni()
|
||||||
|
|
||||||
|
print("\n=== Stahování přehledu registrací ===")
|
||||||
|
rows = stahni_seznam(session)
|
||||||
|
print(f"Staženo: {len(rows)} registrací.")
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print("Žádné registrace — CSV se neuloží.")
|
||||||
|
return
|
||||||
|
|
||||||
|
path = uloz_csv(rows)
|
||||||
|
print(f"\nHotovo — uloženo: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
hlavni()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# RBP (213) — Stahování seznamu registrovaných pojištěnců
|
||||||
|
|
||||||
|
## Co skript dělá
|
||||||
|
|
||||||
|
`StahniSeznamPojistencuRBP.py` provede v jednom spuštění:
|
||||||
|
|
||||||
|
1. **Přihlásí se** certifikátem na portál RBP (čistý Python, bez NMSigneru)
|
||||||
|
— uloží cookies do sdíleného `StahováníZpráv/213 RBP/rbp_cookies.json`
|
||||||
|
2. **Stáhne nové výpisy** z výpisové schránky `schranky-vypis-pojistencu-v-kapitaci`
|
||||||
|
— stahuje soubory, jejichž obsah začíná `H09305001` (textové `odpoved.txt` se přeskočí)
|
||||||
|
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
|
||||||
|
— zastaví se při první již stažené zprávě
|
||||||
|
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
|
||||||
|
3. **Podá žádost** o výpis ke dnešnímu dni (typ=soubor, třídění dle příjmení)
|
||||||
|
|
||||||
|
## Platforma
|
||||||
|
|
||||||
|
RBP běží na stejné platformě jako **ZPŠ, OZP, VoZP** (portalzp.cz / json-api).
|
||||||
|
Login identický se ZPŠ/OZP, jen `BASE_URL = https://portal.rbp-zp.cz`.
|
||||||
|
|
||||||
|
## Stažení přílohy
|
||||||
|
|
||||||
|
GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
|
||||||
|
`fileId` z `onclick="SchrPolOpenFile(<id>)"`. Datový výpis má hlavičku `H09305001`.
|
||||||
|
|
||||||
|
## Podání žádosti
|
||||||
|
|
||||||
|
RBP je **hybrid ZPŠ/OZP**: schránka/filtr jako ZPŠ, ale `datum` je „Ke dni" (aktuální
|
||||||
|
snímek platných registrací k danému dni, default dnešní datum). Nepočítá se měsíc,
|
||||||
|
žádný stav.json — při každém běhu se podá žádost ke dni `date.today()`.
|
||||||
|
|
||||||
|
POST `https://portal.rbp-zp.cz/json-api/formular-schranky/110-vypis-pojistencu-reg-u-pzs/ulozit-formular`
|
||||||
|
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
|
||||||
|
|
||||||
|
### XML žádosti (řádky `\r\n`)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">
|
||||||
|
<PolozkaFiltru Nazev="icz">933189</PolozkaFiltru>
|
||||||
|
<PolozkaFiltru Nazev="datum">17.06.2026</PolozkaFiltru>
|
||||||
|
<PolozkaFiltru Nazev="razeni">jmeno</PolozkaFiltru>
|
||||||
|
<PolozkaFiltru Nazev="typ">soubor</PolozkaFiltru>
|
||||||
|
</SchrankaZadost>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Položka | Hodnota | Význam |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| `icz` | `933189` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). |
|
||||||
|
| `datum` | `DD.MM.YYYY` | „Ke dni" — den, ke kterému chceme snímek (použijeme dnešek). |
|
||||||
|
| `razeni` | `jmeno` | `jmeno`=příjmení a jména, `rc`=rodná čísla |
|
||||||
|
| `typ` | `soubor` | `soubor`=datový soubor netříděno, `sestava`=PDF |
|
||||||
|
|
||||||
|
### Podpis XML
|
||||||
|
|
||||||
|
PKCS7/SHA-256, **bez** certifikátu (`NoCerts`) — stejně jako ZPŠ/OZP.
|
||||||
|
|
||||||
|
## Jak byly endpointy zjištěny
|
||||||
|
|
||||||
|
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy + odchycený XHR na
|
||||||
|
`ulozit-formular`. Skrytý input datumu vypadal jako JWT, ale odchycený XML potvrdil
|
||||||
|
prostý formát `DD.MM.YYYY`. První ostré podání: **ref. 179775430** (17.06.2026).
|
||||||
|
|
||||||
|
## Srovnání
|
||||||
|
|
||||||
|
| | ZPŠ (209) | OZP (207) | RBP (213) |
|
||||||
|
|--|-----------|-----------|-----------|
|
||||||
|
| NazevSchranky | `VypisPojKap` | `SEZNAM_KAP` | `VypisPojKap` |
|
||||||
|
| NazevFiltru | `ZZ_VYP_REG` | `SEZNAM_KAP` | `ZZ_VYP_REG` |
|
||||||
|
| Formulář | `29-…` | `108-…` | `110-…` |
|
||||||
|
| Položka IČZ | `icz`=25520 | `nicoz`=13074913 | `icz`=933189 |
|
||||||
|
| Pole datum | ano (poslední den měsíce) | ne | ano (Ke dni, dnešek) |
|
||||||
|
| razeni / typ | jmeno / soubor | trideni=p / typ=soubor | jmeno / soubor |
|
||||||
|
|
||||||
|
## Soubory
|
||||||
|
|
||||||
|
| Soubor | Popis |
|
||||||
|
|--------|-------|
|
||||||
|
| `StahniSeznamPojistencuRBP.py` | Hlavní skript — stažení výpisů + podání žádosti |
|
||||||
|
| `log_podani.json` | Historie podání s referenčními čísly |
|
||||||
|
|
||||||
|
## Parametry
|
||||||
|
|
||||||
|
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `933189`
|
||||||
|
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
|
||||||
|
|
||||||
|
## Stav
|
||||||
|
|
||||||
|
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (odpoved.txt správně přeskočeny),
|
||||||
|
podání ✓ (ref. 179775430). Výpis z prvního podání dorazí do schránky do příštího dne
|
||||||
|
— při dalším spuštění ověřit, že hlavička `H09305001` u RBP datového souboru sedí.
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"""
|
||||||
|
Stahování seznamu registrovaných pojištěnců RBP (213) — čistý Python, bez NMSigneru.
|
||||||
|
|
||||||
|
RBP běží na stejné platformě jako ZPŠ/OZP/VoZP (portalzp.cz / json-api).
|
||||||
|
- schránka: /app/schranky-vypis-pojistencu-v-kapitaci
|
||||||
|
- formulář: 110-vypis-pojistencu-reg-u-pzs
|
||||||
|
- filtr XML: NazevSchranky="VypisPojKap", NazevFiltru="ZZ_VYP_REG" (jako ZPŠ)
|
||||||
|
- položky: icz (interní ID), datum (Ke dni), razeni (jmeno/rc), typ (soubor/sestava)
|
||||||
|
- datum = "Ke dni" aktuální snímek platných registrací — použijeme dnešní datum,
|
||||||
|
nepočítá se měsíc, žádný stav.json (jako OZP).
|
||||||
|
|
||||||
|
Co skript dělá v jednom spuštění:
|
||||||
|
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
|
||||||
|
2. Stáhne nové výpisy z výpisové schránky (soubory s hlavičkou H09305001)
|
||||||
|
3. Znovu se přihlásí (Playwright invaliduje requests session)
|
||||||
|
4. Podá jednu žádost o aktuální výpis ke dnešnímu dni
|
||||||
|
|
||||||
|
Log podání: log_podani.json — seznam { ref_cislo, datum, podano_kdy }
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
|
||||||
|
|
||||||
|
# UTF-8 výstup i na Windows konzoli
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
|
||||||
|
from Knihovny.najdi_dropbox import get_dropbox_root
|
||||||
|
|
||||||
|
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
|
||||||
|
PFX_PASSWORD = b"Vlado7309208104++"
|
||||||
|
|
||||||
|
BASE_URL = "https://portal.rbp-zp.cz"
|
||||||
|
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
|
||||||
|
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
|
||||||
|
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/110-vypis-pojistencu-reg-u-pzs/ulozit-formular"
|
||||||
|
|
||||||
|
VYPIS_URL = f"{BASE_URL}/app/schranky-vypis-pojistencu-v-kapitaci"
|
||||||
|
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
|
||||||
|
|
||||||
|
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
|
||||||
|
ICZ_INTERNAL = "933189" # IČZ 09305000 — interní ID položky "icz"
|
||||||
|
RAZENI = "jmeno" # jmeno = příjmení a jména, rc = rodná čísla
|
||||||
|
TYP = "soubor" # soubor = datový soubor, sestava = PDF sestava
|
||||||
|
|
||||||
|
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
|
||||||
|
HLAVICKA = "H09305001"
|
||||||
|
|
||||||
|
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
|
||||||
|
|
||||||
|
# Sdílené soubory s RBP skriptem pro stahování zpráv
|
||||||
|
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "213 RBP"))
|
||||||
|
COOKIES_FILE = os.path.join(STAHUJ_DIR, "rbp_cookies.json")
|
||||||
|
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
|
||||||
|
DOWNLOAD_DIR = os.path.join(
|
||||||
|
get_dropbox_root(),
|
||||||
|
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Přihlášení
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def prihlaseni() -> requests.Session:
|
||||||
|
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update({
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Origin": BASE_URL,
|
||||||
|
"Referer": BASE_URL + "/",
|
||||||
|
})
|
||||||
|
|
||||||
|
r = session.get(f"{BASE_URL}/app/prihlaseni")
|
||||||
|
r.raise_for_status()
|
||||||
|
session.cookies.set("pzp_sign", "CERT", domain="portal.rbp-zp.cz", path="/")
|
||||||
|
|
||||||
|
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
|
||||||
|
headers={"Content-Type": "application/json; charset=UTF-8"})
|
||||||
|
r.raise_for_status()
|
||||||
|
zprava = r.json()["data"]["zprava"]
|
||||||
|
|
||||||
|
with open(PFX_PATH, "rb") as f:
|
||||||
|
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
|
||||||
|
|
||||||
|
podpis = (
|
||||||
|
pkcs7.PKCS7SignatureBuilder()
|
||||||
|
.set_data(zprava.encode("utf-8"))
|
||||||
|
.add_signer(cert, private_key, hashes.SHA256())
|
||||||
|
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
|
||||||
|
.decode("ascii").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
|
||||||
|
headers={"Content-Type": "application/json; charset=UTF-8"})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()["data"]
|
||||||
|
|
||||||
|
if not data.get("prihlasen"):
|
||||||
|
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
|
||||||
|
|
||||||
|
print("Přihlášení úspěšné!")
|
||||||
|
|
||||||
|
cookies = [
|
||||||
|
{
|
||||||
|
"name": c.name,
|
||||||
|
"value": c.value,
|
||||||
|
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
|
||||||
|
"path": c.path or "/",
|
||||||
|
"expires": int(c.expires) if c.expires else -1,
|
||||||
|
"secure": bool(c.secure),
|
||||||
|
"httpOnly": False,
|
||||||
|
"sameSite": "Lax",
|
||||||
|
}
|
||||||
|
for c in session.cookies
|
||||||
|
]
|
||||||
|
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cookies, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stahování z výpisové schránky
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def safe_filename(name: str) -> str:
|
||||||
|
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str: str) -> str:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
return "0000-00-00"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_row(cells: list) -> dict:
|
||||||
|
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
|
||||||
|
date_raw = cells[1].strip() if len(cells) > 1 else ""
|
||||||
|
desc_raw = cells[2].strip() if len(cells) > 2 else ""
|
||||||
|
fname_raw = cells[3].strip() if len(cells) > 3 else ""
|
||||||
|
|
||||||
|
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
|
||||||
|
if len(desc_lines) >= 3:
|
||||||
|
description = desc_lines[2]
|
||||||
|
elif len(desc_lines) >= 2:
|
||||||
|
description = desc_lines[1]
|
||||||
|
else:
|
||||||
|
description = desc_lines[0] if desc_lines else ""
|
||||||
|
description = description[:80]
|
||||||
|
|
||||||
|
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
|
||||||
|
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
|
||||||
|
orig_path = Path(original)
|
||||||
|
stem = orig_path.stem or "zprava"
|
||||||
|
ext = orig_path.suffix or ""
|
||||||
|
|
||||||
|
date_iso = parse_date(date_raw)
|
||||||
|
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
|
||||||
|
if len(name) > 240:
|
||||||
|
name = f"{date_iso} ({safe_filename(stem)}){ext}"
|
||||||
|
|
||||||
|
return {"date": date_iso, "desc": description, "original": original, "filename": name}
|
||||||
|
|
||||||
|
|
||||||
|
def stahni_nove_vypisy() -> int:
|
||||||
|
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
print("Chybí playwright: pip install playwright && playwright install chrome")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
with open(COOKIES_FILE, encoding="utf-8") as f:
|
||||||
|
cookies = json.load(f)
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
context = p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir=CHROME_PROFILE,
|
||||||
|
channel="chrome",
|
||||||
|
headless=False,
|
||||||
|
slow_mo=100,
|
||||||
|
ignore_https_errors=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
context.add_cookies(cookies)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
|
||||||
|
if "prihlaseni" in page.url or "login" in page.url.lower():
|
||||||
|
print("Session v prohlížeči expirovala — stahování přeskočeno")
|
||||||
|
return 0
|
||||||
|
print("Prohlížeč přihlášen OK\n")
|
||||||
|
|
||||||
|
already = set(os.listdir(DOWNLOAD_DIR))
|
||||||
|
print(f"V archivu: {len(already)} souborů.\n")
|
||||||
|
|
||||||
|
page_num = 1
|
||||||
|
seen_ids: set = set()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
url = f"{VYPIS_URL}/stranka-{page_num}"
|
||||||
|
print(f" Stránka {page_num}: {url}")
|
||||||
|
try:
|
||||||
|
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Navigace selhala: {e}")
|
||||||
|
break
|
||||||
|
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||||
|
|
||||||
|
data = page.evaluate("""() => {
|
||||||
|
const rows = [];
|
||||||
|
for (const tr of document.querySelectorAll('table tr')) {
|
||||||
|
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
|
||||||
|
if (cells.length < 4) continue;
|
||||||
|
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
|
||||||
|
if (!dlLink) continue;
|
||||||
|
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
|
||||||
|
rows.push({ cells, fileId: mFile ? mFile[0] : null });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}""")
|
||||||
|
rows = [r for r in data if r["fileId"]]
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
|
||||||
|
break
|
||||||
|
|
||||||
|
current_ids = {r["fileId"] for r in rows}
|
||||||
|
if current_ids & seen_ids:
|
||||||
|
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
|
||||||
|
break
|
||||||
|
seen_ids.update(current_ids)
|
||||||
|
print(f" Nalezeno {len(rows)} zpráv.")
|
||||||
|
|
||||||
|
stop = False
|
||||||
|
for row in rows:
|
||||||
|
info = parse_row(row["cells"])
|
||||||
|
target = os.path.join(DOWNLOAD_DIR, info["filename"])
|
||||||
|
|
||||||
|
if info["filename"] in already or os.path.exists(target):
|
||||||
|
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
|
||||||
|
stop = True
|
||||||
|
break
|
||||||
|
|
||||||
|
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
|
||||||
|
try:
|
||||||
|
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
|
||||||
|
if not r.ok:
|
||||||
|
print(f" HTTP {r.status} příloha (id={row['fileId']})")
|
||||||
|
else:
|
||||||
|
body = r.body()
|
||||||
|
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
|
||||||
|
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
|
||||||
|
else:
|
||||||
|
with open(target, "wb") as fh:
|
||||||
|
fh.write(body)
|
||||||
|
print(f" OK: {info['filename']}")
|
||||||
|
already.add(info["filename"])
|
||||||
|
downloaded += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Chyba příloha (id={row['fileId']}): {e}")
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
if stop:
|
||||||
|
break
|
||||||
|
|
||||||
|
page_num += 1
|
||||||
|
|
||||||
|
finally:
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sestavení XML a podpis žádosti
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_xml(datum: date) -> str:
|
||||||
|
"""Sestaví XML žádosti o výpis pojištěnců ke dni `datum`."""
|
||||||
|
datum_str = datum.strftime("%d.%m.%Y")
|
||||||
|
return (
|
||||||
|
f'<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">\r\n'
|
||||||
|
f'<PolozkaFiltru Nazev="icz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
|
||||||
|
f'<PolozkaFiltru Nazev="datum">{datum_str}</PolozkaFiltru>\r\n'
|
||||||
|
f'<PolozkaFiltru Nazev="razeni">{RAZENI}</PolozkaFiltru>\r\n'
|
||||||
|
f'<PolozkaFiltru Nazev="typ">{TYP}</PolozkaFiltru>\r\n'
|
||||||
|
f'</SchrankaZadost>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_xml(xml: str) -> str:
|
||||||
|
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
|
||||||
|
with open(PFX_PATH, "rb") as f:
|
||||||
|
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
|
||||||
|
|
||||||
|
pem = (
|
||||||
|
pkcs7.PKCS7SignatureBuilder()
|
||||||
|
.set_data(xml.encode("utf-8"))
|
||||||
|
.add_signer(cert, private_key, hashes.SHA256())
|
||||||
|
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
|
||||||
|
.decode("ascii")
|
||||||
|
)
|
||||||
|
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def odeslat_zadost(session: requests.Session, datum: date) -> str | None:
|
||||||
|
"""Odešle podepsanou žádost o výpis ke dni `datum`. Vrátí referenční číslo nebo None."""
|
||||||
|
xml = build_xml(datum)
|
||||||
|
podpis = sign_xml(xml)
|
||||||
|
|
||||||
|
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
|
||||||
|
|
||||||
|
r = session.post(SUBMIT_URL, json=payload, headers={
|
||||||
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Referer": BASE_URL + "/",
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = r.json()
|
||||||
|
except Exception:
|
||||||
|
print(f" Odpověď není JSON: {r.text[:300]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
resp_str = json.dumps(resp, ensure_ascii=False)
|
||||||
|
|
||||||
|
if resp.get("errMsg") or resp.get("error"):
|
||||||
|
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
|
||||||
|
ref = m.group(1) if m else None
|
||||||
|
|
||||||
|
if ref:
|
||||||
|
print(f" OK — ref. číslo: {ref}")
|
||||||
|
else:
|
||||||
|
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
|
||||||
|
|
||||||
|
return ref or ("OK" if r.ok else None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def uloz_log(datum: date, ref_cislo: str) -> None:
|
||||||
|
log = []
|
||||||
|
if os.path.exists(LOG_FILE):
|
||||||
|
with open(LOG_FILE, encoding="utf-8") as f:
|
||||||
|
log = json.load(f)
|
||||||
|
log.append({
|
||||||
|
"ref_cislo": ref_cislo,
|
||||||
|
"datum": datum.strftime("%d.%m.%Y"),
|
||||||
|
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
})
|
||||||
|
with open(LOG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(log, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hlavní funkce
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def hlavni() -> None:
|
||||||
|
# 1. Přihlášení — uloží cookies pro Playwright
|
||||||
|
prihlaseni()
|
||||||
|
|
||||||
|
# 2. Stažení nových výpisů z výpisové schránky
|
||||||
|
print("\n=== Stahování nových výpisů ===")
|
||||||
|
stazeno = stahni_nove_vypisy()
|
||||||
|
print(f"Staženo: {stazeno} souborů.\n")
|
||||||
|
|
||||||
|
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
|
||||||
|
print("=== Znovu přihlašuji před podáním ===")
|
||||||
|
session = prihlaseni()
|
||||||
|
|
||||||
|
# 4. Podání žádosti o výpis ke dnešnímu dni
|
||||||
|
datum = date.today()
|
||||||
|
print(f"=== Podávám žádost o výpis ke dni {datum.strftime('%d.%m.%Y')} ===")
|
||||||
|
ref = odeslat_zadost(session, datum)
|
||||||
|
|
||||||
|
if ref:
|
||||||
|
uloz_log(datum, ref)
|
||||||
|
print(f"\nHotovo — žádost podána, ref: {ref}")
|
||||||
|
else:
|
||||||
|
print("\nPodání selhalo — žádost nebyla zaevidována.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
hlavni()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"ref_cislo": "179775825",
|
||||||
|
"datum": "15.05.2026",
|
||||||
|
"podano_kdy": "2026-06-17 05:40:28"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "SID",
|
"name": "SID",
|
||||||
"value": "01bb61e3cd536ffbf7c4f2b74260466e",
|
"value": "22319828cc5b7600290e217c8f533ca0",
|
||||||
"domain": ".portal.rbp-zp.cz",
|
"domain": ".portal.rbp-zp.cz",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": -1,
|
"expires": -1,
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"value": "CERT",
|
"value": "CERT",
|
||||||
"domain": ".portal.rbp-zp.cz",
|
"domain": ".portal.rbp-zp.cz",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1808541922,
|
"expires": 1813203627,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"httpOnly": false,
|
"httpOnly": false,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
|
|||||||
@@ -2118,5 +2118,29 @@
|
|||||||
{
|
{
|
||||||
"original": "7952090443 Kalousová, Eva split_012.pdf",
|
"original": "7952090443 Kalousová, Eva split_012.pdf",
|
||||||
"corrected": "7952090443 2026-06-02 Kalousová, Eva [kultivace moč] [negativní].pdf"
|
"corrected": "7952090443 2026-06-02 Kalousová, Eva [kultivace moč] [negativní].pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Výstupní prohlídka, závěr: Astenie, BMI 16.43].pdf",
|
||||||
|
"corrected": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Výstupní prohlídka, závěr Astenie, BMI 16.43].pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Přítomný stav, BMI 16.43, váha 60.6 kg, výška 192.5 cm, TK 117/74].pdf",
|
||||||
|
"corrected": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Přítomný stav, BMI 16.43, váha 60.6 kg, výška 192.5 cm, TK 11774].pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original": "0612204703 2025-03-17 Štibrányi, Erik [EKG] [sinusový rytmus 62/min, norma, LK norm, způsobilý ke sportu].pdf",
|
||||||
|
"corrected": "0612204703 2025-03-17 Štibrányi, Erik [EKG] [sinusový rytmus 62min, norma, LK norm, způsobilý ke sportu].pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original": "0612204703 2023-03-30 Štibrányi, Erik [LZ kardiologie] [EKG: sinus fr 67/min, bez abnorm. nálezů, způsobilý ke sportu].pdf",
|
||||||
|
"corrected": "0612204703 2023-03-30 Štibrányi, Erik [LZ kardiologie] [EKG sinus fr 67min, bez abnorm. nálezů, způsobilý ke sportu].pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original": "0612204703 2018-11-26 Štibrányi, Erik [Laboratoř] [dg. B949 - Borrelia IgG 122.00 AU/ml (↑), IgM WB pozitivní, IgG WB hraniční, VlsE ++].pdf",
|
||||||
|
"corrected": "0612204703 2018-11-26 Štibrányi, Erik [Laboratoř] [dg. B949 - Borrelia IgG 122.00 AUml (↑), IgM WB pozitivní, IgG WB hraniční, VlsE ++].pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original": "0662204730 2025-01-13 Štibrányi, Gitta [LZ endokrinologie] [Tyreotoxikóza NS, TSH <0.003, fT4 11.4, fT3 3.93, TRAK 6.9, léčba Thyrozolem, ko za2m].pdf",
|
||||||
|
"corrected": "0662204730 2025-01-13 Štibrányi, Gitta [LZ endokrinologie] [Tyreotoxikóza NS, TSH 0.003, fT4 11.4, fT3 3.93, TRAK 6.9, léčba Thyrozolem, ko za2m].pdf"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Video — stahování videí
|
||||||
|
|
||||||
|
## stahni_video.py
|
||||||
|
|
||||||
|
Stahuje videa z Vimea, YouTube a dalších webů přes **yt-dlp**. Nejlepší dostupná
|
||||||
|
kvalita, sloučení video+audio do `.mp4`. Soukromá / nedostupná videa sám pozná
|
||||||
|
a přeskočí (nespadne).
|
||||||
|
|
||||||
|
### Závislosti (jednorázově)
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python -m pip install -U yt-dlp static-ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
- **yt-dlp** — vlastní downloader.
|
||||||
|
- **static-ffmpeg** — dodá `ffmpeg.exe` + `ffprobe.exe` (v systému ffmpeg není).
|
||||||
|
Skript si přes `static_ffmpeg.add_paths()` cestu nastaví sám; binárky se
|
||||||
|
stáhnou při prvním běhu do `site-packages\static_ffmpeg\bin\`.
|
||||||
|
|
||||||
|
### Použití
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python stahni_video.py URL [URL2 ...]
|
||||||
|
python stahni_video.py # vezme URL z urls.txt (1 na řádek)
|
||||||
|
python stahni_video.py --cookies-from-browser firefox URL # video za přihlášením
|
||||||
|
python stahni_video.py -o D:\nekam URL # jiný výstupní adresář
|
||||||
|
```
|
||||||
|
|
||||||
|
Výchozí výstupní adresář je tento (`Video/`). Soubory: `%(title)s [%(id)s].mp4`.
|
||||||
|
|
||||||
|
### Jak pozná soukromé/nedostupné video
|
||||||
|
|
||||||
|
yt-dlp vyhodí `DownloadError` s textem chyby. Funkce `klasifikuj_chybu()` hledá
|
||||||
|
v textu známé fráze (`private video`, `video unavailable`, `removed`,
|
||||||
|
`members-only`, …) a vrátí český popis → video se přeskočí. Jiné chyby (síť,
|
||||||
|
chybí ffmpeg) se vypíšou jako `[CHYBA]`, ale běh pokračuje na další URL.
|
||||||
|
Na konci se vypíše souhrn (staženo / přeskočeno / chyby).
|
||||||
|
|
||||||
|
### Poznámky / úskalí
|
||||||
|
|
||||||
|
- **Soukromé YouTube video opravdu nejde stáhnout**, pokud k němu přihlášený
|
||||||
|
účet nemá udělený přístup — to je záměr, skript ho jen přeskočí.
|
||||||
|
- **Diakritika v názvech**: cesty se zkomolí, když se předávají Windows binárce
|
||||||
|
přes Bug Bash pipe; v běžné konzoli (cp1250) je vše v pořádku.
|
||||||
|
- **Vimeo** dává oddělené video/audio HLS streamy → ffmpeg je nutný pro sloučení.
|
||||||
|
- Při prvním běhu může yt-dlp varovat na chybějící JavaScript runtime (deno);
|
||||||
|
pro běžná veřejná videa to nevadí.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
stahni_video.py — stahování videí (Vimeo, YouTube, …) přes yt-dlp.
|
||||||
|
|
||||||
|
Co umí:
|
||||||
|
* Automaticky nastaví cestu k ffmpeg (přes balík static-ffmpeg) — netřeba ho
|
||||||
|
mít v systému.
|
||||||
|
* Stáhne nejlepší dostupnou kvalitu a sloučí video+audio do .mp4.
|
||||||
|
* Pokud je video SOUKROMÉ / nedostupné / odstraněné, sám to pozná, vypíše
|
||||||
|
srozumitelnou hlášku a přeskočí ho (nespadne, jede dál na další URL).
|
||||||
|
|
||||||
|
Použití:
|
||||||
|
python stahni_video.py URL [URL2 ...]
|
||||||
|
python stahni_video.py # vezme URL z urls.txt (1 na řádek)
|
||||||
|
python stahni_video.py --cookies-from-browser firefox URL # video za přihlášením
|
||||||
|
|
||||||
|
Instalace závislostí (jednorázově):
|
||||||
|
python -m pip install -U yt-dlp static-ffmpeg
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Pojistka: ať výpis nikdy nespadne na znaku, který kódování konzole nezná
|
||||||
|
# (zachová kódování konzole, jen neznámý znak escapne místo pádu programu).
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_stream.reconfigure(errors="backslashreplace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
SKRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
# --- důvody, proč video NEJDE stáhnout (→ přeskočit, ne padat) ----------------
|
||||||
|
# klíč hledáme (case-insensitive) v textu chyby od yt-dlp
|
||||||
|
DUVODY_PRESKOCIT = [
|
||||||
|
("private video", "video je soukromé"),
|
||||||
|
("video is private", "video je soukromé"),
|
||||||
|
("this is a private video", "video je soukromé"),
|
||||||
|
("video unavailable", "video není dostupné"),
|
||||||
|
("this video is unavailable", "video není dostupné"),
|
||||||
|
("video has been removed", "video bylo odstraněno"),
|
||||||
|
("removed by the uploader", "video odstranil autor"),
|
||||||
|
("no longer available", "video už není dostupné"),
|
||||||
|
("members-only", "jen pro členy kanálu"),
|
||||||
|
("available to members", "jen pro členy kanálu"),
|
||||||
|
("account associated with this video has been terminated",
|
||||||
|
"účet autora byl zrušen"),
|
||||||
|
("has been terminated", "účet autora byl zrušen"),
|
||||||
|
("blocked it on copyright", "blokováno kvůli autorským právům"),
|
||||||
|
("not available in your country", "nedostupné ve tvé zemi"),
|
||||||
|
("not available on this app", "nedostupné pro tohoto klienta"),
|
||||||
|
("sign in to confirm your age", "věkově omezené (nutné přihlášení)"),
|
||||||
|
("requires payment", "placené video"),
|
||||||
|
("this live event will begin", "živý přenos zatím nezačal"),
|
||||||
|
("premieres in", "video bude teprve uvedeno (premiéra)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def klasifikuj_chybu(msg: str):
|
||||||
|
"""Vrátí český popis důvodu k přeskočení, nebo None pokud jde o jinou chybu."""
|
||||||
|
m = msg.lower()
|
||||||
|
for klic, popis in DUVODY_PRESKOCIT:
|
||||||
|
if klic in m:
|
||||||
|
return popis
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _TichyLogger:
|
||||||
|
"""Potlačí ukecaný výpis yt-dlp; chyby si hlídáme sami přes výjimky."""
|
||||||
|
|
||||||
|
def debug(self, msg):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def info(self, msg):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def error(self, msg):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_hook(d):
|
||||||
|
if d.get("status") == "downloading":
|
||||||
|
pct = (d.get("_percent_str") or "").strip()
|
||||||
|
spd = (d.get("_speed_str") or "").strip()
|
||||||
|
print(f"\r stahuji {pct} {spd} ", end="", flush=True)
|
||||||
|
elif d.get("status") == "finished":
|
||||||
|
print(f"\r staženo, zpracovávám… ")
|
||||||
|
|
||||||
|
|
||||||
|
def priprav_ffmpeg():
|
||||||
|
"""Zajistí ffmpeg/ffprobe a vrátí adresář s binárkami (nebo None)."""
|
||||||
|
try:
|
||||||
|
import static_ffmpeg
|
||||||
|
static_ffmpeg.add_paths() # přidá ffmpeg/ffprobe do PATH (1. běh = stáhne)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
ff = shutil.which("ffmpeg")
|
||||||
|
if ff:
|
||||||
|
return os.path.dirname(ff)
|
||||||
|
print("UPOZORNĚNÍ: ffmpeg nenalezen — sloučení video+audio nemusí fungovat.")
|
||||||
|
print(" Nainstaluj: python -m pip install -U static-ffmpeg")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def nacti_urls(args_urls):
|
||||||
|
if args_urls:
|
||||||
|
return args_urls
|
||||||
|
soubor = SKRIPT_DIR / "urls.txt"
|
||||||
|
if soubor.exists():
|
||||||
|
radky = [r.strip() for r in soubor.read_text(encoding="utf-8").splitlines()]
|
||||||
|
return [r for r in radky if r and not r.startswith("#")]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def stahni(urls, out_dir: Path, cookies_browser=None):
|
||||||
|
try:
|
||||||
|
import yt_dlp
|
||||||
|
from yt_dlp.utils import DownloadError
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Chybí yt-dlp. Nainstaluj: python -m pip install -U yt-dlp")
|
||||||
|
|
||||||
|
ff_dir = priprav_ffmpeg()
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
"outtmpl": str(out_dir / "%(title)s [%(id)s].%(ext)s"),
|
||||||
|
"format": "bestvideo*+bestaudio/best",
|
||||||
|
"merge_output_format": "mp4",
|
||||||
|
"logger": _TichyLogger(),
|
||||||
|
"progress_hooks": [_progress_hook],
|
||||||
|
"noprogress": True, # vlastní progress řešíme hookem
|
||||||
|
"noplaylist": True,
|
||||||
|
}
|
||||||
|
if ff_dir:
|
||||||
|
ydl_opts["ffmpeg_location"] = ff_dir
|
||||||
|
if cookies_browser:
|
||||||
|
ydl_opts["cookiesfrombrowser"] = (cookies_browser,)
|
||||||
|
|
||||||
|
stazeno, preskoceno, chyby = 0, [], []
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
for i, url in enumerate(urls, 1):
|
||||||
|
print(f"\n[{i}/{len(urls)}] {url}")
|
||||||
|
try:
|
||||||
|
info = ydl.extract_info(url, download=True)
|
||||||
|
nazev = info.get("title", url) if info else url
|
||||||
|
print(f" [HOTOVO] {nazev}")
|
||||||
|
stazeno += 1
|
||||||
|
except DownloadError as e:
|
||||||
|
duvod = klasifikuj_chybu(str(e))
|
||||||
|
if duvod:
|
||||||
|
print(f" [PRESKOCENO] {duvod}")
|
||||||
|
preskoceno.append((url, duvod))
|
||||||
|
else:
|
||||||
|
strucne = str(e).split("\n")[0]
|
||||||
|
print(f" [CHYBA] {strucne}")
|
||||||
|
chyby.append((url, strucne))
|
||||||
|
except Exception as e: # nečekané — taky nezhasnout celý běh
|
||||||
|
print(f" [CHYBA] {e}")
|
||||||
|
chyby.append((url, str(e)))
|
||||||
|
|
||||||
|
print("\n=== SOUHRN ===")
|
||||||
|
print(f" staženo: {stazeno}")
|
||||||
|
print(f" přeskočeno: {len(preskoceno)}")
|
||||||
|
for url, duvod in preskoceno:
|
||||||
|
print(f" - {url} ({duvod})")
|
||||||
|
if chyby:
|
||||||
|
print(f" chyby: {len(chyby)}")
|
||||||
|
for url, msg in chyby:
|
||||||
|
print(f" - {url} ({msg})")
|
||||||
|
|
||||||
|
return stazeno, preskoceno, chyby
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Stáhne videa přes yt-dlp; soukromá/nedostupná sám přeskočí.")
|
||||||
|
p.add_argument("urls", nargs="*", help="URL videí (nebo nech prázdné a použij urls.txt)")
|
||||||
|
p.add_argument("-o", "--out-dir", default=str(SKRIPT_DIR),
|
||||||
|
help="výstupní adresář (výchozí: tento adresář)")
|
||||||
|
p.add_argument("--cookies-from-browser", dest="cookies",
|
||||||
|
help="prohlížeč pro cookies u videí za přihlášením (firefox/chrome/edge…)")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
urls = nacti_urls(a.urls)
|
||||||
|
if not urls:
|
||||||
|
sys.exit("Nezadal jsi žádné URL. Předej je jako argumenty nebo do urls.txt.")
|
||||||
|
|
||||||
|
stahni(urls, Path(a.out_dir), cookies_browser=a.cookies)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
_*.html
|
||||||
@@ -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`.
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
requests
|
||||||
|
beautifulsoup4
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"last_id": "560"
|
||||||
|
}
|
||||||
@@ -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?"')
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# WireGuard road-warrior na MikroTiku (router-hosted)
|
||||||
|
|
||||||
|
Nastaveno 2026-06-18 podle runbooku `wireguard-mikrotik-runbook.md`.
|
||||||
|
|
||||||
|
## Router
|
||||||
|
- **MikrotikFirewall** (hEX, RouterOS 7.19.6), LAN IP `192.168.1.2`, SSH port **22**.
|
||||||
|
- WAN = `pppoe-out1`, veřejná IP `78.80.38.51` (PPPoE, bere se jako statická).
|
||||||
|
|
||||||
|
## DŮLEŽITÉ — proč port 51821, ne 51820
|
||||||
|
Na routeru **už běží jiná WireGuard VPN na Unraidu** (`192.168.1.76`): NAT rule
|
||||||
|
„WireGuard to Unraid" DST-NATuje příchozí UDP **51820** na Unraid. Proto tahle
|
||||||
|
nová, **na routeru hostovaná** VPN běží na **UDP 51821** (51820 by se nikdy
|
||||||
|
nedostalo k routeru). Existující Unraid VPN ani tunel `10.253.0.0/24` nejsou dotčené.
|
||||||
|
|
||||||
|
## Parametry této VPN
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| WG rozhraní | `wg-vpn`, listen-port **51821** |
|
||||||
|
| Server public key | `CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=` |
|
||||||
|
| Tunelová síť | `10.10.10.0/24`, router `10.10.10.1` |
|
||||||
|
| Klienti | `10.10.10.2` (client2), `.3` (client3), `.4` (client4) |
|
||||||
|
| Endpoint | `78.80.38.51:51821` |
|
||||||
|
| Split tunel | AllowedIPs = `192.168.1.0/24` (jen LAN přes VPN) |
|
||||||
|
| DNS klientů | `192.168.1.2` (router) |
|
||||||
|
|
||||||
|
## Přidané firewall pravidla (jen accept, nic nemazáno/nepřeřazeno)
|
||||||
|
- input: accept udp dst-port 51821 in-interface=pppoe-out1 „WireGuard in (router)"
|
||||||
|
- input: accept in-interface=wg-vpn „WG -> router (DNS/ping)" (DNS a ping na router z tunelu)
|
||||||
|
- forward: accept in-interface=wg-vpn „WG -> LAN"
|
||||||
|
Všechna vložena PŘED příslušné `drop` v daném chainu.
|
||||||
|
NAT hairpin **nepřidán** — LAN hosti mají router jako default gw, návratová cesta funguje.
|
||||||
|
|
||||||
|
## Skripty
|
||||||
|
- `rosrun.py` — spouští RouterOS příkazy přes SSH. Creds z env: `ROS_HOST/ROS_PORT/ROS_USER/ROS_PASS`.
|
||||||
|
Pozn.: v Git Bash nutné `MSYS_NO_PATHCONV=1` a příkazy přes stdin (ne `--cmd`, mangluje `/...`).
|
||||||
|
- `gen_clients.py` — generuje klíče (wg.exe) + `.conf` + QR PNG do `wg-clients/`, a `_peers_add.rsc`.
|
||||||
|
|
||||||
|
## Klientské konfigurace
|
||||||
|
`wg-clients/clientN.conf` (import na notebook) + `wg-clients/clientN.png` (QR pro mobilní app).
|
||||||
|
**Obsahují privátní klíče** — po rozdání na zařízení smaž, ať neleží zbytečně.
|
||||||
|
|
||||||
|
## Test (jen zvenku, ne z LAN!)
|
||||||
|
Telefon na mobilních datech → naskenuj QR → ověř `ping 192.168.1.2`. Z LAN to
|
||||||
|
handshake neudělá (accept je vázán na in-interface=pppoe-out1, hairpin pro 51821 není).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
```
|
||||||
|
/interface wireguard peers remove [find interface=wg-vpn]
|
||||||
|
/ip firewall filter remove [find comment="WG -> LAN"]
|
||||||
|
/ip firewall filter remove [find comment="WG -> router (DNS/ping)"]
|
||||||
|
/ip firewall filter remove [find comment="WireGuard in (router)"]
|
||||||
|
/ip address remove [find interface=wg-vpn]
|
||||||
|
/interface wireguard remove [find name=wg-vpn]
|
||||||
|
```
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate WireGuard road-warrior client configs + QR PNGs, and emit RouterOS peer-add commands."""
|
||||||
|
import subprocess, pathlib, qrcode
|
||||||
|
|
||||||
|
WG = r"C:\Program Files\WireGuard\wg"
|
||||||
|
SERVER_PUB = "CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo="
|
||||||
|
ENDPOINT = "78.80.38.51:51821"
|
||||||
|
LAN = "192.168.1.0/24" # split tunnel -> only LAN goes through VPN
|
||||||
|
DNS = "192.168.1.2" # router LAN IP
|
||||||
|
|
||||||
|
CLIENTS = [2, 3, 4]
|
||||||
|
outdir = pathlib.Path(__file__).resolve().parent / "wg-clients"
|
||||||
|
outdir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def wg(*args, inp=None):
|
||||||
|
return subprocess.run([WG, *args], input=inp, capture_output=True,
|
||||||
|
text=True, check=True).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
peer_cmds = []
|
||||||
|
for i in CLIENTS:
|
||||||
|
name = f"client{i}"
|
||||||
|
priv = wg("genkey")
|
||||||
|
pub = wg("pubkey", inp=priv)
|
||||||
|
psk = wg("genpsk")
|
||||||
|
|
||||||
|
conf = f"""[Interface]
|
||||||
|
PrivateKey = {priv}
|
||||||
|
Address = 10.10.10.{i}/32
|
||||||
|
DNS = {DNS}
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = {SERVER_PUB}
|
||||||
|
PresharedKey = {psk}
|
||||||
|
AllowedIPs = {LAN}
|
||||||
|
Endpoint = {ENDPOINT}
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
"""
|
||||||
|
(outdir / f"{name}.conf").write_text(conf, encoding="utf-8")
|
||||||
|
|
||||||
|
img = qrcode.make(conf)
|
||||||
|
img.save(outdir / f"{name}.png")
|
||||||
|
|
||||||
|
peer_cmds.append(
|
||||||
|
f'/interface wireguard peers add interface=wg-vpn '
|
||||||
|
f'public-key="{pub}" preshared-key="{psk}" '
|
||||||
|
f'allowed-address=10.10.10.{i}/32 comment="{name}"'
|
||||||
|
)
|
||||||
|
print(f"[ok] {name}: pub={pub} -> {name}.conf, {name}.png")
|
||||||
|
|
||||||
|
(outdir / "_peers_add.rsc").write_text("\n".join(peer_cmds) + "\n", encoding="utf-8")
|
||||||
|
print("\n--- RouterOS peer-add commands written to wg-clients/_peers_add.rsc ---")
|
||||||
|
for c in peer_cmds:
|
||||||
|
print(c)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# ROLLBACK — obnova Unraid WireGuard objektů na routeru MikrotikFirewall
|
||||||
|
# Odstraněno 2026-06-18 na žádost uživatele. Spusť tyto příkazy pro obnovu.
|
||||||
|
/ip firewall nat add chain=dstnat action=dst-nat to-addresses=192.168.1.76 to-ports=51820 protocol=udp in-interface=pppoe-out1 dst-port=51820 comment="WireGuard to Unraid"
|
||||||
|
/ip firewall filter add chain=input action=accept protocol=udp in-interface=pppoe-out1 dst-port=51820 comment="Allow WireGuard"
|
||||||
|
/ip firewall filter add chain=forward action=accept src-address=10.253.0.0/24 comment="Allow VPN to LAN"
|
||||||
|
/ip firewall filter add chain=forward action=accept dst-address=10.253.0.0/24 comment="Allow LAN to VPN"
|
||||||
|
/ip route add dst-address=10.253.0.0/24 gateway=192.168.1.76 comment="Route to WireGuard VPN via Unraid"
|
||||||
|
# Pozn.: po obnově zkontroluj pořadí filter pravidel (accept musí být PŘED drop).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run RouterOS commands over SSH. Creds from env: ROS_HOST, ROS_PORT, ROS_USER, ROS_PASS.
|
||||||
|
Commands: one per line on stdin, or via --cmd. Prints output per command."""
|
||||||
|
import os, sys, paramiko
|
||||||
|
|
||||||
|
host = os.environ["ROS_HOST"]
|
||||||
|
port = int(os.environ.get("ROS_PORT", "22"))
|
||||||
|
user = os.environ["ROS_USER"]
|
||||||
|
pw = os.environ["ROS_PASS"]
|
||||||
|
|
||||||
|
cmds = []
|
||||||
|
if "--cmd" in sys.argv:
|
||||||
|
cmds = [sys.argv[sys.argv.index("--cmd") + 1]]
|
||||||
|
else:
|
||||||
|
cmds = [l.rstrip("\n") for l in sys.stdin if l.strip()]
|
||||||
|
|
||||||
|
cli = paramiko.SSHClient()
|
||||||
|
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
cli.connect(host, port=port, username=user, password=pw,
|
||||||
|
look_for_keys=False, allow_agent=False, timeout=20)
|
||||||
|
|
||||||
|
for c in cmds:
|
||||||
|
print(f"\n===== CMD: {c}")
|
||||||
|
stdin, stdout, stderr = cli.exec_command(c, timeout=30)
|
||||||
|
out = stdout.read().decode("utf-8", "replace")
|
||||||
|
err = stderr.read().decode("utf-8", "replace")
|
||||||
|
sys.stdout.write(out)
|
||||||
|
if err.strip():
|
||||||
|
sys.stdout.write("--- stderr ---\n" + err)
|
||||||
|
cli.close()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/interface wireguard peers add interface=wg-vpn public-key="nToZ1GzONgfW1ve3O1WeEpGbgzUMhDVKE7qrD/Jc23c=" preshared-key="Y6eHm6MbLa+tyleSgwbPc8oJqLZkXZkMEUJZDU7f5kg=" allowed-address=10.10.10.2/32 comment="client2"
|
||||||
|
/interface wireguard peers add interface=wg-vpn public-key="tqA98HvVupGGYpR1PUe7/j9DO8MtaNP3Fh5tkpqgqD0=" preshared-key="94TmjBE+mTZi3KDy/tWefq/wXPpvmBtjPlX/LZnAKbE=" allowed-address=10.10.10.3/32 comment="client3"
|
||||||
|
/interface wireguard peers add interface=wg-vpn public-key="j/3kzNQ6vmUL4xFmqq5PL6Qf1xVWPzVWEXoOkBIDxFk=" preshared-key="pHR1441168wSrjlLZ2E44J4WrHpLRuWdjfsNHk23CQ8=" allowed-address=10.10.10.4/32 comment="client4"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Interface]
|
||||||
|
PrivateKey = YPvh0rKU+xi82eQftBucCnuQzZNqk9jOHLwfEH0wsGk=
|
||||||
|
Address = 10.10.10.2/32
|
||||||
|
DNS = 192.168.1.2
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
|
||||||
|
PresharedKey = Y6eHm6MbLa+tyleSgwbPc8oJqLZkXZkMEUJZDU7f5kg=
|
||||||
|
AllowedIPs = 192.168.1.0/24
|
||||||
|
Endpoint = 78.80.38.51:51821
|
||||||
|
PersistentKeepalive = 25
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
[Interface]
|
||||||
|
PrivateKey = 8JFWJp/zvoRYl7w2Jon0Xv+9YidiguiC26qGbr4ozlg=
|
||||||
|
Address = 10.10.10.3/32
|
||||||
|
DNS = 192.168.1.2
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
|
||||||
|
PresharedKey = 94TmjBE+mTZi3KDy/tWefq/wXPpvmBtjPlX/LZnAKbE=
|
||||||
|
AllowedIPs = 192.168.1.0/24
|
||||||
|
Endpoint = 78.80.38.51:51821
|
||||||
|
PersistentKeepalive = 25
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
[Interface]
|
||||||
|
PrivateKey = oLcUtFkDW/e0/xmDgdBMlWIpGGL+eOvMxgnyXxtd5Ww=
|
||||||
|
Address = 10.10.10.4/32
|
||||||
|
DNS = 192.168.1.2
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
|
||||||
|
PresharedKey = pHR1441168wSrjlLZ2E44J4WrHpLRuWdjfsNHk23CQ8=
|
||||||
|
AllowedIPs = 192.168.1.0/24
|
||||||
|
Endpoint = 78.80.38.51:51821
|
||||||
|
PersistentKeepalive = 25
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Reference in New Issue
Block a user