Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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",
|
"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"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+262
@@ -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: `<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
|
||||||
|
```
|
||||||
@@ -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="/">Úvod</a>
|
||||||
|
</div>
|
||||||
|
<ul class="nav navbar-nav">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class=" ">
|
||||||
|
|
||||||
|
<a href="/Vyhlaska" class="dropdown-toggle">Vyhláška</a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class=" ">
|
||||||
|
|
||||||
|
<a href="/Vykon" class="dropdown-toggle">Platné výkony</a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="dropdown ">
|
||||||
|
|
||||||
|
<a href="/Ciselnik" class="dropdown-toggle">Číselní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á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">Přípravek</a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
|
||||||
|
<a href="/Ciselnik/Pristroj" class="dropdown-toggle" role="menuitem">Přístroj</a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
|
||||||
|
<a href="/Ciselnik/OmezeniMistem" class="dropdown-toggle" role="menuitem">Omezení mí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á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Í VYŠETŘENÍ PRAKTICKÝM LÉ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>Číslo výkonu</th>
|
||||||
|
|
||||||
|
<td>01021</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Název</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni NAZEV">
|
||||||
|
|
||||||
|
KOMPLEXNÍ VYŠETŘENÍ PRAKTICKÝM LÉKAŘEM
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Nepočítat režii
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<td class="zobrazeni rezieNula">
|
||||||
|
|
||||||
|
<input class="check-box" disabled="disabled" type="checkbox" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Poznámka</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni POZNAMKA">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Autorská odbornost
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni ODBORNOST">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<table class="VnitrniTabulka">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Kód
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Název
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Pořadí
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Sazba režie
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
001
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
všeobecné praktické lékařství
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
1020
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
4,58
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Další odbornost
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni dalsiOdbornost">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<table class="VnitrniTabulka">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Kód
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Ná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í
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Omezení místem</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni OMEZENIMISTEM">
|
||||||
|
|
||||||
|
A - pouze ambulantně
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Omezení frekvencí</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni OMEZENIFREKVENCI">
|
||||||
|
|
||||||
|
1/1 den
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Doba trvání</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni DOBATRVANI">
|
||||||
|
|
||||||
|
60
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Popis</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni POPIS">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Čím výkon začíná</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni definice1">
|
||||||
|
|
||||||
|
Prostudováním dokumentace pacienta a odběrem životní osobní a rodinné anamnézy.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Obsah a rozsah výkonu</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni definice2">
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Čím výkon končí</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni definice3">
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>Podmínky</th>
|
||||||
|
|
||||||
|
<td class="zobrazeni podminky">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
|
||||||
|
Nositelé
|
||||||
|
|
||||||
|
</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ý praktický lé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ály
|
||||||
|
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<table class="VnitrniTabulka">
|
||||||
@@ -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}'")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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.
Reference in New Issue
Block a user