diff --git a/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/NOTES.md b/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/NOTES.md index 3b5f30d..db6a5ab 100644 --- a/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/NOTES.md +++ b/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/NOTES.md @@ -2,37 +2,49 @@ ## Co skript dělá -`PodejZadostSeznamZPS.py` podává žádost o výpis registrovaných pojištěnců ZPŠ -za jeden měsíc (poslední den daného měsíce). Bez prohlížeče, bez NMSigneru — čistý Python. +`PodejZadostSeznamZPS.py` provede v jednom spuštění tři kroky: -## Flow +1. **Přihlásí se** certifikátem na portál ZPŠ (čistý Python, bez NMSigneru) + — uloží cookies do sdíleného `StahováníZpráv/209 ZPŠ/zps_cookies.json` -1. **Přihlášení** — stejný certifikátový login jako ostatní ZPŠ skripty (`01_prihlaseni.py`): - - GET `/app/prihlaseni` → session cookie - - POST `/json-api/prihlaseni/prihlasovaci-zprava` → challenge (`zprava`) - - Podpis challenge certifikátem (PKCS7/SHA-256, **s** certifikátem) - - POST `/json-api/prihlaseni/prihlaseni-certifikatem` → autentizovaná session +2. **Stáhne nové výpisy pojištěnců** ze schránky `schranka-vypis-pojistencu-v-kapitaci` + — stahuje jen `.001` soubory, jejichž obsah začíná `H09305001` + — ukládá do `U:\Dropbox\Ordinace\Dokumentace_ke_zpracování\Zúčtovací zprávy\SeznamyPojištěnců\` + — zastaví se při první již stažené zprávě + — po stahování se **znovu přihlásí** (Playwright invaliduje předchozí requests session) -2. **Sestavení XML žádosti**: - ```xml - - 25520 - DD.MM.YYYY - jmeno - soubor - - ``` - Konce řádků: `\r\n` (NMSigner normalizace) +3. **Podá žádost** o výpis pro 1 následující měsíc (poslední den daného měsíce) -3. **Podpis XML** — PKCS7/SHA-256, **bez** certifikátu v podpisu (`NoCerts`): - - Server při odesílání formuláře zná certifikát z registrace - - Certifikát v podpisu server odmítá ("Podepsaná data obsahují certifikát") - - Rozdíl oproti přihlášení: login certifikát potřebuje, formulář ne +## Flow přihlášení -4. **Odeslání**: - - POST `https://portal.zpskoda.cz/json-api/formular-schranky/29-vypis-registrov-pojistencu/ulozit-formular` - - Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}` - - Odpověď obsahuje referenční číslo podání +1. GET `/app/prihlaseni` → session cookie +2. POST `/json-api/prihlaseni/prihlasovaci-zprava` → challenge (`zprava`) +3. Podpis challenge certifikátem (PKCS7/SHA-256, **s** certifikátem) +4. POST `/json-api/prihlaseni/prihlaseni-certifikatem` → autentizovaná session + +## Sestavení XML žádosti + +```xml + +25520 +DD.MM.YYYY +jmeno +soubor + +``` +Konce řádků: `\r\n` (NMSigner normalizace) + +## Podpis XML + +PKCS7/SHA-256, **bez** certifikátu v podpisu (`NoCerts`): +- Server při odesílání formuláře zná certifikát z registrace +- Certifikát v podpisu server odmítá ("Podepsaná data obsahují certifikát") +- Rozdíl oproti přihlášení: login certifikát potřebuje, formulář ne + +## Odeslání + +POST `https://portal.zpskoda.cz/json-api/formular-schranky/29-vypis-registrov-pojistencu/ulozit-formular` +Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}` ## Klíčový objev @@ -45,13 +57,13 @@ Výsledek: žádný prohlížeč ani NMSigner není potřeba. | Soubor | Popis | |--------|-------| -| `PodejZadostSeznamZPS.py` | Hlavní skript — přihlášení + podání žádosti | +| `PodejZadostSeznamZPS.py` | Hlavní skript — stažení výpisů + podání žádosti | | `stav.json` | Poslední úspěšně podaný měsíc `{"mesic": 4, "rok": 2026}` | | `log_podani.json` | Historie podání s referenčními čísly | ## Parametry -- **IČZ**: 25520 (IČZ: 09305000, MUDr. Michaela Buzalková) +- **IČZ**: 25520 (IČP: 09305001, MUDr. Michaela Buzalková) - **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx` - **Typ výstupu**: `soubor` (Soubor dle datového rozhraní) - **Řazení**: `jmeno` (příjmení a jména) diff --git a/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/PodejZadostSeznamZPS.py b/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/PodejZadostSeznamZPS.py index 9f8060b..d4335bf 100644 --- a/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/PodejZadostSeznamZPS.py +++ b/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/PodejZadostSeznamZPS.py @@ -2,12 +2,9 @@ Podávání žádostí o výpis registrovaných pojištěnců ZPŠ — čistý Python, bez prohlížeče. Co dělá: - - Přihlásí se certifikátem na portál ZPŠ (requests + cryptography) - - Sestaví XML žádosti, podepíše certifikátem (PKCS7/SHA-256) - - Odešle POST na JSON API portálu - - Zjistí poslední podaný měsíc ze stavového souboru stav.json - - Podá žádost pro 1 následující měsíc (poslední den daného měsíce) - - Při úspěchu uloží nový stav a referenční číslo do logu + 1. Přihlásí se certifikátem na portál ZPŠ (uloží cookies pro Playwright) + 2. Stáhne nové soubory z výpisové schránky (schranka-vypis-pojistencu-v-kapitaci) + 3. Podá žádost pro 1 následující měsíc Stavový soubor: stav.json vedle tohoto skriptu. {"mesic": 2, "rok": 2025} — poslední úspěšně podaný měsíc @@ -20,12 +17,17 @@ 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 +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++" @@ -36,22 +38,32 @@ ICZ = "25520" STATE_FILE = os.path.join(os.path.dirname(__file__), "stav.json") LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json") +# Sdílené soubory s ostatními ZPŠ skripty +STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "209 ZPŠ")) +COOKIES_FILE = os.path.join(STAHUJ_DIR, "zps_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ů") + +VYPIS_URL = f"{BASE_URL}/app/schranka-vypis-pojistencu-v-kapitaci" +DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu" +PROTOKOL_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-protokol" + # --------------------------------------------------------------------------- # Přihlášení # --------------------------------------------------------------------------- def prihlaseni() -> requests.Session: - """Přihlásí se certifikátem, vrátí autentizovanou session.""" - challenge_url = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava" - certlogin_url = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem" + """Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright.""" + challenge_url = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava" + certlogin_url = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem" 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 + "/", + "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") @@ -84,9 +96,198 @@ def prihlaseni() -> requests.Session: 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) + print(f"Cookies uloženy: {len(cookies)} → {COOKIES_FILE}") + 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: + 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é soubory 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+/); + const protLink = tr.querySelector('a[onclick*="SchrPolDBProtokol"]'); + const mProt = protLink ? protLink.getAttribute('onclick').match(/\\d+/) : null; + rows.push({ + cells, + fileId: mFile ? mFile[0] : null, + protokolId: mProt ? mProt[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"]) + + # Zajímají nás pouze .001 soubory + if Path(info["original"]).suffix.lower() != ".001": + continue + + 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[:9].decode("ascii", errors="ignore").startswith("H09305001"): + 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 # --------------------------------------------------------------------------- @@ -115,7 +316,6 @@ def sign_xml(xml: str) -> str: .sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts]) .decode("ascii") ) - # NMSigner normalizuje konce řádků na \r\n return pem.replace("\r\n", "\n").replace("\n", "\r\n") @@ -131,9 +331,9 @@ def odeslat_zadost(session: requests.Session, datum: date) -> str | None: payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []} r = session.post(SUBMIT_URL, json=payload, headers={ - "Content-Type": "application/json; charset=UTF-8", + "Content-Type": "application/json; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", - "Referer": BASE_URL + "/", + "Referer": BASE_URL + "/", }) r.raise_for_status() @@ -143,7 +343,6 @@ def odeslat_zadost(session: requests.Session, datum: date) -> str | None: print(f" Odpověď není JSON: {r.text[:300]}") return None - # Hledáme referenční číslo v odpovědi resp_str = json.dumps(resp, ensure_ascii=False) m = re.search(r'\b(17\d{7}|18\d{7})\b', resp_str) ref = m.group(1) if m else None @@ -218,11 +417,23 @@ def hlavni() -> None: ' {"mesic": 2, "rok": 2025}' ) + # 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 pro následující měsíc mesic, rok = dalsi_mesic(*posledni) datum = posledni_den(mesic, rok) - print(f"Podávám žádost pro: {datum.strftime('%d.%m.%Y')}") + print(f"=== Podávám žádost pro: {datum.strftime('%d.%m.%Y')} ===") - session = prihlaseni() ref = odeslat_zadost(session, datum) if ref: diff --git a/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/log_podani.json b/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/log_podani.json index f749a36..fd37a19 100644 --- a/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/log_podani.json +++ b/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/log_podani.json @@ -68,5 +68,10 @@ "datum": "30.04.2026", "ref_cislo": "178201321", "podano_kdy": "2026-05-12 21:59:31" + }, + { + "datum": "31.05.2026", + "ref_cislo": "178213777", + "podano_kdy": "2026-05-13 07:09:12" } ] \ No newline at end of file diff --git a/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/stav.json b/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/stav.json index 22c5bc2..4a524da 100644 --- a/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/stav.json +++ b/Insurance/StahováníSeznamuPojištěnců/209 ZPŠ/stav.json @@ -1 +1 @@ -{"mesic": 4, "rok": 2026} \ No newline at end of file +{"mesic": 5, "rok": 2026} \ No newline at end of file diff --git a/Insurance/StahováníZpráv/209 ZPŠ/zps_cookies.json b/Insurance/StahováníZpráv/209 ZPŠ/zps_cookies.json index f0cd285..c6a0703 100644 --- a/Insurance/StahováníZpráv/209 ZPŠ/zps_cookies.json +++ b/Insurance/StahováníZpráv/209 ZPŠ/zps_cookies.json @@ -1,7 +1,7 @@ [ { "name": "SID", - "value": "8be68e23c6afb14ff6937b6a8832001c", + "value": "cfdefd7ad7d093aeeadee6402dff0fa8", "domain": ".portal.zpskoda.cz", "path": "/", "expires": -1, @@ -14,7 +14,7 @@ "value": "CERT", "domain": ".portal.zpskoda.cz", "path": "/", - "expires": 1808541904, + "expires": 1810184951, "secure": true, "httpOnly": false, "sameSite": "Lax"