Přepsat stahování ICP na Playwright — certifikát přes browser context

Playwright předloží PFX certifikát automaticky při TLS handshake,
klikne na odkaz a zachytí download bez nutnosti ručního přihlášení.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 07:34:18 +02:00
parent d52278ed4d
commit 068c8edbe1
@@ -4,18 +4,14 @@ Před importem automaticky stáhne nejnovější soubor z VZP Point (vyžaduje c
Použití: python import_vzp_pracoviste.py [--no-download] [soubor.Lh7] Použití: python import_vzp_pracoviste.py [--no-download] [soubor.Lh7]
""" """
import base64
import csv import csv
import glob import glob
import hashlib
import io import io
import os import os
import re import re
import secrets
import sys import sys
import zipfile import zipfile
from datetime import date, datetime from datetime import date, datetime
from html.parser import HTMLParser
# Windows konzole - povol UTF-8 výstup # Windows konzole - povol UTF-8 výstup
if sys.stdout.encoding != "utf-8": if sys.stdout.encoding != "utf-8":
@@ -77,233 +73,84 @@ def parse_date(s: str) -> date | None:
return None return None
def _pkce_pair() -> tuple[str, str]:
"""Vrátí (code_verifier, code_challenge) pro PKCE S256."""
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
def _get_bearer_token() -> str | None:
"""
Autentizuje pomocí PKCS12 certifikátu přes OIDC authorization_code + PKCE.
Certifikát je předán při TLS handshake na auth.vzp.cz.
Vrátí Bearer access_token nebo None při chybě.
"""
try:
import requests
from requests_pkcs12 import Pkcs12Adapter
except ImportError:
print("[auth] pip install requests requests-pkcs12")
return None
password = VZP_CERT_PASSWORD.encode() if VZP_CERT_PASSWORD else None
verifier, challenge = _pkce_pair()
nonce = secrets.token_urlsafe(32)
state = secrets.token_urlsafe(32)
session = requests.Session()
# Certifikát pro TLS client auth na auth.vzp.cz i pro redirect na point.vzp.cz
for base in ("https://auth.vzp.cz", "https://point.vzp.cz"):
session.mount(base, Pkcs12Adapter(pkcs12_filename=VZP_CERT_FILE, pkcs12_password=password))
# Krok 1: Authorize — server ověří certifikát a vrátí form_post HTML s code
try:
resp = session.get(
"https://auth.vzp.cz/connect/authorize",
params={
"client_id": "bdesk",
"redirect_uri": "https://point.vzp.cz/home/signin",
"response_type": "code",
"scope": "openid profile email phone offline_access",
"response_mode": "form_post",
"nonce": nonce,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
},
allow_redirects=True,
timeout=30,
)
except Exception as e:
print(f"[auth] Chyba při authorize: {e}")
return None
# Parsuj form_post HTML a vytáhni code
class _FormParser(HTMLParser):
def __init__(self):
super().__init__()
self.fields: dict[str, str] = {}
def handle_starttag(self, tag, attrs):
if tag == "input":
d = dict(attrs)
if d.get("name"):
self.fields[d["name"]] = d.get("value", "")
fp = _FormParser()
fp.feed(resp.text)
code = fp.fields.get("code")
if not code:
print(f"[auth] Autorizační kód nenalezen (HTTP {resp.status_code}). "
f"Zkontroluj certifikát a heslo PFX.")
return None
# Krok 2: Vyměň code za access_token
try:
token_resp = session.post(
"https://auth.vzp.cz/connect/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://point.vzp.cz/home/signin",
"client_id": "bdesk",
"code_verifier": verifier,
},
timeout=30,
)
token_data = token_resp.json()
except Exception as e:
print(f"[auth] Chyba při výměně tokenu: {e}")
return None
token = token_data.get("access_token")
if not token:
print(f"[auth] access_token chybí. Odpověď: {list(token_data.keys())}")
return None
return token
def _get_icp_filename(session, token: str) -> str | None:
"""
Zjistí aktuální název ICP zip souboru z point.vzp.cz/Cms/Document
(atribut download na odkazu 'Soubor platných IČP').
"""
try:
resp = session.get("https://point.vzp.cz/Cms/Document", timeout=30)
resp.raise_for_status()
except Exception as e:
print(f"[stahování] Nelze načíst seznam dokumentů: {e}")
return None
class _DownloadParser(HTMLParser):
def __init__(self):
super().__init__()
self.filename: str | None = None
def handle_starttag(self, tag, attrs):
if tag == "a" and not self.filename:
d = dict(attrs)
dl = d.get("download", "")
if re.search(r"-icp\.zip$", dl, re.IGNORECASE):
self.filename = dl
dp = _DownloadParser()
dp.feed(resp.text)
return dp.filename
def download_latest_file() -> str | None: def download_latest_file() -> str | None:
""" """
1. Autentizuje certifikátem přes OIDC → Bearer token Použije Playwright (Chromium) s PFX certifikátem pro přihlášení na VZP Point.
2. Zjistí aktuální název ICP zip z point.vzp.cz Klikne na 'Soubor platných IČP', zachytí stažený ZIP, rozbalí Lh7 do Import/.
3. Získá SAS URL z www.vzp.cz/api/documents/{id}/files/{filename}
4. Stáhne ZIP, rozbalí PLP111*.Lh7 do Import/
Vrátí cestu k Lh7 nebo None při chybě.
""" """
try: try:
import requests from playwright.sync_api import sync_playwright
from requests_pkcs12 import Pkcs12Adapter
except ImportError: except ImportError:
print("[stahování] pip install requests requests-pkcs12") print("[stahování] pip install playwright && playwright install chromium")
return None return None
password = VZP_CERT_PASSWORD.encode() if VZP_CERT_PASSWORD else None os.makedirs(IMPORT_DIR, exist_ok=True)
# Session s certifikátem (pro point.vzp.cz po autentizaci) with sync_playwright() as p:
session = requests.Session() browser = p.chromium.launch(headless=True)
for base in ("https://auth.vzp.cz", "https://point.vzp.cz"): context = browser.new_context(
session.mount(base, Pkcs12Adapter(pkcs12_filename=VZP_CERT_FILE, pkcs12_password=password)) client_certificates=[{
"origin": "https://auth.vzp.cz",
"pfxPath": VZP_CERT_FILE,
"passphrase": VZP_CERT_PASSWORD,
}],
accept_downloads=True,
)
page = context.new_page()
# Autentizace # 1. Přihlášení — klik na Certifikát, Playwright certifikát předloží automaticky
print("[stahování] Přihlašování certifikátem...") print("[stahování] Přihlašování na VZP Point...")
token = _get_bearer_token() page.goto("https://point.vzp.cz/", wait_until="networkidle", timeout=30_000)
if not token:
return None
print("[stahování] Přihlášení úspěšné.")
# Zjisti název souboru cert_btn = page.locator("text=Certifikát").first
zip_filename = _get_icp_filename(session, token) if cert_btn.is_visible(timeout=5_000):
if not zip_filename: cert_btn.click()
print("[stahování] Název ICP souboru nenalezen.") page.wait_for_url("**/point.vzp.cz/**", timeout=30_000)
return None page.wait_for_load_state("networkidle", timeout=30_000)
# Zkontroluj, jestli Lh7 pro tento rok už máme print("[stahování] Přihlášeno.")
year_match = re.search(r"^(\d{2})", zip_filename)
year = year_match.group(1) if year_match else "" # 2. Stránka s dokumenty
page.goto("https://point.vzp.cz/Cms/Document", wait_until="networkidle", timeout=30_000)
# 3. Zkontroluj aktuální název souboru
icp_link = page.locator("a[download*='-icp.zip']").first
zip_name = icp_link.get_attribute("download", timeout=5_000)
if zip_name:
year = zip_name[:2]
existing = glob.glob(os.path.join(IMPORT_DIR, f"PLP111{year}.Lh7")) existing = glob.glob(os.path.join(IMPORT_DIR, f"PLP111{year}.Lh7"))
if existing: if existing:
print(f"[stahování] {os.path.basename(existing[0])} již existuje — přeskočeno.") print(f"[stahování] {os.path.basename(existing[0])} již existuje — přeskočeno.")
browser.close()
return existing[0] return existing[0]
# Získej SAS URL pro stažení # 4. Stáhni ZIP
api_url = f"https://www.vzp.cz/api/documents/{VZP_DOCUMENT_ID}/files/{zip_filename}" print(f"[stahování] Stahuji {zip_name or 'ICP soubor'}...")
print(f"[stahování] Získávám odkaz ke stažení...") with page.expect_download(timeout=60_000) as dl_info:
try: icp_link.click()
api_resp = requests.get( download = dl_info.value
api_url,
headers={
"Authorization": f"Bearer {token}",
"Origin": "https://point.vzp.cz",
"Referer": "https://point.vzp.cz/",
},
timeout=30,
)
api_resp.raise_for_status()
# Odpověď je SAS URL jako JSON string nebo objekt
try:
data = api_resp.json()
sas_url = data if isinstance(data, str) else (
data.get("url") or data.get("sasUrl") or data.get("uri") or data.get("value")
)
except Exception:
sas_url = api_resp.text.strip().strip('"')
except Exception as e:
print(f"[stahování] Chyba při získávání SAS URL: {e}")
return None
if not sas_url or not sas_url.startswith("http"): zip_path = os.path.join(IMPORT_DIR, download.suggested_filename)
print(f"[stahování] Neplatná SAS URL: {str(sas_url)[:100]}") download.save_as(zip_path)
return None browser.close()
# Stáhni ZIP z Azure Blob Storage # 5. Rozbal Lh7 z archivu
print(f"[stahování] Stahuji {zip_filename}...")
try: try:
zip_resp = requests.get(sas_url, timeout=60) with zipfile.ZipFile(zip_path) as zf:
zip_resp.raise_for_status()
except Exception as e:
print(f"[stahování] Chyba při stahování ZIP: {e}")
return None
# Rozbal Lh7
try:
with zipfile.ZipFile(io.BytesIO(zip_resp.content)) as zf:
lh7_names = [n for n in zf.namelist() if n.lower().endswith(".lh7")] lh7_names = [n for n in zf.namelist() if n.lower().endswith(".lh7")]
if not lh7_names: if not lh7_names:
print("[stahování] ZIP neobsahuje .Lh7 soubor") print("[stahování] ZIP neobsahuje .Lh7 soubor")
return None return None
dest = os.path.join(IMPORT_DIR, os.path.basename(lh7_names[0])) dest = os.path.join(IMPORT_DIR, os.path.basename(lh7_names[0]))
os.makedirs(IMPORT_DIR, exist_ok=True)
with zf.open(lh7_names[0]) as src, open(dest, "wb") as out: with zf.open(lh7_names[0]) as src, open(dest, "wb") as out:
out.write(src.read()) out.write(src.read())
os.remove(zip_path)
print(f"[stahování] Rozbaleno: {os.path.basename(dest)} ({os.path.getsize(dest):,} B)")
return dest
except Exception as e: except Exception as e:
print(f"[stahování] Chyba při rozbalování: {e}") print(f"[stahování] Chyba při rozbalování: {e}")
return None return None
print(f"[stahování] Rozbaleno: {os.path.basename(dest)} ({os.path.getsize(dest):,} B)")
return dest
def find_latest_file() -> str: def find_latest_file() -> str:
files = glob.glob(os.path.join(IMPORT_DIR, "*.Lh7")) files = glob.glob(os.path.join(IMPORT_DIR, "*.Lh7"))