notebookvb
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user