Compare commits
4 Commits
26e44fc721
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e981659621 | |||
| e5315b821e | |||
| 19036b58cc | |||
| 0beaffec45 |
@@ -81,6 +81,12 @@ python euni_stahni.py --seaweed-backfill --from-json
|
||||
|
||||
## 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
|
||||
@@ -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í
|
||||
```
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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