Compare commits

...

4 Commits

Author SHA1 Message Date
Vladimir Buzalka e981659621 notebookvb 2026-06-19 11:30:52 +02:00
administrator e5315b821e z230 2026-06-18 12:15:57 +02:00
Vladimir Buzalka 19036b58cc notebookvb 2026-06-18 05:32:36 +02:00
administrator 0beaffec45 z230 2026-06-17 15:06:06 +02:00
26 changed files with 1986 additions and 8 deletions
+8
View File
@@ -81,6 +81,12 @@ python euni_stahni.py --seaweed-backfill --from-json
## Použití
Nejjednodušší: **`python euni_menu.py`** — interaktivní menu s volbami 19
(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
@@ -89,6 +95,8 @@ python euni_stahni.py --from-json --no-videos # přeskočí scrape, stáhne z M
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í
```
+96
View File
@@ -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()
+13 -6
View File
@@ -376,21 +376,23 @@ def stahni_dokument(s, url, out_dir: Path, label=""):
return ("staženo", dest.name)
def stahni_video(embed, out_dir: Path, referer):
"""Stáhne video přes yt-dlp; soukromé/nedostupné přeskočí. Vrací (stav, info)."""
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ý")
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")
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": "bestvideo*+bestaudio/best",
"format": fmt,
"concurrent_fragment_downloads": frags, # paralelní HLS fragmenty = rychlejší
"merge_output_format": "mp4",
"logger": sv._TichyLogger(),
"progress_hooks": [sv._progress_hook],
@@ -458,6 +460,10 @@ def main():
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"),
@@ -602,7 +608,8 @@ def main():
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"])
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}")
@@ -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",
"value": "01bb61e3cd536ffbf7c4f2b74260466e",
"value": "22319828cc5b7600290e217c8f533ca0",
"domain": ".portal.rbp-zp.cz",
"path": "/",
"expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT",
"domain": ".portal.rbp-zp.cz",
"path": "/",
"expires": 1808541922,
"expires": 1813203627,
"secure": true,
"httpOnly": false,
"sameSite": "Lax"
@@ -2118,5 +2118,29 @@
{
"original": "7952090443 Kalousová, Eva split_012.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"
}
]
+54
View File
@@ -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]
```
+55
View File
@@ -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)
+8
View File
@@ -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).
+30
View File
@@ -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()
+3
View File
@@ -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"
+11
View File
@@ -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

+11
View File
@@ -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

+11
View File
@@ -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