From 5b0f8aa08bc1bb035b96313e29bf77e369da87d7 Mon Sep 17 00:00:00 2001 From: "vladimir.buzalka" Date: Mon, 1 Jun 2026 12:17:41 +0200 Subject: [PATCH] z230 --- Medevio/60 ScansProcessing/corrections.json | 16 + Vykony/NOTES.md | 262 ++++++ Vykony/debug_01021.html | 866 ++++++++++++++++++++ Vykony/debug_detail.py | 80 ++ Vykony/report_vykony.py | 201 +++++ Vykony/stahni_detaily.py | 334 ++++++++ Vykony/stahni_vykony.py | 259 ++++++ Vykony/vykony_report.xlsx | Bin 0 -> 29833 bytes 8 files changed, 2018 insertions(+) create mode 100644 Vykony/NOTES.md create mode 100644 Vykony/debug_01021.html create mode 100644 Vykony/debug_detail.py create mode 100644 Vykony/report_vykony.py create mode 100644 Vykony/stahni_detaily.py create mode 100644 Vykony/stahni_vykony.py create mode 100644 Vykony/vykony_report.xlsx diff --git a/Medevio/60 ScansProcessing/corrections.json b/Medevio/60 ScansProcessing/corrections.json index b376b82..febc7e7 100644 --- a/Medevio/60 ScansProcessing/corrections.json +++ b/Medevio/60 ScansProcessing/corrections.json @@ -1658,5 +1658,21 @@ { "original": "7602044780 2026-05-22 Suchý, Vladimír [PZ interna] [15–22MAY2026 SLE, CKD G5 na ATN, sepse, AKI III.st., hypotenze, tachykardie].pdf", "corrected": "7602044780 2026-05-22 Suchý, Vladimír [PZ interna] [15–22MAY2026 SLE, CKD G5 na ATN, urosepse, AKI III.st., hypotenze, tachykardie].pdf" + }, + { + "original": "480416072 2026-01-26 Štrup, Petr [LZ balneologie] [Lázně Jáchymov, TEP L kyčle reop. 8/2025, osteopenie, hypertenze, chron. bronchitis].pdf", + "corrected": "480416072 2026-02-22 Štrup, Petr [LZ balneologie] [26JAN-22FEB2026, Lázně Jáchymov, TEP L kyčle reop. 82025, osteopenie, hypertenze, chron. bronchitis].pdf" + }, + { + "original": "485702422 2026-05-27 Štrupová, Eva [LZ kardiologie] [Incip. sick sinus sy, bradykardie, bez indikace KS, EF LK 66%, sy ND NYHA I-II, stop. mitr./trikusp./pulm. regurg.].pdf", + "corrected": "485702422 2026-05-27 Štrupová, Eva [LZ kardiologie] [kontrola po půl roce, Incip. sick sinus sy, bradykardie, bez indikace KS, EF LK 66%, sy ND NYHA I-II, stop. mitr.trikusp.pulm. regurg.].pdf" + }, + { + "original": "535510353 2026-01-23 Rejfířová, Marcela [Barthelův test ADL] [65b – lehká závislost; oblékání s pomocí, vozík, chůze po schodech s pomocí].pdf", + "corrected": "535510353 2026-01-23 Rejfířová, Marcela [Barthelův test ADL] [75b – lehká závislost; oblékání s pomocí, vozík, chůze po schodech s pomocí].pdf" + }, + { + "original": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [40b – nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf", + "corrected": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [35b – nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf" } ] \ No newline at end of file diff --git a/Vykony/NOTES.md b/Vykony/NOTES.md new file mode 100644 index 0000000..8459d7b --- /dev/null +++ b/Vykony/NOTES.md @@ -0,0 +1,262 @@ +# Vykony — stahování a zpracování zdravotních výkonů + +## Přehled + +Kompletní pipeline pro stahování zdravotních výkonů ze **szv.mzd.gov.cz** do MongoDB, +včetně detailů každého výkonu a exportu do XLSX reportu. + +``` +szv.mzd.gov.cz + │ + ├── /Vykon/ → stahni_vykony.py → MongoDB: vykony + vykony_historie + └── /Vykon/Detail/{id}/ → stahni_detaily.py → MongoDB: detaily + detaily_historie + ↓ + report_vykony.py → vykony_report.xlsx +``` + +--- + +## Skripty + +| Skript | Co dělá | +|--------|---------| +| `stahni_vykony.py` | Stahuje seznam všech výkonů (přehledová data), ukládá do `vykony` s historií | +| `stahni_detaily.py` | Stahuje detailní stránku každého výkonu, ukládá do `detaily` s historií | +| `report_vykony.py` | Exportuje výkony + detaily do XLSX, jeden list na odbornost | +| `debug_detail.py` | Ladící skript — stáhne HTML jednoho výkonu a vypíše co parser vidí | + +### Doporučené pořadí spuštění + +```bash +python stahni_vykony.py # 1. seznam výkonů (~4246 kusů, ~1 min) +python stahni_detaily.py # 2. detaily (~4246 HTTP requestů, ~10–20 min) +python report_vykony.py # 3. export do XLSX +``` + +--- + +## Nastavení skriptů + +Každý skript má na začátku blok konfiguračních proměnných — **není potřeba sahat do kódu**: + +### `stahni_detaily.py` +```python +KOLIK = 0 # 0 = vše; jinak max. počet ke stažení (pro testování: 10) +FORCE = False # True = přestáhni i už stažené záznamy +WORKERS = 5 # počet paralelních vláken (doporučeno 5–20) +``` + +### `report_vykony.py` +```python +ODBORNOSTI = ["001", "002"] # [] = všechny odbornosti +VYSTUP = Path(__file__).parent / "vykony_report.xlsx" +``` + +--- + +## MongoDB + +**Host:** `192.168.1.76:27017` +**DB:** `zdravotni_vykony` +**GUI:** MongoDB Compass (zdarma, od MongoDB Inc.) + +### Kolekce `vykony` — aktuální přehledový stav + +Vždy obsahuje poslední verzi každého výkonu. Hodnoty jsou zkrácené (kódy bez popisů). + +| Pole | Typ | Popis | +|------|-----|-------| +| `cislo_vykonu` | str | Unikátní klíč (např. `01021`) | +| `odbornost` | str | Kód odbornosti (např. `001`) | +| `nazev_vykonu` | str | Název výkonu | +| `kategorie` | str | Kód kategorie (např. `P`) | +| `typ_vykonu` | str | Kód typu (např. `A`) | +| `doba_trvani` | float\|null | Minuty | +| `omezeni_mistem` | str\|null | Kód omezení (např. `A`) | +| `omezeni_frekvenci` | str\|null | Např. `1/1 den` | +| `prime_naklady` | float\|null | Body | +| `osobni` | float\|null | Body | +| `body_rezijni` | float\|null | Body | +| `body_celkem` | float\|null | Body | +| `revize` | datetime\|null | Datum poslední revize výkonu | +| `detail_url` | str\|null | URL detailu na szv.mzd.gov.cz | +| `_aktivni` | bool | `false` pokud výkon zmizel ze scrape | +| `_platny_od` | datetime | Kdy tato verze začala platit | +| `_scraped_at` | datetime | Čas posledního úspěšného scrape | +| `_deaktivovano` | datetime | Kdy byl výkon deaktivován (jen pokud `_aktivni=false`) | + +**Indexy:** `cislo_vykonu` (unique), `odbornost`, `_aktivni`, `_platny_od` + +--- + +### Kolekce `vykony_historie` — archiv změn přehledu + +Každý záznam = jedna historická verze výkonu (stav před změnou). +Obsahuje stejná pole jako `vykony`, navíc: + +| Pole | Typ | Popis | +|------|-----|-------| +| `_platny_do` | datetime | Kdy tato verze přestala platit | +| `_zmenena_pole` | list[str] | Která pole se změnila (nebo `["_aktivni"]` při deaktivaci) | + +**Indexy:** `cislo_vykonu`, `_platny_do`, `_zmenena_pole` + +--- + +### Kolekce `detaily` — aktuální detailní stav + +Obsahuje kompletní data z detailní stránky každého výkonu. Hodnoty jsou dlouhé (s popisy). +Skalární pole + vnořené sub-tabulky jako pole objektů. + +**Skalární pole:** + +| Pole | Typ | Popis | +|------|-----|-------| +| `cislo_vykonu` | str | Unikátní klíč, propojení s `vykony` | +| `nazev` | str | Plný název výkonu | +| `kategorie` | str | Dlouhý popis (např. `P - hrazen plně`) | +| `typ_formulare` | str | Např. `ambulantní` | +| `omezeni_mistem` | str | Dlouhý popis (např. `A - pouze ambulantně`) | +| `omezeni_frekvenci` | str | Např. `1/1 den` | +| `doba_trvani` | float\|null | Minuty | +| `nepocitat_rezii` | bool | | +| `popis` | str | Obecný popis (často prázdný) | +| `poznamka` | str | Poznámka | +| `podminky` | str | Podmínky pro vykázání | +| `cim_zacina` | str | Popis začátku výkonu | +| `obsah_rozsah` | str | Obsah a rozsah výkonu | +| `cim_konci` | str | Popis konce výkonu | +| `body_prime` | float\|null | Bodová hodnota — přímé náklady | +| `body_osobni` | float\|null | Bodová hodnota — osobní náklady | +| `body_rezijni` | float\|null | Bodová hodnota — režijní náklady | +| `body_celkem` | float\|null | Bodová hodnota — celkem | +| `detail_url` | str | URL zdroje | +| `_platny_od` | datetime | Kdy byla tato verze detailu uložena | +| `_scraped_at` | datetime | Čas posledního scrape | + +**Sub-tabulky (pole objektů):** + +| Pole | Klíče objektů | +|------|---------------| +| `autorska_odbornost` | Kód, Název, Pořadí, Sazba režie | +| `dalsi_odbornost` | Kód, Název, Sazba režie | +| `nositele` | Kategorie, Funkce, Praxe, Cas, Bodyaktualni, Poznamka | +| `materialy` | Kód, Název, Doplněk, Množství, Jednotka, Cena, DPH %, Body | +| `pripravky` | Kód, Název, Doplněk, ATC, Omezení, Množství, Jednotka, Cena, Body | +| `pristroje` | Kód, Název, D.Ž., N.Ú., D.P., DPH %, Body | +| `zum` | Kód, Název | +| `zulp` | Kód, Název | +| `bodova_hodnota` | Přímé, Osobní, Režijní, Celkem (1 řádek, flatten na skalární body_*) | + +**Indexy:** `cislo_vykonu` (unique), `_scraped_at`, `_platny_od` + +--- + +### Kolekce `detaily_historie` — archiv změn detailů + +Každý záznam = stav detailu před změnou. +Obsahuje stejná pole jako `detaily`, navíc: + +| Pole | Typ | Popis | +|------|-----|-------| +| `_platny_do` | datetime | Kdy tato verze přestala platit | +| `_zmenena_pole` | list[str] | Která pole se změnila | + +**Indexy:** `cislo_vykonu`, `_platny_do`, `_zmenena_pole` + +--- + +## Logika detekce změn + +Stejná logika platí pro oba páry kolekcí (`vykony`/`vykony_historie` i `detaily`/`detaily_historie`): + +1. Načte celou aktivní kolekci do paměti (`cislo_vykonu → dokument`) +2. Pro každý nově stažený záznam: + - **Nový** → vloží s `_platny_od = run_at` + - **Změněn** (liší se v některém z `COMPARE_FIELDS`) → stará verze jde do historie s `_platny_do` + `_zmenena_pole`, nová verze se zapíše + - **Nezměněn** → jen aktualizuje `_scraped_at` +3. Výkony co v novém scrape chybí → `_aktivni = False`, stará verze do historie (jen `vykony`) + +--- + +## Technické poznámky + +### Proč ne MVCGridHandler.axd? +Ajax endpoint (`/MVCGridHandler.axd?Name=VykonGrid&pageSize=9999`) ignoruje `pageSize` +a vrací max 50 záznamů. Navíc po vyčerpání dat cyklí od začátku. +Správný endpoint je přímo stránka `/Vykon/`. + +### HTML struktura detailní stránky +- Hlavní tabulka: `` +- Řádky: `` (ne `
labelhodnota` jak by se čekalo) +- Sub-tabulky: `` uvnitř `
` +- Parser iteruje `find_all("tr", recursive=False)` — bez rekurze nevleze do vnořených tabulek + +### Encoding +Server vrací latin znaky i při UTF-8 hlavičce → `resp.apparent_encoding` místo `resp.encoding`. + +### Rate limiting +`stahni_detaily.py` dělá `time.sleep(0.1)` po každém requestu per worker. +Při 20 workerech ≈ 200 requestů/s — server to bez problémů zvládá. + +--- + +## Typické MongoDB dotazy + +```js +// Všechny aktivní výkony odbornosti 001 +db.vykony.find({ odbornost: "001", _aktivni: true }) + +// Detail konkrétního výkonu +db.detaily.findOne({ cislo_vykonu: "01021" }) + +// Výkony s podmínkami (neprázdné pole) +db.detaily.find({ podminky: { $ne: "" } }) + +// Výkony které se někdy změnily +db.vykony_historie.distinct("cislo_vykonu") + +// Historie konkrétního výkonu (detail) +db.detaily_historie.find({ cislo_vykonu: "01021" }).sort({ _platny_do: 1 }) + +// Co se změnilo při posledním runu +db.detaily_historie.find({ _platny_do: { $gte: ISODate("2026-06-01") } }) + +// Výkony s nositelem kategorie L3 +db.detaily.find({ "nositele.Kategorie": "L3" }) + +// Výkony s materiálem (neprázdné materialy) +db.detaily.find({ "materialy.0": { $exists: true } }) + +// Deaktivované výkony +db.vykony.find({ _aktivni: false }) +``` + +--- + +## XLSX Report + +Skript `report_vykony.py` generuje `vykony_report.xlsx`: + +- Jeden list na odbornost (název listu: `Odbornost 001` atd.) +- Výkony řazené podle čísla výkonu +- Více nositelů → více řádků pro jeden výkon (střídavé zbarvení po výkonech) +- Sloupec **Postup výkonu** = čím začíná + obsah a rozsah + čím končí (odděleno prázdným řádkem) +- Záhlaví zmrazeno (freeze panes) + +**Sloupce reportu:** +Číslo výkonu · Název · Kategorie · Typ formuláře · Doba trvání · Omezení místem · +Omezení frekvencí · Nepočítat režii · Body přímé · Body osobní · Body režijní · Body celkem · +Postup výkonu · Nositel kategorie · Nositel funkce · Nositel čas · Nositel body + +--- + +## Závislosti + +``` +requests +beautifulsoup4 +lxml +pymongo +openpyxl +``` diff --git a/Vykony/debug_01021.html b/Vykony/debug_01021.html new file mode 100644 index 0000000..01fd4c7 --- /dev/null +++ b/Vykony/debug_01021.html @@ -0,0 +1,866 @@ + + + + + + Detail - Zdravotní výkony + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +

Seznam zdravotních výkonů

+
+
+ + + + + + + + + + + + + + + +

Registrační list - 01021

+

KOMPLEXNÍ VYŠETŘENÍ PRAKTICKÝM LÉKAŘEM

+ +
+

Obsah registračního listu

+
+
+
+ + Tisk + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Číslo výkonu01021
Název + KOMPLEXNÍ VYŠETŘENÍ PRAKTICKÝM LÉKAŘEM + + +
+ Nepočítat režii + + + + + +
Poznámka + + + +
+ Autorská odbornost + + + + + + + + + + + + + + + + + + + + + +
+ Kód + + Název + + Pořadí + + Sazba režie +
+ 001 + + všeobecné praktické lékařství + + 1020 + + 4,58 +
+ + +
+ Další odbornost + + + + + + + + + + + + + +
+ Kód + + Název + + Sazba režie +
+ + + +
Kategorie + P - hrazen plně + + +
+ Typ formuláře + + ambulantní +
Omezení místem + A - pouze ambulantně + + + +
Omezení frekvencí + 1/1 den + + + + +
Doba trvání + 60 + + +
Popis + + + + +
Čím výkon začíná + Prostudováním dokumentace pacienta a odběrem životní osobní a rodinné anamnézy. + + +
Obsah a rozsah výkonu + Celkové interní fyzikální vyšetření. Orientační vyšetření neurologické. Orientační vyšetření funkcí pohybového aparátu. Vyšetření moče. Indikace dalších potřebných vyšetření podle výsledku prohlídky. Zhodnocení zdravotního stavu. Vyšetření zraku, sluchu. + + +
Čím výkon končí + Poučením pacienta, informace o možné intervenci rizikových faktorů. Administrativní činností související s výkonem (vystavení zprávy pro ošetřujícího lékaře, potřebných receptů, poukazů, žádanek, vystavení formulářů na pracovní neschopnost či ošetřování člena rodiny, vypsání povinných hlášení, určení ev. data další návštěvy, rozhodnutí o případné nezbytné zdravotnické dopravě pacienta a vystavení poukazu na ni). + + +
Podmínky + + + +
+ Nositelé + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kategorie + + Funkce + + Praxe + + Cas + + Bodyaktualni + + Poznamka +
+ L3 + + všeobecný praktický lékař + + + + 60 + + 838,87 + + + + + +
Celkem:838,87
+ +
+ Materiály + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kód + + Název + + Doplněk + + Množství + + Jednotka + + Cena + + DPH % + + Body +
+ P000057 + + PMAT ke komplexnímu vyšetření + + + + 1 + + + + 8,00 + + 0,00 + + 8,00 + + + +
+ + Celkem: 8,00 8,00
+
+ Přípravky + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kód + + Název + + Doplněk + + ATC + + Omezení + + Množství + + Jednotka + + DPH % + + Cena + + Body +
+ + + Celkem: + + 0,00 + + 0,00 +
+
+ Přístroje + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kód + + Název + + D.Ž. + + N.Ú. + + D.P. + + DPH % + + Procento z času výkonu + + Cena + + Body +
Celkem: + 0,00 + + + 0,00 + +
+
+ ZUM + + + + + + + + + + + +
+ Kód + + Název +
+ +
+ ZULP + + + + + + + + + + + +
+ Kód + + Název +
+ +
Bodová hodnota + + + + + + + + + + + + + + + +
+ Přímé + OsobníRežijníCelkem
8,00838,87274,801 122
+
+ +
+

+ + + +Zpět na výpis +

+ + + + +
+
+ + V případě podávání návrhů nových registračních listů zdravotních výkonů, popř. úpravu stávajících registračních listů zdravotních výkonů, přístupů do aplikace a jiných podnětů, se obracejte na Ing. Martinu Cetelovou. Technickým správcem aplikace je ÚZIS ČR.

+ Financováno z projektu ESF - Rozvoj technologické platformy NZIS (CZ.03.4.74/0.0/0.0/15_019/0002748) +
+ + + + + + +
+ + + + + + + + + +
+ ÚZIS ČR + Ústav zdravotnických informací
a statistiky České republiky
+
+ + + +
+
+ ©2016 - 2026 Seznam zdravotních výkonů 2.0.200.3, ÚZIS ČR +
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/Vykony/debug_detail.py b/Vykony/debug_detail.py new file mode 100644 index 0000000..c30335b --- /dev/null +++ b/Vykony/debug_detail.py @@ -0,0 +1,80 @@ +""" +Ladění parseru detailu. Stáhne HTML jednoho výkonu, uloží na disk +a vypíše co parser vidí (tabulky, řádky, labely). + +Spuštění: + python debug_detail.py + python debug_detail.py 09581 +""" + +import sys +import requests +from bs4 import BeautifulSoup +from pathlib import Path + +CISLO = sys.argv[1] if len(sys.argv) > 1 else "01021" +BASE_URL = "https://szv.mzd.gov.cz" +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept-Language": "cs,en;q=0.9", +} + +url = f"{BASE_URL}/Vykon/Detail/{CISLO}/" +print(f"Stahuji: {url}") +resp = requests.get(url, headers=HEADERS, timeout=30) +resp.encoding = resp.apparent_encoding or "utf-8" + +# Ulož HTML na disk +html_path = Path(__file__).parent / f"debug_{CISLO}.html" +html_path.write_text(resp.text, encoding="utf-8") +print(f"HTML uloženo: {html_path}") + +soup = BeautifulSoup(resp.text, "lxml") + +# --- Kolik tabulek je na stránce? --- +all_tables = soup.find_all("table") +print(f"\nPočet na stránce: {len(all_tables)}") + +# --- Co najde aktuální selektor? --- +main_table = soup.select_one("div.container table") or soup.find("table") +print(f"Selektor 'div.container table': {bool(soup.select_one('div.container table'))}") +print(f"Fallback soup.find('table'): {bool(soup.find('table'))}") + +if not main_table: + print("ŽÁDNÁ TABULKA NENALEZENA!") + sys.exit(1) + +# --- Přímé potomky hlavní tabulky --- +direct_trs = main_table.find_all("tr", recursive=False) +print(f"\nPřímých v hlavní tabulce: {len(direct_trs)}") + +print("\n--- Labely v přímých řádcích ---") +for i, tr in enumerate(direct_trs): + tds = tr.find_all("td", recursive=False) + if len(tds) >= 2: + label = tds[0].get_text(strip=True) + preview = tds[1].get_text(strip=True)[:60] + print(f" [{i:2d}] label='{label}' | hodnota='{preview}'") + elif len(tds) == 1: + print(f" [{i:2d}] 1 buňka: '{tds[0].get_text(strip=True)[:80]}'") + else: + ths = tr.find_all("th", recursive=False) + if ths: + print(f" [{i:2d}] s {len(tbody_trs)} přímými ") + print("--- Labely v tbody řádcích ---") + for i, tr in enumerate(tbody_trs[:5]): + tds = tr.find_all("td", recursive=False) + if len(tds) >= 2: + label = tds[0].get_text(strip=True) + preview = tds[1].get_text(strip=True)[:60] + print(f" [{i:2d}] label='{label}' | hodnota='{preview}'") diff --git a/Vykony/report_vykony.py b/Vykony/report_vykony.py new file mode 100644 index 0000000..ccf3fd6 --- /dev/null +++ b/Vykony/report_vykony.py @@ -0,0 +1,201 @@ +""" +Export zdravotních výkonů do XLSX — jeden list na odbornost. + +Každý výkon se rozvine do tolika řádků, kolik má nositelů. +Pokud nositelé chybí, výkon dostane jeden prázdný řádek. + +Požadavky: + pip install pymongo openpyxl +""" + +from pathlib import Path +from datetime import datetime, timezone + +from pymongo import MongoClient +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +# ── Nastavení ───────────────────────────────────────────────────────────────── +ODBORNOSTI = ["001", "002"] # [] = všechny odbornosti +VYSTUP = Path(__file__).parent / "vykony_report.xlsx" +# ────────────────────────────────────────────────────────────────────────────── + +MONGO_URI = "mongodb://192.168.1.76:27017/" +MONGO_DB = "zdravotni_vykony" + +SLOUPCE = [ + ("cislo_vykonu", "Číslo výkonu", 10), + ("nazev", "Název", 45), + ("kategorie", "Kategorie", 22), + ("typ_formulare", "Typ formuláře", 14), + ("doba_trvani", "Doba trvání", 10), + ("omezeni_mistem", "Omezení místem", 22), + ("omezeni_frekvenci", "Omezení frekvencí", 16), + ("nepocitat_rezii", "Nepočítat režii", 12), + ("body_prime", "Body přímé", 10), + ("body_osobni", "Body osobní", 10), + ("body_rezijni", "Body režijní", 10), + ("body_celkem", "Body celkem", 10), + ("postup", "Postup výkonu", 50), + ("nositel_kategorie", "Nositel – kategorie", 14), + ("nositel_funkce", "Nositel – funkce", 30), + ("nositel_cas", "Nositel – čas", 10), + ("nositel_body", "Nositel – body", 12), +] + +BARVA_HLAVICKA = "1F4E79" +BARVA_VYKON_A = "DEEAF1" # sudý výkon +BARVA_VYKON_B = "FFFFFF" # lichý výkon + + +def _bool_text(v) -> str: + if v is True: return "ano" + if v is False: return "ne" + return "" + + +def _postup(d: dict) -> str: + casti = [] + if d.get("cim_zacina"): casti.append(d["cim_zacina"].strip()) + if d.get("obsah_rozsah"): casti.append(d["obsah_rozsah"].strip()) + if d.get("cim_konci"): casti.append(d["cim_konci"].strip()) + return "\n\n".join(casti) + + +def _nositele(d: dict) -> list[dict]: + """Vrátí nositelé bez řádků Celkem a bez prázdných řádků.""" + result = [] + for n in d.get("nositele", []): + # přeskoč řádky kde kdekoliv je "Celkem:" + hodnoty = [str(v) for v in n.values()] + if any("celkem" in v.lower() for v in hodnoty): + continue + # přeskoč zcela prázdné řádky + if not any(v.strip() for v in hodnoty if v): + continue + result.append(n) + return result + + +def _vykon_radky(v: dict, d: dict) -> list[dict]: + """Rozlož výkon do řádků podle nositelů. Min. 1 řádek.""" + base = { + "cislo_vykonu": v.get("cislo_vykonu", ""), + "nazev": d.get("nazev") or v.get("nazev_vykonu", ""), + "kategorie": d.get("kategorie") or v.get("kategorie", ""), + "typ_formulare": d.get("typ_formulare", ""), + "doba_trvani": d.get("doba_trvani") if d.get("doba_trvani") is not None else v.get("doba_trvani", ""), + "omezeni_mistem": d.get("omezeni_mistem") or v.get("omezeni_mistem", ""), + "omezeni_frekvenci": d.get("omezeni_frekvenci") or v.get("omezeni_frekvenci", ""), + "nepocitat_rezii": _bool_text(d.get("nepocitat_rezii")), + "body_prime": d.get("body_prime") if d.get("body_prime") is not None else v.get("prime_naklady", ""), + "body_osobni": d.get("body_osobni") if d.get("body_osobni") is not None else v.get("osobni", ""), + "body_rezijni": d.get("body_rezijni") if d.get("body_rezijni") is not None else v.get("body_rezijni", ""), + "body_celkem": d.get("body_celkem") if d.get("body_celkem") is not None else v.get("body_celkem", ""), + "postup": _postup(d), + } + + nositele = _nositele(d) + if not nositele: + return [{**base, "nositel_kategorie": "", "nositel_funkce": "", "nositel_cas": "", "nositel_body": ""}] + + radky = [] + for n in nositele: + radky.append({ + **base, + "nositel_kategorie": n.get("Kategorie", ""), + "nositel_funkce": n.get("Funkce", ""), + "nositel_cas": n.get("Cas", ""), + "nositel_body": n.get("Bodyaktualni", ""), + }) + return radky + + +def nastav_list(ws): + """Záhlaví + šířky sloupců.""" + hl_font = Font(bold=True, color="FFFFFF", size=10) + hl_fill = PatternFill("solid", fgColor=BARVA_HLAVICKA) + hl_align = Alignment(horizontal="center", vertical="center", wrap_text=True) + thin = Side(style="thin", color="AAAAAA") + border = Border(left=thin, right=thin, bottom=thin) + + for col_idx, (_, label, sirka) in enumerate(SLOUPCE, 1): + cell = ws.cell(row=1, column=col_idx, value=label) + cell.font = hl_font + cell.fill = hl_fill + cell.alignment = hl_align + cell.border = border + ws.column_dimensions[get_column_letter(col_idx)].width = sirka + + ws.row_dimensions[1].height = 30 + ws.freeze_panes = "A2" + + +def zapis_radek(ws, row_idx: int, radek: dict, barva: str): + fill = PatternFill("solid", fgColor=barva) + thin = Side(style="thin", color="DDDDDD") + border = Border(left=thin, right=thin, bottom=thin) + align_def = Alignment(vertical="top", wrap_text=False) + align_wrap= Alignment(vertical="top", wrap_text=True) + + for col_idx, (klic, _, _) in enumerate(SLOUPCE, 1): + hodnota = radek.get(klic, "") + cell = ws.cell(row=row_idx, column=col_idx, value=hodnota) + cell.fill = fill + cell.border = border + cell.alignment = align_wrap if klic in ("nazev", "postup", "nositel_funkce") else align_def + + +def main(): + print("Připojuji k MongoDB...") + client = MongoClient(MONGO_URI) + col_vykony = client[MONGO_DB]["vykony"] + col_detaily = client[MONGO_DB]["detaily"] + + # Zjisti odbornosti + if ODBORNOSTI: + odbornosti = ODBORNOSTI + else: + odbornosti = sorted(col_vykony.distinct("odbornost", {"_aktivni": True})) + print(f"Odbornosti: {odbornosti}") + + wb = openpyxl.Workbook() + wb.remove(wb.active) # odstraň defaultní prázdný list + + for odbornost in odbornosti: + print(f" Zpracovávám odbornost {odbornost}...") + + vykony = list(col_vykony.find( + {"odbornost": odbornost, "_aktivni": True}, + {"_id": 0}, + ).sort("cislo_vykonu", 1)) + + cisla = [v["cislo_vykonu"] for v in vykony] + detaily_map = { + d["cislo_vykonu"]: d + for d in col_detaily.find({"cislo_vykonu": {"$in": cisla}}, {"_id": 0}) + } + + ws = wb.create_sheet(title=f"Odbornost {odbornost}") + nastav_list(ws) + + row_idx = 2 + for vykon_idx, v in enumerate(vykony): + d = detaily_map.get(v["cislo_vykonu"], {}) + radky = _vykon_radky(v, d) + barva = BARVA_VYKON_A if vykon_idx % 2 == 0 else BARVA_VYKON_B + for radek in radky: + zapis_radek(ws, row_idx, radek, barva) + row_idx += 1 + + print(f" → {row_idx - 2} řádků, {len(vykony)} výkonů") + + client.close() + + wb.save(VYSTUP) + print(f"\nUloženo: {VYSTUP}") + + +if __name__ == "__main__": + main() diff --git a/Vykony/stahni_detaily.py b/Vykony/stahni_detaily.py new file mode 100644 index 0000000..ece94b4 --- /dev/null +++ b/Vykony/stahni_detaily.py @@ -0,0 +1,334 @@ +""" +Stahování detailů zdravotních výkonů ze szv.mzd.gov.cz. + +Čte cislo_vykonu z kolekce `vykony`, stahuje detailní stránky +a ukládá do kolekce `detaily` (upsert podle cislo_vykonu). + +Požadavky: + pip install requests beautifulsoup4 pymongo lxml +""" + +# ── Nastavení skriptu ────────────────────────────────────────────────────────── +KOLIK = 0 # 0 = vše; jinak maximální počet výkonů ke stažení (např. 50) +FORCE = True # True = přestáhni i už stažené záznamy +WORKERS = 5 # počet paralelních vláken +# ────────────────────────────────────────────────────────────────────────────── + +import logging +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone + +import requests +from bs4 import BeautifulSoup +from pymongo import MongoClient, UpdateOne, InsertOne + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +MONGO_URI = "mongodb://192.168.1.76:27017/" +MONGO_DB = "zdravotni_vykony" +COL_VYKONY = "vykony" +COL_DETAILY = "detaily" +COL_DETAILY_HISTORIE = "detaily_historie" + +# Pole porovnávaná pro detekci změn (metadata vynecháme) +COMPARE_FIELDS = [ + "nazev", "poznamka", "kategorie", "typ_formulare", + "omezeni_mistem", "omezeni_frekvenci", "doba_trvani", + "nepocitat_rezii", "popis", "cim_zacina", "obsah_rozsah", + "cim_konci", "podminky", + "autorska_odbornost", "dalsi_odbornost", "nositele", + "materialy", "pripravky", "pristroje", "zum", "zulp", + "body_prime", "body_osobni", "body_rezijni", "body_celkem", +] + +BASE_URL = "https://szv.mzd.gov.cz" + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept-Language": "cs,en;q=0.9", +} + +# Mapování label → klíč pro skalární pole +SCALAR_MAP = { + "Číslo výkonu": "cislo_vykonu", + "Název": "nazev", + "Poznámka": "poznamka", + "Kategorie": "kategorie", + "Typ formuláře": "typ_formulare", + "Omezení místem": "omezeni_mistem", + "Omezení frekvencí": "omezeni_frekvenci", + "Doba trvání": "doba_trvani", + "Popis": "popis", + "Čím výkon začíná": "cim_zacina", + "Obsah a rozsah výkonu": "obsah_rozsah", + "Čím výkon končí": "cim_konci", + "Podmínky": "podminky", +} + +# Mapování label → klíč pro vnořené sub-tabulky +SUB_MAP = { + "Autorská odbornost": "autorska_odbornost", + "Další odbornost": "dalsi_odbornost", + "Nositelé": "nositele", + "Materiály": "materialy", + "Přípravky": "pripravky", + "Přístroje": "pristroje", + "ZUM": "zum", + "ZULP": "zulp", + "Bodová hodnota": "bodova_hodnota", +} + + +def _to_float(s: str) -> float | None: + s = s.strip().replace("\xa0", "").replace(" ", "").replace(" ", "").replace(",", ".") + try: + return float(s) + except (ValueError, AttributeError): + return None + + +def _parse_subtable(table_el) -> list[dict]: + """Parsuje vnořenou tabulku → seznam diktů. Přeskočí řádek Celkem.""" + rows = table_el.find_all("tr") + if not rows: + return [] + headers = [th.get_text(strip=True) for th in rows[0].find_all(["th", "td"])] + if not headers: + return [] + records = [] + for row in rows[1:]: + cells = [td.get_text(strip=True) for td in row.find_all("td")] + if not cells: + continue + if cells[0].lower().startswith("celkem"): + continue + records.append(dict(zip(headers, cells))) + return records + + +def parse_detail(html: str, cislo: str) -> dict: + soup = BeautifulSoup(html, "lxml") + doc: dict = { + "cislo_vykonu": cislo, + "detail_url": f"{BASE_URL}/Vykon/Detail/{cislo}/", + } + + main_table = soup.select_one("table.detailTabulka") + if not main_table: + return doc + + # Řádky: + for tr in main_table.find_all("tr", recursive=False): + th = tr.find("th", recursive=False) + value_td = tr.find("td", recursive=False) + if not th or not value_td: + continue + label = th.get_text(strip=True) + + if label == "Nepočítat režii": + chk = value_td.find("input", {"type": "checkbox"}) + doc["nepocitat_rezii"] = bool(chk and chk.has_attr("checked")) + + elif label in SCALAR_MAP: + key = SCALAR_MAP[label] + doc[key] = value_td.get_text(strip=True) + + elif label in SUB_MAP: + key = SUB_MAP[label] + nested = value_td.find("table") + doc[key] = _parse_subtable(nested) if nested else [] + + # Záloha: checkbox mimo párový řádek + if "nepocitat_rezii" not in doc: + chk = soup.find("input", {"type": "checkbox"}) + doc["nepocitat_rezii"] = bool(chk and chk.has_attr("checked")) + + # Číselné přetypování skalárů + if "doba_trvani" in doc: + doc["doba_trvani"] = _to_float(doc["doba_trvani"]) + + # Bodová hodnota: tabulka s 1 datovým řádkem → flatten na skalární pole + bh = doc.get("bodova_hodnota", []) + if bh and isinstance(bh, list) and len(bh) == 1: + row = bh[0] + doc["body_prime"] = _to_float(row.get("Přímé", "")) + doc["body_osobni"] = _to_float(row.get("Osobní", "")) + doc["body_rezijni"] = _to_float(row.get("Režijní", "")) + doc["body_celkem"] = _to_float(row.get("Celkem", "")) + + return doc + + +def fetch_detail(cislo: str, session: requests.Session) -> dict | None: + url = f"{BASE_URL}/Vykon/Detail/{cislo}/" + try: + resp = session.get(url, headers=HEADERS, timeout=30) + resp.raise_for_status() + resp.encoding = resp.apparent_encoding or "utf-8" + return parse_detail(resp.text, cislo) + except Exception as exc: + logger.warning(f"Chyba při stahování {cislo}: {exc}") + return None + + +def worker(cislo: str) -> dict | None: + """Každý worker má vlastní session (thread-safe).""" + s = requests.Session() + result = fetch_detail(cislo, s) + time.sleep(0.1) + return result + + +def process_changes( + new_detaily: list[dict], + col_detaily, + col_historie, + run_at: datetime, +) -> dict: + existing = {d["cislo_vykonu"]: d for d in col_detaily.find({}, {"_id": 0})} + + ops_detaily = [] + ops_historie = [] + stats = {"novy": 0, "zmenen": 0, "nezmenen": 0} + + for detail in new_detaily: + cid = detail["cislo_vykonu"] + detail["_scraped_at"] = run_at + + if cid not in existing: + detail["_platny_od"] = run_at + ops_detaily.append(UpdateOne({"cislo_vykonu": cid}, {"$set": detail}, upsert=True)) + stats["novy"] += 1 + else: + old = existing[cid] + changed = [f for f in COMPARE_FIELDS if old.get(f) != detail.get(f)] + if changed: + archive = dict(old) + archive["_platny_do"] = run_at + archive["_zmenena_pole"] = changed + ops_historie.append(InsertOne(archive)) + + detail["_platny_od"] = run_at + ops_detaily.append(UpdateOne({"cislo_vykonu": cid}, {"$set": detail})) + stats["zmenen"] += 1 + else: + ops_detaily.append(UpdateOne( + {"cislo_vykonu": cid}, + {"$set": {"_scraped_at": run_at}}, + )) + stats["nezmenen"] += 1 + + if ops_detaily: + col_detaily.bulk_write(ops_detaily, ordered=False) + if ops_historie: + col_historie.bulk_write(ops_historie, ordered=False) + + return stats + + +def create_indexes(col_detaily, col_historie): + col_detaily.create_index("cislo_vykonu", unique=True) + col_detaily.create_index("_scraped_at") + col_detaily.create_index("_platny_od") + + col_historie.create_index("cislo_vykonu") + col_historie.create_index("_platny_do") + col_historie.create_index("_zmenena_pole") + logger.info("Indexy vytvořeny") + + +def main(): + logger.info("=== Spouštím stahování detailů výkonů ===") + logger.info(f"Nastavení: KOLIK={KOLIK or 'vše'}, FORCE={FORCE}, WORKERS={WORKERS}") + run_at = datetime.now(timezone.utc) + + client = MongoClient(MONGO_URI) + col_vykony = client[MONGO_DB][COL_VYKONY] + col_detaily = client[MONGO_DB][COL_DETAILY] + col_historie = client[MONGO_DB][COL_DETAILY_HISTORIE] + create_indexes(col_detaily, col_historie) + + vsechna_cisla = [ + d["cislo_vykonu"] + for d in col_vykony.find({"_aktivni": True}, {"cislo_vykonu": 1, "_id": 0}) + ] + logger.info(f"Aktivních výkonů v DB: {len(vsechna_cisla)}") + + if not FORCE: + uz_stazeno = { + d["cislo_vykonu"] + for d in col_detaily.find({}, {"cislo_vykonu": 1, "_id": 0}) + } + cisla = [c for c in vsechna_cisla if c not in uz_stazeno] + logger.info(f"Již staženo: {len(uz_stazeno)}, zbývá: {len(cisla)}") + else: + cisla = vsechna_cisla + logger.info("FORCE=True: přestahuju vše") + + if KOLIK: + cisla = cisla[:KOLIK] + logger.info(f"KOLIK={KOLIK}: omezuji na {len(cisla)} výkonů") + + if not cisla: + logger.info("Nic ke stahování.") + client.close() + return + + # Stahování + fetch_chyby = 0 + batch: list[dict] = [] + BATCH_SIZE = 100 + + def flush_batch(): + if batch: + stats = process_changes(batch, col_detaily, col_historie, run_at) + batch.clear() + return stats + return {"novy": 0, "zmenen": 0, "nezmenen": 0} + + total_stats = {"novy": 0, "zmenen": 0, "nezmenen": 0} + + with ThreadPoolExecutor(max_workers=WORKERS) as pool: + futures = {pool.submit(worker, c): c for c in cisla} + done = 0 + for future in as_completed(futures): + done += 1 + result = future.result() + + if result: + batch.append(result) + else: + fetch_chyby += 1 + + if len(batch) >= BATCH_SIZE: + s = flush_batch() + for k in total_stats: + total_stats[k] += s[k] + + if done % 200 == 0 or done == len(cisla): + logger.info( + f"Průběh: {done}/{len(cisla)} " + f"(chyby stahování: {fetch_chyby})" + ) + + s = flush_batch() + for k in total_stats: + total_stats[k] += s[k] + + client.close() + + logger.info( + f"Výsledek: nové={total_stats['novy']}, změněné={total_stats['zmenen']}, " + f"nezměněné={total_stats['nezmenen']}, chyby={fetch_chyby}" + ) + logger.info("=== Hotovo ===") + + +if __name__ == "__main__": + main() diff --git a/Vykony/stahni_vykony.py b/Vykony/stahni_vykony.py new file mode 100644 index 0000000..43f53f8 --- /dev/null +++ b/Vykony/stahni_vykony.py @@ -0,0 +1,259 @@ +""" +Stahování zdravotních výkonů ze szv.mzd.gov.cz a uložení do MongoDB. + +Kolekce: + vykony -- aktuální stav každého výkonu (_aktivni, _platny_od) + vykony_historie -- archiv každé změny (_platny_do, _zmenena_pole) + +Požadavky: + pip install requests beautifulsoup4 pymongo lxml +""" + +import re +import requests +from bs4 import BeautifulSoup +from pymongo import MongoClient, UpdateOne, InsertOne +import logging +from datetime import datetime, timezone + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +MONGO_URI = "mongodb://192.168.1.76:27017/" +MONGO_DB = "zdravotni_vykony" +COL_VYKONY = "vykony" +COL_HISTORIE = "vykony_historie" + +BASE_URL = "https://szv.mzd.gov.cz" +COLS = ( + "Odbornost,CisloVykonu,NazevVykonu,Kategorie,TypVykonu," + "DobaTrvani,OmezeniMistem,OmezeniFrekvenci,PrimeNaklady,Osobni," + "BodyRezijni,BodyCelkem,Revize,Detail" +) +PAGE_URL = f"{BASE_URL}/Vykon/?cols={COLS}&page={{page}}" + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept-Language": "cs,en;q=0.9", +} + +# Pole která se porovnávají pro detekci změn (metadata vynecháme) +COMPARE_FIELDS = [ + "odbornost", "nazev_vykonu", "kategorie", "typ_vykonu", + "doba_trvani", "omezeni_mistem", "omezeni_frekvenci", + "prime_naklady", "osobni", "body_rezijni", "body_celkem", + "revize", "detail_url", +] + + +def parse_decimal(value: str) -> float | None: + if not value or value.strip() in ("", "-", "–"): + return None + cleaned = value.strip().replace("\xa0", "").replace(" ", "").replace(",", ".") + try: + return float(cleaned) + except ValueError: + return None + + +def parse_date(value: str) -> datetime | None: + if not value or value.strip() in ("", "-"): + return None + try: + return datetime.strptime(value.strip(), "%d.%m.%Y") + except ValueError: + return None + + +def parse_page(html: str) -> tuple[list[dict], int, int]: + """Vrátí (výkony, zobrazenoDo, celkem).""" + soup = BeautifulSoup(html, "lxml") + + displayed_to = total = 0 + m = re.search(r"Zobrazeno\s+\d+\s+\S+\s+(\d+)\s+z\S+\s+z\s+(\d+)", soup.get_text(" ")) + if m: + displayed_to = int(m.group(1)) + total = int(m.group(2)) + + table = soup.find("table") + if not table: + return [], displayed_to, total + + vykony = [] + for row in table.find_all("tr")[1:]: + cells = row.find_all(["td", "th"]) + if len(cells) < 13: + continue + + def cell_text(idx): + cell = cells[idx] + for elem in cell.find_all(attrs={"title": True}): + t = elem.get("title", "").strip() + if t: + return t + return cell.get_text(separator=" ", strip=True) + + detail_link = None + if len(cells) >= 14: + link = cells[13].find("a") or cells[13].find("button") + if link: + href = link.get("href", "") or link.get("data-url", "") + if href: + detail_link = BASE_URL + href if href.startswith("/") else href + + vykony.append({ + "cislo_vykonu": cell_text(1).strip(), + "odbornost": cell_text(0).strip(), + "nazev_vykonu": cell_text(2).strip(), + "kategorie": cell_text(3).strip(), + "typ_vykonu": cell_text(4).strip(), + "doba_trvani": parse_decimal(cell_text(5)), + "omezeni_mistem": cell_text(6).strip() or None, + "omezeni_frekvenci": cell_text(7).strip() or None, + "prime_naklady": parse_decimal(cell_text(8)), + "osobni": parse_decimal(cell_text(9)), + "body_rezijni": parse_decimal(cell_text(10)), + "body_celkem": parse_decimal(cell_text(11)), + "revize": parse_date(cell_text(12)), + "detail_url": detail_link, + }) + + return vykony, displayed_to, total + + +def fetch_all(session: requests.Session) -> list[dict]: + all_vykony = [] + page = 1 + while True: + url = PAGE_URL.format(page=page) + logger.info(f"Stahuji stránku {page} ...") + resp = session.get(url, headers=HEADERS, timeout=60) + resp.raise_for_status() + resp.encoding = resp.apparent_encoding or "utf-8" + + batch, displayed_to, total = parse_page(resp.text) + all_vykony.extend(batch) + logger.info(f" {len(batch)} výkonů (zobrazeno {displayed_to} z {total})") + + if displayed_to >= total > 0: + logger.info("Poslední stránka — konec.") + break + if not batch: + logger.warning("Prázdná stránka — konec.") + break + if len(batch) < 50: + logger.info("Poslední stránka (méně než 50 záznamů) — konec.") + break + page += 1 + + return all_vykony + + +def process_changes(new_vykony: list[dict], col_vykony, col_historie, run_at: datetime) -> dict: + """ + Porovná nová data s aktuálním stavem v DB. + Změněné verze archivuje do vykony_historie. + Výkony co zmizely označí _aktivni=False. + """ + existing = {d["cislo_vykonu"]: d for d in col_vykony.find({}, {"_id": 0})} + new_ids = {v["cislo_vykonu"] for v in new_vykony} + + ops_vykony = [] + ops_historie = [] + stats = {"novy": 0, "zmenen": 0, "nezmenen": 0, "deaktivovan": 0} + + for vykon in new_vykony: + cid = vykon["cislo_vykonu"] + vykon["_aktivni"] = True + vykon["_scraped_at"] = run_at + + if cid not in existing: + vykon["_platny_od"] = run_at + ops_vykony.append(UpdateOne({"cislo_vykonu": cid}, {"$set": vykon}, upsert=True)) + stats["novy"] += 1 + else: + old = existing[cid] + changed = [f for f in COMPARE_FIELDS if old.get(f) != vykon.get(f)] + if changed: + archive = {k: v for k, v in old.items()} + archive["_platny_do"] = run_at + archive["_zmenena_pole"] = changed + ops_historie.append(InsertOne(archive)) + + vykon["_platny_od"] = run_at + ops_vykony.append(UpdateOne({"cislo_vykonu": cid}, {"$set": vykon})) + stats["zmenen"] += 1 + else: + ops_vykony.append(UpdateOne( + {"cislo_vykonu": cid}, + {"$set": {"_scraped_at": run_at, "_aktivni": True}}, + )) + stats["nezmenen"] += 1 + + # Výkony co v novém scrape chybí → deaktivovat + for cid, old in existing.items(): + if cid not in new_ids and old.get("_aktivni", True): + archive = {k: v for k, v in old.items()} + archive["_platny_do"] = run_at + archive["_zmenena_pole"] = ["_aktivni"] + ops_historie.append(InsertOne(archive)) + ops_vykony.append(UpdateOne( + {"cislo_vykonu": cid}, + {"$set": {"_aktivni": False, "_deaktivovano": run_at}}, + )) + stats["deaktivovan"] += 1 + + if ops_vykony: + col_vykony.bulk_write(ops_vykony, ordered=False) + if ops_historie: + col_historie.bulk_write(ops_historie, ordered=False) + + return stats + + +def create_indexes(col_vykony, col_historie): + col_vykony.create_index("cislo_vykonu", unique=True) + col_vykony.create_index("odbornost") + col_vykony.create_index("_aktivni") + col_vykony.create_index("_platny_od") + + col_historie.create_index("cislo_vykonu") + col_historie.create_index("_platny_do") + col_historie.create_index("_zmenena_pole") + logger.info("Indexy vytvořeny") + + +def main(): + logger.info("=== Spouštím stahování zdravotních výkonů ===") + run_at = datetime.now(timezone.utc) + + session = requests.Session() + vykony = fetch_all(session) + + if not vykony: + logger.error("Žádná data nebyla naparsována!") + return + + logger.info(f"Celkem naparsováno: {len(vykony)} výkonů") + + client = MongoClient(MONGO_URI) + col_vykony = client[MONGO_DB][COL_VYKONY] + col_historie = client[MONGO_DB][COL_HISTORIE] + create_indexes(col_vykony, col_historie) + + stats = process_changes(vykony, col_vykony, col_historie, run_at) + logger.info( + f"Výsledek: nové={stats['novy']}, změněné={stats['zmenen']}, " + f"nezměněné={stats['nezmenen']}, deaktivované={stats['deaktivovan']}" + ) + + client.close() + logger.info("=== Hotovo ===") + + +if __name__ == "__main__": + main() diff --git a/Vykony/vykony_report.xlsx b/Vykony/vykony_report.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..afc45f5a981fdb4c3a00baa197aefe3746f67f40 GIT binary patch literal 29833 zcmZ^KbxbC~vn}rK`r+>G?hXr!yE}`!ySux?qKm_aEU>t{ySuwR?)~M-{o^Iw)2Yd1 zGIe^o>ZDJXvK%-B1_%fU3<$Vpudd{ff^_P)HSyh1zPqV|iL#T!FJ~sBU%wbV>}(aL z6yW=q5umnvez>(}MgdBY#08@AI;R+2LtALF$9G%nPv#jVEnKJ4MBN*B zRVe#+HQ9OpP!UzF6TqPyc~J;Pqs#H6(?NbktBzr+<{geYyqiMyKzCp({|^@)bHI0Q zzCWD~3E{!O85ue3(d@lKsbw5^f;jQ&Dg3T|^9$v|5~6IS!kitwU5( z;uFgU^TT(PP;|l*981Pp2944X6^rytyCx)xdSr-9zY3D5+gPZ-3G95Ps2Jj>7U)@7 zZ>&XK`S`U9euiPbu7&-FXzIR$& zk?;T;x2*S;4*zBThT20{)oY*Mh&|{z@P2PB?fNgdp*2h9HlLY}&PG0H=i1K?OdSsY z6Q4}o95NIr5Re=y5D@fld^~KKTrAD(%>Hv_`7eYnv~`?UxiS2%>ZUy`9_`=hfcJ?}0VjgP}3 zQf@sASD4lofvrrN+ZwFos3Od`<#pG8;Y#K8o3*V#`ni2a(9!zWgQ8*NJTS%i! zn8+4So7OheL#%8IaimNQef8-rg{Re2v>gnHn+}NBo-xPNV$F&{(KSRU+asE8Uu*~7 zS2qRCrcQVXSp)5v@imG4CmqW76}2OF)cXGYwYCsaw;7t5l@16R@lAp3ka;Yg!-Qc) zzaZ8`C>)(vLvQM2zOVoB>(yVU6;uKA@G9FCUaNvVf5Xr!L-N!>ZaHNFIE7zBI(k8{< zstUCH0+>CO{O)|bpI@H5q#!!^tCJq~=8a6pD4fck58a8Ez(Uk#>hUqLl!A5Y>ILph zk>7$yJ*{4EYHB|(#)%BL@u=zhs3c`iwp97Ik#s3Vl3XHxn4x1ImAhF6kriDCCO0g@ zTA2W>1ePVOFzmT$uo`t6-M6(HW_T+$;;G6ZOF*MgQ)V^f8ATFrH^=$VdB*k)x>R!J z@n`3Hak?fZXgbao`)>~Vfklthzc)2G{DkOe6DjaeB4O#|lxUSaehSj)pLC_GpE+~F z*C|Zoud<2pR*hukE|e3;Y4Q_=I2<_>GbzAy&X^KplBp zR!tu&ov5HOE+?K-Gge@WV`Ye;Hrn8fhvIA#>bq7oN3x*d@cBfd>*Qi05^9n@*Y~rN z(Fv$MfG}>^tcxN?O^A;?{eGF=QDpz0CbI@NNe@5S_(yR7+fx_8V zE;3(>-nnR3>+ z6669KMabLUqeri4|G^%c>QKOrsm+;jMWN-1;@h97KFFpn9n`f`xAE)jpVSq)2+~iz z@EaU8O~A8>wK~b2wxo17Q*z7R{~7-kFjlP$WQ1D=ZQFgn>qYAJn(p3fc>D2dL<;$U@5ta@PpsDOabxe* zKABHiKF4za71Tunj4YJ^3JaTcU->dA0SX;-B4tGU8Z5}Pj36N66~5~hp`8YEvPO`{ zUxGe3Oi_Q90u3Xo9s!aB+iCCWD%sjSX6cna&@M!c)pP-SR4lSrfXzv4S&ibZc9jx7 zbZu8{0*Kd5X(-q4WuMDH^|huyFVKW7AbD4uxeaIxch2@|>yxPd(p&_*ux|1ETI1aW z*FQw37c24W^^J-OK&>+F?x<6Gp~&HD^1cZq&VFB9D|y`5M(~-h(k?ZnLlu&P_904w zGKtZ-XE%t)Fs>>4Q!}{Ag5VjTd52JF&b<{bk%h;$5M9j^3+EuRAbcRSHjileh{#Tm z2&vDK*}_@8uL#G#{&|I1-}f(W=OVfX^nWIh!9IHY9aa#KcXm(^oc|;ccLygMXG=3P z7iXsb9{!U>e6^g{+FCBXzu=KdIs~I7&~0t_IYRe=KE77@=L| zdOnRwVZow780flyQ!)T!9WX4YpsiPs>YXK_py_C=IP_v64LPCLiwV-Wr7uCjICFRl zGpPr|i|6O;E0MMalXr_}7lInGp}aZSssqQp{r!6nGvH_TmVar!Lf@7@Vv@(n>m<3j zN`v;w&SP*R!sqiv>;vV;_Mor!zy097Eq}c=rO6uftvV`_&ol01R(Fj!qV~Xe(W5s9 z2j0b=o|dcs5I0e!lr z;T~dx#UG#}W!tiYLzxHxG~KPhrMd0UbMci?y|d41S)}jUD&RMiif?JQ1h0L!oeQD}0 zmOnq1n4iHzoA^CnZa2;A$OhAKX+#V>p_qEQ9DRd)S{);LT>t!iek%6oP@H1A`I=}P zv&1bx{A}Lnyay9{dEg#oNH!i~V>}PmUxv}JZ(?$vgsSi^ty>#Pc*F>)1y}1@)aY8P zMV$d1Now}LuQRXmg@rvmh17#q#UI!Si@aH>1~C>q9f;9D8Z=YKuX$b{oPi@ z4v%YfeAlvS6~xbrYeX8}>)wvXr$emb?CC zQA2^8yXCpkNJxl1Y}cJE2+GN9y#G7IF|Uyap^{ULcr`tIof0%0n@hp_y5!m4z?fL> z$u!7>{vdGEg-dR6%gSSmx`Il4ayCY;aAS*IN?>9*HeHh#s2Ln(ml7fHM-dMTLZe!{ z5y5G&f282eAI3VjiD{;vtJ7;WcI=W+Yp!O@{&ir#Y_8`_?*W!xGhK|GIA16I1Br<% zWLj}+57sLlt1#BUzDYl6zzeCHbIFV5(K?IXPKeRqLQe(6s5g5{Ny$%OGs3XUp*YyP z11Tvt_}}98rQd9HLmY!VfiFz^J*Kba%;S7vF1FMoB=rn`U4A}h^uvO2Y@@m!8mF?H zmU^)YJ^MUUn~XGwF#rQ|9Bu-fsTUZzoX3;#cS`q`u0e%4zo8N-7jt*G1$MTaVV8oD z0Lppk2^WNHtySawX`|(Hj49*8yXog`>DiEC22JY4fl!Cjgvyh5|B5xdCh< zt{p^1IKB7!Un?H47cFsNDVCg_V;3HX-@ln{<{@9 zAK{ow_j?qEN+P<4AzQ;EMJN3joBQeq1P_FH?Dew+&4xf5m1XVsJ7#v1yE;;bG4y-w>fOyWbF=n9zQ|;qDp%cVgr}9l{+CFn(#vH9Q@E zZc3s$8K{2SSF$n_@sZwQDiZm5SX>v3sT50{_oH@hyiG9~&f2O?5qy`oS{rCf#d2~# zgq(|7%y=CBgXja~_Yb136u5aQjK3Iea%FcUG?&1(HMW9P8!sj7#qR6};Xo#4{x;IX$3~Z5Z%t4m>B^w*Gs3^8Xqj%M)uH?F zj&Q*V**uxby2?$h?Fb8d>&M!fKYrmbypAHjac>P@X75Ev(o-=z8sw4Hjn0rzesX5W zphc$LkW${vnzzWBCnJ)4U08ZhrxD|U*g>PpN6#_$)K1GWCs!#vp?#_)t5anDU${*C z7cTPOxR`(AVqKPInWH;DMLxTr5uKa2R9ajGN__UNW^Dcn$;Ei2%R1DnU8|NYWMYnG zc&&u$*1x3(4Y`93ttjFzp;MNUwLclK+ag+#!C3w~*e9_P0#2Erq1T=<2aG>{`s{$IVl_LB18@TQF;*`;}KReG3gjvE&%rIMtnl= zE%LYoAqUnusAHmAd#(y{z4%O2VAgR>Tu~XDw2bdt8Ibib zLa04+V?(cDbI0ZMS9~J0D8XtBTqx194vdDNz7XM<;Cd{X%ayJVIN0SIO6nK-y z6WmG4#n_ca4q%>!{G&sj9AZkjA_EV@O4DG>+lJbUI|X~vIlNEogbLLyy%!;hXzu3- zW#@9vH@A(0QegSmgX!s0ExoAkrD;JL*^oz%B8>mRG(lrXi+w{Tsl?+x7;l?uhsFHn z$TGFn<{U{@x7n3(LU^h5!9V{ta`|cPR=(uR44kvc5?Ia^aSjgm3gexKUTrTmaxcdP z@^Ju8Mb2;gD|@GH?>&Ut&U9TRz`{M(j{fv*Pft8|aN#)yEtwFnbeO(hx&4}oAUO*D zaIl!D4U7wFos}P5Y*Sb17PL4uxAmWLXgwV&>$o z;8!o7s~cWvF5rY^a){h;q8U}(Fh;7Ey@AC-yKynOvp3}rHyz6Ay^=dfrdrGcdK+{T z?jj|O-7Bw!`o*qzu&o7dLoV)9O*u+KuGP**LwK=5Gm& ztwK-9Z=7kGM+;Liobhsz3?ioeCGCF|?}3HbJJx(JhIPbv4tVfu?hRLdIrB5F0q$Ls zD)t(Kc#dVDt2I0*NBMGmvm+IQQMCEVkJl|&cYId)gf+RDHcT7TWf}pY)=to{I=ax# zq!b%EJ?{y^R%UtC>6M{?MNFT`)73_Jvs23&-qn@R=%I5{c?Si_@)&FCTBwLoYEX|4 z9k53I57Bk-?T6d|t-9{G$OnDXXFnt3u7bqraCm_a9D}VGp^}`Zz>l1J`x&whz|&Q@ zsgNIJP+K?OPZ8bkwH;wVjZjS!-1fb;1KZZMk#=7t4N)}S7=zoii;SAjELfeY2C5IW z^g7ybK~xv)Wv6Ql7+NReiZ5yXv&kAp@)&bd34s|Dix5o$vP=lf9xBP%5-3O2EM_@4 zjp(RN19!rX*?w1$-3SyxFen`N@sN^;9|XgTc()&N$frZ%TgVBSz6(pe@P%$=e6UT5 ztjENk?_T!HP=A7#s%&?-x(lH_Z0T|v2Hrr;7)wJi#+2h`>bpo+S+uKQ;LO%Y&VQ3YT@8^&s6340{`|!qN#Xe?=AK^ ziOQli=&@s;v_3%!_s(NA)GFcBO1s%B?uf&Wj?l$Rt4{?ECO5^w^6o`Km*Rb?n9jLV z{?2PT!lP$TlSyp+Mm@#32tBEY_O;mRnSVwNcf{tzGW4N5a-}-yZ4q6NrrqUPSrpNg z^PYz;l!5zZ+~}*3q2i?5H*%)31H}!pE(^neScX9r_iTsjzy7ZTO^N5hS~DDJO~Z5z zbux{MJwohredOXCHJ)o5BfrswU)9IWvA#%(*~4+Loe3Vepo-^<&!>;pNGDQ@7o33u zx(iuP`gG6GK!5YTT?6uIq*R=VzH=akanI)#%DA~C#5d*9mpU{g!8SH);CG^uuV*Hf z-X$N}rR_!eGohU}>R&cXq$mb*`C(uleL^6JM#9l~l*&pp&5*{60#_#QY(*!qs7klR z4MfcJI1p1XgEaux&$`!%%zZ?z&jnN21FYBtCjtZqx?j26t!S-BQ}h`yRv-Ll8=Y3a zE+zqyuXvKmN!UL;E`-fz5GzFW-FyCOUH3t9Z%4o!b8ihexxtmde+<;hLH5#GbOa9#Y=t}t?zV>YRURszhHi%O&_qrFS3D!J8g=Y4YQAx%tcE|y zlmY}ti0MYD1<{FK@=GDomFb3a%d>?sx)Z@{+?!Gh=~z(rIPDrLKB}9RKVTS#lt_0a zgD#OVfpD;xXhWSLFx3V9?4Ny6ya2ZRD+pb4oF>eE6Za;;Lm&ZwSzRWbMd%87kPx}u zp8q0CUJsILsoB{f2#O^vhM~LJASmt`LrR?W?A6T5m2RI|y>WLtzGQU~8xNk-+F5lo z3h>~jd(Xtti~?9qtZTN-|Lh=N6_lu?3u%hhkhqY~7DG9e4{fL?G5~r>u2#!2e zVJRV-vIu4=%ZXXpR)E!EYPW$QnzU6B102%osyazw+DAr2jz_Pbr%%cLD)5RkQuTb%5r`heVoBzJ6Y;ORBkoJQcLZ$Fw8a=Zo z4^f3U>1QF(>fqq}y7mw+qb|xA1%W9U381oa0n2Y@v&$hL_^5X>Gbi?oZ?Z;I_KD9& zsEo1waUi!0OpJYcPj7x-t9i+~|7v)9-#dg}stJUD($Uqs3KgCzF z32GJL_|vEG6EcZ}Fhf)YZbttp+`|-|$=H-eK|ydu$d2d7h&79n2nNLp0T*LO;a`G) z-z=8A*xh9c2!S*H37Y=1Ktmv}uS1$Ry?G)g6e zBlGc&)1U)|#lc1pY-DCU2I@m%Bh*nwz=!cd=?>?bG9;(dZ-xi96+BVm8X)81tjAtn zEjt`1Zdthj8u3*+7l!}tgc}bU@!KF8|27OeMf+_y2g${s z*gia64_0L)>4t!Hgrpxfhl|4Vcr(O`V3;ok+dqat8d(?zD&rJRKenG}#vu|BcDlLc10-}Cz9Om}>fem;&GkHUALMf1YREWjjqvy9JJv7v(~Wo+k>?r4?%YIC z7&P!2uLurU@!WHQ(22!8EMT4NTumMWTaQZ_O9g**!Zpx`ry;3U24o^7b{!tFGGUr+EG?#sSP9Oxc$6$A{ z&)S6tUF+t9!Ao=z!_QI9;@DB&-@C+=3A{@bQF$(ROJi%=|`dpzM-FDgXyUL zk`lS};X$eIvqw72uHS8%PUFI3ZDz(ryV4wMYF#f&X!bWEKa-e*y3+VhF`~hPM?!YG zkFb}HYj$V~;-O*_J!i(6PdU={fHH!qFQ$U?J~POzTIW9(nI?vY52{ua^fbMSkjHhz z6c}g^>Q25DYoGA+G)@GrE}l{qo4F-4ciW@8r-fLWP7KP}c8Zk8Jh#}*KtOpeRh_Jt ze|Y;ETeOTQ9-=;%D*iw0N9#kZ&BptM_-1MKbiG;ema|bw9WXdp_@H{$7}vUP=bw7X3*4;o2rjshQA`3E1^Ww zV#~y1(s56Y7*K;12_}Yk1aj6 zSBLlM_KU5%cn1%{^<+BTJt%}k(~R`d^PL61hqM`=KRj57tMzjGUZ?$k9dH!Zuef>kdPo&vJ0ej^w~~ zEByNj&saD*(Gi4+6WGQj4ss^y+yWdj2)7YqJLzp2M?m$Fox)BjMzSaLum5i(N&EW2 zxXh-R2&1nlYZ0X|W0G*sJwW1#97+c8vM=8Hbp`5LN=FjjG21>_I7xp{)gq00>ZG(9 zHj~@}nU}(KuP@l@o;&kQBGl^Vzk%=Oa2fiGfOwH$&|vY~-$jtm21Hz2q+=$Vgwiq8 zo_HWVbEhOwr8SFnx#D0_=`7Y6_t(7fh5E;?8Wdr(=ACF9o~for{3EaTMekpX_MYxN zzC@Ib@K0G$tdccuueZY$8;#XA5~lTpd3APsim(v=bv~%TkV3&Q4s8KvTMA%jIEB*B z%+}x7EXem{D1r)8;WF|G6#aj4V>yAu_b`;PNBrx$x@aMdVX}J;qeg789_GK`Aizm* z0cQY<@G=dk(?Jcf(z`irvDFSNN46(eTO407o$VPTv8y-oox_U>*C%O&z>vt@jR_A{ zz=Pz|@gzifxZ^9^cQPvf&6W*U4t@X1Hf(LV?Lt&8Z0MnYqPXBhob!7?nEPjRvl{mT zQ)~XO4yGJq{*TKLa>N@ImZsyKUyIw?uzS^?o>#$*ul6b1)!j*`7S0x9VKXZYkN{53 zdqz{V$z4R|SLUpY>w9KE@^?PE#u!$v+dcu+qeN_QxpyIomo@Y#FUJPS`+XqH|1&z! z15P?w;b&Nw*#o-LQ+c@%o+4sg1#=F7pQrWYXV8wC!3a!wyNz}KaTZ+omF=;>)$_@| zSg*rgp(@FrsV+{9_(EVR>eIq!IV3Dacs%sI5XbN==%wIg<=`U@%Ah-YLFiQ$+UTYm4#S`x7#@FsT}zha9u`02fmdYmX36?RWQO69$j zuBaeC8lBUiK4#gM-wZ$IA==rT`j2CL;B3IUsLV1cr$L0wHkCR(A6E_UZr z^CMFX{{92ISn&b{A*8koo13yoh~15mnOs=?xr}y-tg<*s@@MY$kc-CVczdAbFTTuz zuh%*QzZX{Q9F>Sa4`r<4WyEwKm%P%At-R|69AX0Nsz;tu=+RM`Uf}uU5%5Q~22G`u zqB8Ub7TpsHDFbEFaS3F3nhUC~Dk5LDEk5R%IXn)=QfSBRccluVw^A<(TQ2xinh(>N zi9h1^c@!d`=tJ=Sy-_rn^%0VhG|-@hkC3ACK7EnF^?%EiEmS+W3Nbp#4e6@N7-n(? z${7C%hf$8p|Q$oES;mgn0Wxf`V?0(Khx&M8I2;>`G~r@M>^M z1w<^Xw4ohyQG6@6u^Hovp_?XdO}d-8vgi@TMEroB@^f#7LJ)L$X5SyD0XTsSSRQ?u za8}hFVgZ-_1x3Z`mIvf%l`uU8Tw$2e-{%kLDXIS*l8YKf35D<_ho+o0wN89>9@W%0 zP{5Y=6X79}E-KWT2OZBjuMmap++|H7vxOh)WjErf{*vUC4YjsEo z{aCP|EXL&gfBzu7U<7Fi#7qMi90waIR9q_ufxxkNCuT-*Ct|P|i1T|(^l>XVaaB=k zv=i^_K^@}D0_lJf2BqkniJXWp@_+o9sH+`@d&1V6g zAVwv$I%Hr!QUyRkD`S3iTSsFg(mn~;;P_5W_M#{vipk2yi7)NRa5dj5IPl0No52p< zvoj_m*c3#!8-b*%oGd6BBN8W85crQwkB%9R>{F;K);>9+fQPv=nxtt%Aap^bfQvGT z%wk+Q&9P!QsT?h~kjR7wsc_qIcQp(Hg~~g5Hq79$u#Z zFbuDLR(fZ_c`Z81BuZ=Nbvqu&%+Ru@Rpo;gmHzBgLLzf4okAd2`nkY&l(~ia##z*u z*RfT;N!Hm5;@f(Yug|g?yQ~v_3DZZ#<;%~JG*A1NXk{Pfcf$7YMuLwD?8tW0{k5#t zEUx>9ymx6^&qQ_0P58UYV4Q~3)5*A$gh0_FpSO6UMEzJ=Tn8AO3?2dAv>{=@faN&p z?Hmb>*ft_@-lWg@nPJ|H2fl-ju|@a;ju*Ywy9%qXTjzRMmrtof8=m|OTut$Xtx8^W zZCq`R8K50{m}&?uvD821RpJoL+jzIZ^%8fAC=-J+O|c52B}zQ1fC-q$ilMyA`#B6yT|A$GiBZ+KffN&9F3TzCtS%ql~ z^un-S8op%uf%Ed%K*DX--Cp>RJzf*d+NaW>CI4wW^qx}YhumltE}ar`gN!`Y&0Ve6 zZ|3$B#ONLT5?W#*p&0iD5qV6%TCe$*6q8`^4k~<8R6hU|V0h~3Mjk4x{Mxx$8tALy z&}8a|WV$8ju!zqp@-1yNr}jhk&mzL&jEzuhggOg@_l`t&cxW&cCU#y?Rj)D4N?AL= zggB6kV24zwO322%)QY;pV`!KEF22tQuJ+~)W6l5yrpLJ>iS&LZfHIz$_!ZGnIya&c z0r+GJ9|b6)SY23FMk}x>iw82PU|o^>CQdEsC)p4IxJiISQzH{%F1r_(L~6BADkvCY zvCq6`KJF+8Q;AO?|h&AS^a8Vr7^S}HKa85e>Kj!Vq*}Kq2gZH0sTbuT=M*%9oomyXJe}? z?xgQHOYF){EpE{!3C?DqdXQ!-y~AdNqiD@le^)Py@Dq8rfck%+O2qU+jKBuhD=7{d;&Zs>|~g~ z+fA}7%nK+Jega#9^<~mFY!ViukngvNO*2Pd887Z*k5w_66wzoZ6HFScKnaY(Ls7IJ z%s-*5_x0Sk+V>t^+f7T_T!@j{*m;h!g_rq_pbpzv$Kuj+>NibX*5_AW2}NWz{KOHa zuHQt&K5U(G1&zqD^Fhqa?Vb#KTYB$nbgPhV>t*)0<4XRvCvGsv~SoP~&$YJau z{k*N|Ezke6<;ZpY{V49}yZJB2NqJL#Ip1A>x2X;OzuwT`;XzMGPz?>?Tg{OWL0rmV zvKfsFs|p*3B=L=l?1NfB5Ql=S*S|6gVzO`}e9&E0^f$vMM{INt6v(_Kl0A^gw}sFy z{j!L4UQ9NC^?U-#=WG%A)DKvccZHRF0_JZkjb^r_&})QJg0R>Y1|9=6b~L~#Kn0gv zVdTg{^Sn+*tD#t#*9`K;xv&M?Iw*Q!q*uOYEV;g76rY1fuat5$r5Os<;PhNT%fqc+@&K3HEz*F?$DuZ_klSPc`b#PL|&5xBUc|jx9E36uU*+ zTNlXw&2`*B=LZWZ;|7_O#HDvkN*&Ian9!su5GBhyy8mZjU*v8Z``1x?7&+o&obX zLvGrWkYME4vVv0k-3Y;-s%0$4_`P||D+`lY_8B49lUT@F$0%1w0)@%DQ`oC1DTcKo zTG}bG zwII#ug)m;bb@??GjR?2Z`J^eJGyt3jeK))bc{)HLZqz@3iRl~tT9ta7fm z7&V(6T|O6K=Wpyh4YJfadnnnUJa~!Xt8h^gIEXpFdoFU9$d%*!^vH>vKYxyk)WB4& zcP{bI3A>+d&xs)*B9mlJb+gtbFJP%7lL zPE2!EaiAF_5k@&a)as&eCM14!e3L)qeLYwiArPBGwCoiQ(e=A18(-tf6kIZLm#|(! zmFM}ap3Od{Pnf{Fm;O6FDcm<^+=DR%`1_aQ81NEA;YC7azkkjxp;MW~McVqI8eS%S z*i7neUsuoTM7XobLj zp4U=c;Q+1SpHS-pOu5Q>!>G!A*RB0y63wxOm0sH0FhR%ZiUVmDE|2)UG@V*Yb;iS} zeI=4#6GzB|XTK#MgvR<^QU*D4f%_|q^`r*ot*if%EMz(@`wg|k^6Q{E*onV3d`Z(Q zkRl^PzT)+&okpN*kES`Lp5gg5G}6V5tui4$+G;x>`4csoz z+51#NzE4+4d7yj|_jFM}Q?t5~_{<%IW39b2Pv?e5{teWm;pctN+NLPic4D@HzU78V zo+JuCHZ|B|A~PX>Sx`YafR0-KUCcPRH2eXFYen(YqZ0u9BXObZ?lH3{+Fd$%^%Sor zH9g-DG=PU%m!c60?`PW2^*)RF7PSU*?Yut--xq|h+!?ZTyz^uERIQNx6~HdisGhvq zz-Y~IG2sG0>$HM{-Q>0kOpzoi(<)?7&bC)neLVk!C+GH%A6TRA-u;)VD_ zJ9?u8;m;|hVPjr=L;lq8$*`gvyyjPC4su961s-@o59b&C0r?%s_A}|1`&(b!n@2vc z9;$ENNj>@Gb#No99l9G(8FMYW_#(o7z5QnsT@2$S53(&GVyDfSw|MGCNw0n>%rd?d zY{}!QaO?^+ka$=)K8=;}XMRgvU1Iy%e=@+{PT5{PgiZ|zZ?@gEP8ppSaT9a_l;r{6 z^v$UJ_e z5hx{%EJSR~?8og`F~h(i32bY_vC}{m7LL|E8v<1gD}q_Rs=F_jY=DFTtkBGAW+`|Y zKZNfbMo_m=Z~5gQ_x3CnhkH{eWf%fY%XzDgfc&}VcUTDk3zbg?JqYMM`rc~+F8Bsb zea{UDWJX|P^lPQDKtUR?{`3O=CLe*(I<*WzO{|~yy~@4zJjhwG`gB*=jk1rAajoBb znK}0NV8(8aM->Z=sO8jnda_+bv=b3Y+*OyTq{@{TvCb+DCl)wxPrBL%uF-jt|M_$@j-9bXu)NHo{@#KD-suKHi!up_?B4>h)vqxk3jpS zC!zb}t)m|rX{E<+9TV*tJzbWCxDZhO{I9v21%>#V&NBq63W;NCW$c7+o%RdK)x|=V zyN5}*@~*?DdIMTkZ=QjNkA*$oF4O!teW4k>T~P8~ZOsWIQkwm%nNNrOhpBavMTVaU zQXC7jO7xva^rlxYFC;-wT;4}*n2*k|g*^Rh7elhJ3CGH2qDxkoE8z+Pjgi{`!ap4U zg`5GWsNHh2pwt@laG6+1BsDff)zYvx1tg-8L_^Al+KPQwIOzjg8V*AU<|3f$8C1=rKa5D3Dm zHOJXvX3VE`yE)KrCT)jkxMjM6ZcU!LKNxMdT+|w6H}H_2byP~;+u68s42ntOG+KJq zy)~@)w^pS**e9PcExELzt4i8AAh=$kt{a2mhq73pAA~DJX`zx)%D={e5YH3->u`zYE#7GU(SyO_uHyE^2crP#h5PVq!yE25K;x0Y|NInZrp zamNAR4z!8_ZdeD(*k`>iJHm;4E;8W^_+26mn@wtGoiG)zSFnD{2N_+?b`_Wi&Dkn_{z&eZ=bWdw}>DA|#h~Qt%a0 zBO-3FPNx(_)t<)>^;y$GMQh)g6|6*2fm5SEACdO0RE^ch_z097R&4HqmxjCSuvvoE z=8bb|rg=>?h`DJfPGdNmti^szz<#!*m$gq;u$C{Zfpfhm)|l(|SSEsS5{og+DIM@> zNvcf&M-|?Y!=sOJ2S}p`3m{oVfkY2GZV06iZqjEQPe5e3)M-9!hbq$XR2xeZ9E@FP zp=bVWCP!2x^$UsumCm8V_vAIL%~gNH1z5a!jGpl@J^kl*hAoOJXtItlh9qtZVofJZ zih|O9n~1Qdj#v#RmiMBl95e!gNy4{4%B=aq2}uq1Y7BM&VjwS_wHs}vX&Zh&P`xb| zDQ+RVtB?u&Az5`ST$`e~?k0oFP`1fmk6qOw+M9B)MA6`sF6WnX?G|Fk}aoFyUEXZ!kHobv-W{V9m!&+P4?jxk7j zEG3e$f||;fmW5}mr_f00BK(I?WNE4p)nRbL-J$Hw=Dg_vDj^?G`QJZRV(3q4wp1P% z$fUD9dvyAdMwpG%e^en>2xyLQk$K~s9-(Q>Oe={de7DmA)p#acSjArl?VLaKBA=8! z7`VUB#^8%1QAI)JxO|ikX`axcC8Guc8h{+Yh{zx-(tCz8V}Et{RUh z^YULvTDZ4P9$})pLdOD~u1sZ3D)c0ST@Ox)De5(@UizE-otz?v zJrxgTo}dNK;GB}os|*8m`+SuqW8y2um?NdetwJ$Xh8M51B;SpyTuu7L%j~95#}bgGMt05$zgFe)Qm~h7Uc3o3P}=oFu|PJzoUgt)87;t=a}OWIbIA{^Q;whA<=b!^gU~hvgsr zXteX60fhjC_*T~DEviE@X7#F>T7#U?cL2<>he}UE4O5`;-xGMX(T;?ZF24uCEJY>e z2bG046|WYR5{`TRUgA3XtIN?}x7#UoO^G{+sUYjUCQ7&+pnKWflP3}vq87s|qhw0W z@;mtS)8>0(azxU*El*bDBKRw4oYiBW9w7!L8U@%Lxh{2H5<6nz^0UBx(k!FZRN`NAs>u$MRQUIKa|Hu zLKD#Q1%PmT9b!rQf-ZAtirxAW`k!SnRA4N5`HUbS_Tc~DvKZF?F0S;oQ6=NLyE?NF z3boFswQh}bH?(C&Nm9f8)j)5^w?4VzOSNV!;K9H5rJJ*y|Jh{koXDO;vd=T(Ed3MB zR<6eTscxa>lE^fjR;>lBh*;#Z!Q7&-p6@5VQ(rr+lYpAPviuPHPffR6MPj%^ zH#vT78K3ulR{|7-s7>8^VW2h$W;eG{{N@(q2!V!g0)5H!nu(|wZzmtzlMNms{^9oK z+`)B#WDhaYMM_xMl+a1~=DwT=V%ec*p`!k)=$mQ9pI>PLU!e;0^7A<5`}9a(?#vVB zLR~^$@n!p{M@DYwU!R!~BfJIol#Uq}yHW*EE*i3jRdt{_;P=d^$*aJI!*((@BiJ ztg71N$7(*`f2+A&}Z;Le~x=^qkc9 z^CA*Yx!W*@ea8doJwM9zZv{$GU2je9$lwiHLiz(-6b~I!zS^=y#+sHd#Zma0(eZ^3 z9(zZ^su5hL&Rv-TOMT;$18{!ltyOK-q(_Vf@b6z-y#pKle;Jd`x1#gL58;@NL?uT) zSK|!78&k3AqHK?Ka%xAkPYm}7pDbA@d&v*`NEFGKvw!CdM|KoUW!bNDo-?+vnYlF;HU^ds>${H$&#a!X6DCbhKyy z2CUMU2rVqPHA}e>S<#Ae{fU}=zaSJy`ia&cj(UZf*)nR;x11< zlDprHT;_nCKyF}(ZLd(fq{hAX*M0-lsGe9P32(TA(ba}t0f3ii6T#tXy1<`Ty!i7) z;_4Dg*4Nl9V3&{yZa^7Z@8L>*eO7Q66(ySjCj(qN%%zbCH-4i%7fwIs4n<1q&gw)1 zRvDw~_2t*q(8kYH+|{*X5v6p;Zjy@Ctaf0}s>};e5M*lj-^)2B*<4xAbhw)u(F%6g zznh)JB>{fMugayic^j8iZ~a#NQ(2}1_eZ3&(Ao>{CwwH6$#b}u`b%&`ISIgh;H zsH7d(n`!Di${b*jp%`~xbc8Ll?5-8ch=azvE_%B#bu1O=7XyYk6`R*H@}pweGpzO% zAg}4SEs*%UkWbMB%c6i{V!=(MDx+?O@z@%5@>HQ|I*B~+#Ky38bqTl$bZ^xXM7))7 z{riJH(aJZ?P4D9jJJoB1T1IMlao!Lx9xV(u9y3u>irjX>F z52r;L`O|-G?O~efOo6%19jj(BrQb!-GPwF;7uqqZ@Bos2lyabPy$QhVa1&%ojL3gK zEpD4xOV#LsYC+6F>8$9gP7M6{E}o#s7p1|XN(Ea525D3yqJl6&K`@?c7%lgWCezq{ zIYW~cW_`VvZV|r;<_x=ZIyu@xk};MIeqdgK?z* z`zIHid1+)edDfi3hqr|jrZ zT`}zbEH?YaHPeFc5~QfMrF3}65w@efj#sbM$=&XiYL0V z4%n(}tRMU@NZ$7zDES5)G#sne zS7$s(1xv`C5=7>@-WVMv?tOuubOcC6^4_~3Y5&ikGN?si#+ZwJ11Cj`C4b6k8;n@z zGRF^-%uy0v(7=4HFgXQD<%~RW>54~XH;Y`ud&3}nTjrSgrnQA9!YCj~5D@Cbx`e>$ zbDY{(WG?W9g(RJ=b|{5zHB;`g7`-z?&PZCwBDpt~XbL7=IZn1yq2EI4SpTLxe{GzX-dV4bIl`HdVF!x?2@NQqE5_0q z)?F3*tQRm-NpvW#)&$ulVfntye@H_Y0GN1q5H!!IzEyn*vzMf;z0h{Q7=IdvQ6?P7 z=ao72d<|BU)C22d!T$DLG~|?qc6WNm#T5coE8hgllSkX%E-Q8?cvjlVv1Z-*PC7Qy`I0v`pGPjJ7NWr$QDP>7T$7XTx3dd_ z|2HKSY6du&v?tdXRkA*BXyGJP3k+j;Hu9C(&`&@^~iF^v`qAC$UuvSFAL*LiS1Ue+qLH#qMO_j7-@^7CG z37I#h7YRV#l8xr66c&xLIU^Pd* zcnR0m5o_LZxa;oG9HW{})th^DVV@WBy)kSZcXYJJmBs>_PO&DnsFLQ5~zC|ATAPcr zCxb?v1q$vz%&Q(_0u1<6+en&SedSug8t!?X=jJ4%@ zz2(&>=oO}}6lZT;M6$45Hx~NlB-hQYV;_;J|FPBsw9{4Bf54Nz`3F2{e87`_5sG5h z09(=_p_g>z)IkxT(zo@3cJL!EyEiqN>rVOanQzmfertA-4IGBHNjNW!3b`bkow~9V zDkao7;2J_lM$yzhjYOE8mQS)6RDyR^!3Xr}?+!U?v)1cP?CJpqs>66I%lbz@Hku4t2LkBn*`U!&lDFr`#_-km2ylBYO95f`Cs}26XMB^Ix`2 zh($KRv5bJ#N<&$leYwIYL8k&6h(V{Xx6mx38xhrLFe@kiGS(_TX5oU65pZ1(+YOF! z*WZxeRUE!+s+;``uvht!J%(L+sN9pFqVnS-Df7tqX%Xn^wKxjMe68r{X5|S934QGM zRF74e_etCYpfB~0ijXyM#%y2S{?vSZaPfR;QkJ8dP@}y?ZE`y6+&_t(57c2G4%j$H zSigTmR?^7XS6Jv2!m6ob7<4%=T@o|Rs@#^1&wC#dm@`X+8}x&CZ-#&8-xK=5+%nTw z{S30y2)FF~J&hQ=6MMYJ6|>TS`0VGCQ=tFrC~^YL3XbPT99Ofqv8oH!s-JY9OsW~W zK#E7UoB?h+n&#|FeY^r^Nk0fOSG{sk z-dsiV2HlcGQsSw}7c8ogIxjzOtUvT7R50lOCpffixiAMDk zDU|` zSh1L4mxRy7vYNpR)1ApW`Ik1%PPS=2Rg(5)kJIlzzvv+-_V~~-TrIOc`S8ScdAW7{ zPG~vdC13LvG+fAo5Aor0e+xwHgr+>M{VgedI1$Y=F*TJrhYpVdhMcl8MuHFudG z)}F_|)zCPn&D8SjBEkLu4Sgb04^g7`F( zTOLc0QT+)%!dub*YgmZj**Y@MsOYBq+Zl<6(l4R!T?J?vhj9?d*}hUJ{(9#Kz2qth zBH{;xk_Onl$2X{gm-(p=!z`uuX!cRAgl5hDe4Ow|Ip?vOIneHejZh%#nbsC!&}K5s zPagJAz9tNIa?*kDKjIfMU74(k(LjU^Jcn-ooQ81!*YP;)hJ6fHDE5G+kYDmf>gzDsa_VO8 zj(d5TCe%1!+q9_t_*I7qr!t8&F*6K|PHIkNl4stLNIV7m8UhweZoNM>Gq)#cxC4}^ zaG$KePv!CXoN(uOmG;KQ!QAtuuNk0+H6B?;5E3elQ=~vOMZZ}X{d2P5sth^QrRcUi z^Ye>!UoGyruywJbRQw-7O(tT*7&f~#=g|G=G91J zBMC#o8)(GPT>WB4Zj55`L55UKR;iW=86_b-bnDJ13^E5QS(dkJ zGd&)1{H#RqH^=iZY4WK{{tM*a%?iJ2V=5$FE8u~O!p-`ll5{u1Mo>%GZSr#(kJW{K zelILB*qd>~j!EdUqcgxgeP+a`3&EMJM02?ZEU65Y6+@LLicHD4 zlGbDAqYfFR z-&`6|Ss6H{3S{pZgrRIR7Mml>Xc4gY$0$xhx9S=`+;iVD*Ob!_-#>m`!%bP+NO>v? zuShtl3OAI^-r~P7Mu`~*2}-t^@fo>EP7 z$}P7?X{R;%2e4lD8?3rz`NJRxB?xh-{xO3*6LWbT?fP;zw1Z4j1cA(VCgeEB}}7!pK}L9O5h%`EmS{-Hoau+yq`c@GF9%1um)pm{B8xgMG+>ZR9wP<~Wajs&vBK`?9J?oIQL7 zp)7)9Lh!^T4;-YMU$V$Mz+M*hWn?+ZGEH9SWs0r-1 zlZ2EwbCMwujNQxqjiK@ci2^MuOrR6)=zG{bXc{Y0PTp!pA%(^?PyAp`O|LTTO?*sL z>I#Ra05_}_c?}G^vnCst8mkVT-#GUA=;Z+Yp;d+Rdrp9SOAU+B0opRbDs?++EdLaV z)9+urm7TIXigiX9ZgXf|Yc}ngTgRrA=hE&w5EY^nl78k=A#OXrqQ@W730@0rXT?4< zUf}eTn#&S!5FD{NlvYF545vgBOwFgT#HvNi1+a!Oz6#TgNsb%7Ur_&N&Naoa%pAT8VFkGeGs|iW*F9V5FP~SLGt*6bSfLihbgiH1B1_i&`71Y&tn6mxUgf zez}Ub;#U#JAn|O=(2)U%n6d`O5#wKOD8~eFEtawX&tXGhYhMkF>Rv3Ow0qM~oZxja zV2~XD{IIa1&mjkMkol_ar)|FtwOgh8Yb4-dD)=V)Nk3f{n2XK&w|lcKAcgVhC^M^8%cyM`5XWST z9VK)N6v+uh4b>G$^N>Balrs4RuUgiA8tWBnuc+G}X%Ja>Il>|KcpfcnWuInu=O~ks z_pV#V)yWDQVo)f`$5t%Hn3k`>LU!2)*1eaFBZF~>;I(i28-YF_H$v^jt|Z~0Yp7Ee zn@O_Dv<>q%$evt-#m@x;0-<@(y*)Ay@RrKF`qxvc%{J|0*tbewH)3+r)7j-PYiW3YyF@h&cPy(G(UNZJtd zR&&rd7qeTFrZ+8G;XrYr>i`Kzu$^Ynp6t+KHffGK*^9n+65k)L#$N%|#rT>AI`mnw zqC;>|sN%5&e9^U!9VJhV{d7(_<+-DD|dkLfoI%6uiMhw-6aqEqSd#4W>%$ z{wRyk#yOraN6mhFmDq1m=-nrHiv_f(+1#6>qn{rzc_(Ih&M?^Zgl0im z*9@R6+scAdwSgJm`q_urBy=J>!#ylHhh6&H)i1H8iy0pzFV>*J8%_}*Nh6@lm+k&= zr^BOrlO*#a>uCKj#4#Ca51Z9Y)|6YsNx>C?T=?P zCKvLjU%N6d!!&Q*f~kF*H*hG=J@7Mf62vL^ znwat5soNJQn}<&)?q3|jN*=DGRbnQD+dLniRfMY##CHoajR)w@mw@HJNgF29+0BBK zr`G8A7rss(w-O6>QgnqHhlBdqC&Whhg;Y^~0_-;VmBp9R!mS+}&t5JGu;CxohR%ML zVYFR4==B_EZL;HJFg$QP@a*=O8Haluu;H_kezP$JK}*JdFB#&UsSLbQZ#m3NHnt73 z3ZRByG&d&Y%;xT$djyDuk!JqsGk*Gsc57`<_uc!~vh zL)fI@qJrQS(72f*wba}@*ZjRxd zyct(}>z)_{(#+-Q4U)Wth-s?et>h2pKr*9d$Ajtc+?%4f+}oBnuxSf zKuA$E5=Hl;#8c#Vf#d9!>Ak0=F;s7|eugH^>Ap2^JHlHEvaZhK>~R1}jZ)8SY&On- z2Eo}+C%xfRp|iqV7D5`Dhx>Cs*F|pl{w3MMtq)itBn4s9l zA)p9&=^3P!N!TPrnCpLM#wK-0Y%%x7~GP{v^J>>=07JTW^qNpA)O$1`-%w zp6#Y@ZS1i;?k{tUGk-E1UFlvojIq%#*FC;J&cVQFZ<_@aaXxRJ122t@xJJ8`mTh6v z?Qs{d18Rt>1maY9`xA6=7vxP0Geb*Q%NYj@3C7ZKlWD=4hf7aOZ0M!#5_%hB&NTn= zmC1I^1z_ozRYN!ZTBYNJo8R%O=_Ds>G3=Y~yOJ|0LG2Sf;db?R@>svVDDUqnIC#8+ zCxf|e@hhR?xPc`$g#8<1NAWOQMXWyC&>v^X$$e@ujL)OHQ!A1eTSMIC8d+RaE!WvmwbcMk4zlAdfwVnRiBXwm;O( z$;T_80Ya0Nk)GK_DF_vHLTMVryFs!RY(pXGoz`bLaASE7o7RAm89~{O^>2zkSsvfjkrv&Cg0vKBRSw;RP|h zdY)@}pYN!M$2zIw+D~C-$n!Gm;e^6wZ1BPdbramvceyCWo!QGY1cJMbIRW-_IT^!E z1eKWgCsh4+f7*?}Uvz2onAg>4Rv>yJ6M&ty`gTGo&JT<3ngTgWQPWyh1xO~KTjI`Z zm{b^8S&B)|F|imz7T7SwEHKL~Ivmea8wbgOR3I!Y$*u!Y!;eRRCb~|M~#uNW$806fq%XM z1TBDOI7&cz*?>K^QOkLI)E%#)uHrh;@E3xZFsw zA7qlF|22|wH~1KQ?ze$NN6*=4Y9i;Ej{(98LEy-0F`C+7b~A!{{IbGc>rtb}b~y3A zmHW?)&vR$Rb}cN+C3{RydCyyWGRo-lQU~|IO;lez-ytVR|IoHB?9J;t3kO;MQbgu4 zX#*138ks|RLGr<8G3m8=VeL@x9zQXNbS;rYLJlpY#KiJUXsk%GCz)7Z`AWmo?D1e^8j18P5!y#7KR*-N>WT~+~)U?06CCcQHF(-|& zH^Krt^Aaku#*ExT)M)>>)Ba;Y(^u3h5>1e?V4&3aWDD_-2j>LfkT%h zVoNAhjL8$F=!*V9U7zguOv=+jbV0(+fPz#VuIHbrLoxZDo_>?> zodj!}>3dfv!B#HuD^kQKpNCw%UL!w)2pix0t{*7_ej_H915LZab%+VX4%2t*028Ef zMBs!QyH}Y4Z|}0i!UH#%QtUW^d=;HZ_RI2&89;+J$2GJFOFADdiEsx}lUNNHSn004 z@X-f33uS!o*h-mtAdxs-Y(1J`Cxu9KR1e2*1Rp)5$9}Zqxd`SlFjjjl|Fq^6q)z+q z!2(;kTY38sx6>6xdOxQ89GJr%82w4gHLRu6{CQ&DE*rl~<&D_C-=D+0y(>y39`*2K zM5ASOD!t-Xu2>y2Cl1W6LWqLkZU&^Xb zBYu*S^;^$uU>_TxHWv$tgV-ACF+7-YNBKln33;B-T|jHNu#6uqk##%T%oca75Z}3| z?MuCexKMo2sN@XYK;*;~%IL!udzpV(E;_W6CQ4fIOcH%Ch&ZDvZ^dd|zjP-;KPuT8 zo-D(x-JgG3c9GCl)K*o@7caH2oxd?fsIKqjGH=${;Bk7F51;_*yJto@YV5msyI za$FPU<{7Kr&(`oFBOJbEi+Naib+OYl#V68nXud+!rIj|}ZcW8~rr63dfgRiP-}BSr zK6A9MA4uA3+K5F?I5{&x_ty|H@+6R(pW__wYUMgIzW;#RAN(FT)jNifLRE-g@@W}t zoE_?Cv5QXQ0$jyyYY>^SDB5taIes(&m3(h&JKh9h-YNP{vwg6#`{Fl0TPWrGl<1Vm zO~PamxTe@Rd$4ju+gbMzPHO@+x1Y^t#)_c_C4|*$ET^hl#X{n|w;{`CD~dhBt+=ma zn?KfX$C-(>RRGm+k;(x8skIxvE18e|?mj_pQly2NT^wopsR2P|WSQ;K6|kt=-(?<3 zhKT0vBO7%!{bXIsZvnB9h&OGU8Y&+hUyIHss*Fq)D|}CdR9IKp2@^>)W_cx39BXkj zPoUs-WDTpVy1RtpSY$5fP5o^J&jl9W`z$*K2XHiEtpb4}JlQ}peRUb}A&a_JRv$8U zR9MC)?!B)r#D99Yys<}P%^0yjoG(OR%uMVuU&6x^-WZ0~#0TWOHF)O&5X*9K&Gl#gQ`n8>`nV;=b8oSCMTB~rnCn5r@7E*vi_hQmt7oI zLsJv4e?1r)-{77nSTlG9k$m{TW1!*p%E})`d|#semOpy8qSMdX5ur9_BQ+R$!=e@h z?v=2TGb>m0FsV+{^>sU`P^qkl7o{g>ZgKSQrE5AhCcD49Sus2o<(nQjvKPA5Yza+i z9iXl-YxZMpSuz%w5Qk-fYPNJAS8_RAl0qsyF;5qCN9}$k!s}?1h*bC8086t}AxBye zokUv7%+LY=-!JRU=7*7iN*F0#;`hGbAgnTpVatFWJuIoJdsZ9+j>~IkqxT`U*iw+x z+bH_J7Ss!uKkGpi8(CXqP3n_r|NdG$I^f==8-NC4bx|5+C5#)ZgpS;%D#DG7*wtEx^luZWQaFYb{2UE^^QCAliWsD zveW?4@5oRt?9JBriAV?&ei#ocA@xDa1E*;7JvYqEnVF_fq5lo|G!z>?Z;|9xgRu(y zaCf>ToJ1k8Qw#BYH`hGTi#g)N3>Q#x_GHuRFvsBfQDah^#tY=fgUSY!S8;irEhl~# zo3^J@HShDHrcN1(&@rWD@ZcxYWEwx=k0}F)?2rW2s}+)=n%Jq#p_`fDsbYz7Xav3j zu-rodk~Ui#(SNBCFMVQweKRu(*Nu3e$EU({H5BVi~<+u4c&&ML+zGfNSFecHb%{iTn(m{<` zDcNgrl(-LIu=wDnkHeNvt!Zo<1p?*>5J@5r{Vci3Z;d$wI$o@H4{IOlTofC+dKaF&L!_1c%55`%4`0VZjfReXNIRuOUrxhUNT{Tn_TUSGIH@ zPI{Oqmy+fcseZ6betzpU=U3UoRERaww+M>h$4w2YjyQFU<$Lp!SA^2XkvFHQZl=f7 zS|z;cvPZ#hH2w5R5PxGWsDGxTr=zv%DR?dR;}U)Du6G4&vXS1Fk8TUTLk*v(T&<7V z8}$<*Ycidajhi%U&GoiQomzQ>ASl+qCDxB)RMEGdV{Re^tr2lgfjI+dzAX|hZ+#XvNX9>>`ZjLj{U`aDw{Nj(@ zilvxE-cyKonYgzxq%~rc_6n(agW?k)q8alc?m}*ZSZk$-d#!#Ry)glr=4m-=WKSaH zXJdds1*hAr@~(?&E5oQj@`|^P8$%w1)qID2dM8%$)3Cmey8D&pidCcl(>R%iMwq#* z%BHMVJ^^>nBcLkT7{8%uqHLJ1exBuMW%pSXR8hJErm){o4aKd!K!I~S*tL~%K{>OH zKcy|1&`qJ6RgmzaVS&e3eHMwmw81SEWxKkQnU?TCy8*)i+CxTI_T%jAIYD9SSq!F` zEcagTA;>|wKnow}JKOt{olE^G2cU1Tw)PW%7^-}^-+$_V$)~dQf%|Cb-fL`%CLjW@ zoIzKKxF23GRw6th^7zo3@__?*p`8a!9p$I&!M^Nx-H1+`h|l?eA+4`GVVR-@ ziCxF6nv>gR8iFz0iWX;cxzoxkIx&Gpn#F~D#W%wovBkFMMLamdzuX?U3#3$c#;Xpr zRj0S0k3#g!_JH$NMY*)-oAjz1NjOzAE2_QN<@z?=C>hjS`h|W76ZzYeSNZyx^}w0U zjT9a4<&)NEiEK6r^${$xa(kth8K>azc7hE6vNVowg=SHi2G#w=;qT_tO;HPk`%R}2 z&gY?-v4+QDXlpUA<`gKRvOui8&-G42F%F&O|U&zhE$JBgu*<)RBaUmWAY8b~5Ngy#keNhUuZ? zzVOARPLwfL2~;+;9yE+9aU>uvRL)VR59F9R`HgiFN_MoWx;Bg*R61g3*U&(6j!fJ` zZr0mMYMA2)4SinORd7o zU(*5Q4krq(SAwlzO7|1-9A=&k#w^O4SmP`1PSPnld^T9aD+`)0PVTrCC@PwOe!5A+ za&1NgswvjV#_@yt8mY-WpGwQiRVK~@Y)yLjlBa&@A8iR*%pCD-tGhN&4;+qB2pibV zS{b`&*fiFj6|;-gyJY?T`CWheVi%u!Gl)j=vlo<{&&KEQcZx?LdLgr`>yn2zACA}t z20*Q_CDa#LF@)ZWIHxQiN{7h%15bJbm)~2@e>Sy)7s+N5LV|%Mqkw@S{fq0t#nsEs z+~psg?CVXRx#z6S^AS6y4K8|8u}9+X96s~VjSYCY=ZPv}r*?S)v@!V#g1x@vGbXho^c7>zN7 z6prBPS4Mp4gQAk!XUzL|&AOW9cZ()^74ttI`%D1g-th{)^wWB>l3mjMYQp4MRTS(# zvO|v>ktL+s2uy_~99S98pJhPQ7074s+V1Xjnh2lzd%bHnD;!>o6dg1(KxWp%KY&0u zd*Z6*ex8dX!)MLfG;Rr6x|1RRZZuj>X{Uo48_Y=@1vhcR?wEbt!rkc`@gTZ z_gzdTqI17n-#+i}+u(IK%zPeJD%j66?L=oQsgHBsC6S2YEeVLJ-DO>8z4BTxCvpBl%z+ zpJ@Kf}qE)E<1Pm2g@?EJ^%jHl3#xU2!4<(R!@C~ z1mL%2%$pJ?OnFTyY%Aek=;{tMaMF~>XFWLTa3X#@VtW1hZ3DkD3I)6Jd;bVt zW$sCjN+L;i3Sj zwTj^)-M?pDwreM5)BhT z(t&O4k;{YTP$x*JeOQ4mrbW-@D|oLq_o?BQezkn7>L zEHJ&`a}qz=V1KSl_x;X+5B)xf^BwFz(hfhvc>hQ5^rj*f6CXH{Lfk2c~zrPGsw``%61_uM{`Y`kZGX6m> z_783t6Gunee;`Je>Ws~Yv9HNnDB82NSehB#m3H*eO_c=~O8Ff5%~)nl>(6&Cg#GYA zbtQs8zKEOkl^gb}JPJMvl0_cFQq985qOuy@TF|C4ERzDhNd;wN=NI3RT-!~Ea8BzS z^SOB&R~uH6Hm)oHH-+-21Oy|LQ`BE?J{v8-Pl72{%0dWOQ!wf#GkHS@^C4sE+j@M$ z7&{?ZRt;5Y(X{hsxTpTm6(pCnew6 zF<{J1y%=+K;w=}}fl;?(QWM6N;^FLX^3n4g+I;UG@@%7CISO2TN*^>Z*hgMa;k4pg#uLE!y4j2$j3_}2av=$)u6Al zsaA|VU72fP)O`3isI^T#pAF)U;)E@)p#L+tVjdWD8$Wy<@!@m)fBF1h@%2AF|Ib(( zN>GFkVnLOvL3j3#1tMg*%kng;H{ePP#$tW+o*A5-q{2_gGJ!>1hXIV+Pn znbGxp6L2TcGgRdpaWcIL+}i=$n3CW8Pg@;IC_0Hh;!6waBbh#I)f0Dga5Z;uHB|Td zYVKn2j~E+Dno|0Rv0=laF*h4F_bvZL1AqF{UWDL9gM$5^CXTL-ajOoAA_XRN=&4dM zXBP*LQT2ZOQoWTfi>9CP^B;3DQ;>LK*d#6q%fy{C9=Xrd=gliTay7^Iy6A$aFO$@2 zK69N=m)>ot-mhJ8Wo#k*=*=G`9vRHhfFmm4ETW372&(B;xyBH6-ir^4iD=z|%Bx0y z9-Z=6TR#C;Wy3Pl%RZFk|D~F(AtQ}IQ$ksNi+c=8QVwyxXq7Ng$fdUf8`a^BWGV&@ zVuW7#aAyjSDuDC4WQs|+GxVLcHc$qnl;y!8Fv0%+gpVIS{pZ8~!^!`f z{_$_=zquCwB?|`T4?gferT>e0@o(|JsR;iqzVq?({|7PQ-wOVwqWUid%>fi}{|!F> zi~t>y2f(tm032S>H}rGgMg~`h$NG3=IC` O8T
: {[th.get_text(strip=True) for th in ths]}") + +# --- Zkus také tbody --- +tbody = main_table.find("tbody") +if tbody: + tbody_trs = tbody.find_all("tr", recursive=False) + print(f"\nTabulka má
labelhodnota