Compare commits

...

6 Commits

Author SHA1 Message Date
Vladimir Buzalka e981659621 notebookvb 2026-06-19 11:30:52 +02:00
administrator e5315b821e z230 2026-06-18 12:15:57 +02:00
Vladimir Buzalka 19036b58cc notebookvb 2026-06-18 05:32:36 +02:00
administrator 0beaffec45 z230 2026-06-17 15:06:06 +02:00
administrator 26e44fc721 Merge remote-tracking branch 'origin/master' 2026-06-17 12:06:53 +02:00
administrator dc07e19179 z230 2026-06-17 11:53:54 +02:00
44 changed files with 4274 additions and 2 deletions
+3
View File
@@ -50,3 +50,6 @@ Import vždy přes `sys.path` na kořen projektu nebo přímou cestou.
|--------|---------|-------|
| `stahni_str8ts.py` | `SběrDatRůzné/DailyStr8ts/` | Stahuje daily Str8ts puzzle jako PDF, odesílá emailem — viz [NOTES.md](SběrDatRůzné/DailyStr8ts/NOTES.md) |
| `10_StahnoutXML.py`, `11_ParseXML.py` | `Recepty/NačteníPředpisuWithClaude/` | Pipeline pro stahování detailů receptů z eRecept SÚKL — viz [NacistPredpis_DOKUMENTACE.md](Recepty/NačteníPředpisuWithClaude/NacistPredpis_DOKUMENTACE.md) |
| `watcher.py` | `Webináře/` | Hlídá nové webináře na praktickylekar.online, přes Telegram potvrdí a přihlásí Buzalkovi — viz [NOTES.md](Webináře/NOTES.md) |
| `stahni_video.py` | `Video/` | Stahuje videa (Vimeo, YouTube…) přes yt-dlp; soukromá/nedostupná sám přeskočí — viz [NOTES.md](Video/NOTES.md) |
| `euni_stahni.py`, `euni_db.py`, `euni_report.py` | `Euni/` | Stahování kurzů z euni.cz (PDF + videa) s trackingem v MongoDB EUNI (idempotentní) — viz [NOTES.md](Euni/NOTES.md) |
+4
View File
@@ -0,0 +1,4 @@
# Přihlašovací údaje k euni.cz — zkopíruj do souboru .env a vyplň.
# (.env je v .gitignore, do gitu se nedostane.)
EUNI_USERNAME=tvoje_prihlasovaci_jmeno
EUNI_PASSWORD=tvoje_heslo
+3
View File
@@ -0,0 +1,3 @@
# stažený obsah a inventura — do gitu nepatří
stazeno/
euni_kurzy.json
+120
View File
@@ -0,0 +1,120 @@
# Euni — stahování a tracking kurzů z euni.cz
Přihlásí se na euni.cz, projde kurzy, vytěží odkazy + metadata a stahuje obsah
(PDF/prezentace a videa Vimeo/YouTube). Vše se trackuje v **MongoDB EUNI**, takže
stahování je idempotentní — skript ví, co už má, a netahá dvakrát.
## Soubory
| Soubor | Popis |
|--------|-------|
| `euni_stahni.py` | hlavní pipeline: login → scrape → ingest do Mongo → stahování → záloha do SeaweedFS |
| `euni_db.py` | připojení a operace nad MongoDB EUNI (kolekce, indexy, upserty) |
| `euni_seaweed.py` | nahrávání/stahování souborů do SeaweedFS (filer HTTP API) |
| `euni_restore.py` | obnova všech souborů ze SeaweedFS na disk (na jakémkoli PC) |
| `euni_report.py` | dashboard: přehled stavu (kolik staženo/čeká/přeskočeno) |
| `.env` | `EUNI_USERNAME`, `EUNI_PASSWORD` (v .gitignore) |
| `euni_kurzy.json` | poslední inventura (záloha; primární zdroj je Mongo) |
| `stazeno/` | stažený obsah, `stazeno/<id>-<slug>/{dokumenty,videa}/` |
## Závislosti
```bat
python -m pip install -U requests beautifulsoup4 python-dotenv yt-dlp static-ffmpeg pymongo
```
Video stahuje sdílený modul `../Video/stahni_video.py` (yt-dlp + static-ffmpeg,
soukromá videa sám přeskočí).
## MongoDB EUNI
Server `mongodb://192.168.1.76:27017` (bez hesla), DB `EUNI`. Lze přepsat env
proměnnou `EUNI_MONGO_URI`.
### Kolekce `kurzy` (1 dokument na kurz)
`_id` = euni ID kurzu. Pole: `slug, nazev, url, profese[], autor,
autor_medailonek_url, datum_publikace, revidovano, akreditace, kredity,
pocet_videi, pocet_dokumentu, first_seen, updated_at`.
### Kolekce `materialy` (1 dokument na soubor)
Unikátní index `{kurz_id, klic}`. Pole: `kurz_id, kurz_nazev, druh
(video|dokument), platforma (vimeo|youtube), klic (vimeo:ID / youtube:ID /
doc:hash), zdroj_url, watch_url, popis, pripona, stav, duvod, soubor,
velikost_b, pokusy, posledni_chyba, first_seen, updated_at, stazeno_at`.
**Stavy:** `ceka``stazeno` / `preskoceno` (soukromé video) / `chyba`.
**SeaweedFS reference** (po nahrání kopie): `seaweed_path` (cesta ve filer =
identifikátor pro vyžádání, např. `euni/5618-.../dokumenty/x.pdf`),
`seaweed_fids` (fid chunků = čísla souborů v SeaweedFS), `seaweed_md5`,
`seaweed_size`, `seaweed_at`.
## SeaweedFS záloha + obnova
Každý stažený soubor se nahraje do **SeaweedFS** (filer na Unraidu,
default `http://192.168.1.50:8888`, přepíše env `EUNI_FILER`). Do Mongo se k
materiálu uloží `seaweed_path` + `seaweed_fids`, takže soubor lze kdykoli vyžádat.
- Strukturu na disku zrcadlí cesta: `euni/<id>-<slug>/<typ>/<soubor>`.
- Filer metadata (mapa cesta→chunky) jsou v Mongo DB `seaweedfs` na 192.168.1.76;
bloby na poli Unraidu. (Setup: `U:\\PythonProject\\Janssen\\SeaweedFS\\`.)
- Pozn.: přímý přístup přes raw fid/volume zvenčí nefunguje (volume se uvnitř
Dockeru jmenuje `seaweed-volume`); proto se čte/zapisuje přes filer.
**Obnova kdekoliv** (stačí síť na Mongo + filer):
```bat
python euni_restore.py # vše → ./obnoveno
python euni_restore.py --out D:\Euni # jiný cíl
python euni_restore.py --kurz 5618 # jen jeden kurz
python euni_restore.py --dry-run # jen výpis
```
**Backfill** (dohrát do SeaweedFS soubory stažené dřív):
```bat
python euni_stahni.py --seaweed-backfill --from-json
```
### Idempotence
- Scrape dělá *upsert*: nový materiál → `ceka`; existující si **drží stav**
(nepřepíše stažené). Lze tedy bez obav scrapovat opakovaně.
- Stahování bere jen `stav: ceka` (a volitelně `chyba` pro retry).
## Použití
Nejjednodušší: **`python euni_menu.py`** — interaktivní menu s volbami 19
(test / dokumenty / vše / 720p / dashboard / obnova / backfill / re-scrape).
Po doběhnutí akce se vrátí do menu, `Ctrl+C` přeruší jen aktuální akci.
Ručně přes CLI:
```bat
python euni_stahni.py --scrape-only # jen inventura → Mongo + JSON
python euni_stahni.py --no-videos # scrape + stáhne jen dokumenty
python euni_stahni.py # scrape + dokumenty + videa
python euni_stahni.py --from-json --no-videos # přeskočí scrape, stáhne z Mongo/JSON
python euni_stahni.py --professions all # všechny profese (2,4,5,6,7)
python euni_stahni.py --limit 3 # jen prvních 3 kurzy (test)
python euni_stahni.py --no-mongo # bez zápisu do Mongo
python euni_stahni.py --frags 20 # víc paralelních HLS fragmentů (rychlejší)
python euni_stahni.py --video-format "bestvideo[height<=720]+bestaudio/best" # 720p
python euni_report.py # přehled stavu
python euni_report.py --soukroma # seznam přeskočených videí
```
## Jak to funguje (ověřeno)
- **Login** `/sign/` — formulář se parsuje (kopírují se skrytá Nette pole `_do`).
- **Seznam kurzů** — signál `studyAreaList-nextPage` vrací JSON snippet, stránkuje
se dokud přibývají kurzy (profese: 2=Lékař, 4=Farmaceut, 5/6=studenti, 7=NLZP).
- **Detail kurzu** — server-rendered HTML; videa z `<iframe>` (u Vimea se zachová
`?h=` hash), dokumenty z přímých odkazů i `/redirect/<base64>`.
- Metadata z bloků `lecture-info-label``lecture-info-mark`.
## Úskalí
- **Vimeo** dává oddělené video/audio HLS → nutný ffmpeg (řeší static-ffmpeg).
Domain-restricted videa se stahují s referer `https://www.euni.cz/`.
- **Soukromá videa** (autor je zamkl) nejdou stáhnout — skript je označí
`preskoceno` s důvodem, nepadá.
- Anotace kurzu na stránce není (jen obecný text webu) → neukládá se.
- Diakritika v názvech: v konzoli cp1250 OK; výpis má pojistku proti pádu.
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
euni_db.py — připojení a operace nad MongoDB databází EUNI.
Server: mongodb://192.168.1.76:27017 (bez hesla), databáze "EUNI".
Kolekce:
kurzy — 1 dokument na kurz (metadata + počty)
materialy — 1 dokument na stahovatelný soubor (video/dokument) + stav stahování
Idempotence: materialy mají unikátní index {kurz_id, klic}. Upsert nový soubor
založí jako "ceka"; u existujícího NEPŘEPÍŠE stav stahování (jen popisná pole).
"""
import os
from datetime import datetime, timezone
import pymongo
MONGO_URI = os.environ.get("EUNI_MONGO_URI", "mongodb://192.168.1.76:27017")
DB_NAME = "EUNI"
# stavy materiálu
CEKA = "ceka"
STAZENO = "stazeno"
PRESKOCENO = "preskoceno"
CHYBA = "chyba"
def now():
return datetime.now(timezone.utc)
def get_db():
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=4000)
client.admin.command("ping")
return client[DB_NAME]
def ensure_indexes(db=None):
if db is None:
db = get_db()
db.materialy.create_index([("kurz_id", 1), ("klic", 1)], unique=True,
name="uniq_kurz_klic")
db.materialy.create_index("stav", name="stav")
db.materialy.create_index([("druh", 1), ("stav", 1)], name="druh_stav")
db.kurzy.create_index("profese", name="profese")
return db
# ----------------------------------------------------------------- kurzy ------
def upsert_kurz(db, kurz: dict):
"""Vloží/aktualizuje kurz. Zachová first_seen, profese sjednotí."""
_id = kurz["id"]
sets = {
"slug": kurz.get("slug"),
"nazev": kurz.get("nazev") or kurz.get("title"),
"url": kurz.get("url"),
"autor": kurz.get("autor"),
"autor_medailonek_url": kurz.get("autor_medailonek_url"),
"datum_publikace": kurz.get("datum_publikace"),
"revidovano": kurz.get("revidovano"),
"akreditace": kurz.get("akreditace"),
"kredity": kurz.get("kredity"),
"pocet_videi": kurz.get("pocet_videi"),
"pocet_dokumentu": kurz.get("pocet_dokumentu"),
"updated_at": now(),
}
profese = kurz.get("profese") or []
db.kurzy.update_one(
{"_id": _id},
{
"$set": sets,
"$setOnInsert": {"first_seen": now()},
"$addToSet": {"profese": {"$each": profese}} if profese else {},
} if profese else {
"$set": sets,
"$setOnInsert": {"first_seen": now()},
},
upsert=True,
)
# -------------------------------------------------------------- materialy -----
def upsert_material(db, mat: dict):
"""Idempotentní upsert souboru. Nepřepíše stav existujícího záznamu."""
klic_filter = {"kurz_id": mat["kurz_id"], "klic": mat["klic"]}
popisne = {
"kurz_nazev": mat.get("kurz_nazev"),
"druh": mat.get("druh"),
"platforma": mat.get("platforma"),
"zdroj_url": mat.get("zdroj_url"),
"watch_url": mat.get("watch_url"),
"popis": mat.get("popis"),
"pripona": mat.get("pripona"),
"updated_at": now(),
}
db.materialy.update_one(
klic_filter,
{
"$set": popisne,
"$setOnInsert": {
"stav": CEKA,
"duvod": None,
"soubor": None,
"velikost_b": None,
"pokusy": 0,
"posledni_chyba": None,
"stazeno_at": None,
"first_seen": now(),
},
},
upsert=True,
)
def set_status(db, kurz_id, klic, stav, soubor=None, velikost_b=None,
duvod=None, chyba=None):
"""Nastaví výsledek stahování jednoho materiálu."""
sets = {"stav": stav, "updated_at": now()}
if stav == STAZENO:
sets.update({"soubor": soubor, "velikost_b": velikost_b,
"duvod": None, "posledni_chyba": None, "stazeno_at": now()})
elif stav == PRESKOCENO:
sets.update({"duvod": duvod})
elif stav == CHYBA:
sets.update({"posledni_chyba": chyba})
upd = {"$set": sets}
if stav in (STAZENO, CHYBA):
upd["$inc"] = {"pokusy": 1}
db.materialy.update_one({"kurz_id": kurz_id, "klic": klic}, upd)
def set_seaweed(db, kurz_id, klic, path, fids=None, md5=None, size=None):
"""Uloží referenci na kopii v SeaweedFS (cesta + fid chunků)."""
db.materialy.update_one(
{"kurz_id": kurz_id, "klic": klic},
{"$set": {
"seaweed_path": path,
"seaweed_fids": fids or [],
"seaweed_md5": md5,
"seaweed_size": size,
"seaweed_at": now(),
"updated_at": now(),
}},
)
def materialy_bez_seaweed(db):
"""Stažené materiály, které ještě nemají kopii v SeaweedFS (pro backfill)."""
return list(db.materialy.find({
"stav": STAZENO,
"soubor": {"$ne": None},
"$or": [{"seaweed_path": {"$exists": False}}, {"seaweed_path": None}],
}))
def materialy_v_seaweed(db):
"""Materiály s kopií v SeaweedFS (pro restore)."""
return list(db.materialy.find({"seaweed_path": {"$exists": True, "$ne": None}}))
def cekajici_materialy(db, druh=None, vcetne_chyb=False):
"""Vrátí materiály ke stažení (stav 'ceka', volitelně i 'chyba')."""
stavy = [CEKA] + ([CHYBA] if vcetne_chyb else [])
q = {"stav": {"$in": stavy}}
if druh:
q["druh"] = druh
return list(db.materialy.find(q))
# ----------------------------------------------------------------- stats ------
def stats(db=None):
if db is None:
db = get_db()
out = {"kurzy": db.kurzy.count_documents({})}
pipe = [{"$group": {"_id": {"druh": "$druh", "stav": "$stav"},
"n": {"$sum": 1}}}]
for row in db.materialy.aggregate(pipe):
d = row["_id"]["druh"]
st = row["_id"]["stav"]
out.setdefault(d, {})[st] = row["n"]
return out
if __name__ == "__main__":
import json
db = ensure_indexes()
print("Připojeno k EUNI na", MONGO_URI)
print(json.dumps(stats(db), ensure_ascii=False, indent=2))
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
euni_menu.py — interaktivní menu pro stahování kurzů z euni.cz.
Spuštění:
python euni_menu.py
Jen vyber číslo a Enter. Každá volba spustí příslušný skript a po doběhnutí
se vrátíš do menu (Ctrl+C přeruší aktuální akci, ne celé menu).
"""
import os
import subprocess
import sys
from pathlib import Path
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
SKRIPT_DIR = Path(__file__).resolve().parent
PY = sys.executable
# klíč -> (popis, skript, argumenty)
AKCE = {
"1": ("Test - 3 kurzy, jen dokumenty (rychle)",
"euni_stahni.py", ["--from-json", "--no-videos", "--limit", "3"]),
"2": ("Vsechny dokumenty (PDF/prezentace)",
"euni_stahni.py", ["--from-json", "--no-videos"]),
"3": ("Vse vcetne videi - nejvyssi kvalita (1080p, velke)",
"euni_stahni.py", ["--from-json"]),
"4": ("Vse vcetne videi - 720p (mensi, rychlejsi)",
"euni_stahni.py",
["--from-json", "--video-format",
"bestvideo[height<=720]+bestaudio/best"]),
"5": ("Jen videa (1080p)",
"euni_stahni.py", ["--from-json", "--no-docs"]),
"6": ("Prehled stavu (dashboard)", "euni_report.py", []),
"7": ("Obnova ze SeaweedFS na disk", "euni_restore.py", []),
"8": ("Backfill - dohrat chybejici kopie do SeaweedFS",
"euni_stahni.py", ["--seaweed-backfill", "--from-json"]),
"9": ("Aktualizovat seznam kurzu (znovu scrape do Mongo)",
"euni_stahni.py", ["--scrape-only"]),
}
def vycisti_obrazovku():
os.system("cls" if os.name == "nt" else "clear")
def vypis_menu():
print("=" * 60)
print(" EUNI - stahovani kurzu z euni.cz")
print("=" * 60)
print()
for k in sorted(AKCE):
print(f" {k}) {AKCE[k][0]}")
print()
print(" 0) Konec")
print()
def main():
while True:
vycisti_obrazovku()
vypis_menu()
try:
volba = input("Vyber cislo a stiskni Enter: ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if volba in ("0", "q", "exit", "konec"):
break
akce = AKCE.get(volba)
if not akce:
continue
_, skript, args = akce
print()
try:
subprocess.run([PY, str(SKRIPT_DIR / skript), *args],
cwd=str(SKRIPT_DIR))
except KeyboardInterrupt:
print("\nPreruseno uzivatelem.")
try:
input("\n=== HOTOVO. Stiskni Enter pro navrat do menu ===")
except (EOFError, KeyboardInterrupt):
break
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""
euni_report.py — přehled stavu stahování z databáze EUNI.
python euni_report.py # souhrnný přehled
python euni_report.py --chyby # vypíše materiály ve stavu chyba
python euni_report.py --soukroma # vypíše přeskočená (soukromá) videa
"""
import argparse
import sys
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
import euni_db as edb
CARA = "" * 56
def lidsky(n):
for j, u in [(1e9, "GB"), (1e6, "MB"), (1e3, "kB")]:
if n >= j:
return f"{n/j:.1f} {u}"
return f"{n} B"
def main():
p = argparse.ArgumentParser()
p.add_argument("--chyby", action="store_true", help="vypiš materiály ve stavu chyba")
p.add_argument("--soukroma", action="store_true", help="vypiš přeskočená videa")
a = p.parse_args()
db = edb.get_db()
print(CARA)
print(f" EUNI — přehled ({edb.MONGO_URI})")
print(CARA)
print(f" Kurzů: {db.kurzy.count_documents({})}")
kr = db.kurzy.aggregate([{"$group": {"_id": None, "k": {"$sum": "$kredity"}}}])
kr = next(kr, {}).get("k") or 0
print(f" Kreditů celkem (akreditované kurzy): {kr}")
print(CARA)
for druh in ("video", "dokument"):
print(f" {druh.upper()}:")
pipe = [{"$match": {"druh": druh}},
{"$group": {"_id": "$stav", "n": {"$sum": 1},
"b": {"$sum": {"$ifNull": ["$velikost_b", 0]}}}}]
celkem = 0
for row in sorted(db.materialy.aggregate(pipe), key=lambda r: r["_id"]):
vel = f" ({lidsky(row['b'])})" if row["b"] else ""
print(f" {row['_id']:<11} {row['n']:>5}{vel}")
celkem += row["n"]
print(f" {'celkem':<11} {celkem:>5}")
print(CARA)
if a.chyby:
print(" CHYBY:")
for m in db.materialy.find({"stav": edb.CHYBA}):
print(f" - [{m['druh']}] {m.get('kurz_nazev','')[:40]} | "
f"{m.get('posledni_chyba','')[:60]}")
print(f" {m['zdroj_url']}")
if a.soukroma:
print(" PŘESKOČENÁ VIDEA (soukromá/nedostupná):")
for m in db.materialy.find({"stav": edb.PRESKOCENO}):
print(f" - {m.get('kurz_nazev','')[:45]} | {m.get('duvod','')}")
print(f" {m.get('watch_url') or m['zdroj_url']}")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
euni_restore.py — obnoví všechny stažené soubory ze SeaweedFS na disk.
Funguje na libovolném počítači: čte reference (cesty/fid) z MongoDB EUNI a každý
soubor stáhne z filer SeaweedFS zpět do souborového systému se stejnou strukturou
jako stazeno/<id>-<slug>/<typ>/<soubor>.
Potřebuje jen síťový přístup k Mongu (192.168.1.76) a filer (192.168.1.50) a:
python -m pip install pymongo requests
Použití:
python euni_restore.py # obnoví do ./obnoveno
python euni_restore.py --out D:\\Euni # jiný cílový adresář
python euni_restore.py --kurz 5618 # jen jeden kurz
python euni_restore.py --dry-run # jen vypíše, co by stáhl
"""
import argparse
import sys
from pathlib import Path
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
import euni_db as edb
import euni_seaweed as sw
def lidsky(n):
n = n or 0
for j, u in [(1e9, "GB"), (1e6, "MB"), (1e3, "kB")]:
if n >= j:
return f"{n/j:.1f} {u}"
return f"{n} B"
def main():
p = argparse.ArgumentParser(description="Obnoví soubory ze SeaweedFS na disk.")
p.add_argument("--out", default="obnoveno", help="cílový adresář (výchozí ./obnoveno)")
p.add_argument("--kurz", help="obnovit jen tento kurz_id")
p.add_argument("--dry-run", action="store_true", help="jen vypsat, nestahovat")
a = p.parse_args()
out = Path(a.out)
db = edb.get_db()
if not sw.ping():
sys.exit(f"SeaweedFS filer nedostupný ({sw.FILER}).")
mats = edb.materialy_v_seaweed(db)
if a.kurz:
mats = [m for m in mats if m.get("kurz_id") == a.kurz]
print(f"Obnovuji {len(mats)} souborů z {sw.FILER} -> {out.resolve()}")
ok = preskoc = chyb = 0
bajtu = 0
for m in mats:
remote = m["seaweed_path"]
# lokální cesta: zrcadlí seaweed cestu bez prefixu 'euni/'
parts = remote.split("/")
rel = Path(*parts[1:]) if parts and parts[0] == sw.PREFIX else Path(*parts)
dest = out / rel
want = m.get("seaweed_size")
if dest.exists() and (want is None or dest.stat().st_size == want):
preskoc += 1
continue
if a.dry_run:
print(f" [BY STÁHL] {rel} ({lidsky(want)})")
ok += 1
continue
try:
n = sw.download(remote, dest)
bajtu += n
ok += 1
print(f" [OK] {rel} ({lidsky(n)})")
except Exception as e:
chyb += 1
print(f" [CHYBA] {rel} ({str(e)[:60]})")
print(f"\nHotovo: {ok} obnoveno, {preskoc} přeskočeno (už je), {chyb} chyb. "
f"Staženo {lidsky(bajtu)}.")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
euni_seaweed.py — nahrávání/stahování souborů do SeaweedFS přes filer HTTP API.
Filer běží na Unraidu (default http://192.168.1.50:8888). Soubory se ukládají
podle cesty, která zrcadlí lokální strukturu: euni/<id>-<slug>/<typ>/<soubor>.
Filer metadata jdou do Mongo "seaweedfs" (na 192.168.1.76) — viz README v
U:\\PythonProject\\Janssen\\SeaweedFS\\.
Identifikátor pro vyžádání souboru = cesta (filer). Navíc se ukládají fid(y)
jednotlivých chunků (číslo souboru v SeaweedFS).
Přepsání endpointu: env EUNI_FILER.
"""
import os
from urllib.parse import quote
import requests
FILER = os.environ.get("EUNI_FILER", "http://192.168.1.50:8888")
PREFIX = "euni" # kořenová složka v SeaweedFS
def _url(remote_path):
return f"{FILER}/" + quote(remote_path.lstrip("/"), safe="/")
def entry_meta(remote_path, timeout=30):
"""Detailní metadata souboru (vč. chunků s fid), nebo None když neexistuje."""
try:
r = requests.get(_url(remote_path) + "?metadata=true", timeout=timeout)
if r.status_code == 200:
return r.json()
except requests.RequestException:
pass
return None
def exists(remote_path):
return entry_meta(remote_path) is not None
def upload(local_path, remote_path, timeout=900):
"""Nahraje soubor na filer. Vrátí dict: path, fids, size, md5."""
fname = os.path.basename(remote_path)
with open(local_path, "rb") as f:
r = requests.post(_url(remote_path), files={"file": (fname, f)},
timeout=timeout)
r.raise_for_status()
meta = entry_meta(remote_path) or {}
fids = [c.get("file_id") for c in (meta.get("chunks") or []) if c.get("file_id")]
return {
"path": remote_path,
"fids": fids,
"size": meta.get("FileSize"),
"md5": meta.get("Md5"),
}
def download(remote_path, local_path, timeout=900):
"""Stáhne soubor z fileru na lokální cestu. Vrátí velikost v bajtech."""
r = requests.get(_url(remote_path), stream=True, timeout=timeout)
r.raise_for_status()
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
tmp = str(local_path) + ".part"
with open(tmp, "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
if chunk:
f.write(chunk)
os.replace(tmp, local_path)
return os.path.getsize(local_path)
def ping():
try:
r = requests.get(f"{FILER}/?limit=1", headers={"Accept": "application/json"},
timeout=5)
return r.status_code == 200
except requests.RequestException:
return False
if __name__ == "__main__":
print("Filer:", FILER, "dostupný:" , ping())
+647
View File
@@ -0,0 +1,647 @@
#!/usr/bin/env python3
"""
euni_stahni.py — přihlásí se na euni.cz, projde kurzy a stáhne, co se stáhnout dá
(dokumenty: PDF/DOCX/PPTX/XLSX/ZIP a videa: Vimeo/YouTube).
Postup:
1) login přes /sign/ (formulář se parsuje, kopírují se i skrytá Nette pole)
2) sběr kurzů přes signál studyAreaList-nextPage (stránkování, dokud přibývají)
3) z každého kurzu se vytáhnou <iframe> videa a odkazy na dokumenty
(vč. /redirect/<base64>)
4) vše se stáhne do stazeno/<id>-<slug>/ (dokumenty/ a videa/)
Soukromá / nedostupná videa se samo přeskočí (nepadá).
Závislosti:
python -m pip install -U requests beautifulsoup4 python-dotenv yt-dlp static-ffmpeg
Údaje: Euni/.env -> EUNI_USERNAME=... EUNI_PASSWORD=...
Příklady:
python euni_stahni.py # vše: scrape + dokumenty + videa (profese Lékař)
python euni_stahni.py --scrape-only # jen inventura do euni_kurzy.json
python euni_stahni.py --from-json # přeskočí scrape, použije euni_kurzy.json
python euni_stahni.py --no-videos # jen dokumenty
python euni_stahni.py --professions 2,4 # více profesí (2=Lékař,4=Farmaceut,7=NLZP)
python euni_stahni.py --limit 3 # jen první 3 kurzy (test)
"""
import argparse
import base64
import hashlib
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin, unquote, urlparse
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
# výpis ať nikdy nespadne na znaku mimo kódování konzole
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(errors="backslashreplace")
except Exception:
pass
SKRIPT_DIR = Path(__file__).resolve().parent
load_dotenv(SKRIPT_DIR / ".env")
# reuse stahovače videí z ../Video/stahni_video.py
sys.path.insert(0, str(SKRIPT_DIR.parent / "Video"))
try:
import stahni_video as sv
except Exception:
sv = None
try:
import euni_db as edb
except Exception:
edb = None
try:
import euni_seaweed as sw
except Exception:
sw = None
BASE = "https://www.euni.cz"
LOGIN_URL = f"{BASE}/sign/?bid=1"
LIST_URL = f"{BASE}/seznam-kurzu?bid=1"
NEXTPAGE = f"{BASE}/seznam-kurzu?studyAreaList-professionId={{prof}}&bid=1&do=studyAreaList-nextPage"
DOC_RE = re.compile(r"\.(pdf|docx?|pptx?|xlsx?|zip)(\?|$)", re.I)
FILE_PATH_RE = re.compile(r"fileUploader/download|files/resources", re.I)
VIDEO_RE = re.compile(r"vimeo|youtube|youtu\.be", re.I)
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120 Safari/537.36")
# ---------------------------------------------------------------- pomocné -----
def bezpecny_nazev(s: str, max_len: int = 120) -> str:
"""Očistí řetězec na bezpečný název souboru/složky pro Windows."""
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", s).strip(" .")
s = re.sub(r"\s+", " ", s)
return (s[:max_len].strip() or "bez_nazvu")
def make_session():
s = requests.Session()
s.headers.update({"User-Agent": UA})
return s
def _relpath(p):
"""Cesta k souboru relativně k adresáři Euni (pro uložení do DB)."""
if not p:
return None
try:
return str(Path(p).resolve().relative_to(SKRIPT_DIR))
except Exception:
return str(p)
def _seaweed_path(dest, out_root):
"""Cesta v SeaweedFS zrcadlící lokální strukturu: euni/<id-slug>/<typ>/<soubor>."""
try:
rel = Path(dest).resolve().relative_to(Path(out_root).resolve())
except Exception:
rel = Path(dest).name
return sw.PREFIX + "/" + "/".join(Path(rel).parts)
def _zaloh_do_seaweed(db, dest, out_root, kurz_id, klic):
"""Nahraje soubor do SeaweedFS a uloží referenci (fid) k materiálu do Mongo."""
if sw is None or not dest or not Path(dest).exists():
return None
remote = _seaweed_path(dest, out_root)
try:
meta = sw.entry_meta(remote)
if meta and meta.get("FileSize") == Path(dest).stat().st_size:
# už tam je se stejnou velikostí — jen zaznamenat referenci
info = {"path": remote,
"fids": [c.get("file_id") for c in (meta.get("chunks") or [])
if c.get("file_id")],
"size": meta.get("FileSize"), "md5": meta.get("Md5")}
else:
info = sw.upload(str(dest), remote)
if db is not None:
edb.set_seaweed(db, kurz_id, klic, info["path"],
fids=info.get("fids"), md5=info.get("md5"),
size=info.get("size"))
return info
except Exception as e:
print(f" [SEAWEED-CHYBA] {remote} ({str(e)[:60]})")
return None
# ----------------------------------------------------------------- login ------
def login(s):
r = s.get(LOGIN_URL, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
form = next((f for f in soup.find_all("form")
if f.find("input", {"type": "password"})), None)
if not form:
raise RuntimeError("Přihlašovací formulář nenalezen.")
data, user_field, pass_field = {}, None, None
for inp in form.find_all("input"):
name = inp.get("name")
if not name:
continue
itype = (inp.get("type") or "text").lower()
data[name] = inp.get("value", "") # zachová skrytá pole (_do, _token...)
if itype == "password":
pass_field = name
elif itype in ("text", "email") and user_field is None:
user_field = name
user = os.environ.get("EUNI_USERNAME")
pwd = os.environ.get("EUNI_PASSWORD")
if not user or not pwd:
sys.exit("Chybí EUNI_USERNAME / EUNI_PASSWORD. Vyplň je v Euni/.env "
"(vzor je v .env.example).")
data[user_field] = user
data[pass_field] = pwd
action = urljoin(LOGIN_URL, form.get("action") or LOGIN_URL)
r = s.post(action, data=data, headers={"Referer": LOGIN_URL}, timeout=30)
r.raise_for_status()
if "Odhlásit" not in r.text and "odhlasit" not in r.text.lower():
raise RuntimeError("Přihlášení se nezdařilo zkontroluj údaje v .env.")
print("✓ Přihlášeno")
# ------------------------------------------------------------- seznam kurzů ----
def get_courses_for_profession(s, profession_id):
# inicializace stránkování pro danou profesi
s.get(f"{BASE}/seznam-kurzu?studyAreaList-professionId={profession_id}&bid=1",
timeout=30)
seen, prev, guard = {}, -1, 0
while guard < 200:
guard += 1
r = s.get(NEXTPAGE.format(prof=profession_id),
headers={"X-Requested-With": "XMLHttpRequest"}, timeout=30)
r.raise_for_status()
try:
snippet = r.json().get("snippets", {}).get(
"snippet-studyAreaList-areaList", "")
except ValueError:
break
if not snippet:
break
soup = BeautifulSoup(snippet, "html.parser")
for a in soup.select("a.workshop"):
href = (a.get("href") or "").split("?")[0]
m = re.match(r"/lecture/(\d+)-(.+)", href)
if m:
seen[m.group(1)] = {
"id": m.group(1),
"slug": m.group(2),
"title": (a.find("h3").get_text(strip=True)
if a.find("h3") else m.group(2)),
"url": urljoin(BASE, href),
"profession": profession_id,
}
if len(seen) == prev:
break
prev = len(seen)
time.sleep(0.25)
return list(seen.values())
def get_all_courses(s, professions):
vse = {}
for prof in professions:
kurzy = get_courses_for_profession(s, prof)
print(f" profese {prof}: {len(kurzy)} kurzů")
for k in kurzy:
vse.setdefault(k["id"], k)
return list(vse.values())
# --------------------------------------------------------- extrakce odkazů ----
def decode_redirect(href):
m = re.search(r"/redirect/([A-Za-z0-9+/=]+)", href)
if m:
try:
return base64.b64decode(m.group(1)).decode("utf-8", "ignore")
except Exception:
pass
return None
def watch_url(embed):
m = re.search(r"player\.vimeo\.com/video/(\d+)", embed)
if m:
return f"https://vimeo.com/{m.group(1)}"
m = re.search(r"youtube\.com/embed/([\w-]+)", embed)
if m:
return f"https://www.youtube.com/watch?v={m.group(1)}"
return embed
def _text(el):
return " ".join(el.get_text(" ", strip=True).split()) if el else None
def _parse_date(s):
m = re.search(r"(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})", s or "")
if m:
try:
return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1)))
except ValueError:
return None
return None
def _mark_for_label(soup, label_text):
"""Najde hodnotu (lecture-info-mark/bold) ve stejném containeru jako daný label."""
for lab in soup.select(".lecture-info-label"):
if label_text.lower() in lab.get_text(strip=True).lower():
par = lab.parent
mark = (par.select_one(".lecture-info-mark")
or par.select_one(".lecture-info-bold"))
if mark:
return _text(mark)
return None
def extract_course_meta(soup):
meta = {}
autor_el = soup.select_one(".lecture-info-column-author")
if autor_el:
meta["autor"] = _text(autor_el.select_one(".lecture-info-mark"))
href = autor_el.get("href") or ""
if "vimeo" in href or "youtube" in href:
meta["autor_medailonek_url"] = href
if not meta.get("autor"):
meta["autor"] = (_mark_for_label(soup, "Autor kurzu")
or _mark_for_label(soup, "Autorka kurzu"))
meta["datum_publikace"] = _parse_date(_mark_for_label(soup, "Datum publikace"))
meta["revidovano"] = _parse_date(_mark_for_label(soup, "Revidováno"))
meta["akreditace"] = _mark_for_label(soup, "Akreditace")
m = re.search(r"(\d+)\s*kredit", soup.get_text(" "), re.I)
meta["kredity"] = int(m.group(1)) if m else None
return meta
def material_klic(druh, item):
"""Vrátí (klic, platforma) pro deduplikaci materiálu."""
if druh == "video":
e = item["embed"]
m = re.search(r"vimeo\.com/(?:video/)?(\d+)", e)
if m:
return f"vimeo:{m.group(1)}", "vimeo"
m = (re.search(r"youtube\.com/embed/([\w-]+)", e)
or re.search(r"youtu\.be/([\w-]+)", e)
or re.search(r"[?&]v=([\w-]+)", e))
if m:
return f"youtube:{m.group(1)}", "youtube"
return "video:" + hashlib.sha1(e.encode()).hexdigest()[:16], None
return "doc:" + hashlib.sha1(item["url"].encode()).hexdigest()[:16], None
def _pripona(url):
m = re.search(r"\.([a-z0-9]{2,4})(\?|$)", url, re.I)
return m.group(1).lower() if m else None
def extract_course_links(s, course_url):
r = s.get(course_url, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
videos, vseen = [], set()
for f in soup.find_all("iframe"):
src = f.get("src") or f.get("data-src") or ""
if src.startswith("//"):
src = "https:" + src
if VIDEO_RE.search(src) and src not in vseen:
vseen.add(src)
videos.append({"embed": src, "watch": watch_url(src)})
docs, seen = [], set()
for a in soup.find_all("a", href=True):
target = decode_redirect(a["href"]) or urljoin(BASE, a["href"])
if DOC_RE.search(target) or FILE_PATH_RE.search(target):
url = unquote(target)
if url in seen:
continue
seen.add(url)
docs.append({
"label": " ".join(a.get_text(" ", strip=True).split())[:70],
"url": url,
})
return {"videos": videos, "documents": docs, "meta": extract_course_meta(soup)}
# ------------------------------------------------------------- stahování ------
def stahni_dokument(s, url, out_dir: Path, label=""):
out_dir.mkdir(parents=True, exist_ok=True)
r = s.get(url, stream=True, timeout=120)
r.raise_for_status()
# jméno souboru z Content-Disposition, jinak z URL
fname = None
cd = r.headers.get("Content-Disposition", "")
m = re.search(r"filename\*?=(?:UTF-8'')?\"?([^\";]+)", cd)
if m:
fname = unquote(m.group(1))
if not fname:
fname = os.path.basename(urlparse(url).path) or "soubor"
fname = bezpecny_nazev(fname)
if "." not in fname and label:
fname = bezpecny_nazev(label)
dest = out_dir / fname
if dest.exists() and dest.stat().st_size > 0:
return ("existuje", dest.name)
tmp = dest.with_suffix(dest.suffix + ".part")
with open(tmp, "wb") as fp:
for chunk in r.iter_content(chunk_size=65536):
if chunk:
fp.write(chunk)
tmp.replace(dest)
return ("staženo", dest.name)
def stahni_video(embed, out_dir: Path, referer, fmt="bestvideo*+bestaudio/best",
frags=10):
"""Stáhne video přes yt-dlp; soukromé/nedostupné přeskočí. Vrací (stav, info, fp)."""
if sv is None:
return ("chyba", "modul stahni_video není dostupný", None)
try:
import yt_dlp
from yt_dlp.utils import DownloadError
except ImportError:
return ("chyba", "yt-dlp není nainstalován", None)
out_dir.mkdir(parents=True, exist_ok=True)
ff_dir = sv.priprav_ffmpeg()
opts = {
"outtmpl": str(out_dir / "%(title)s [%(id)s].%(ext)s"),
"format": fmt,
"concurrent_fragment_downloads": frags, # paralelní HLS fragmenty = rychlejší
"merge_output_format": "mp4",
"logger": sv._TichyLogger(),
"progress_hooks": [sv._progress_hook],
"noprogress": True,
"noplaylist": True,
"http_headers": {"Referer": referer, "User-Agent": UA},
}
if ff_dir:
opts["ffmpeg_location"] = ff_dir
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(embed, download=True)
fp = None
rd = (info or {}).get("requested_downloads")
if rd:
fp = rd[0].get("filepath")
return ("staženo", info.get("title", embed) if info else embed, fp)
except DownloadError as e:
duvod = sv.klasifikuj_chybu(str(e))
if duvod:
return ("přeskočeno", duvod, None)
return ("chyba", str(e).split("\n")[0], None)
except Exception as e:
return ("chyba", str(e), None)
def _ingest_course(db, c):
"""Zapíše kurz + jeho materiály do Mongo (idempotentně)."""
meta = c.get("meta") or {}
nazev = c.get("nazev") or c.get("title")
kurz = {
"id": c["id"], "slug": c.get("slug"), "nazev": nazev, "url": c.get("url"),
"profese": [c["profession"]] if c.get("profession") else c.get("profese", []),
"pocet_videi": len(c.get("videos", [])),
"pocet_dokumentu": len(c.get("documents", [])),
}
for k in ("autor", "autor_medailonek_url", "datum_publikace", "revidovano",
"akreditace", "kredity"):
kurz[k] = meta.get(k)
edb.upsert_kurz(db, kurz)
for v in c.get("videos", []):
klic, plat = material_klic("video", v)
edb.upsert_material(db, {
"kurz_id": c["id"], "kurz_nazev": nazev, "druh": "video",
"platforma": plat, "klic": klic, "zdroj_url": v["embed"],
"watch_url": v.get("watch"), "popis": None, "pripona": "mp4",
})
for d in c.get("documents", []):
klic, _ = material_klic("dokument", d)
edb.upsert_material(db, {
"kurz_id": c["id"], "kurz_nazev": nazev, "druh": "dokument",
"platforma": None, "klic": klic, "zdroj_url": d["url"],
"watch_url": None, "popis": d.get("label"), "pripona": _pripona(d["url"]),
})
# ---------------------------------------------------------------- hlavní ------
def main():
p = argparse.ArgumentParser(description="Stáhne obsah kurzů z euni.cz.")
p.add_argument("--professions", default="2",
help="ID profesí oddělené čárkou (2=Lékař,4=Farmaceut,7=NLZP), nebo 'all'")
p.add_argument("--scrape-only", action="store_true", help="jen inventura do JSON")
p.add_argument("--from-json", action="store_true",
help="přeskočí scrape, použije existující euni_kurzy.json")
p.add_argument("--no-videos", action="store_true", help="nestahovat videa")
p.add_argument("--no-docs", action="store_true", help="nestahovat dokumenty")
p.add_argument("--video-format", default="bestvideo*+bestaudio/best",
help="yt-dlp formát videa (např. \"bestvideo[height<=720]+bestaudio/best\")")
p.add_argument("--frags", type=int, default=10,
help="počet paralelně stahovaných HLS fragmentů videa (default 10)")
p.add_argument("--limit", type=int, default=0, help="jen prvních N kurzů (test)")
p.add_argument("--out", default=str(SKRIPT_DIR / "stazeno"), help="výstupní adresář")
p.add_argument("--json", default=str(SKRIPT_DIR / "euni_kurzy.json"),
help="cesta k inventurnímu JSON")
p.add_argument("--no-mongo", action="store_true",
help="nezapisovat do MongoDB (jen JSON / stahování)")
p.add_argument("--no-seaweed", action="store_true",
help="nenahrávat kopie do SeaweedFS")
p.add_argument("--seaweed-backfill", action="store_true",
help="jen dohraje do SeaweedFS stažené soubory, které tam chybí")
a = p.parse_args()
json_path = Path(a.json)
out_root = Path(a.out)
s = make_session()
db = None
if not a.no_mongo:
if edb is None:
print("UPOZORNĚNÍ: modul euni_db nedostupný — pokračuji bez Mongo.")
else:
try:
db = edb.ensure_indexes()
print(f"✓ Mongo EUNI připojeno ({edb.MONGO_URI})")
except Exception as e:
print(f"UPOZORNĚNÍ: Mongo nedostupné ({e}) — pokračuji bez něj.")
use_seaweed = not a.no_seaweed and sw is not None
if use_seaweed:
if sw.ping():
print(f"✓ SeaweedFS filer dostupný ({sw.FILER})")
else:
print(f"UPOZORNĚNÍ: SeaweedFS filer nedostupný ({sw.FILER}) — "
f"pokračuji bez záloh.")
use_seaweed = False
# režim: jen dohrát do SeaweedFS chybějící stažené soubory
if a.seaweed_backfill:
if db is None or not use_seaweed:
sys.exit("Backfill potřebuje Mongo i SeaweedFS.")
chybi = edb.materialy_bez_seaweed(db)
print(f"Backfill do SeaweedFS: {len(chybi)} souborů")
ok = 0
for m in chybi:
dest = SKRIPT_DIR / m["soubor"]
if not dest.exists():
continue
remote = _seaweed_path(dest, out_root)
info = _zaloh_do_seaweed(db, dest, out_root, m["kurz_id"], m["klic"])
if info:
ok += 1
print(f" [SEAWEED] {remote}")
print(f"Hotovo: {ok}/{len(chybi)} nahráno.")
return
if a.from_json:
if not json_path.exists():
sys.exit(f"JSON {json_path} neexistuje — spusť nejdřív bez --from-json.")
results = json.loads(json_path.read_text(encoding="utf-8"))
print(f"✓ Načteno z JSON: {len(results)} kurzů")
login(s) # přihlášení potřeba pro stahování dokumentů
else:
login(s)
if a.professions.lower() == "all":
profs = [2, 4, 5, 6, 7]
else:
profs = [int(x) for x in a.professions.split(",") if x.strip()]
print(f"Sbírám kurzy (profese {profs})…")
courses = get_all_courses(s, profs)
print(f"✓ Nalezeno kurzů: {len(courses)}")
if a.limit:
courses = courses[: a.limit]
print(f" (--limit: zpracuji jen prvních {len(courses)})")
results = []
for i, c in enumerate(courses, 1):
try:
links = extract_course_links(s, c["url"])
except Exception as e:
links = {"videos": [], "documents": [], "error": str(e)}
course = {**c, **links}
results.append(course)
if db is not None and "error" not in links:
try:
_ingest_course(db, course)
except Exception as e:
print(f" [MONGO-CHYBA] {c['id']}: {e}")
print(f"[{i}/{len(courses)}] {c['title']}"
f"{len(links.get('videos', []))} videí, "
f"{len(links.get('documents', []))} dokumentů")
time.sleep(0.35)
json_path.write_text(
json.dumps(results, ensure_ascii=False, indent=2, default=str),
encoding="utf-8")
print(f"✓ Inventura uložena: {json_path}")
# souhrn inventury
n_vid = sum(len(c.get("videos", [])) for c in results)
n_doc = sum(len(c.get("documents", [])) for c in results)
print(f"\nCelkem: {len(results)} kurzů, {n_vid} videí, {n_doc} dokumentů")
if a.scrape_only:
return
# stahování
if a.limit:
results = results[: a.limit]
stat = {"doc_ok": 0, "doc_skip": 0, "doc_err": 0,
"vid_ok": 0, "vid_skip": 0, "vid_err": 0, "sw_ok": 0}
for i, c in enumerate(results, 1):
folder = out_root / bezpecny_nazev(f"{c['id']}-{c.get('slug', '')}", 80)
print(f"\n[{i}/{len(results)}] {c.get('title', c['id'])}")
if not a.no_docs:
for d in c.get("documents", []):
klic = material_klic("dokument", d)[0]
try:
stav, name = stahni_dokument(s, d["url"], folder / "dokumenty",
d.get("label", ""))
dest = folder / "dokumenty" / name
if stav == "staženo":
stat["doc_ok"] += 1
print(f" [DOK] {name}")
else:
stat["doc_skip"] += 1
if db is not None:
sz = dest.stat().st_size if dest.exists() else None
edb.set_status(db, c["id"], klic, edb.STAZENO,
soubor=_relpath(dest), velikost_b=sz)
if use_seaweed and dest.exists():
if _zaloh_do_seaweed(db, dest, out_root, c["id"], klic):
stat["sw_ok"] += 1
except Exception as e:
stat["doc_err"] += 1
print(f" [DOK-CHYBA] {d['url']} ({e})")
if db is not None:
edb.set_status(db, c["id"], klic, edb.CHYBA, chyba=str(e))
if not a.no_videos:
for v in c.get("videos", []):
klic = material_klic("video", v)[0]
stav, info, fp = stahni_video(v["embed"], folder / "videa", c["url"],
fmt=a.video_format, frags=a.frags)
if stav == "staženo":
stat["vid_ok"] += 1
print(f" [VIDEO] {info}")
if db is not None:
sz = (Path(fp).stat().st_size
if fp and Path(fp).exists() else None)
edb.set_status(db, c["id"], klic, edb.STAZENO,
soubor=_relpath(fp) if fp else None,
velikost_b=sz)
if use_seaweed and fp and Path(fp).exists():
if _zaloh_do_seaweed(db, fp, out_root, c["id"], klic):
stat["sw_ok"] += 1
elif stav == "přeskočeno":
stat["vid_skip"] += 1
print(f" [VIDEO-PŘESKOČENO] {info}")
if db is not None:
edb.set_status(db, c["id"], klic, edb.PRESKOCENO, duvod=info)
else:
stat["vid_err"] += 1
print(f" [VIDEO-CHYBA] {info}")
if db is not None:
edb.set_status(db, c["id"], klic, edb.CHYBA, chyba=info)
print("\n=== SOUHRN STAHOVÁNÍ ===")
print(f" dokumenty: {stat['doc_ok']} staženo, {stat['doc_skip']} přeskočeno, "
f"{stat['doc_err']} chyb")
print(f" videa: {stat['vid_ok']} staženo, {stat['vid_skip']} přeskočeno "
f"(soukromá/nedostupná), {stat['vid_err']} chyb")
if not a.no_seaweed:
print(f" SeaweedFS: {stat['sw_ok']} souborů zazálohováno")
print(f" výstup: {out_root}")
if __name__ == "__main__":
main()
@@ -0,0 +1,87 @@
# VZP (111) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuVZP.py` (Playwright + Chrome):
1. **Přihlásí se** certifikátem na VZP Point (auto-výběr cert z Windows store)
2. Projde **ODESLANÁ PODÁNÍ** (řazeno od nejnovějšího) a najde podání typu
„Seznam registrovaných pojištěnců"
3. Stahuje **přiložené datové dávky** `F111MMRR.nnn` (CP852) do
`…\Zúčtovací zprávy\SeznamyPojištěnců\` od nejnovějšího a **zastaví se na první
už stažené dávce** (inkrementálně — starší jsou stažené, nejde hluboko do minulosti).
4. **Podá novou žádost** o výpis (datové rozhraní) za nejnovější dostupné období
(zjištěno z configu) — výsledek dorazí do ODESLANÝCH PODÁNÍ a stáhne se příště.
Dávky pak zpracovává `Insurance/SeznamPojistencu/01_parse_seznam_dg_tool.py`.
## Platforma — ODLIŠNÁ
VZP běží na **point.vzp.cz** (VZP Point), NE portalzp.cz ani eforms. Login je
certifikátem přes Chrome — politika `AutoSelectCertificateForUrls` vybere cert
automaticky (issuer `I.CA Public CA/RSA 06/2022`), bez NMSigneru. Plně Playwright.
## Jak se seznam získává
VZP seznam **není** samočinná zpráva — musí se **požádat podáním**:
- NOVÉ PODÁNÍ → „Seznam registrovaných pojištěnců ke dni"
- **Formát výstupu = „Datové rozhraní"** (NE „PDF"!) + období (měsíc/rok)
- VZP požadavek zpracuje (~minuty) a výsledek = datová dávka III-1.1.2,
stažitelná z detailu zpracovaného podání (sloupec „Přiložený soubor").
> Pozn.: pokud se zvolí formát „PDF", výsledkem je PDF (p…pdf), které parser neumí.
> Vždy volit „Datové rozhraní".
## Formát dávky (III-1.1.2)
Soubor `F111MMRR.nnn`, pevná šířka, **CP852**. Hlavička typ H:
`H09305001` (IČP) + počet + RRMMDD. Věty typu I: příjmení, jméno, číslo poj.,
datum registrace, kód pojišťovny. (Detaily v `SeznamPojistencu/01_parse_seznam_dg_tool.py`.)
## Stažení dávky z detailu podání
Detail `/Desk/Form/Detail/{id}` → záložka „Výsledky zpracování" → odkaz s názvem
`F111MMRR.nnn` (href="#", JS handler). Stahuje se Playwright klikem
(`expect_download` + `dispatch_event('click')`) — žádná přímá URL.
## Podání žádosti (REST API — bez podpisu!)
Podání jde čistě přes REST API Pointu (Bearer token z inline `"bearerToken"` na dashboardu),
**žádný elektronický podpis** — autentizace stačí přes session + token. Tři kroky:
1. **Config** (zjištění období): `GET /api/desk/draft/form65/config`
`periodLimits {from, until}` + `defaultModel.period {month, year}`.
Podává se za **nejnovější dostupné období** (`until` / `defaultModel`), ne za kalendářní
měsíc (ten portál odmítne — HTTP 400 při publish).
2. **Vytvoř koncept**: `POST /api/desk/draft/form65/{partnerId}`
body `{"outputFormat":"Text","period":{"month":M,"year":Y}}``{"draftId":"...","state":"Verified"}`
- `outputFormat:"Text"` = **Datové rozhraní** (NE "Pdf"!)
- partnerId = `3197807` (subjekt MUDr. Buzalková)
3. **Publikuj**: `POST /api/desk/draft/form65/{draftId}/publish` (prázdné tělo)
`{"formId": <id odeslaného podání>}`
Token se čte stejně jako v `StahováníZpráv/111 VZP/stahovanipodani.py`.
### Jak bylo zjištěno
Formulář Form65 je React SPA s custom comboboxem, který nešel proklikat headless ani
naslepo. Odchyceno tak, že uživatel podal jedno podání ručně a do stránky byl vložen
háček ukládající fetch/XHR do `localStorage` (přežije přesměrování) — z toho se vyčetly
přesné endpointy a payloady.
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuVZP.py` | Login + stažení datových dávek z podání |
## Parametry
- **IČP**: 09305001, **IČZ**: 09305000 (MUDr. Michaela Buzalková)
- **Login**: certifikát ve Windows store (sdílený profil `StahováníZpráv/111 VZP/chrome_profile`)
## Stav
Hotovo a otestováno (17.06.2026): login ✓, backfill 23 dávek `F111….0NN` (všechny `H09305001`),
inkrementální běh zastaví na první už stažené dávce ✓, **podání žádosti přes REST API ✓**
(auto období z configu = 04/2026, create+publish → formId). Download i podání plně automatické.
@@ -0,0 +1,322 @@
"""
Stahování seznamu registrovaných pojištěnců VZP (111) — VZP Point (Playwright).
VZP běží na ODLIŠNÉ platformě (point.vzp.cz) — ne portalzp.cz, ne eforms:
- login: certifikát přes Chrome (auto-výběr z Windows store, politika
AutoSelectCertificateForUrls), Playwright. Bez NMSigneru.
- seznam: požaduje se podáním "Seznam registrovaných pojištěnců" s formátem
výstupu "Datové rozhraní". Výsledek = datová dávka III-1.1.2
(soubor F111MMRR.nnn, CP852, hlavička H09305001), stažitelná
z detailu zpracovaného podání.
Tento skript STAHUJE výsledky už zpracovaných podání "Seznam registrovaných
pojištěnců" (datová dávka) do složky SeznamyPojištěnců.
Podání žádosti (NOVÉ PODÁNÍ) zatím dělá uživatel ručně na portálu — viz NOTES.md.
Soubory dávek pak zpracovává Insurance/SeznamPojistencu/01_parse_seznam_dg_tool.py.
"""
import json
import os
import re
import sys
import time
import winreg
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
POINT_URL = "https://point.vzp.cz"
DASHBOARD_URL = f"{POINT_URL}/Desk/FormDashboard"
INBOX_URL = f"{POINT_URL}/Inbox/Message"
# Sdílené s VZP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "111 VZP"))
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
COOKIES_FILE = os.path.join(STAHUJ_DIR, "vzp_cookies.json")
DEST_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
CERT_ISSUER_CN = "I.CA Public CA/RSA 06/2022"
# Název podání i přílohy
PODANI_NAZEV = "Seznam registrovaných pojištěnců"
DAVKA_RE = re.compile(r"^F\d{7}\.\d+$") # F111MMRR.nnn
# Podání žádosti (REST API, ověřeno odchytem)
PARTNER_ID = "3197807" # subjekt MUDr. Buzalková (partnerId z formuláře Form65)
OUTPUT_FORMAT = "Text" # "Text" = Datové rozhraní (NE "Pdf"!)
# Období podávané žádosti se zjistí automaticky z configu (nejnovější dostupné, viz
# config.defaultModel / periodLimits.until). Pro ruční přepsání nastav OVERRIDE_OBDOBI
# na (měsíc, rok), jinak ponech None.
OVERRIDE_OBDOBI: tuple[int, int] | None = None
# Kolikrát max. kliknout 'Načíst další' při hledání podání (dashboard míchá typy).
# Stahování se stejně zastaví na první už stažené dávce, takže do minulosti nejde hluboko.
MAX_LOADS = 8
def _set_chrome_cert_policy() -> None:
policy = json.dumps({"pattern": "https://[*.]vzp.cz",
"filter": {"ISSUER": {"CN": CERT_ISSUER_CN}}})
try:
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER,
r"SOFTWARE\Policies\Google\Chrome\AutoSelectCertificateForUrls")
winreg.SetValueEx(key, "1", 0, winreg.REG_SZ, policy)
winreg.CloseKey(key)
except Exception as e:
print(f" Varování: nelze nastavit Chrome politiku: {e}")
def _load_cookies(context) -> int:
if not os.path.exists(COOKIES_FILE):
return 0
try:
with open(COOKIES_FILE, encoding="utf-8") as f:
context.add_cookies(json.load(f))
return 1
except Exception:
return 0
def _save_cookies(context) -> None:
try:
vzp = [c for c in context.cookies() if "vzp.cz" in c.get("domain", "")]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(vzp, f, indent=2, ensure_ascii=False)
except Exception:
pass
def prihlaseni(context):
"""Zajistí přihlášení na VZP Point. Vrátí přihlášenou page."""
_load_cookies(context)
page = context.new_page()
page.goto(DASHBOARD_URL, wait_until="domcontentloaded", timeout=30_000)
if page.url.startswith("https://auth.vzp.cz/signin"):
print("Přihlašuji certifikátem...")
cert_btn = page.locator("a, button").filter(has_text=re.compile(r"certifikát", re.I)).first
cert_btn.wait_for(state="visible", timeout=10_000)
cert_btn.click(no_wait_after=True)
try:
page.wait_for_url("https://point.vzp.cz/**", timeout=60_000)
except Exception:
pass
if not page.url.startswith(POINT_URL):
raise RuntimeError(f"Přihlášení selhalo. URL: {page.url}")
print("Přihlášení OK.")
_save_cookies(context)
return page
def _bearer_token(page) -> str:
"""Vytáhne Bearer token z inline <script> na stránce VZP Point."""
scripts = page.evaluate(
"() => Array.from(document.querySelectorAll('script:not([src])')).map(s => s.textContent)"
)
for text in scripts:
m = re.search(r'"bearerToken"\s*:\s*"([^"]+)"', text)
if m:
return m.group(1)
raise RuntimeError("bearerToken nenalezen na stránce")
def zjisti_obdobi(page) -> tuple[int, int]:
"""Vrátí nejnovější dostupné období (měsíc, rok) z configu formuláře Form65."""
token = _bearer_token(page)
cfg = page.evaluate(
"""async (token) => {
const r = await fetch('/api/desk/draft/form65/config',
{headers:{'Authorization':'Bearer '+token, 'Accept':'application/json'}});
return await r.json();
}""",
token,
)
period = (cfg.get("defaultModel") or {}).get("period") \
or (cfg.get("periodLimits") or {}).get("until") or {}
return int(period["month"]), int(period["year"])
def podej_zadost(page, mesic: int, rok: int) -> int | None:
"""Podá žádost 'Seznam registrovaných pojištěnců' (datové rozhraní) za období mesic/rok.
Vytvoří koncept (POST .../form65/{partnerId}) a publikuje ho
(POST .../form65/{draftId}/publish). Vrátí formId odeslaného podání nebo None.
"""
token = _bearer_token(page)
res = page.evaluate(
"""async ({token, partner, fmt, mesic, rok}) => {
const h = {'Authorization':'Bearer '+token,
'Content-Type':'application/json', 'Accept':'application/json'};
const r1 = await fetch('/api/desk/draft/form65/'+partner, {
method:'POST', headers:h,
body: JSON.stringify({outputFormat: fmt, period: {month: mesic, year: rok}})});
let j1=null; try { j1 = await r1.json(); } catch(e){}
if (!r1.ok || !j1 || !j1.draftId)
return {ok:false, step:'create', status:r1.status, body: JSON.stringify(j1)};
const r2 = await fetch('/api/desk/draft/form65/'+j1.draftId+'/publish', {
method:'POST', headers:h});
let j2=null; try { j2 = await r2.json(); } catch(e){}
return {ok: r2.ok, step:'publish', status:r2.status,
formId: j2 && j2.formId, state: j1.state};
}""",
{"token": token, "partner": PARTNER_ID, "fmt": OUTPUT_FORMAT, "mesic": mesic, "rok": rok},
)
if res.get("ok"):
print(f" OK — podání odesláno, formId: {res.get('formId')} (stav konceptu: {res.get('state')})")
return res.get("formId")
print(f" Podání selhalo ({res.get('step')}, HTTP {res.get('status')}): {res.get('body','')[:200]}")
return None
def _seznam_podani_v_dom(page) -> list[dict]:
"""Vrátí podání 'Seznam registrovaných pojištěnců' aktuálně načtená v DOMu (pořadí = nejnovější první)."""
podani = page.evaluate(r"""() => {
return Array.from(document.querySelectorAll('a[href*="/Desk/Form/Detail/"]'))
.map(a => ({ text: (a.innerText || a.title || '').replace(/\s+/g, ' ').trim(),
href: a.getAttribute('href') }))
.filter(x => /Seznam registrovaných pojištěnců/i.test(x.text));
}""")
seen, out = set(), []
for p in podani:
if p["href"] in seen:
continue
seen.add(p["href"])
out.append(p)
return out
def _nacti_dalsi(page) -> bool:
"""Klikne 'Načíst další záznamy'. Vrátí True pokud tlačítko existovalo."""
clicked = page.evaluate("""() => {
const a = Array.from(document.querySelectorAll('a,button'))
.find(e => /Načíst další/i.test(e.innerText || ''));
if (a) { a.scrollIntoView(); a.click(); return true; }
return false;
}""")
if clicked:
page.wait_for_timeout(1500)
return clicked
def stahni_davku_z_podani(page, href: str, already: set) -> tuple[int, bool]:
"""Otevře detail podání a stáhne přiloženou datovou dávku (F...).
Vrátí (počet_stažených, narazil_na_uz_stazenou). Druhý příznak je True, pokud
má podání dávku, kterou už máme v archivu — signál, že jsme dorazili do už
stažené minulosti a stahování lze ukončit.
"""
page.goto(POINT_URL + href, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2000)
fnames = page.evaluate(r"""() => Array.from(document.querySelectorAll('a'))
.map(a => (a.innerText || '').trim())
.filter(t => /^F\d{7}\.\d+$/.test(t))""")
fnames = list(dict.fromkeys(fnames))
downloaded = 0
hit_existing = False
for fname in fnames:
if fname in already or os.path.exists(os.path.join(DEST_DIR, fname)):
print(f" [stop] dávka už stažena: {fname}")
hit_existing = True
continue
link = page.locator("a", has_text=fname).first
try:
with page.expect_download(timeout=30_000) as di:
link.dispatch_event("click")
body = di.value
target = os.path.join(DEST_DIR, fname)
body.save_as(target)
with open(target, "rb") as fh:
head = fh.read(9)
if not head.decode("cp852", errors="ignore").startswith("H09305001"):
print(f" POZOR: {fname} nemá hlavičku H09305001 (přesto uloženo)")
print(f" OK: {fname}")
already.add(fname)
downloaded += 1
time.sleep(0.5)
except Exception as e:
print(f" Chyba při stahování {fname}: {e}")
return downloaded, hit_existing
def hlavni() -> None:
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
sys.exit(1)
os.makedirs(DEST_DIR, exist_ok=True)
_set_chrome_cert_policy()
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
accept_downloads=True,
args=["--force-renderer-accessibility"],
)
try:
page = prihlaseni(context)
already = set(os.listdir(DEST_DIR))
print(f"V archivu: {len(already)} souborů.\n")
# Nasbírej podání 'Seznam...' — ODESLANÁ PODÁNÍ řadí od nejnovějšího.
# Dashboard míchá typy podání, proto je potřeba pár 'Načíst další'.
page.goto(DASHBOARD_URL, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2500)
for _ in range(MAX_LOADS):
if not _nacti_dalsi(page):
break
podani = _seznam_podani_v_dom(page)
print(f"Nalezeno podání '{PODANI_NAZEV}': {len(podani)}\n")
# Stahuj od nejnovějšího; jakmile narazíš na už staženou dávku, skonči
# (starší jsou všechny stažené — není třeba jít hlouběji do minulosti).
celkem = 0
for pdn in podani:
print(f"Podání: {pdn['text']}")
dl, hit_existing = stahni_davku_z_podani(page, pdn["href"], already)
celkem += dl
if hit_existing:
print("Dosaženo už stažené dávky — končím (starší jsou stažené).")
break
print(f"\nStaženo nových dávek: {celkem}")
# Podání žádosti o nový výpis (datové rozhraní) za zvolené období.
# Výsledek dorazí do ODESLANÝCH PODÁNÍ a stáhne se při příštím spuštění.
page.goto(DASHBOARD_URL, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2000)
mesic, rok = OVERRIDE_OBDOBI if OVERRIDE_OBDOBI else zjisti_obdobi(page)
print(f"\n=== Podávám žádost za období {mesic:02d}/{rok} ===")
podej_zadost(page, mesic, rok)
print("\nHotovo.")
finally:
_save_cookies(context)
context.close()
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,87 @@
# VoZP (201) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuVoZP.py` provede v jednom spuštění:
1. **Přihlásí se** certifikátem na portál VoZP (čistý Python, bez NMSigneru)
— uloží cookies do sdíleného `StahováníZpráv/201 VoZP/vozp_cookies.json`
2. **Stáhne nové výpisy** ze schránky `vypis-registrovanych-pacientu-praktickeho-lekare`
— stahuje soubory s hlavičkou `H09305001` (PDF protokoly se přeskočí)
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
3. **Podá žádost** o aktuální výpis (datové rozhraní)
## Platforma
VoZP běží na stejné platformě jako **ZPŠ, OZP, RBP** (portalzp.cz / json-api).
Login identický, jen `BASE_URL = https://portal.vozp.cz`.
## Schránka a stažení
Schránka má **vlastní URL** (ne `schranky-vypis-pojistencu-v-kapitaci` jako OZP/RBP):
`/app/vypis-registrovanych-pacientu-praktickeho-lekare`
Stažení přílohy: GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
`fileId` z `onclick="SchrPolOpenFile(<id>)"`. Datové soubory `f201MMRR.001`, hlavička `H09305001`.
Ve schránce bývá i PDF protokol — header checkem se přeskočí.
## Podání žádosti
Formulář `106-zadost-o-vypis` je **nejjednodušší** — jen IČZ + Třídění, žádné datum ani typ.
Výpis je aktuální snímek registrovaných pacientů. Pro datový soubor se volí třídění = `d`
(Datové rozhraní). Žádný stav.json.
POST `https://portal.vozp.cz/json-api/formular-schranky/106-zadost-o-vypis/ulozit-formular`
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
### XML žádosti (řádky `\r\n`)
```xml
<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">
<PolozkaFiltru Nazev="nicoz">-109305000</PolozkaFiltru>
<PolozkaFiltru Nazev="trideni">d</PolozkaFiltru>
</SchrankaZadost>
```
| Položka | Hodnota | Význam |
|---------|---------|--------|
| `nicoz` | `-109305000` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). Pozor: záporné! Ověřeno. |
| `trideni` | `d` | `p`=příjmení, `i`=IČP+příjmení, `r`=rodná čísla, **`d`=Datové rozhraní** (datový soubor) |
### Podpis XML
PKCS7/SHA-256, **bez** certifikátu (`NoCerts`) — stejně jako ZPŠ/OZP/RBP.
## Jak byly endpointy zjištěny
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy + odchycený XHR na
`ulozit-formular`. První ostré podání: **ref. 179776197** (17.06.2026).
## Srovnání platformy portalzp.cz
| | ZPŠ (209) | OZP (207) | RBP (213) | VoZP (201) |
|--|-----------|-----------|-----------|------------|
| Schránka | schranka-vypis-… | schranky-vypis-… | schranky-vypis-… | vypis-registrovanych-pacientu-… |
| Formulář | 29-… | 108-… | 110-… | 106-… |
| Schránka/filtr | VypisPojKap / ZZ_VYP_REG | SEZNAM_KAP | VypisPojKap / ZZ_VYP_REG | SEZNAM_KAP |
| IČZ položka | icz=25520 | nicoz=13074913 | icz=933189 | nicoz=-109305000 |
| datum | poslední den měsíce | — | Ke dni (dnešek) | — |
| typ/trideni | razeni+typ=soubor | trideni=p+typ=soubor | razeni+typ=soubor | trideni=d (Datové rozhraní) |
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuVoZP.py` | Hlavní skript — stažení výpisů + podání žádosti |
| `log_podani.json` | Historie podání s referenčními čísly |
## Parametry
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `-109305000`
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
## Stav
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (3 datové soubory, PDF přeskočeno),
podání ✓ (ref. 179776197). Výpis z prvního podání dorazí do schránky.
@@ -0,0 +1,408 @@
"""
Stahování seznamu registrovaných pojištěnců VoZP (201) — čistý Python, bez NMSigneru.
VoZP běží na stejné platformě jako ZPŠ/OZP/RBP (portalzp.cz / json-api), s rozdíly:
- schránka: /app/vypis-registrovanych-pacientu-praktickeho-lekare
- formulář: 106-zadost-o-vypis
- filtr XML: NazevSchranky = NazevFiltru = "SEZNAM_KAP" (jako OZP)
- položky: nicoz (interní ID = -109305000), trideni (p/i/r/d)
trideni="d" = Datové rozhraní → datový soubor f201MMRR.001
- BEZ pole "datum" a BEZ pole "typ" — výpis je aktuální snímek registrovaných pacientů.
Co skript dělá v jednom spuštění:
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
2. Stáhne nové výpisy ze schránky (soubory s hlavičkou H09305001)
3. Znovu se přihlásí (Playwright invaliduje requests session)
4. Podá jednu žádost o aktuální výpis (datové rozhraní)
Log podání: log_podani.json — seznam { ref_cislo, podano_kdy }
"""
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++"
BASE_URL = "https://portal.vozp.cz"
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/106-zadost-o-vypis/ulozit-formular"
VYPIS_URL = f"{BASE_URL}/app/vypis-registrovanych-pacientu-praktickeho-lekare"
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
ICZ_INTERNAL = "-109305000" # IČZ 09305000 — interní ID položky "nicoz"
TRIDENI = "d" # p=příjmení, i=IČP+příjmení, r=rodná čísla, d=Datové rozhraní
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
HLAVICKA = "H09305001"
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
# Sdílené soubory s VoZP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "201 VoZP"))
COOKIES_FILE = os.path.join(STAHUJ_DIR, "vozp_cookies.json")
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
DOWNLOAD_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
# ---------------------------------------------------------------------------
# Přihlášení
# ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Origin": BASE_URL,
"Referer": BASE_URL + "/",
})
r = session.get(f"{BASE_URL}/app/prihlaseni")
r.raise_for_status()
session.cookies.set("pzp_sign", "CERT", domain="portal.vozp.cz", path="/")
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
zprava = r.json()["data"]["zprava"]
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
podpis = (
pkcs7.PKCS7SignatureBuilder()
.set_data(zprava.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
.decode("ascii").strip()
)
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
data = r.json()["data"]
if not data.get("prihlasen"):
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!")
cookies = [
{
"name": c.name,
"value": c.value,
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
"path": c.path or "/",
"expires": int(c.expires) if c.expires else -1,
"secure": bool(c.secure),
"httpOnly": False,
"sameSite": "Lax",
}
for c in session.cookies
]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, indent=2, ensure_ascii=False)
return session
# ---------------------------------------------------------------------------
# Stahování z výpisové schránky
# ---------------------------------------------------------------------------
def safe_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def parse_date(date_str: str) -> str:
try:
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
except Exception:
try:
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
return "0000-00-00"
def parse_row(cells: list) -> dict:
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
date_raw = cells[1].strip() if len(cells) > 1 else ""
desc_raw = cells[2].strip() if len(cells) > 2 else ""
fname_raw = cells[3].strip() if len(cells) > 3 else ""
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
if len(desc_lines) >= 3:
description = desc_lines[2]
elif len(desc_lines) >= 2:
description = desc_lines[1]
else:
description = desc_lines[0] if desc_lines else ""
description = description[:80]
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
orig_path = Path(original)
stem = orig_path.stem or "zprava"
ext = orig_path.suffix or ""
date_iso = parse_date(date_raw)
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
if len(name) > 240:
name = f"{date_iso} ({safe_filename(stem)}){ext}"
return {"date": date_iso, "desc": description, "original": original, "filename": name}
def stahni_nove_vypisy() -> int:
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
return 0
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
with open(COOKIES_FILE, encoding="utf-8") as f:
cookies = json.load(f)
downloaded = 0
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
)
try:
context.add_cookies(cookies)
page = context.new_page()
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
if "prihlaseni" in page.url or "login" in page.url.lower():
print("Session v prohlížeči expirovala — stahování přeskočeno")
return 0
print("Prohlížeč přihlášen OK\n")
already = set(os.listdir(DOWNLOAD_DIR))
print(f"V archivu: {len(already)} souborů.\n")
page_num = 1
seen_ids: set = set()
while True:
url = f"{VYPIS_URL}/stranka-{page_num}"
print(f" Stránka {page_num}: {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f" Navigace selhala: {e}")
break
page.wait_for_load_state("networkidle", timeout=15_000)
data = page.evaluate("""() => {
const rows = [];
for (const tr of document.querySelectorAll('table tr')) {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
if (cells.length < 4) continue;
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
if (!dlLink) continue;
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
rows.push({ cells, fileId: mFile ? mFile[0] : null });
}
return rows;
}""")
rows = [r for r in data if r["fileId"]]
if not rows:
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
break
current_ids = {r["fileId"] for r in rows}
if current_ids & seen_ids:
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
break
seen_ids.update(current_ids)
print(f" Nalezeno {len(rows)} zpráv.")
stop = False
for row in rows:
info = parse_row(row["cells"])
target = os.path.join(DOWNLOAD_DIR, info["filename"])
if info["filename"] in already or os.path.exists(target):
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
stop = True
break
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
try:
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
if not r.ok:
print(f" HTTP {r.status} příloha (id={row['fileId']})")
else:
body = r.body()
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
else:
with open(target, "wb") as fh:
fh.write(body)
print(f" OK: {info['filename']}")
already.add(info["filename"])
downloaded += 1
except Exception as e:
print(f" Chyba příloha (id={row['fileId']}): {e}")
time.sleep(1.0)
if stop:
break
page_num += 1
finally:
context.close()
return downloaded
# ---------------------------------------------------------------------------
# Sestavení XML a podpis žádosti
# ---------------------------------------------------------------------------
def build_xml() -> str:
"""Sestaví XML žádosti o aktuální výpis registrovaných pacientů (datové rozhraní)."""
return (
f'<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">\r\n'
f'<PolozkaFiltru Nazev="nicoz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="trideni">{TRIDENI}</PolozkaFiltru>\r\n'
f'</SchrankaZadost>'
)
def sign_xml(xml: str) -> str:
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
pem = (
pkcs7.PKCS7SignatureBuilder()
.set_data(xml.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii")
)
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
def odeslat_zadost(session: requests.Session) -> str | None:
"""Odešle podepsanou žádost o aktuální výpis. Vrátí referenční číslo nebo None."""
xml = build_xml()
podpis = sign_xml(xml)
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
r = session.post(SUBMIT_URL, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE_URL + "/",
})
r.raise_for_status()
try:
resp = r.json()
except Exception:
print(f" Odpověď není JSON: {r.text[:300]}")
return None
resp_str = json.dumps(resp, ensure_ascii=False)
if resp.get("errMsg") or resp.get("error"):
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
return None
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
ref = m.group(1) if m else None
if ref:
print(f" OK — ref. číslo: {ref}")
else:
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
return ref or ("OK" if r.ok else None)
# ---------------------------------------------------------------------------
# Log
# ---------------------------------------------------------------------------
def uloz_log(ref_cislo: str) -> None:
log = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, encoding="utf-8") as f:
log = json.load(f)
log.append({
"ref_cislo": ref_cislo,
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, indent=2, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Hlavní funkce
# ---------------------------------------------------------------------------
def hlavni() -> None:
# 1. Přihlášení — uloží cookies pro Playwright
prihlaseni()
# 2. Stažení nových výpisů z výpisové schránky
print("\n=== Stahování nových výpisů ===")
stazeno = stahni_nove_vypisy()
print(f"Staženo: {stazeno} souborů.\n")
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
print("=== Znovu přihlašuji před podáním ===")
session = prihlaseni()
# 4. Podání žádosti o aktuální výpis
print("=== Podávám žádost o aktuální výpis (datové rozhraní) ===")
ref = odeslat_zadost(session)
if ref:
uloz_log(ref)
print(f"\nHotovo — žádost podána, ref: {ref}")
else:
print("\nPodání selhalo — žádost nebyla zaevidována.")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,6 @@
[
{
"ref_cislo": "179776533",
"podano_kdy": "2026-06-17 05:48:36"
}
]
@@ -0,0 +1,64 @@
# ZPMVČR (211) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuZPMVCR.py` (čistý Python, requests + bs4):
1. **Přihlásí se** PIN + heslem (POST formulář, bez certifikátu / NMSigneru)
2. **Projde stránkovaný přehled** všech registrací pro IČP 09305001
3. **Uloží CSV** do `…\Zúčtovací zprávy\SeznamyPojištěnců\`
## Platforma — ODLIŠNÁ od ostatních
ZPMVČR běží na **eforms.zpmvcr.cz**, NE na portalzp.cz. Žádné certifikáty, žádné schránky,
žádné datové rozhraní .001. Login je PIN + heslo.
## Zásadní rozdíl: NENÍ datový soubor
Ostatní pojišťovny dávají datový soubor (.001 / F-soubor). ZPMVČR **nemá** ekvivalent:
- EP2 sekce (`dokumenty_ke_stazeni/ep2`) je prázdná — *"nebylo stahování dokumentů nastaveno"*.
- Jediný zdroj seznamu je **HTML přehled** na stránce `registrovani_pojistenci`,
který se musí naparsovat → proto výstupem je **CSV**, ne datový soubor.
## Přihlášení
POST `https://eforms.zpmvcr.cz/eforms/ekomunikace`
Pole: `pin` (9023895287), `pin2` (prázdné), `pwd` (heslo).
## Stažení seznamu
POST `https://eforms.zpmvcr.cz/eforms/smluvni_zdravotnicke_zarizeni/registrovani_pojistenci`
| Pole | Hodnota | Význam |
|------|---------|--------|
| `icp` | `09305001` | IČP (nebo "Vše") |
| `arztart` | `` (prázdné = Vše) | odbornost D/G/P/S |
| `mesic` / `rok` | aktuální měsíc/rok | období |
| `registrace` | `3` | 1=platné, 2=neplatné, **3=všechny** |
| `tridit` | `1` | 1=příjmení, 2=číslo pojištěnce |
| `vyhledat` | `Vyhledat` | submit |
Výsledek je **stránkovaný** (~20 řádků/strana). Další strany: POST + pole `page=N`.
Řádky v HTML: `<tr class="c1|c2">`, hodnoty za `<span class="responsiveColumn">Label:</span>`.
Hláška "Přehled ... (celkem N)" udává očekávaný počet (kontrola úplnosti).
## CSV výstup
Soubor `YYYY-MM-DD 211 ZPMVČR vsechny registrace.csv`, kódování utf-8-sig (Excel),
oddělovač `;`. Sloupce: Číslo pojištěnce; Titul; Příjmení; Jméno; Registrace od; Registrace do.
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuZPMVCR.py` | Hlavní skript — login + scrape přehledu → CSV |
## Parametry
- **IČP**: 09305001 (MUDr. Michaela Buzalková)
- **Login**: PIN 9023895287 + heslo (v kódu, stejně jako StahováníZpráv/211)
## Stav
Hotovo a otestováno (17.06.2026): login ✓, staženo 172 registrací (9 stran, sedí s "celkem 172"),
CSV uloženo. Volba uživatele: VŠECHNY registrace (registrace=3).
@@ -0,0 +1,174 @@
"""
Stahování seznamu registrovaných pojištěnců ZPMVČR (211) — čistý Python (requests + bs4).
ZPMVČR běží na ODLIŠNÉ platformě (eforms.zpmvcr.cz) — ne portalzp.cz:
- login: PIN + heslo (POST formulář), bez certifikátu a bez NMSigneru
- seznam: NENÍ datový soubor jako u ostatních pojišťoven (EP2 sekce je prázdná).
Jediný zdroj je HTML "Přehled registrací" na stránce registrovani_pojistenci,
který se naparsuje a uloží jako CSV.
Co skript dělá:
1. Přihlásí se (PIN + heslo)
2. Projde stránkovaný přehled VŠECH registrací (platné i neplatné) pro IČP 09305001
3. Uloží výsledek jako CSV do složky SeznamyPojištěnců (sloupce níže)
CSV sloupce: Číslo pojištěnce; Titul; Příjmení; Jméno; Registrace od; Registrace do
"""
import csv
import os
import sys
from datetime import date
import requests
from bs4 import BeautifulSoup
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
# ── Přihlašovací údaje ────────────────────────────────────────────────────────
PIN = "9023895287"
PIN2 = ""
HESLO = "Ax162q8+"
# ─────────────────────────────────────────────────────────────────────────────
BASE_URL = "https://eforms.zpmvcr.cz"
LOGIN_URL = f"{BASE_URL}/eforms/ekomunikace"
SEZNAM_URL = f"{BASE_URL}/eforms/smluvni_zdravotnicke_zarizeni/registrovani_pojistenci"
ICP = "09305001" # IČP MUDr. Michaela Buzalková
REGISTRACE = "3" # 1=platné, 2=neplatné, 3=všechny
TRIDIT = "1" # 1=příjmení, 2=číslo pojištěnce
CSV_HLAVICKA = ["Číslo pojištěnce", "Titul", "Příjmení", "Jméno", "Registrace od", "Registrace do"]
DEST_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
def prihlaseni() -> requests.Session:
"""Přihlásí se PIN + heslem, vrátí session."""
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
session.get(LOGIN_URL, timeout=15).raise_for_status()
r = session.post(LOGIN_URL, data={"pin": PIN, "pin2": PIN2, "pwd": HESLO}, timeout=15)
r.raise_for_status()
if 'name="pin"' in r.text and "Přihlásit" in r.text:
raise RuntimeError("Přihlášení selhalo — zkontroluj PIN a heslo")
print("Přihlášení úspěšné!")
return session
def parse_rows(html: str) -> list[list[str]]:
"""Naparsuje řádky přehledu. Vrátí seznam [číslo, titul, příjmení, jméno, reg_od, reg_do]."""
soup = BeautifulSoup(html, "html.parser")
rows = []
for tr in soup.select("tr.c1, tr.c2"):
vals = []
for td in tr.find_all("td"):
for sp in td.select("span.responsiveColumn"):
sp.extract()
vals.append(td.get_text(strip=True))
# platný datový řádek má vyplněné číslo pojištěnce v prvním sloupci
if len(vals) >= 6 and vals[0]:
rows.append(vals[:6])
return rows
def precti_celkem(html: str) -> int | None:
"""Z hlášky 'Přehled ... (celkem N)' získá očekávaný počet."""
import re
m = re.search(r"celkem\s+(\d+)", html)
return int(m.group(1)) if m else None
def stahni_seznam(session: requests.Session) -> list[list[str]]:
"""Projde stránkovaný přehled a vrátí všechny řádky."""
base_data = {
"icp": ICP, "arztart": "",
"mesic": str(date.today().month), "rok": str(date.today().year),
"registrace": REGISTRACE, "tridit": TRIDIT, "vyhledat": "Vyhledat",
}
vsechny: list[list[str]] = []
videno: set = set()
celkem_ocekavano = None
page = 1
while page <= 200:
data = dict(base_data)
if page > 1:
data["page"] = str(page)
r = session.post(SEZNAM_URL, data=data, timeout=30)
r.raise_for_status()
if celkem_ocekavano is None:
celkem_ocekavano = precti_celkem(r.text)
if celkem_ocekavano is not None:
print(f"Přehled hlásí celkem {celkem_ocekavano} registrací.")
rows = parse_rows(r.text)
nove = [row for row in rows if tuple(row) not in videno]
if not nove:
break
for row in nove:
videno.add(tuple(row))
vsechny.extend(nove)
print(f" Strana {page}: +{len(nove)} (celkem {len(vsechny)})")
# poslední strana — méně řádků než plná stránka
if len(rows) < 20:
break
page += 1
if celkem_ocekavano is not None and len(vsechny) != celkem_ocekavano:
print(f" POZOR: staženo {len(vsechny)}, ale přehled hlásil {celkem_ocekavano}.")
return vsechny
def uloz_csv(rows: list[list[str]]) -> str:
"""Uloží řádky jako CSV (Excel-friendly: utf-8-sig, oddělovač ;). Vrátí cestu."""
os.makedirs(DEST_DIR, exist_ok=True)
dnes = date.today().strftime("%Y-%m-%d")
filename = f"{dnes} 211 ZPMVČR vsechny registrace.csv"
path = os.path.join(DEST_DIR, filename)
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f, delimiter=";")
w.writerow(CSV_HLAVICKA)
w.writerows(rows)
return path
def hlavni() -> None:
session = prihlaseni()
print("\n=== Stahování přehledu registrací ===")
rows = stahni_seznam(session)
print(f"Staženo: {len(rows)} registrací.")
if not rows:
print("Žádné registrace — CSV se neuloží.")
return
path = uloz_csv(rows)
print(f"\nHotovo — uloženo: {path}")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,90 @@
# RBP (213) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuRBP.py` provede v jednom spuštění:
1. **Přihlásí se** certifikátem na portál RBP (čistý Python, bez NMSigneru)
— uloží cookies do sdíleného `StahováníZpráv/213 RBP/rbp_cookies.json`
2. **Stáhne nové výpisy** z výpisové schránky `schranky-vypis-pojistencu-v-kapitaci`
— stahuje soubory, jejichž obsah začíná `H09305001` (textové `odpoved.txt` se přeskočí)
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
— zastaví se při první již stažené zprávě
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
3. **Podá žádost** o výpis ke dnešnímu dni (typ=soubor, třídění dle příjmení)
## Platforma
RBP běží na stejné platformě jako **ZPŠ, OZP, VoZP** (portalzp.cz / json-api).
Login identický se ZPŠ/OZP, jen `BASE_URL = https://portal.rbp-zp.cz`.
## Stažení přílohy
GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
`fileId` z `onclick="SchrPolOpenFile(<id>)"`. Datový výpis má hlavičku `H09305001`.
## Podání žádosti
RBP je **hybrid ZPŠ/OZP**: schránka/filtr jako ZPŠ, ale `datum` je „Ke dni" (aktuální
snímek platných registrací k danému dni, default dnešní datum). Nepočítá se měsíc,
žádný stav.json — při každém běhu se podá žádost ke dni `date.today()`.
POST `https://portal.rbp-zp.cz/json-api/formular-schranky/110-vypis-pojistencu-reg-u-pzs/ulozit-formular`
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
### XML žádosti (řádky `\r\n`)
```xml
<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">
<PolozkaFiltru Nazev="icz">933189</PolozkaFiltru>
<PolozkaFiltru Nazev="datum">17.06.2026</PolozkaFiltru>
<PolozkaFiltru Nazev="razeni">jmeno</PolozkaFiltru>
<PolozkaFiltru Nazev="typ">soubor</PolozkaFiltru>
</SchrankaZadost>
```
| Položka | Hodnota | Význam |
|---------|---------|--------|
| `icz` | `933189` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). |
| `datum` | `DD.MM.YYYY` | „Ke dni" — den, ke kterému chceme snímek (použijeme dnešek). |
| `razeni` | `jmeno` | `jmeno`=příjmení a jména, `rc`=rodná čísla |
| `typ` | `soubor` | `soubor`=datový soubor netříděno, `sestava`=PDF |
### Podpis XML
PKCS7/SHA-256, **bez** certifikátu (`NoCerts`) — stejně jako ZPŠ/OZP.
## Jak byly endpointy zjištěny
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy + odchycený XHR na
`ulozit-formular`. Skrytý input datumu vypadal jako JWT, ale odchycený XML potvrdil
prostý formát `DD.MM.YYYY`. První ostré podání: **ref. 179775430** (17.06.2026).
## Srovnání
| | ZPŠ (209) | OZP (207) | RBP (213) |
|--|-----------|-----------|-----------|
| NazevSchranky | `VypisPojKap` | `SEZNAM_KAP` | `VypisPojKap` |
| NazevFiltru | `ZZ_VYP_REG` | `SEZNAM_KAP` | `ZZ_VYP_REG` |
| Formulář | `29-…` | `108-…` | `110-…` |
| Položka IČZ | `icz`=25520 | `nicoz`=13074913 | `icz`=933189 |
| Pole datum | ano (poslední den měsíce) | ne | ano (Ke dni, dnešek) |
| razeni / typ | jmeno / soubor | trideni=p / typ=soubor | jmeno / soubor |
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuRBP.py` | Hlavní skript — stažení výpisů + podání žádosti |
| `log_podani.json` | Historie podání s referenčními čísly |
## Parametry
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `933189`
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
## Stav
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (odpoved.txt správně přeskočeny),
podání ✓ (ref. 179775430). Výpis z prvního podání dorazí do schránky do příštího dne
— při dalším spuštění ověřit, že hlavička `H09305001` u RBP datového souboru sedí.
@@ -0,0 +1,415 @@
"""
Stahování seznamu registrovaných pojištěnců RBP (213) — čistý Python, bez NMSigneru.
RBP běží na stejné platformě jako ZPŠ/OZP/VoZP (portalzp.cz / json-api).
- schránka: /app/schranky-vypis-pojistencu-v-kapitaci
- formulář: 110-vypis-pojistencu-reg-u-pzs
- filtr XML: NazevSchranky="VypisPojKap", NazevFiltru="ZZ_VYP_REG" (jako ZPŠ)
- položky: icz (interní ID), datum (Ke dni), razeni (jmeno/rc), typ (soubor/sestava)
- datum = "Ke dni" aktuální snímek platných registrací — použijeme dnešní datum,
nepočítá se měsíc, žádný stav.json (jako OZP).
Co skript dělá v jednom spuštění:
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
2. Stáhne nové výpisy z výpisové schránky (soubory s hlavičkou H09305001)
3. Znovu se přihlásí (Playwright invaliduje requests session)
4. Podá jednu žádost o aktuální výpis ke dnešnímu dni
Log podání: log_podani.json — seznam { ref_cislo, datum, podano_kdy }
"""
import json
import os
import re
import sys
import time
from datetime import date, datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
# UTF-8 výstup i na Windows konzoli
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++"
BASE_URL = "https://portal.rbp-zp.cz"
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/110-vypis-pojistencu-reg-u-pzs/ulozit-formular"
VYPIS_URL = f"{BASE_URL}/app/schranky-vypis-pojistencu-v-kapitaci"
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
ICZ_INTERNAL = "933189" # IČZ 09305000 — interní ID položky "icz"
RAZENI = "jmeno" # jmeno = příjmení a jména, rc = rodná čísla
TYP = "soubor" # soubor = datový soubor, sestava = PDF sestava
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
HLAVICKA = "H09305001"
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
# Sdílené soubory s RBP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "213 RBP"))
COOKIES_FILE = os.path.join(STAHUJ_DIR, "rbp_cookies.json")
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
DOWNLOAD_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
# ---------------------------------------------------------------------------
# Přihlášení
# ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Origin": BASE_URL,
"Referer": BASE_URL + "/",
})
r = session.get(f"{BASE_URL}/app/prihlaseni")
r.raise_for_status()
session.cookies.set("pzp_sign", "CERT", domain="portal.rbp-zp.cz", path="/")
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
zprava = r.json()["data"]["zprava"]
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
podpis = (
pkcs7.PKCS7SignatureBuilder()
.set_data(zprava.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
.decode("ascii").strip()
)
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
data = r.json()["data"]
if not data.get("prihlasen"):
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!")
cookies = [
{
"name": c.name,
"value": c.value,
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
"path": c.path or "/",
"expires": int(c.expires) if c.expires else -1,
"secure": bool(c.secure),
"httpOnly": False,
"sameSite": "Lax",
}
for c in session.cookies
]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, indent=2, ensure_ascii=False)
return session
# ---------------------------------------------------------------------------
# Stahování z výpisové schránky
# ---------------------------------------------------------------------------
def safe_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def parse_date(date_str: str) -> str:
try:
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
except Exception:
try:
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
return "0000-00-00"
def parse_row(cells: list) -> dict:
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
date_raw = cells[1].strip() if len(cells) > 1 else ""
desc_raw = cells[2].strip() if len(cells) > 2 else ""
fname_raw = cells[3].strip() if len(cells) > 3 else ""
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
if len(desc_lines) >= 3:
description = desc_lines[2]
elif len(desc_lines) >= 2:
description = desc_lines[1]
else:
description = desc_lines[0] if desc_lines else ""
description = description[:80]
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
orig_path = Path(original)
stem = orig_path.stem or "zprava"
ext = orig_path.suffix or ""
date_iso = parse_date(date_raw)
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
if len(name) > 240:
name = f"{date_iso} ({safe_filename(stem)}){ext}"
return {"date": date_iso, "desc": description, "original": original, "filename": name}
def stahni_nove_vypisy() -> int:
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
return 0
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
with open(COOKIES_FILE, encoding="utf-8") as f:
cookies = json.load(f)
downloaded = 0
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
)
try:
context.add_cookies(cookies)
page = context.new_page()
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
if "prihlaseni" in page.url or "login" in page.url.lower():
print("Session v prohlížeči expirovala — stahování přeskočeno")
return 0
print("Prohlížeč přihlášen OK\n")
already = set(os.listdir(DOWNLOAD_DIR))
print(f"V archivu: {len(already)} souborů.\n")
page_num = 1
seen_ids: set = set()
while True:
url = f"{VYPIS_URL}/stranka-{page_num}"
print(f" Stránka {page_num}: {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f" Navigace selhala: {e}")
break
page.wait_for_load_state("networkidle", timeout=15_000)
data = page.evaluate("""() => {
const rows = [];
for (const tr of document.querySelectorAll('table tr')) {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
if (cells.length < 4) continue;
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
if (!dlLink) continue;
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
rows.push({ cells, fileId: mFile ? mFile[0] : null });
}
return rows;
}""")
rows = [r for r in data if r["fileId"]]
if not rows:
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
break
current_ids = {r["fileId"] for r in rows}
if current_ids & seen_ids:
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
break
seen_ids.update(current_ids)
print(f" Nalezeno {len(rows)} zpráv.")
stop = False
for row in rows:
info = parse_row(row["cells"])
target = os.path.join(DOWNLOAD_DIR, info["filename"])
if info["filename"] in already or os.path.exists(target):
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
stop = True
break
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
try:
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
if not r.ok:
print(f" HTTP {r.status} příloha (id={row['fileId']})")
else:
body = r.body()
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
else:
with open(target, "wb") as fh:
fh.write(body)
print(f" OK: {info['filename']}")
already.add(info["filename"])
downloaded += 1
except Exception as e:
print(f" Chyba příloha (id={row['fileId']}): {e}")
time.sleep(1.0)
if stop:
break
page_num += 1
finally:
context.close()
return downloaded
# ---------------------------------------------------------------------------
# Sestavení XML a podpis žádosti
# ---------------------------------------------------------------------------
def build_xml(datum: date) -> str:
"""Sestaví XML žádosti o výpis pojištěnců ke dni `datum`."""
datum_str = datum.strftime("%d.%m.%Y")
return (
f'<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">\r\n'
f'<PolozkaFiltru Nazev="icz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="datum">{datum_str}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="razeni">{RAZENI}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="typ">{TYP}</PolozkaFiltru>\r\n'
f'</SchrankaZadost>'
)
def sign_xml(xml: str) -> str:
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
pem = (
pkcs7.PKCS7SignatureBuilder()
.set_data(xml.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii")
)
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
def odeslat_zadost(session: requests.Session, datum: date) -> str | None:
"""Odešle podepsanou žádost o výpis ke dni `datum`. Vrátí referenční číslo nebo None."""
xml = build_xml(datum)
podpis = sign_xml(xml)
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
r = session.post(SUBMIT_URL, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE_URL + "/",
})
r.raise_for_status()
try:
resp = r.json()
except Exception:
print(f" Odpověď není JSON: {r.text[:300]}")
return None
resp_str = json.dumps(resp, ensure_ascii=False)
if resp.get("errMsg") or resp.get("error"):
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
return None
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
ref = m.group(1) if m else None
if ref:
print(f" OK — ref. číslo: {ref}")
else:
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
return ref or ("OK" if r.ok else None)
# ---------------------------------------------------------------------------
# Log
# ---------------------------------------------------------------------------
def uloz_log(datum: date, ref_cislo: str) -> None:
log = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, encoding="utf-8") as f:
log = json.load(f)
log.append({
"ref_cislo": ref_cislo,
"datum": datum.strftime("%d.%m.%Y"),
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, indent=2, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Hlavní funkce
# ---------------------------------------------------------------------------
def hlavni() -> None:
# 1. Přihlášení — uloží cookies pro Playwright
prihlaseni()
# 2. Stažení nových výpisů z výpisové schránky
print("\n=== Stahování nových výpisů ===")
stazeno = stahni_nove_vypisy()
print(f"Staženo: {stazeno} souborů.\n")
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
print("=== Znovu přihlašuji před podáním ===")
session = prihlaseni()
# 4. Podání žádosti o výpis ke dnešnímu dni
datum = date.today()
print(f"=== Podávám žádost o výpis ke dni {datum.strftime('%d.%m.%Y')} ===")
ref = odeslat_zadost(session, datum)
if ref:
uloz_log(datum, ref)
print(f"\nHotovo — žádost podána, ref: {ref}")
else:
print("\nPodání selhalo — žádost nebyla zaevidována.")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,7 @@
[
{
"ref_cislo": "179775825",
"datum": "15.05.2026",
"podano_kdy": "2026-06-17 05:40:28"
}
]
@@ -1,7 +1,7 @@
[
{
"name": "SID",
"value": "01bb61e3cd536ffbf7c4f2b74260466e",
"value": "22319828cc5b7600290e217c8f533ca0",
"domain": ".portal.rbp-zp.cz",
"path": "/",
"expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT",
"domain": ".portal.rbp-zp.cz",
"path": "/",
"expires": 1808541922,
"expires": 1813203627,
"secure": true,
"httpOnly": false,
"sameSite": "Lax"
@@ -2118,5 +2118,29 @@
{
"original": "7952090443 Kalousová, Eva split_012.pdf",
"corrected": "7952090443 2026-06-02 Kalousová, Eva [kultivace moč] [negativní].pdf"
},
{
"original": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Výstupní prohlídka, závěr: Astenie, BMI 16.43].pdf",
"corrected": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Výstupní prohlídka, závěr Astenie, BMI 16.43].pdf"
},
{
"original": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Přítomný stav, BMI 16.43, váha 60.6 kg, výška 192.5 cm, TK 117/74].pdf",
"corrected": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Přítomný stav, BMI 16.43, váha 60.6 kg, výška 192.5 cm, TK 11774].pdf"
},
{
"original": "0612204703 2025-03-17 Štibrányi, Erik [EKG] [sinusový rytmus 62/min, norma, LK norm, způsobilý ke sportu].pdf",
"corrected": "0612204703 2025-03-17 Štibrányi, Erik [EKG] [sinusový rytmus 62min, norma, LK norm, způsobilý ke sportu].pdf"
},
{
"original": "0612204703 2023-03-30 Štibrányi, Erik [LZ kardiologie] [EKG: sinus fr 67/min, bez abnorm. nálezů, způsobilý ke sportu].pdf",
"corrected": "0612204703 2023-03-30 Štibrányi, Erik [LZ kardiologie] [EKG sinus fr 67min, bez abnorm. nálezů, způsobilý ke sportu].pdf"
},
{
"original": "0612204703 2018-11-26 Štibrányi, Erik [Laboratoř] [dg. B949 - Borrelia IgG 122.00 AU/ml (↑), IgM WB pozitivní, IgG WB hraniční, VlsE ++].pdf",
"corrected": "0612204703 2018-11-26 Štibrányi, Erik [Laboratoř] [dg. B949 - Borrelia IgG 122.00 AUml (↑), IgM WB pozitivní, IgG WB hraniční, VlsE ++].pdf"
},
{
"original": "0662204730 2025-01-13 Štibrányi, Gitta [LZ endokrinologie] [Tyreotoxikóza NS, TSH <0.003, fT4 11.4, fT3 3.93, TRAK 6.9, léčba Thyrozolem, ko za2m].pdf",
"corrected": "0662204730 2025-01-13 Štibrányi, Gitta [LZ endokrinologie] [Tyreotoxikóza NS, TSH 0.003, fT4 11.4, fT3 3.93, TRAK 6.9, léčba Thyrozolem, ko za2m].pdf"
}
]
+47
View File
@@ -0,0 +1,47 @@
# Video — stahování videí
## stahni_video.py
Stahuje videa z Vimea, YouTube a dalších webů přes **yt-dlp**. Nejlepší dostupná
kvalita, sloučení video+audio do `.mp4`. Soukromá / nedostupná videa sám pozná
a přeskočí (nespadne).
### Závislosti (jednorázově)
```bat
python -m pip install -U yt-dlp static-ffmpeg
```
- **yt-dlp** — vlastní downloader.
- **static-ffmpeg** — dodá `ffmpeg.exe` + `ffprobe.exe` (v systému ffmpeg není).
Skript si přes `static_ffmpeg.add_paths()` cestu nastaví sám; binárky se
stáhnou při prvním běhu do `site-packages\static_ffmpeg\bin\`.
### Použití
```bat
python stahni_video.py URL [URL2 ...]
python stahni_video.py # vezme URL z urls.txt (1 na řádek)
python stahni_video.py --cookies-from-browser firefox URL # video za přihlášením
python stahni_video.py -o D:\nekam URL # jiný výstupní adresář
```
Výchozí výstupní adresář je tento (`Video/`). Soubory: `%(title)s [%(id)s].mp4`.
### Jak pozná soukromé/nedostupné video
yt-dlp vyhodí `DownloadError` s textem chyby. Funkce `klasifikuj_chybu()` hledá
v textu známé fráze (`private video`, `video unavailable`, `removed`,
`members-only`, …) a vrátí český popis → video se přeskočí. Jiné chyby (síť,
chybí ffmpeg) se vypíšou jako `[CHYBA]`, ale běh pokračuje na další URL.
Na konci se vypíše souhrn (staženo / přeskočeno / chyby).
### Poznámky / úskalí
- **Soukromé YouTube video opravdu nejde stáhnout**, pokud k němu přihlášený
účet nemá udělený přístup — to je záměr, skript ho jen přeskočí.
- **Diakritika v názvech**: cesty se zkomolí, když se předávají Windows binárce
přes Bug Bash pipe; v běžné konzoli (cp1250) je vše v pořádku.
- **Vimeo** dává oddělené video/audio HLS streamy → ffmpeg je nutný pro sloučení.
- Při prvním běhu může yt-dlp varovat na chybějící JavaScript runtime (deno);
pro běžná veřejná videa to nevadí.
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
stahni_video.py — stahování videí (Vimeo, YouTube, …) přes yt-dlp.
Co umí:
* Automaticky nastaví cestu k ffmpeg (přes balík static-ffmpeg) — netřeba ho
mít v systému.
* Stáhne nejlepší dostupnou kvalitu a sloučí video+audio do .mp4.
* Pokud je video SOUKROMÉ / nedostupné / odstraněné, sám to pozná, vypíše
srozumitelnou hlášku a přeskočí ho (nespadne, jede dál na další URL).
Použití:
python stahni_video.py URL [URL2 ...]
python stahni_video.py # vezme URL z urls.txt (1 na řádek)
python stahni_video.py --cookies-from-browser firefox URL # video za přihlášením
Instalace závislostí (jednorázově):
python -m pip install -U yt-dlp static-ffmpeg
"""
import argparse
import os
import shutil
import sys
from pathlib import Path
# Pojistka: ať výpis nikdy nespadne na znaku, který kódování konzole nezná
# (zachová kódování konzole, jen neznámý znak escapne místo pádu programu).
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(errors="backslashreplace")
except Exception:
pass
SKRIPT_DIR = Path(__file__).resolve().parent
# --- důvody, proč video NEJDE stáhnout (→ přeskočit, ne padat) ----------------
# klíč hledáme (case-insensitive) v textu chyby od yt-dlp
DUVODY_PRESKOCIT = [
("private video", "video je soukromé"),
("video is private", "video je soukromé"),
("this is a private video", "video je soukromé"),
("video unavailable", "video není dostupné"),
("this video is unavailable", "video není dostupné"),
("video has been removed", "video bylo odstraněno"),
("removed by the uploader", "video odstranil autor"),
("no longer available", "video už není dostupné"),
("members-only", "jen pro členy kanálu"),
("available to members", "jen pro členy kanálu"),
("account associated with this video has been terminated",
"účet autora byl zrušen"),
("has been terminated", "účet autora byl zrušen"),
("blocked it on copyright", "blokováno kvůli autorským právům"),
("not available in your country", "nedostupné ve tvé zemi"),
("not available on this app", "nedostupné pro tohoto klienta"),
("sign in to confirm your age", "věkově omezené (nutné přihlášení)"),
("requires payment", "placené video"),
("this live event will begin", "živý přenos zatím nezačal"),
("premieres in", "video bude teprve uvedeno (premiéra)"),
]
def klasifikuj_chybu(msg: str):
"""Vrátí český popis důvodu k přeskočení, nebo None pokud jde o jinou chybu."""
m = msg.lower()
for klic, popis in DUVODY_PRESKOCIT:
if klic in m:
return popis
return None
class _TichyLogger:
"""Potlačí ukecaný výpis yt-dlp; chyby si hlídáme sami přes výjimky."""
def debug(self, msg):
pass
def info(self, msg):
pass
def warning(self, msg):
pass
def error(self, msg):
pass
def _progress_hook(d):
if d.get("status") == "downloading":
pct = (d.get("_percent_str") or "").strip()
spd = (d.get("_speed_str") or "").strip()
print(f"\r stahuji {pct} {spd} ", end="", flush=True)
elif d.get("status") == "finished":
print(f"\r staženo, zpracovávám… ")
def priprav_ffmpeg():
"""Zajistí ffmpeg/ffprobe a vrátí adresář s binárkami (nebo None)."""
try:
import static_ffmpeg
static_ffmpeg.add_paths() # přidá ffmpeg/ffprobe do PATH (1. běh = stáhne)
except ImportError:
pass
ff = shutil.which("ffmpeg")
if ff:
return os.path.dirname(ff)
print("UPOZORNĚNÍ: ffmpeg nenalezen — sloučení video+audio nemusí fungovat.")
print(" Nainstaluj: python -m pip install -U static-ffmpeg")
return None
def nacti_urls(args_urls):
if args_urls:
return args_urls
soubor = SKRIPT_DIR / "urls.txt"
if soubor.exists():
radky = [r.strip() for r in soubor.read_text(encoding="utf-8").splitlines()]
return [r for r in radky if r and not r.startswith("#")]
return []
def stahni(urls, out_dir: Path, cookies_browser=None):
try:
import yt_dlp
from yt_dlp.utils import DownloadError
except ImportError:
sys.exit("Chybí yt-dlp. Nainstaluj: python -m pip install -U yt-dlp")
ff_dir = priprav_ffmpeg()
out_dir.mkdir(parents=True, exist_ok=True)
ydl_opts = {
"outtmpl": str(out_dir / "%(title)s [%(id)s].%(ext)s"),
"format": "bestvideo*+bestaudio/best",
"merge_output_format": "mp4",
"logger": _TichyLogger(),
"progress_hooks": [_progress_hook],
"noprogress": True, # vlastní progress řešíme hookem
"noplaylist": True,
}
if ff_dir:
ydl_opts["ffmpeg_location"] = ff_dir
if cookies_browser:
ydl_opts["cookiesfrombrowser"] = (cookies_browser,)
stazeno, preskoceno, chyby = 0, [], []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
for i, url in enumerate(urls, 1):
print(f"\n[{i}/{len(urls)}] {url}")
try:
info = ydl.extract_info(url, download=True)
nazev = info.get("title", url) if info else url
print(f" [HOTOVO] {nazev}")
stazeno += 1
except DownloadError as e:
duvod = klasifikuj_chybu(str(e))
if duvod:
print(f" [PRESKOCENO] {duvod}")
preskoceno.append((url, duvod))
else:
strucne = str(e).split("\n")[0]
print(f" [CHYBA] {strucne}")
chyby.append((url, strucne))
except Exception as e: # nečekané — taky nezhasnout celý běh
print(f" [CHYBA] {e}")
chyby.append((url, str(e)))
print("\n=== SOUHRN ===")
print(f" staženo: {stazeno}")
print(f" přeskočeno: {len(preskoceno)}")
for url, duvod in preskoceno:
print(f" - {url} ({duvod})")
if chyby:
print(f" chyby: {len(chyby)}")
for url, msg in chyby:
print(f" - {url} ({msg})")
return stazeno, preskoceno, chyby
def main():
p = argparse.ArgumentParser(
description="Stáhne videa přes yt-dlp; soukromá/nedostupná sám přeskočí.")
p.add_argument("urls", nargs="*", help="URL videí (nebo nech prázdné a použij urls.txt)")
p.add_argument("-o", "--out-dir", default=str(SKRIPT_DIR),
help="výstupní adresář (výchozí: tento adresář)")
p.add_argument("--cookies-from-browser", dest="cookies",
help="prohlížeč pro cookies u videí za přihlášením (firefox/chrome/edge…)")
a = p.parse_args()
urls = nacti_urls(a.urls)
if not urls:
sys.exit("Nezadal jsi žádné URL. Předej je jako argumenty nebo do urls.txt.")
stahni(urls, Path(a.out_dir), cookies_browser=a.cookies)
if __name__ == "__main__":
main()
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
.env
*.log
__pycache__/
_*.html
+90
View File
@@ -0,0 +1,90 @@
# Webináře — hlídač nových webinářů (praktickylekar.online)
## Účel
Jednou denně (8:00, Plánovač úloh) zkontroluje [praktickylekar.online](https://www.praktickylekar.online/),
zda přibyl nový webinář. Když ano → přes **Telegram** se zeptá, jestli má přihlásit
osoby z `config.json` (Michaela + Vladimír Buzalkovi), po potvrzení je přihlásí a
výsledek pošle zpět na Telegram. Po přihlášení chodí potvrzovací e-mail automaticky z webu.
## Soubory
| Soubor | Popis |
|--------|-------|
| `watcher.py` | hlavní skript |
| `config.json` | URL + údaje přihlašovaných osob |
| `state.json` | vytvoří se sám; pamatuje poslední zpracované `idwebinar` |
| `watcher.log` | log běhů |
## Přepínače v `watcher.py` (nahoře)
- `POSILATINFOPOKAZDEKONTROLE``True` = pošle Telegram zprávu po **každé** ranní
kontrole (i když nic nového; vhodné při zaběhávání). `True` je teď nastaveno.
Až bude vše ověřené → přepnout na `False` (ozve se jen při novém webináři).
- `DRY_RUN``True` = nic se reálně neodešle (registrace se jen simuluje), Telegram
dotaz proběhne. `False` = ostrý režim (reálné přihlášení po potvrzení „ano").
- `ASK_TIMEOUT` — kolik sekund ráno čekat na odpověď ano/ne (default 1800 = 30 min).
## CLI
```
python watcher.py # ostrý denní běh
python watcher.py --test # ignoruje state + VŽDY dry-run (otestuje plumbing)
python watcher.py --reset # smaže state.json
```
## Ověřená struktura webu (k 2026-06-17)
1. **Banner** na hlavní stránce: `<a href="/webinar.php?idwebinar=560">` → z něj se čte ID.
2. **Brána** `POST /check2.php` s `zdravotnicky-pracovnik=on` & `laicka-verejnost=on`
→ nastaví cookie `souhlas=1`. **Bez ní se registrační formulář vůbec nezobrazí.**
3. **Registrace** `POST /registrovat4.php`, pole:
- `email` (povinné)
- `clen` = `1` (člen SVL Ano) / `2` (Ne) → Buzalkovi `1`
- `prukaz` = číslo průkazu SVL (povinné když clen=1)
- `clk` = evidenční číslo ČLK, **přesně 10 znaků** (`pattern=.{10,10}`)
- `titul1, jmeno, prijmeni, pracoviste, mesto` — jen pro nečleny (clen=2)
- `souhlas` = `on` (souhlas se zpracováním OÚ, povinné)
- **skrytá** `webid` (= idwebinar) a `cislo` (= `PL` + DDMMRRRR, dle data webináře)
**čtou se živě z formuláře, nehádají se.**
> Pokud provozovatel změní názvy polí / strukturu, skript loguje, co našel
> (`watcher.log`) — podle toho se selektory upraví.
## Nasazení na tower (PRODUKCE) — Unraid, python-runner
Běží na **toweru** (Unraid, 192.168.1.76) v kontejneru **`python-runner`**,
plánováno přes **User Scripts plugin** na **8:00 denně**.
- Soubory: `/mnt/user/Scripts/Webinare/` → v kontejneru `/scripts/Webinare/`
- Telegram: na serveru **není** `Knihovny/` ani `Medevio/.env`, proto je přibalená
kopie `telegram_notify.py` + lokální `/scripts/Webinare/.env`
(jen `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID`, práva 600).
- Wrapper: `/boot/config/plugins/user.scripts/scripts/WebinarWatcher/script`
(`flock` + `docker exec`, log `/mnt/user/Scripts/logs/webinar_watcher.log`).
- Rozvrh: záznam v `schedule.json` (`custom: 0 8 * * *`) + řádek v
`customSchedule.cron``update_cron``/etc/cron.d/root`.
- `state.json` na serveru seedován na `560` (na ten jste registrovaní).
### Nasazení / správa z Windows — `deploy_tower.py`
Heslo NIKDY v souboru, bere se z env `TOWER_PW`:
```bash
TOWER_PW=... python deploy_tower.py recon # zmapuje server (jen čte)
TOWER_PW=... python deploy_tower.py deploy # nahraje soubory (+ seed state.json)
TOWER_PW=... python deploy_tower.py env # naplní serverový .env z Medevio/.env
TOWER_PW=... python deploy_tower.py smoke # test: telegram .env + detekce (neodesílá)
TOWER_PW=... python deploy_tower.py schedule # založí/aktualizuje rozvrh 8:00
TOWER_PW=... python deploy_tower.py prodrun # ruční spuštění ostrého běhu
```
Po změně `watcher.py`/`config.json` lokálně → `deploy` znovu (idempotentní,
`state.json` ani `.env` nepřepisuje).
### Heartbeat → tichý režim
Server běží s `POSILATINFOPOKAZDEKONTROLE=True` (ranní „zkontrolováno"). Až bude
ověřeno, v lokálním `watcher.py` přepnout na `False` a `deploy` znovu.
## Alternativa — Plánovač úloh (Windows), pokud poběží lokálně
```powershell
schtasks /Create /TN "WebinarWatcher" /SC DAILY /ST 08:00 ^
/TR "python \"U:\ordinaceprojekt\Webináře\watcher.py\"" /F
```
## Notifikace
Přes sdílenou knihovnu `Knihovny/telegram_notify.py`
(`posli_telegram`, `zeptej_se_telegram`), bot **@Vlado_Claude_Bot**,
token/chat_id z `Medevio/.env`.
+28
View File
@@ -0,0 +1,28 @@
{
"watch_url": "https://www.praktickylekar.online/",
"base_url": "https://www.praktickylekar.online",
"registrants": [
{
"jmeno": "Michaela",
"prijmeni": "Buzalková",
"titul1": "",
"email": "michaela.buzalkova@buzalka.cz",
"clen": "1",
"prukaz": "761790",
"clk": "5141811171",
"pracoviste": "",
"mesto": ""
},
{
"jmeno": "Vladimír",
"prijmeni": "Buzalka",
"titul1": "",
"email": "vladimir.buzalka@buzalka.cz",
"clen": "1",
"prukaz": "761791",
"clk": "1143687173",
"pracoviste": "",
"mesto": ""
}
]
}
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
deploy_tower.py — nasazení webinar-watcheru na tower (Unraid, python-runner).
Heslo se NIKDY neukládá do souboru — bere se z proměnné prostředí TOWER_PW:
TOWER_PW=... python deploy_tower.py recon
TOWER_PW=... python deploy_tower.py deploy
TOWER_PW=... python deploy_tower.py schedule
TOWER_PW=... python deploy_tower.py smoke # rychlý test (neblokuje na Telegramu)
Vzor převzat z EmailAgent / MedicusFirebird:
- skripty v /mnt/user/Scripts/<Název>/ → v kontejneru /scripts/<Název>/
- spouští se: docker exec python-runner python3 /scripts/Webinare/watcher.py
- plánování přes Unraid User Scripts plugin (wrapper + schedule.json cron)
"""
import os
import sys
import json
import posixpath
import paramiko
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HOST = "192.168.1.76"
USER = "root"
CONTAINER = "python-runner"
LOCAL_DIR = os.path.dirname(os.path.abspath(__file__))
HOST_DIR = "/mnt/user/Scripts/Webinare" # na hostiteli (Unraid)
CONT_DIR = "/scripts/Webinare" # uvnitř kontejneru
PLUGIN_DIR = "/boot/config/plugins/user.scripts"
USERSCRIPTS = PLUGIN_DIR + "/scripts"
SCHEDULE_JSON = PLUGIN_DIR + "/schedule.json"
CUSTOM_CRON = PLUGIN_DIR + "/customSchedule.cron"
US_NAME = "WebinarWatcher"
CRON_EXPR = "0 8 * * *"
# soubory, které kopírujeme na server (telegram_notify.py = přibalená kopie,
# protože /scripts/Knihovny na serveru není)
FILES = ["watcher.py", "telegram_notify.py", "config.json", "requirements.txt", "NOTES.md"]
def connect():
pw = os.environ.get("TOWER_PW")
if not pw:
sys.exit("Chybí TOWER_PW v prostředí.")
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=pw, timeout=20, allow_agent=False, look_for_keys=False)
return c
def run(c, cmd, timeout=180):
_in, out, err = c.exec_command(cmd, timeout=timeout)
o = out.read().decode("utf-8", "replace")
e = err.read().decode("utf-8", "replace")
rc = out.channel.recv_exit_status()
return rc, o, e
def show(c, cmd, timeout=180):
rc, o, e = run(c, cmd, timeout)
print(f"$ {cmd}")
body = (o + (("\n[stderr] " + e) if e.strip() else "")).rstrip()
print(body if body else "(prázdné)")
print(f" rc={rc}\n")
return rc, o, e
# ── RECON ────────────────────────────────────────────────────────────────────
def recon(c):
show(c, "hostname; uname -r")
show(c, "docker ps --format '{{.Names}}' | sort")
show(c, "ls -la /mnt/user/Scripts/ | head -50")
show(c, f"docker exec {CONTAINER} ls /scripts/ | head -50")
show(c, f"docker exec {CONTAINER} ls -la /scripts/Knihovny/telegram_notify.py")
show(c, f"docker exec {CONTAINER} sh -lc 'test -f /scripts/Medevio/.env && grep -oE \"^(TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID)=\" /scripts/Medevio/.env || echo NENI_ENV'")
show(c, f"docker exec {CONTAINER} python3 -c \"import requests,bs4;print('deps_ok requests',requests.__version__,'bs4',bs4.__version__)\"")
show(c, f"ls -la {USERSCRIPTS}/ | head -50")
# vzor existujícího wrapperu + rozvrhu (StahovaniFaktur)
show(c, f"cat {USERSCRIPTS}/StahovaniFaktur/script 2>/dev/null")
show(c, f"cat {USERSCRIPTS}/StahovaniFaktur/schedule.json 2>/dev/null")
show(c, "grep -n 'Scripts' /etc/cron.d/root 2>/dev/null | head")
# ── DEPLOY (kopie souborů) ───────────────────────────────────────────────────
def deploy(c):
run(c, f"mkdir -p {HOST_DIR} /mnt/user/Scripts/logs")
sftp = c.open_sftp()
for f in FILES:
lp = os.path.join(LOCAL_DIR, f)
if not os.path.exists(lp):
print(f" přeskakuji (není lokálně): {f}")
continue
rp = posixpath.join(HOST_DIR, f)
sftp.put(lp, rp)
print(f"{f}{rp}")
# seed state.json jen když na serveru ještě není (ať se nepřemazává běhový stav)
rp_state = posixpath.join(HOST_DIR, "state.json")
rc, _o, _e = run(c, f"test -f {rp_state}")
if rc != 0:
lp_state = os.path.join(LOCAL_DIR, "state.json")
if os.path.exists(lp_state):
sftp.put(lp_state, rp_state)
print(f" ↑ state.json (seed) → {rp_state}")
else:
with sftp.open(rp_state, "w") as fh:
fh.write('{"last_id": null}\n')
print(" ↑ state.json (prázdný)")
else:
print(" state.json na serveru už existuje — neměním.")
sftp.close()
show(c, f"ls -la {HOST_DIR}/")
# ── ENV (naplní /scripts/Webinare/.env Telegram klíči z lokálního Medevio/.env) ─
def env(c):
src = os.path.join(os.path.dirname(LOCAL_DIR), "Medevio", ".env")
if not os.path.exists(src):
sys.exit("Lokální Medevio/.env nenalezen.")
chteji = ("TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID")
radky = []
with open(src, encoding="utf-8") as fh:
for line in fh:
s = line.strip()
if "=" in s and not s.startswith("#"):
k = s.split("=", 1)[0].strip()
if k in chteji:
radky.append(s)
keys = [r.split("=", 1)[0] for r in radky]
if not all(k in keys for k in chteji):
sys.exit(f"V Medevio/.env chybí některý z klíčů: {chteji}")
run(c, f"mkdir -p {HOST_DIR}")
sftp = c.open_sftp()
with sftp.open(posixpath.join(HOST_DIR, ".env"), "w") as fh:
fh.write("\n".join(radky) + "\n")
sftp.chmod(posixpath.join(HOST_DIR, ".env"), 0o600)
sftp.close()
print(f" .env zapsán na server ({', '.join(keys)}) — hodnoty se nevypisují.")
show(c, f"docker exec {CONTAINER} sh -lc 'grep -oE \"^(TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID)=\" {CONT_DIR}/.env'")
# ── CRON RECON (zjistí, jak User Scripts ukládá rozvrh) ──────────────────────
def cron(c):
show(c, "ls -la /boot/config/plugins/user.scripts/scripts/StahovaniFaktur/")
show(c, "ls -la /boot/config/plugins/user.scripts/scripts/MedicusFirebirdRestore/")
show(c, "cat /boot/config/plugins/user.scripts/scripts/MedicusFirebirdRestore/schedule.json 2>/dev/null || echo bez_schedule_json")
show(c, "ls -la /etc/cron.d/")
show(c, "cat /etc/cron.d/root 2>/dev/null")
show(c, "crontab -l 2>/dev/null | tail -40")
# ── CRONSTORE RECON (kam plugin persistuje rozvrh přes reboot) ───────────────
def cronstore(c):
show(c, "ls -la /boot/config/plugins/user.scripts/")
show(c, "find /boot/config/plugins/user.scripts/ -maxdepth 1 -type f -exec ls -la {} +")
show(c, "grep -rsl 'StahovaniFaktur' /boot/config/ 2>/dev/null | grep -v '/scripts/StahovaniFaktur/'")
show(c, "grep -rsn '6,18\\|cron\\|schedule' /boot/config/plugins/user.scripts/ --include='*.json' --include='*.cfg' --include='*.dat' --include='*.php' 2>/dev/null | head -40")
# ── CRONFILES (dump přesného formátu schedule.json + customSchedule.cron) ────
def cronfiles(c):
show(c, "sed -n '185,210p' /boot/config/plugins/user.scripts/schedule.json")
show(c, "head -8 /boot/config/plugins/user.scripts/schedule.json")
show(c, "tail -8 /boot/config/plugins/user.scripts/schedule.json")
show(c, "cat /boot/config/plugins/user.scripts/customSchedule.cron")
show(c, "ls -la /usr/local/sbin/update_cron /usr/local/emhttp/plugins/user.scripts/startCustom.php 2>&1")
# ── SMOKE TEST (neblokuje na Telegramu) ──────────────────────────────────────
def smoke(c):
# ověří přibalený telegram modul + načtení .env (jen délky, ne hodnoty)
# + detekci webináře na webu. NEodesílá Telegram ani registraci.
py = (
"import sys; sys.path.insert(0,'/scripts/Webinare');"
"import telegram_notify as t;"
"print('telegram .env OK: token_len',len(t._token()),'chat_id_set',bool(t._resolve_chat_id(None)));"
"import json,requests,re;"
"from bs4 import BeautifulSoup;"
"cfg=json.load(open('/scripts/Webinare/config.json',encoding='utf-8'));"
"s=requests.Session(); s.get(cfg['watch_url'],headers={'User-Agent':'Mozilla/5.0'},timeout=30);"
"r=s.get(cfg['watch_url'],headers={'User-Agent':'Mozilla/5.0'},timeout=30);"
"a=BeautifulSoup(r.text,'html.parser').select('a[href*=\\\"webinar.php?idwebinar=\\\"]')[0];"
"print('detekce OK webinar=',re.search(r'idwebinar=(\\\\d+)',a['href']).group(1))"
)
show(c, f"docker exec {CONTAINER} python3 -c \"{py}\"", timeout=90)
# ── SCHEDULE (User Scripts plugin, denně 8:00) ───────────────────────────────
def schedule(c):
d = f"{USERSCRIPTS}/{US_NAME}"
script_path = f"{d}/script"
cron_line = (f"{CRON_EXPR} /usr/local/emhttp/plugins/user.scripts/startCustom.php "
f"{script_path} > /dev/null 2>&1")
# wrapper (styl převzat z StahovaniFaktur: flock + docker exec + log s datem/rc)
wrapper = (
"#!/bin/bash\n"
"# WebinarWatcher - denne 8:00, hlidac webinaru praktickylekar.online. flock proti prekryvu.\n"
"LOG=/mnt/user/Scripts/logs/webinar_watcher.log\n"
"mkdir -p /mnt/user/Scripts/logs\n"
"exec 9>/tmp/webinar_watcher.lock\n"
"flock -n 9 || exit 0\n"
"OUT=$(docker exec -e PYTHONIOENCODING=utf-8 -e TZ=Europe/Prague " + CONTAINER + " python3 " + CONT_DIR + "/watcher.py 2>&1)\n"
"RC=$?\n"
"{ echo \"===== $(date '+%F %T') (rc=$RC) =====\"; echo \"$OUT\"; } >> \"$LOG\"\n"
)
run(c, f"mkdir -p {d}")
sftp = c.open_sftp()
with sftp.open(script_path, "w") as fh:
fh.write(wrapper)
with sftp.open(f"{d}/name", "w") as fh:
fh.write(US_NAME)
with sftp.open(f"{d}/description", "w") as fh:
fh.write("Hlidac webinaru praktickylekar.online, denne 8:00")
# ── schedule.json: přidej/aktualizuj záznam (se zálohou) ──
run(c, f"cp -a {SCHEDULE_JSON} {SCHEDULE_JSON}.bak_webinar")
with sftp.open(SCHEDULE_JSON, "r") as fh:
data = json.loads(fh.read().decode("utf-8"))
data[script_path] = {
"script": script_path,
"frequency": "custom",
"id": "schedule" + US_NAME,
"custom": CRON_EXPR,
}
with sftp.open(SCHEDULE_JSON, "w") as fh:
fh.write(json.dumps(data, indent=2))
# ── customSchedule.cron: přidej řádek (se zálohou), pokud chybí ──
with sftp.open(CUSTOM_CRON, "r") as fh:
cron_txt = fh.read().decode("utf-8")
if script_path not in cron_txt:
run(c, f"cp -a {CUSTOM_CRON} {CUSTOM_CRON}.bak_webinar")
with sftp.open(CUSTOM_CRON, "w") as fh:
fh.write(cron_txt.rstrip() + "\n\n" + cron_line + "\n")
sftp.close()
run(c, f"chmod +x {script_path}")
# ── regeneruj systémový cron + ověř ──
show(c, "/usr/local/sbin/update_cron")
print("── OVĚŘENÍ ──")
show(c, f"ls -la {d}/")
show(c, f"grep -n '{US_NAME}' {CUSTOM_CRON}")
show(c, f"grep -n '{US_NAME}' /etc/cron.d/root")
show(c, f"grep -n '{US_NAME}' {SCHEDULE_JSON}")
# ── PRODRUN (spustí přesně to, co pustí cron — pro ruční test/trigger) ────────
def prodrun(c):
show(c, f"docker exec -e PYTHONIOENCODING=utf-8 {CONTAINER} python3 {CONT_DIR}/watcher.py",
timeout=200)
MODES = {"recon": recon, "deploy": deploy, "env": env, "cron": cron,
"cronstore": cronstore, "cronfiles": cronfiles, "smoke": smoke,
"schedule": schedule, "prodrun": prodrun}
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "recon"
if mode not in MODES:
sys.exit(f"Neznámý režim '{mode}'. Použij: {', '.join(MODES)}")
c = connect()
try:
print(f"=== {mode.upper()} na {USER}@{HOST} ===\n")
MODES[mode](c)
finally:
c.close()
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
requests
beautifulsoup4
+3
View File
@@ -0,0 +1,3 @@
{
"last_id": "560"
}
+115
View File
@@ -0,0 +1,115 @@
"""
telegram_notify.py — PŘIBALENÁ kopie pro běh na serveru (python-runner)
=======================================================================
Na toweru není balík `Knihovny/` ani `Medevio/.env`, proto má watcher tuto
soběstačnou kopii. Funkce jsou shodné s `Knihovny/telegram_notify.py`.
Token a chat_id se hledají v `.env` na víc místech (první nalezené vyhrává):
1) `.env` ve stejném adresáři jako tento soubor (server: /scripts/Webinare/.env)
2) `../Medevio/.env` (lokální vývoj)
3) `../../Medevio/.env` (kořen projektu)
TELEGRAM_BOT_TOKEN=123456789:AAE...
TELEGRAM_CHAT_ID=6639316354
"""
import os
import sys
import time
from pathlib import Path
import requests
def _load_env():
here = Path(__file__).resolve().parent
kandidati = [
here / ".env",
here.parent / "Medevio" / ".env",
here.parent.parent / "Medevio" / ".env",
]
for env_path in kandidati:
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
_load_env()
API_BASE = "https://api.telegram.org/bot{token}/{method}"
def _token() -> str:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError("Chybí TELEGRAM_BOT_TOKEN (.env)")
return token
def _resolve_chat_id(chat_id):
chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
if not chat_id:
raise RuntimeError("Chybí TELEGRAM_CHAT_ID (zadej argumentem nebo v .env)")
return str(chat_id)
def _call(method, *, http_timeout=15, **params):
url = API_BASE.format(token=_token(), method=method)
r = requests.post(url, json=params, timeout=http_timeout)
data = r.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram {method} selhal [{r.status_code}]: {data}")
return data["result"]
def posli_telegram(text, *, chat_id=None, parse_mode=None, disable_notification=False):
params = {
"chat_id": _resolve_chat_id(chat_id),
"text": text,
"disable_notification": disable_notification,
}
if parse_mode:
params["parse_mode"] = parse_mode
return _call("sendMessage", **params)
def zeptej_se_telegram(otazka, *, chat_id=None, timeout=300, poll_timeout=30, parse_mode=None):
cid = _resolve_chat_id(chat_id)
existujici = _call("getUpdates", http_timeout=15)
offset = (existujici[-1]["update_id"] + 1) if existujici else 0
posli_telegram(otazka, chat_id=cid, parse_mode=parse_mode)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
zbyva = int(deadline - time.monotonic())
if zbyva <= 0:
break
lp = max(1, min(poll_timeout, zbyva))
updates = _call("getUpdates", http_timeout=lp + 10, offset=offset, timeout=lp)
for u in updates:
offset = u["update_id"] + 1
msg = u.get("message") or {}
if str(msg.get("chat", {}).get("id")) != cid:
continue
text = msg.get("text")
if text:
return text
return None
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
args = sys.argv[1:]
if args and args[0] == "--ask":
print(zeptej_se_telegram(" ".join(args[1:]) or "?", timeout=240) or "(bez odpovědi)")
elif args:
posli_telegram(" ".join(args))
print("Odesláno OK")
else:
print('Použití: python telegram_notify.py "text" | --ask "otázka?"')
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""
watcher.py — Hlídač nových webinářů na praktickylekar.online
============================================================
Co dělá při každém spuštění (cíleno na 1× denně v 8:00 přes Plánovač úloh):
1. Stáhne hlavní stránku a najde banner s nadcházejícím webinářem
(odkaz `webinar.php?idwebinar=<ID>`).
2. Porovná ID s posledním zpracovaným (uloženo ve `state.json`).
3. Pokud je webinář NOVÝ:
a) projde "bránu" (potvrzení zdravotnického odborníka, POST /check2.php) —
teprve potom se na stránce webináře objeví registrační formulář,
b) z formuláře ŽIVĚ přečte skrytá pole `webid` a `cislo`
(cislo = PL + DDMMRRRR, mění se podle data — NIKDY se nehádá),
c) přes Telegram se ZEPTÁ, jestli má osoby z config.json přihlásit,
d) po potvrzení ("ano") odešle registraci za každou osobu,
e) výsledek potvrdí přes Telegram.
4. Pokud nový webinář NENÍ a POSILATINFOPOKAZDEKONTROLE=True, pošle ráno
informaci "zkontrolováno, nic nového".
Po přihlášení chodí potvrzovací e-mail automaticky z webu — e-mail tedy
neřešíme, notifikace jdou jen přes Telegram.
CLI:
python watcher.py # ostrý denní běh
python watcher.py --test # test: ignoruje state, VŽDY dry-run (nic neodešle)
python watcher.py --reset # smaže state.json (zapomene poslední webinář)
"""
import json
import logging
import os
import re
import sys
from pathlib import Path
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
# ── Telegram: lokálně sdílená knihovna z kořene, na serveru přibalená kopie ──
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
try:
# lokálně (Windows): kořen projektu má balík Knihovny + Medevio/.env
from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram # noqa: E402
except ModuleNotFoundError:
# server (python-runner): Knihovny tu není → přibalená kopie + lokální .env
from telegram_notify import posli_telegram, zeptej_se_telegram # noqa: E402
# ════════════════════════════════════════════════════════════════════════════
# PŘEPÍNAČE
# ════════════════════════════════════════════════════════════════════════════
# True = po KAŽDÉ ranní kontrole pošli na Telegram zprávu "zkontrolováno"
# (i když není nic nového) — užitečné při zaběhávání, ať víš, že to jede.
# False = ozvi se jen když je NOVÝ webinář. (Nastav, až bude vše ověřené.)
POSILATINFOPOKAZDEKONTROLE = True
# True = NIC se reálně neodešle (registrace se jen "nasucho" simuluje a vypíše).
# Telegram dotaz/potvrzení proběhne normálně. Pro bezpečné otestování.
# False = ostrý režim — po potvrzení "ano" na Telegramu se reálně přihlásí.
DRY_RUN = False
# Jak dlouho (s) čekat ráno na odpověď ano/ne na Telegramu, než to vzdá.
ASK_TIMEOUT = 1800 # 30 minut
# ════════════════════════════════════════════════════════════════════════════
HERE = Path(__file__).resolve().parent
CONFIG_PATH = HERE / "config.json"
STATE_PATH = HERE / "state.json"
LOG_PATH = HERE / "watcher.log"
HEADERS = {"User-Agent": "Mozilla/5.0 (webinar-watcher; osobni pouziti)"}
TIMEOUT = 30
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_PATH, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("watcher")
# ── pomocné I/O ──────────────────────────────────────────────────────────────
def load_json(path: Path, default=None):
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
def save_json(path: Path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# ── krok 1: najdi nadcházející webinář na hlavní stránce ─────────────────────
def find_upcoming_webinar(session, watch_url):
"""Vrátí (id, text_banneru, absolutni_url) nebo None."""
r = session.get(watch_url, headers=HEADERS, timeout=TIMEOUT)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
# Zakomentované bannery jsou HTML komentáře → BeautifulSoup je nebere jako <a>.
odkazy = soup.select('a[href*="webinar.php?idwebinar="]')
if not odkazy:
return None
if len(odkazy) > 1:
log.warning("Na stránce je víc odkazů na webinář (%d), beru první.", len(odkazy))
a = odkazy[0]
href = a.get("href", "")
m = re.search(r"idwebinar=(\d+)", href)
if not m:
return None
wid = m.group(1)
text = " ".join(a.get_text().split())
return wid, text, urljoin(watch_url, href)
# ── krok 2: projdi bránu (potvrzení zdravotnického odborníka) ────────────────
def projdi_branu(session, base_url, reg_url):
"""
POST /check2.php se dvěma checkboxy → nastaví cookie souhlas=1, díky které
se na stránce webináře objeví registrační formulář. Vrací True/False.
"""
data = {"zdravotnicky-pracovnik": "on", "laicka-verejnost": "on"}
r = session.post(
urljoin(base_url, "/check2.php"),
data=data,
headers={**HEADERS, "Referer": reg_url},
timeout=TIMEOUT,
)
r.raise_for_status()
ok = session.cookies.get("souhlas") == "1"
log.info("Brána check2.php: %s (cookies=%s)", "OK" if ok else "?", session.cookies.get_dict())
return ok
# ── krok 3: přečti registrační formulář a jeho skrytá pole ───────────────────
def parse_registration_form(session, reg_url):
"""
Načte stránku webináře (už po projití brány) a vrátí
(action_url, hidden_fields_dict). Skrytá pole (webid, cislo) se ČTOU,
nehádají. Hledá konkrétně formulář mířící na 'registrovat'.
"""
r = session.get(reg_url, headers={**HEADERS, "Referer": reg_url}, timeout=TIMEOUT)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
form = None
for f in soup.find_all("form"):
if "registrovat" in (f.get("action") or "").lower():
form = f
break
if form is None:
raise RuntimeError(
"Registrační formulář nenalezen (brána neprošla, nebo se změnila struktura webu)."
)
action = urljoin(reg_url, form.get("action", ""))
hidden = {}
for inp in form.find_all("input", attrs={"type": "hidden"}):
name = inp.get("name")
if name:
hidden[name] = inp.get("value", "")
return action, hidden
# ── krok 4: sestav a odešli registraci ───────────────────────────────────────
def build_payload(person, hidden):
payload = {
"email": person["email"],
"clen": person.get("clen", "1"),
"prukaz": person.get("prukaz", ""),
"clk": person.get("clk", ""),
"titul1": person.get("titul1", ""),
"jmeno": person.get("jmeno", ""),
"prijmeni": person.get("prijmeni", ""),
"pracoviste": person.get("pracoviste", ""),
"mesto": person.get("mesto", ""),
"souhlas": "on", # souhlas se zpracováním osobních údajů (nutné pro odeslání)
}
payload.update(hidden) # webid, cislo, … (živě z formuláře)
return payload
def register_person(session, action_url, reg_url, person, hidden):
"""Vrátí (ok: bool, info: str)."""
payload = build_payload(person, hidden)
cele_jmeno = f"{person['jmeno']} {person['prijmeni']}"
if DRY_RUN:
log.info("DRY_RUN NEodesílám. Payload pro %s: %s", cele_jmeno, payload)
return True, "DRY-RUN (nic neodesláno)"
r = session.post(
action_url,
data=payload,
headers={**HEADERS, "Referer": reg_url},
timeout=TIMEOUT,
)
r.raise_for_status()
txt_low = r.text.lower()
ok = any(k in txt_low for k in ("úspěš", "uspes", "zaregistr", "děkuj", "dekuj"))
# snippet pro případnou ruční kontrolu
snippet = " ".join(BeautifulSoup(r.text, "html.parser").get_text().split())[:200]
return ok, f"HTTP {r.status_code} | {snippet}"
# ── Telegram dotaz ano/ne ────────────────────────────────────────────────────
def je_souhlas(odpoved: str | None) -> bool:
if not odpoved:
return False
return odpoved.strip().lower() in ("ano", "a", "yes", "y", "jo", "ok")
# ── hlavní logika ────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
test_mode = "--test" in args
if "--reset" in args:
if STATE_PATH.exists():
STATE_PATH.unlink()
log.info("state.json smazán.")
return
cfg = load_json(CONFIG_PATH)
if not cfg:
log.error("Chybí config.json"); sys.exit(1)
dry = DRY_RUN or test_mode # --test vždy jen nasucho
globals()["DRY_RUN"] = dry
state = load_json(STATE_PATH, default={"last_id": None})
session = requests.Session()
session.get(cfg["watch_url"], headers=HEADERS, timeout=TIMEOUT) # init PHPSESSID
found = find_upcoming_webinar(session, cfg["watch_url"])
if not found:
log.info("Žádný nadcházející webinář na stránce nenalezen.")
if POSILATINFOPOKAZDEKONTROLE:
posli_telegram("🔎 Webináře: zkontrolováno, žádný nadcházející webinář na stránce.")
return
wid, banner, reg_url = found
banner_clean = banner.replace("\n", " ")
log.info("Nadcházející webinář: id=%s | %s | %s", wid, banner_clean, reg_url)
je_novy = test_mode or state.get("last_id") != wid
if not je_novy:
log.info("Beze změny (id=%s už zpracováno).", wid)
if POSILATINFOPOKAZDEKONTROLE:
posli_telegram(
f"✅ Webináře: zkontrolováno v 8:00, nic nového.\n"
f"Aktuální (už řešený): {banner_clean}"
)
return
# ── NOVÝ webinář ─────────────────────────────────────────────────────────
log.info("NOVÝ webinář! id=%s", wid)
try:
if not projdi_branu(session, cfg["base_url"], reg_url):
log.warning("Bránu se nepodařilo projít zkouším formulář i tak.")
action_url, hidden = parse_registration_form(session, reg_url)
except Exception as e:
log.exception("Chyba při čtení formuláře.")
posli_telegram(f"⚠️ Webináře: nový webinář {banner_clean}, ale NEPODAŘILO se přečíst formulář:\n{e}")
return
log.info("Formulář action=%s, skrytá pole=%s", action_url, hidden)
jmena = ", ".join(f"{p['jmeno']} {p['prijmeni']}" for p in cfg["registrants"])
# ── Telegram: zeptej se na souhlas s přihlášením ─────────────────────────
otazka = (
f"🆕 NOVÝ webinář na praktickylekar.online!\n\n"
f"{banner_clean}\n{reg_url}\n"
f"(webid={hidden.get('webid','?')}, cislo={hidden.get('cislo','?')})\n\n"
f"Mám přihlásit: {jmena}?\n"
f"{'[TEST nic se reálně neodešle] ' if dry else ''}"
f"Odpověz ANO / NE."
)
odpoved = zeptej_se_telegram(otazka, timeout=ASK_TIMEOUT)
if odpoved is None:
log.info("Bez odpovědi (timeout) state NEukládám, zeptám se zítra znovu.")
return
if not je_souhlas(odpoved):
log.info("Odpověď '%s' → NEpřihlašuji.", odpoved)
state["last_id"] = wid # rozhodnuto (ne) → příště se neptat znovu
save_json(STATE_PATH, state)
posli_telegram(f"👌 OK, webinář {banner_clean} nechávám bez přihlášení.")
return
# ── přihlášení ───────────────────────────────────────────────────────────
vysledky = []
for p in cfg["registrants"]:
cele = f"{p['jmeno']} {p['prijmeni']}"
try:
ok, info = register_person(session, action_url, reg_url, p, hidden)
vysledky.append(f"{'' if ok else ''} {cele}: {'OK' if ok else 'NEJISTÉ zkontroluj'}")
log.info("Registrace %s: %s | %s", cele, ok, info)
except Exception as e:
vysledky.append(f"{cele}: CHYBA {e}")
log.exception("Chyba při registraci %s", p["email"])
# state ukládáme až po pokusu o registraci
state["last_id"] = wid
save_json(STATE_PATH, state)
shrnuti = (
f"{'🧪 TEST (nic neodesláno) ' if dry else '📨 '}Přihlášení na webinář:\n"
f"{banner_clean}\n\n" + "\n".join(vysledky) +
("\n\n(Po reálném přihlášení dorazí potvrzovací e-mail z webu.)" if not dry else "")
)
posli_telegram(shrnuti)
log.info("Hotovo (last_id=%s).", wid)
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
# WireGuard road-warrior na MikroTiku (router-hosted)
Nastaveno 2026-06-18 podle runbooku `wireguard-mikrotik-runbook.md`.
## Router
- **MikrotikFirewall** (hEX, RouterOS 7.19.6), LAN IP `192.168.1.2`, SSH port **22**.
- WAN = `pppoe-out1`, veřejná IP `78.80.38.51` (PPPoE, bere se jako statická).
## DŮLEŽITÉ — proč port 51821, ne 51820
Na routeru **už běží jiná WireGuard VPN na Unraidu** (`192.168.1.76`): NAT rule
„WireGuard to Unraid" DST-NATuje příchozí UDP **51820** na Unraid. Proto tahle
nová, **na routeru hostovaná** VPN běží na **UDP 51821** (51820 by se nikdy
nedostalo k routeru). Existující Unraid VPN ani tunel `10.253.0.0/24` nejsou dotčené.
## Parametry této VPN
| | |
|---|---|
| WG rozhraní | `wg-vpn`, listen-port **51821** |
| Server public key | `CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=` |
| Tunelová síť | `10.10.10.0/24`, router `10.10.10.1` |
| Klienti | `10.10.10.2` (client2), `.3` (client3), `.4` (client4) |
| Endpoint | `78.80.38.51:51821` |
| Split tunel | AllowedIPs = `192.168.1.0/24` (jen LAN přes VPN) |
| DNS klientů | `192.168.1.2` (router) |
## Přidané firewall pravidla (jen accept, nic nemazáno/nepřeřazeno)
- input: accept udp dst-port 51821 in-interface=pppoe-out1 „WireGuard in (router)"
- input: accept in-interface=wg-vpn „WG -> router (DNS/ping)" (DNS a ping na router z tunelu)
- forward: accept in-interface=wg-vpn „WG -> LAN"
Všechna vložena PŘED příslušné `drop` v daném chainu.
NAT hairpin **nepřidán** — LAN hosti mají router jako default gw, návratová cesta funguje.
## Skripty
- `rosrun.py` — spouští RouterOS příkazy přes SSH. Creds z env: `ROS_HOST/ROS_PORT/ROS_USER/ROS_PASS`.
Pozn.: v Git Bash nutné `MSYS_NO_PATHCONV=1` a příkazy přes stdin (ne `--cmd`, mangluje `/...`).
- `gen_clients.py` — generuje klíče (wg.exe) + `.conf` + QR PNG do `wg-clients/`, a `_peers_add.rsc`.
## Klientské konfigurace
`wg-clients/clientN.conf` (import na notebook) + `wg-clients/clientN.png` (QR pro mobilní app).
**Obsahují privátní klíče** — po rozdání na zařízení smaž, ať neleží zbytečně.
## Test (jen zvenku, ne z LAN!)
Telefon na mobilních datech → naskenuj QR → ověř `ping 192.168.1.2`. Z LAN to
handshake neudělá (accept je vázán na in-interface=pppoe-out1, hairpin pro 51821 není).
## Rollback
```
/interface wireguard peers remove [find interface=wg-vpn]
/ip firewall filter remove [find comment="WG -> LAN"]
/ip firewall filter remove [find comment="WG -> router (DNS/ping)"]
/ip firewall filter remove [find comment="WireGuard in (router)"]
/ip address remove [find interface=wg-vpn]
/interface wireguard remove [find name=wg-vpn]
```
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Generate WireGuard road-warrior client configs + QR PNGs, and emit RouterOS peer-add commands."""
import subprocess, pathlib, qrcode
WG = r"C:\Program Files\WireGuard\wg"
SERVER_PUB = "CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo="
ENDPOINT = "78.80.38.51:51821"
LAN = "192.168.1.0/24" # split tunnel -> only LAN goes through VPN
DNS = "192.168.1.2" # router LAN IP
CLIENTS = [2, 3, 4]
outdir = pathlib.Path(__file__).resolve().parent / "wg-clients"
outdir.mkdir(exist_ok=True)
def wg(*args, inp=None):
return subprocess.run([WG, *args], input=inp, capture_output=True,
text=True, check=True).stdout.strip()
peer_cmds = []
for i in CLIENTS:
name = f"client{i}"
priv = wg("genkey")
pub = wg("pubkey", inp=priv)
psk = wg("genpsk")
conf = f"""[Interface]
PrivateKey = {priv}
Address = 10.10.10.{i}/32
DNS = {DNS}
[Peer]
PublicKey = {SERVER_PUB}
PresharedKey = {psk}
AllowedIPs = {LAN}
Endpoint = {ENDPOINT}
PersistentKeepalive = 25
"""
(outdir / f"{name}.conf").write_text(conf, encoding="utf-8")
img = qrcode.make(conf)
img.save(outdir / f"{name}.png")
peer_cmds.append(
f'/interface wireguard peers add interface=wg-vpn '
f'public-key="{pub}" preshared-key="{psk}" '
f'allowed-address=10.10.10.{i}/32 comment="{name}"'
)
print(f"[ok] {name}: pub={pub} -> {name}.conf, {name}.png")
(outdir / "_peers_add.rsc").write_text("\n".join(peer_cmds) + "\n", encoding="utf-8")
print("\n--- RouterOS peer-add commands written to wg-clients/_peers_add.rsc ---")
for c in peer_cmds:
print(c)
+8
View File
@@ -0,0 +1,8 @@
# ROLLBACK — obnova Unraid WireGuard objektů na routeru MikrotikFirewall
# Odstraněno 2026-06-18 na žádost uživatele. Spusť tyto příkazy pro obnovu.
/ip firewall nat add chain=dstnat action=dst-nat to-addresses=192.168.1.76 to-ports=51820 protocol=udp in-interface=pppoe-out1 dst-port=51820 comment="WireGuard to Unraid"
/ip firewall filter add chain=input action=accept protocol=udp in-interface=pppoe-out1 dst-port=51820 comment="Allow WireGuard"
/ip firewall filter add chain=forward action=accept src-address=10.253.0.0/24 comment="Allow VPN to LAN"
/ip firewall filter add chain=forward action=accept dst-address=10.253.0.0/24 comment="Allow LAN to VPN"
/ip route add dst-address=10.253.0.0/24 gateway=192.168.1.76 comment="Route to WireGuard VPN via Unraid"
# Pozn.: po obnově zkontroluj pořadí filter pravidel (accept musí být PŘED drop).
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Run RouterOS commands over SSH. Creds from env: ROS_HOST, ROS_PORT, ROS_USER, ROS_PASS.
Commands: one per line on stdin, or via --cmd. Prints output per command."""
import os, sys, paramiko
host = os.environ["ROS_HOST"]
port = int(os.environ.get("ROS_PORT", "22"))
user = os.environ["ROS_USER"]
pw = os.environ["ROS_PASS"]
cmds = []
if "--cmd" in sys.argv:
cmds = [sys.argv[sys.argv.index("--cmd") + 1]]
else:
cmds = [l.rstrip("\n") for l in sys.stdin if l.strip()]
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
cli.connect(host, port=port, username=user, password=pw,
look_for_keys=False, allow_agent=False, timeout=20)
for c in cmds:
print(f"\n===== CMD: {c}")
stdin, stdout, stderr = cli.exec_command(c, timeout=30)
out = stdout.read().decode("utf-8", "replace")
err = stderr.read().decode("utf-8", "replace")
sys.stdout.write(out)
if err.strip():
sys.stdout.write("--- stderr ---\n" + err)
cli.close()
+3
View File
@@ -0,0 +1,3 @@
/interface wireguard peers add interface=wg-vpn public-key="nToZ1GzONgfW1ve3O1WeEpGbgzUMhDVKE7qrD/Jc23c=" preshared-key="Y6eHm6MbLa+tyleSgwbPc8oJqLZkXZkMEUJZDU7f5kg=" allowed-address=10.10.10.2/32 comment="client2"
/interface wireguard peers add interface=wg-vpn public-key="tqA98HvVupGGYpR1PUe7/j9DO8MtaNP3Fh5tkpqgqD0=" preshared-key="94TmjBE+mTZi3KDy/tWefq/wXPpvmBtjPlX/LZnAKbE=" allowed-address=10.10.10.3/32 comment="client3"
/interface wireguard peers add interface=wg-vpn public-key="j/3kzNQ6vmUL4xFmqq5PL6Qf1xVWPzVWEXoOkBIDxFk=" preshared-key="pHR1441168wSrjlLZ2E44J4WrHpLRuWdjfsNHk23CQ8=" allowed-address=10.10.10.4/32 comment="client4"
+11
View File
@@ -0,0 +1,11 @@
[Interface]
PrivateKey = YPvh0rKU+xi82eQftBucCnuQzZNqk9jOHLwfEH0wsGk=
Address = 10.10.10.2/32
DNS = 192.168.1.2
[Peer]
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
PresharedKey = Y6eHm6MbLa+tyleSgwbPc8oJqLZkXZkMEUJZDU7f5kg=
AllowedIPs = 192.168.1.0/24
Endpoint = 78.80.38.51:51821
PersistentKeepalive = 25
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+11
View File
@@ -0,0 +1,11 @@
[Interface]
PrivateKey = 8JFWJp/zvoRYl7w2Jon0Xv+9YidiguiC26qGbr4ozlg=
Address = 10.10.10.3/32
DNS = 192.168.1.2
[Peer]
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
PresharedKey = 94TmjBE+mTZi3KDy/tWefq/wXPpvmBtjPlX/LZnAKbE=
AllowedIPs = 192.168.1.0/24
Endpoint = 78.80.38.51:51821
PersistentKeepalive = 25
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+11
View File
@@ -0,0 +1,11 @@
[Interface]
PrivateKey = oLcUtFkDW/e0/xmDgdBMlWIpGGL+eOvMxgnyXxtd5Ww=
Address = 10.10.10.4/32
DNS = 192.168.1.2
[Peer]
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
PresharedKey = pHR1441168wSrjlLZ2E44J4WrHpLRuWdjfsNHk23CQ8=
AllowedIPs = 192.168.1.0/24
Endpoint = 78.80.38.51:51821
PersistentKeepalive = 25
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB