Merge remote-tracking branch 'origin/master'

This commit is contained in:
Vladimir Buzalka
2026-06-02 06:37:43 +02:00
8 changed files with 2018 additions and 0 deletions
@@ -1658,5 +1658,21 @@
{
"original": "7602044780 2026-05-22 Suchý, Vladimír [PZ interna] [1522MAY2026 SLE, CKD G5 na ATN, sepse, AKI III.st., hypotenze, tachykardie].pdf",
"corrected": "7602044780 2026-05-22 Suchý, Vladimír [PZ interna] [1522MAY2026 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"
}
]
+262
View File
@@ -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ů, ~1020 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 520)
```
### `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: `<table class="detailTabulka">`
- Řádky: `<th>label</th><td>hodnota</td>` (ne `<td><td>` jak by se čekalo)
- Sub-tabulky: `<table class="VnitrniTabulka">` uvnitř `<td>`
- 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
```
+866
View File
@@ -0,0 +1,866 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Detail - Zdravotní výkony</title>
<link href="/Content/css/select2.min.css" rel="stylesheet"/>
<script src="/Scripts/jquery-1.10.2.js"></script>
<link href="/Content/PagedList.css" rel="stylesheet"/>
<link href="/Content/smoke/smoke.min.css" rel="stylesheet"/>
<link href="/Content/select2.min.css" rel="stylesheet"/>
<link href="/Content/Site.css" rel="stylesheet"/>
<script src="/Scripts/Scripts.js"></script>
<link href="/Content/bootstrap.min.css" rel="stylesheet"/>
<script src="/Scripts/bootstrap.min.js"></script>
<script src="/Scripts/respond.js"></script>
<script src="/Scripts/Smoke/js/smoke.min.js"></script>
<script src="/Scripts/Smoke/lang/cs.min.js"></script>
<script src="/Scripts/jquery.unobtrusive-ajax.js" type="text/javascript"></script>
<script src="/Scripts/jquery.validate.js" type="text/javascript"></script>
<script src="/Scripts/jquery.validate.unobtrusive.js" type="text/javascript"></script>
<script src="/Scripts/select2.js"></script>
<link href="/Content/UpravenyGrid.css" rel="stylesheet"/>
<link href="/Content/Style.css" rel="stylesheet"/>
<script src="/Scripts/modernizr-2.6.2.js"></script>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<script>
// override validačních jquery validačních funkcí tak aby braly desetinnou čárku
$.validator.methods.number = function (value, element) {
return this.optional(element) || /-?(?:\d+|\d{1,3}(?:[\s\.,]\d{3})+)(?:[\.,]\d+)?$/.test(value);
}
$.validator.methods.range = function (value, element, param) {
var globalizedValue = value.replace(",", ".");
return this.optional(element) || (globalizedValue >= param[0] && globalizedValue <= param[1]);
}
</script>
</head>
<body>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-125495940-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'UA-125495940-1');
</script>
<style>
.dropdown:hover .dropdown-menu {
display: block;
}
</style>
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand" href="/">&#218;vod</a>
</div>
<ul class="nav navbar-nav">
<li class=" ">
<a href="/Vyhlaska" class="dropdown-toggle">Vyhl&#225;ška</a>
</li>
<li class=" ">
<a href="/Vykon" class="dropdown-toggle">Platn&#233; v&#253;kony</a>
</li>
<li class="dropdown ">
<a href="/Ciselnik" class="dropdown-toggle">Č&#237;seln&#237;ky</a>
<ul class="dropdown-menu" role="menu" aria-labelledby="dropdownCommonMenu">
<li>
<a href="/Ciselnik/Kategorie" class="dropdown-toggle" role="menuitem">Kategorie</a>
</li>
<li>
<a href="/Ciselnik/Material" class="dropdown-toggle" role="menuitem">Materi&#225;l</a>
</li>
<li>
<a href="/Ciselnik/Nositel" class="dropdown-toggle" role="menuitem">Nositel</a>
</li>
<li>
<a href="/Ciselnik/Odbornost" class="dropdown-toggle" role="menuitem">Odbornost</a>
</li>
<li>
<a href="/Ciselnik/Pripravek" class="dropdown-toggle" role="menuitem">&#237;pravek</a>
</li>
<li>
<a href="/Ciselnik/Pristroj" class="dropdown-toggle" role="menuitem">&#237;stroj</a>
</li>
<li>
<a href="/Ciselnik/OmezeniMistem" class="dropdown-toggle" role="menuitem">Omezen&#237; m&#237;stem</a>
</li>
</ul>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="/Account/Register/" id="registerLink">Registrovat</a></li>
<li><a href="/Account/Login/" id="loginLink">Přihl&#225;sit</a></li>
</ul>
</div>
</div>
<div style="height: 4em; margin: 10px; margin-left: 20px;" id="zahlavi">
<a href="http://ec.europa.eu/ceskarepublika/index_cs.htm" style="border: none;">
<img src="/Content/Images/Opz.png" alt="" style="float: left; margin-right: 1em; height: 4em;" />
</a>
<img src="/Content/Images/mzcr_logo.png" alt="" style="float: left; margin-right: 1em; height: 4em;" />
<h1>Seznam zdravotních výkonů</h1>
</div>
<div class="container body-content">
<!-- display errors -->
<!-- display warnings -->
<!-- display success -->
<!-- display success -->
<style type="text/css">
.has-feedback .smk-error-msg { position: relative; }
.clickable.otevrit { float: right; }
</style>
<h2>Registrační list - 01021</h2>
<h4>KOMPLEXN&#205; VYŠETŘEN&#205; PRAKTICK&#221;M L&#201;KAŘEM</h4>
<div>
<h4> Obsah registračního listu</h4>
<hr/>
<div class="well">
<div class="form-inline">
<a class="btn btn-default" href="/Vykon/Tisk/01021/" target="_blank">Tisk</a>
</div>
</div>
<table class="detailTabulka">
<tr>
<th>Č&#237;slo v&#253;konu</th>
<td>01021</td>
</tr>
<tr>
<th>N&#225;zev</th>
<td class="zobrazeni NAZEV">
KOMPLEXN&#205; VYŠETŘEN&#205; PRAKTICK&#221;M L&#201;KAŘEM
</td>
</tr>
<tr>
<th>
Nepoč&#237;tat režii
</th>
<td class="zobrazeni rezieNula">
<input class="check-box" disabled="disabled" type="checkbox" />
</td>
</tr>
<tr>
<th>Pozn&#225;mka</th>
<td class="zobrazeni POZNAMKA">
</td>
</tr>
<tr>
<th>
Autorsk&#225; odbornost
</th>
<td class="zobrazeni ODBORNOST">
<table class="VnitrniTabulka">
<tr>
<th>
K&#243;d
</th>
<th>
N&#225;zev
</th>
<th>
Pořad&#237;
</th>
<th>
Sazba režie
</th>
</tr>
<tr>
<td>
001
</td>
<td>
všeobecn&#233; praktick&#233; l&#233;kařstv&#237;
</td>
<td>
1020
</td>
<td>
4,58
</td>
</tr>
</table>
</td>
</tr>
<tr>
<th>
Dalš&#237; odbornost
</th>
<td class="zobrazeni dalsiOdbornost">
<table class="VnitrniTabulka">
<tr>
<th>
K&#243;d
</th>
<th>
N&#225;zev
</th>
<th>
Sazba režie
</th>
<th></th>
</tr>
</table>
</td>
</tr>
<tr>
<th>Kategorie</th>
<td class="zobrazeni KATEGORIE">
P - hrazen plně
</td>
</tr>
<tr>
<th>
Typ formuláře
</th>
<td class="zobrazeni typ-vykonu">
ambulantn&#237;
</td>
</tr>
<tr>
<th>Omezen&#237; m&#237;stem</th>
<td class="zobrazeni OMEZENIMISTEM">
A - pouze ambulantně
</td>
</tr>
<tr>
<th>Omezen&#237; frekvenc&#237;</th>
<td class="zobrazeni OMEZENIFREKVENCI">
1/1 den
</td>
</tr>
<tr>
<th>Doba trv&#225;n&#237;</th>
<td class="zobrazeni DOBATRVANI">
60
</td>
</tr>
<tr>
<th>Popis</th>
<td class="zobrazeni POPIS">
</td>
</tr>
<tr>
<th>Č&#237;m v&#253;kon zač&#237;n&#225;</th>
<td class="zobrazeni definice1">
Prostudov&#225;n&#237;m dokumentace pacienta a odběrem životn&#237; osobn&#237; a rodinn&#233; anamn&#233;zy.
</td>
</tr>
<tr>
<th>Obsah a rozsah v&#253;konu</th>
<td class="zobrazeni definice2">
Celkov&#233; intern&#237; fyzik&#225;ln&#237; vyšetřen&#237;. Orientačn&#237; vyšetřen&#237; neurologick&#233;. Orientačn&#237; vyšetřen&#237; funkc&#237; pohybov&#233;ho apar&#225;tu. Vyšetřen&#237; moče. Indikace dalš&#237;ch potřebn&#253;ch vyšetřen&#237; podle v&#253;sledku prohl&#237;dky. Zhodnocen&#237; zdravotn&#237;ho stavu. Vyšetřen&#237; zraku, sluchu.
</td>
</tr>
<tr>
<th>Č&#237;m v&#253;kon konč&#237;</th>
<td class="zobrazeni definice3">
Poučen&#237;m pacienta, informace o možn&#233; intervenci rizikov&#253;ch faktorů. Administrativn&#237; činnost&#237; souvisej&#237;c&#237; s v&#253;konem (vystaven&#237; zpr&#225;vy pro ošetřuj&#237;c&#237;ho l&#233;kaře, potřebn&#253;ch receptů, poukazů, ž&#225;danek, vystaven&#237; formul&#225;řů na pracovn&#237; neschopnost či ošetřov&#225;n&#237; člena rodiny, vyps&#225;n&#237; povinn&#253;ch hl&#225;šen&#237;, určen&#237; ev. data dalš&#237; n&#225;vštěvy, rozhodnut&#237; o př&#237;padn&#233; nezbytn&#233; zdravotnick&#233; dopravě pacienta a vystaven&#237; poukazu na ni).
</td>
</tr>
<tr>
<th>Podm&#237;nky</th>
<td class="zobrazeni podminky">
</td>
</tr>
<tr>
<th>
Nositel&#233;
</th>
<td>
<table class="VnitrniTabulka">
<tr>
<th>
Kategorie
</th>
<th>
Funkce
</th>
<th>
Praxe
</th>
<th>
Cas
</th>
<th>
Bodyaktualni
</th>
<th>
Poznamka
</th>
<th></th>
</tr>
<tr>
<td>
L3
</td>
<td>
všeobecn&#253; praktick&#253; l&#233;kař
</td>
<td class="doprava">
</td>
<td class="doprava">
60
</td>
<td class="doprava">
838,87
</td>
<td>
</td>
<td>
</td>
</tr>
<tfoot>
<tr>
<td></td>
<td></td>
<td></td>
<td>Celkem:</td>
<td class="doprava">838,87 </td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
</td>
</tr>
<tr>
<th>
Materi&#225;ly
</th>
<td>
<table class="VnitrniTabulka">
+80
View File
@@ -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 <table> 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é <tr> potomky hlavní tabulky ---
direct_trs = main_table.find_all("tr", recursive=False)
print(f"\nPřímých <tr> 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}] <th>: {[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á <tbody> s {len(tbody_trs)} přímými <tr>")
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}'")
+201
View File
@@ -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()
+334
View File
@@ -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: <th>label</th><td>hodnota</td>
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()
+259
View File
@@ -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()
Binary file not shown.