notebookvb

This commit is contained in:
Vladimir Buzalka
2026-05-13 07:43:33 +02:00
parent 1619d78241
commit 71b8ed676a
5 changed files with 278 additions and 50 deletions
@@ -2,18 +2,28 @@
## Co skript dělá ## Co skript dělá
`PodejZadostSeznamZPS.py` podává žádost o výpis registrovaných pojištěnců ZPŠ `PodejZadostSeznamZPS.py` provede v jednom spuštění tři kroky:
za jeden měsíc (poslední den daného měsíce). Bez prohlížeče, bez NMSigneru — čistý Python.
## 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`): 2. **Stáhne nové výpisy pojištěnců** ze schránky `schranka-vypis-pojistencu-v-kapitaci`
- GET `/app/prihlaseni` → session cookie — stahuje jen `.001` soubory, jejichž obsah začíná `H09305001`
- POST `/json-api/prihlaseni/prihlasovaci-zprava` → challenge (`zprava`) — ukládá do `U:\Dropbox\Ordinace\Dokumentace_ke_zpracování\Zúčtovací zprávy\SeznamyPojištěnců\`
- Podpis challenge certifikátem (PKCS7/SHA-256, **s** certifikátem) — zastaví se při první již stažené zprávě
- POST `/json-api/prihlaseni/prihlaseni-certifikatem` → autentizovaná session — po stahování se **znovu přihlásí** (Playwright invaliduje předchozí requests session)
3. **Podá žádost** o výpis pro 1 následující měsíc (poslední den daného měsíce)
## Flow přihlášení
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
2. **Sestavení XML žádosti**:
```xml ```xml
<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG"> <SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">
<PolozkaFiltru Nazev="icz">25520</PolozkaFiltru> <PolozkaFiltru Nazev="icz">25520</PolozkaFiltru>
@@ -24,15 +34,17 @@ za jeden měsíc (poslední den daného měsíce). Bez prohlížeče, bez NMSign
``` ```
Konce řádků: `\r\n` (NMSigner normalizace) Konce řádků: `\r\n` (NMSigner normalizace)
3. **Podpis XML** — PKCS7/SHA-256, **bez** certifikátu v podpisu (`NoCerts`): ## Podpis XML
PKCS7/SHA-256, **bez** certifikátu v podpisu (`NoCerts`):
- Server při odesílání formuláře zná certifikát z registrace - 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") - 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 - Rozdíl oproti přihlášení: login certifikát potřebuje, formulář ne
4. **Odeslání**: ## Odeslání
- POST `https://portal.zpskoda.cz/json-api/formular-schranky/29-vypis-registrov-pojistencu/ulozit-formular`
- Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}` POST `https://portal.zpskoda.cz/json-api/formular-schranky/29-vypis-registrov-pojistencu/ulozit-formular`
- Odpověď obsahuje referenční číslo podání Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
## Klíčový objev ## Klíčový objev
@@ -45,13 +57,13 @@ Výsledek: žádný prohlížeč ani NMSigner není potřeba.
| Soubor | Popis | | 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}` | | `stav.json` | Poslední úspěšně podaný měsíc `{"mesic": 4, "rok": 2026}` |
| `log_podani.json` | Historie podání s referenčními čísly | | `log_podani.json` | Historie podání s referenčními čísly |
## Parametry ## 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` - **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
- **Typ výstupu**: `soubor` (Soubor dle datového rozhraní) - **Typ výstupu**: `soubor` (Soubor dle datového rozhraní)
- **Řazení**: `jmeno` (příjmení a jména) - **Řazení**: `jmeno` (příjmení a jména)
@@ -2,12 +2,9 @@
Podávání žádostí o výpis registrovaných pojištěnců ZPŠ — čistý Python, bez prohlížeče. Podávání žádostí o výpis registrovaných pojištěnců ZPŠ — čistý Python, bez prohlížeče.
Co dělá: Co dělá:
- Přihlásí se certifikátem na portál ZPŠ (requests + cryptography) 1. Přihlásí se certifikátem na portál ZPŠ (uloží cookies pro Playwright)
- Sestaví XML žádosti, podepíše certifikátem (PKCS7/SHA-256) 2. Stáhne nové soubory z výpisové schránky (schranka-vypis-pojistencu-v-kapitaci)
- Odešle POST na JSON API portálu 3. Podá žádost pro 1 následující měsíc
- 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
Stavový soubor: stav.json vedle tohoto skriptu. Stavový soubor: stav.json vedle tohoto skriptu.
{"mesic": 2, "rok": 2025} — poslední úspěšně podaný měsíc {"mesic": 2, "rok": 2025} — poslední úspěšně podaný měsíc
@@ -20,12 +17,17 @@ import json
import os import os
import re import re
import sys import sys
import time
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path
import requests import requests
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12 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_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++" PFX_PASSWORD = b"Vlado7309208104++"
@@ -36,13 +38,23 @@ ICZ = "25520"
STATE_FILE = os.path.join(os.path.dirname(__file__), "stav.json") STATE_FILE = os.path.join(os.path.dirname(__file__), "stav.json")
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.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í # Přihlášení
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session: def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session.""" """Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
challenge_url = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava" challenge_url = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
certlogin_url = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem" certlogin_url = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
@@ -84,9 +96,198 @@ def prihlaseni() -> requests.Session:
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}") raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!") 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 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 # Sestavení XML a podpis
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -115,7 +316,6 @@ def sign_xml(xml: str) -> str:
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts]) .sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii") .decode("ascii")
) )
# NMSigner normalizuje konce řádků na \r\n
return pem.replace("\r\n", "\n").replace("\n", "\r\n") return pem.replace("\r\n", "\n").replace("\n", "\r\n")
@@ -143,7 +343,6 @@ def odeslat_zadost(session: requests.Session, datum: date) -> str | None:
print(f" Odpověď není JSON: {r.text[:300]}") print(f" Odpověď není JSON: {r.text[:300]}")
return None return None
# Hledáme referenční číslo v odpovědi
resp_str = json.dumps(resp, ensure_ascii=False) resp_str = json.dumps(resp, ensure_ascii=False)
m = re.search(r'\b(17\d{7}|18\d{7})\b', resp_str) m = re.search(r'\b(17\d{7}|18\d{7})\b', resp_str)
ref = m.group(1) if m else None ref = m.group(1) if m else None
@@ -218,11 +417,23 @@ def hlavni() -> None:
' {"mesic": 2, "rok": 2025}' ' {"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) mesic, rok = dalsi_mesic(*posledni)
datum = posledni_den(mesic, rok) 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) ref = odeslat_zadost(session, datum)
if ref: if ref:
@@ -68,5 +68,10 @@
"datum": "30.04.2026", "datum": "30.04.2026",
"ref_cislo": "178201321", "ref_cislo": "178201321",
"podano_kdy": "2026-05-12 21:59:31" "podano_kdy": "2026-05-12 21:59:31"
},
{
"datum": "31.05.2026",
"ref_cislo": "178213777",
"podano_kdy": "2026-05-13 07:09:12"
} }
] ]
@@ -1 +1 @@
{"mesic": 4, "rok": 2026} {"mesic": 5, "rok": 2026}
@@ -1,7 +1,7 @@
[ [
{ {
"name": "SID", "name": "SID",
"value": "8be68e23c6afb14ff6937b6a8832001c", "value": "cfdefd7ad7d093aeeadee6402dff0fa8",
"domain": ".portal.zpskoda.cz", "domain": ".portal.zpskoda.cz",
"path": "/", "path": "/",
"expires": -1, "expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT", "value": "CERT",
"domain": ".portal.zpskoda.cz", "domain": ".portal.zpskoda.cz",
"path": "/", "path": "/",
"expires": 1808541904, "expires": 1810184951,
"secure": true, "secure": true,
"httpOnly": false, "httpOnly": false,
"sameSite": "Lax" "sameSite": "Lax"