This commit is contained in:
2026-06-15 16:43:46 +02:00
parent 495cf8da21
commit 91f1825109
14 changed files with 547 additions and 1247 deletions
@@ -0,0 +1,111 @@
# ============================================================
# export_from_seaweed_v1.0.py
# Verze: 1.0
# Datum: 2026-06-15
# Popis: Sestaví na disk strom dokumentů jedné úrovně studie
# ze SeaweedFS (zdroj pravdy je Mongo VTMF.documents +
# objekty v SeaweedFS Fileru). Pojmenování podle původních
# Dropbox pravidel (pipeline v1.5):
#
# <OUTPUT_ROOT>\<Type>\<Subtype>\
# "YYYY-MM-DD Description [VTMF-xxx] [v1.0].<přípona>"
#
# Datum/verze se vynechají, když chybí. Stahuje jen
# deleted=False, downloaded=True, placeholder!=True,
# se seaweed_path. Idempotentní — existující soubory
# přeskakuje (resume), takže lze pouštět opakovaně pro
# „aktuální verzi" čehokoli.
#
# Konfigurace (konstanty níže): STUDY, LEVEL, OUTPUT_ROOT.
# Pro jinou úroveň/studii stačí změnit tyto tři.
#
# Spuštění:
# & "...\.venv\Scripts\python.exe" "...\export_from_seaweed_v1.0.py"
# ============================================================
import re
import sys
import urllib.request
from pathlib import Path
from pymongo import MongoClient, ASCENDING
# --- Konfigurace -------------------------------------------------------
STUDY = "77242113UCO3001"
LEVEL = "study" # study | country | site
OUTPUT_ROOT = Path(r"U:\77242113UCO3001_STUDYLEVEL")
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "VTMF"
MONGO_COLL = "documents"
OVERWRITE = False # True = přepsat i existující soubory
BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f]")
def log(msg):
print(msg, flush=True)
def clean(s):
s = BAD_CHARS_RE.sub("_", str(s or ""))
s = re.sub(r"\s+", " ", s)
s = re.sub(r"_{2,}", "_", s)
return s.strip(" ._")
def target_path(doc):
"""<OUTPUT_ROOT>\\<Type>\\<Subtype>\\
'YYYY-MM-DD Description [VTMF-xxx] [v1.0].<přípona>'."""
ext = Path(doc.get("seaweed_path", "")).suffix
date = doc.get("date") or ""
date_prefix = (date + " ") if date else ""
version = f" [{doc['version']}]" if doc.get("version") else ""
desc = clean(doc.get("desc")) or clean(doc.get("vtmf"))
filename = f"{date_prefix}{desc} [{doc['vtmf']}]{version}{ext}"
typ = clean(doc.get("type")) or "_"
sub = clean(doc.get("subtype")) or "_"
return OUTPUT_ROOT / typ / sub / filename
def main():
coll = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)[MONGO_DB][MONGO_COLL]
q = {"level": LEVEL, "deleted": False, "downloaded": True,
"placeholder": {"$ne": True}, "seaweed_path": {"$ne": None}}
docs = list(coll.find(q).sort([("vtmf", ASCENDING), ("version", ASCENDING)]))
log(f"[i] {LEVEL}-level dokumentů studie {STUDY}: {len(docs)}")
log(f"[i] Cíl: {OUTPUT_ROOT}\n")
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
written = skipped = failed = 0
total_bytes = 0
for n, doc in enumerate(docs, 1):
dest = target_path(doc)
if dest.exists() and not OVERWRITE:
skipped += 1
continue
try:
with urllib.request.urlopen(doc["seaweed_url"], timeout=120) as r:
data = r.read()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
written += 1
total_bytes += len(data)
kb = len(data) / 1024
size = f"{kb:.0f} KB" if kb < 1024 else f"{kb / 1024:.1f} MB"
log(f"[{n}/{len(docs)}] {dest.relative_to(OUTPUT_ROOT)} ({size})")
except Exception as e:
failed += 1
log(f"[!] {doc['_id']}: {e}")
mb = total_bytes / 1024 / 1024
log(f"\n[ok] Hotovo: {written} zapsáno ({mb:.1f} MB), "
f"{skipped} přeskočeno (už existuje), {failed} chyb.")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,167 @@
# ============================================================
# export_from_seaweed_v1.1.py
# Verze: 1.1
# Datum: 2026-06-15
# Popis: Sestaví na disk strom dokumentů studie ze SeaweedFS
# (zdroj pravdy = Mongo VTMF.documents + objekty ve Fileru).
# Pojmenování podle původních Dropbox pravidel (pipeline v1.5):
#
# <základní podadresář>\<Type>\<Subtype>\
# "YYYY-MM-DD Description [VTMF-xxx] [v1.0].<přípona>"
#
# Základní podadresář pod OUTPUT_ROOT podle úrovně:
# study -> STUDY
# country -> COUNTRY
# site -> <číslo centra> (dokument se zapíše do složky
# KAŽDÉHO centra, do kterého je referencovaný)
#
# RYCHLÉ NASTAVENÍ (nahoře): STUDY + OUTPUT_ROOT + přepínače EXPORT
# (True/False u study/country/site).
#
# Stahuje jen deleted=False, downloaded=True, placeholder!=True,
# se seaweed_path. Idempotentní — existující soubory přeskakuje
# (resume), takže lze pouštět opakovaně pro „aktuální verzi".
#
# v1.1: přepínače úrovní (True/False) + základní podadresář dle úrovně
# (STUDY / COUNTRY / číslo centra); site-level se kopíruje do
# složky každého centra. (v1.0 v TRASH/ — jen study, pevně.)
#
# Spuštění:
# & "...\.venv\Scripts\python.exe" "...\export_from_seaweed_v1.1.py"
# ============================================================
import re
import sys
import urllib.request
from pathlib import Path
from pymongo import MongoClient, ASCENDING
# ====================================================================
# RYCHLÉ NASTAVENÍ
# ====================================================================
STUDY = "77242113UCO3001" # která studie
OUTPUT_ROOT = Path(r"U:\77242113UCO3001") # kam (pod ní vzniknou
# podadresáře dle úrovně)
# Co stáhnout — přepni True/False:
EXPORT = {
"study": True, # -> OUTPUT_ROOT\STUDY\<Type>\<Subtype>\...
"country": False, # -> OUTPUT_ROOT\COUNTRY\<Type>\<Subtype>\...
"site": False, # -> OUTPUT_ROOT\<číslo centra>\<Type>\<Subtype>\...
}
OVERWRITE = False # True = přepsat i existující soubory
# ====================================================================
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "VTMF"
MONGO_COLL = "documents"
BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f]")
def log(msg):
print(msg, flush=True)
def clean(s):
s = BAD_CHARS_RE.sub("_", str(s or ""))
s = re.sub(r"\s+", " ", s)
s = re.sub(r"_{2,}", "_", s)
return s.strip(" ._")
def filename_for(doc):
"""'YYYY-MM-DD Description [VTMF-xxx] [v1.0].<přípona>'."""
ext = Path(doc.get("seaweed_path", "")).suffix
date = doc.get("date") or ""
date_prefix = (date + " ") if date else ""
version = f" [{doc['version']}]" if doc.get("version") else ""
desc = clean(doc.get("desc")) or clean(doc.get("vtmf"))
return f"{date_prefix}{desc} [{doc['vtmf']}]{version}{ext}"
def base_dirs_for(doc, level):
"""Seznam základních podadresářů (pod OUTPUT_ROOT) pro daný dokument.
study -> [STUDY], country -> [COUNTRY], site -> [<každé centrum>]."""
if level == "study":
return [OUTPUT_ROOT / "STUDY"]
if level == "country":
return [OUTPUT_ROOT / "COUNTRY"]
# site: jedna kopie do složky každého centra
sites = [clean(s) for s in doc.get("sites", []) if clean(s)]
if not sites:
return [OUTPUT_ROOT / "SITE_nezname"]
return [OUTPUT_ROOT / s for s in sites]
def target_paths(doc, level):
fname = filename_for(doc)
typ = clean(doc.get("type")) or "_"
sub = clean(doc.get("subtype")) or "_"
return [base / typ / sub / fname for base in base_dirs_for(doc, level)]
def export_level(coll, level):
q = {"level": level, "studies": STUDY, "deleted": False,
"downloaded": True, "placeholder": {"$ne": True},
"seaweed_path": {"$ne": None}}
docs = list(coll.find(q).sort([("vtmf", ASCENDING), ("version", ASCENDING)]))
log(f"\n=== {level.upper()} === dokumentů: {len(docs)}")
if not docs:
if level != "study":
log(f"[i] (žádné {level}-level dokumenty v Mongo — pipeline pro "
f"tuto úroveň zatím možná neproběhla)")
return 0, 0, 0, 0
written = skipped = failed = 0
total_bytes = 0
for n, doc in enumerate(docs, 1):
dests = target_paths(doc, level)
need = dests if OVERWRITE else [d for d in dests if not d.exists()]
if not need:
skipped += len(dests)
continue
try:
with urllib.request.urlopen(doc["seaweed_url"], timeout=120) as r:
data = r.read()
for dest in need:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
written += 1
total_bytes += len(data)
skipped += len(dests) - len(need)
kb = len(data) / 1024
size = f"{kb:.0f} KB" if kb < 1024 else f"{kb / 1024:.1f} MB"
extra = f" (+{len(need)} kopií)" if len(need) > 1 else ""
log(f"[{n}/{len(docs)}] {need[0].relative_to(OUTPUT_ROOT)} ({size}){extra}")
except Exception as e:
failed += 1
log(f"[!] {doc['_id']}: {e}")
return written, skipped, failed, total_bytes
def main():
coll = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)[MONGO_DB][MONGO_COLL]
levels = [lvl for lvl in ("study", "country", "site") if EXPORT.get(lvl)]
log(f"[i] Studie: {STUDY}")
log(f"[i] Cíl: {OUTPUT_ROOT}")
log(f"[i] Úrovně: {', '.join(levels) if levels else '(žádná — zapni něco v EXPORT)'}")
if not levels:
sys.exit(0)
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
tw = ts = tf = tb = 0
for level in levels:
w, s, f, b = export_level(coll, level)
tw += w; ts += s; tf += f; tb += b
mb = tb / 1024 / 1024
log(f"\n[ok] Hotovo: {tw} souborů zapsáno ({mb:.1f} MB), "
f"{ts} přeskočeno (už existuje), {tf} chyb.")
sys.exit(1 if tf else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,69 @@
# export_from_seaweed_v1.2 — sestavení stromu dokumentů ze SeaweedFS
**Verze:** 1.2 · **Datum:** 2026-06-15
Sestaví na disk strom dokumentů studie z **Mongo `VTMF.documents` + objektů
v SeaweedFS Fileru**. Náhrada za dřívější ukládání do Dropboxu — pipeline
ukládá jen do SeaweedFS, tenhle skript kdykoli „vytáhne aktuální verzi"
čehokoli na disk pod čitelnými jmény.
## ⚠️ Pravidlo příslušnosti ke studii = `scopes[]`, NE `studies[]`
Tohle je klíčové a platí pro každý dotaz „co je ve VTMF na study/country/site
úrovni studie X":
- **`scopes[]`** = `"<level>|<study>|<country>"` — odkud jsme dokument reálně
natáhli (který report/úroveň). **Tohle určuje příslušnost** k TMF studie.
- **`studies[]`** = jen M:N reference — kam všude je dokument ve Vaultu
přilinkovaný (klidně 812 studií). Pro výběr „TMF studie X" se NEpoužívá.
Příklad: dokument sdílený s CRD3001/MDD3003 je má v `studies[]`, ale když má
`scopes=['study|77242113UCO3001|']`, patří do TMF **UCO3001**, ne CRD3001.
Skript proto vybírá přes scopes (regex `^<level>\|<study>\|`); studie, která
přes scopes nemá nic (neproběhla pro ni pipeline), se přeskočí.
## Pojmenování a struktura
Podle původních Dropbox pravidel (pipeline v1.5):
```
<OUTPUT_ROOT>\<základní podadresář>\<Type>\<Subtype>\
"YYYY-MM-DD Description [VTMF-xxx] [v1.0].<přípona>"
```
Základní podadresář podle úrovně:
| Úroveň | Podadresář | Poznámka |
|---|---|---|
| study | `STUDY` | |
| country | `COUNTRY` | |
| site | `<číslo centra>` | dokument se zapíše do složky **každého** centra ve `sites[]` (sdílený dokument = víc kopií → browsable strom po centrech) |
Datum / verze se z názvu vynechají, když chybí. `Type`/`Subtype` prázdné → `_`.
## Rychlé nastavení (nahoře ve skriptu)
```python
STUDIES = ["77242113UCO3001", "77242113UCO3002",
"77242113CRD3001", "42847922MDD3003"] # víc najednou
OUTPUT_ROOT_TMPL = r"U:\{study}" # {study} = kód studie
EXPORT = {"study": True, "country": True, "site": True} # True/False
OVERWRITE = False # True = přepsat existující
```
## Chování
- Stahuje jen `deleted=False, downloaded=True, placeholder!=True, seaweed_path!=None`.
- **Idempotentní** — existující soubory přeskakuje (resume). Lze pouštět
opakovaně pro „aktuální stav".
- Stáhne obsah jednoho dokumentu jednou, u site-level ho zapíše do všech
cílových složek center.
- Studie bez scopes se přeskočí s hláškou (pipeline pro ni ještě neběžela).
## Spuštění
```powershell
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\export_from_seaweed_v1.2.py"
```
Předchůdci: v1.0 (jen study, pevně), v1.1 (přepínače, filtr přes studies[]) — v TRASH/.
@@ -0,0 +1,186 @@
# ============================================================
# export_from_seaweed_v1.2.py
# Verze: 1.2
# Datum: 2026-06-15
# Popis: Sestaví na disk strom dokumentů studie ze SeaweedFS
# (zdroj pravdy = Mongo VTMF.documents + objekty ve Fileru).
# Pojmenování podle původních Dropbox pravidel (pipeline v1.5):
#
# <základní podadresář>\<Type>\<Subtype>\
# "YYYY-MM-DD Description [VTMF-xxx] [v1.0].<přípona>"
#
# Základní podadresář pod OUTPUT_ROOT podle úrovně:
# study -> STUDY
# country -> COUNTRY
# site -> <číslo centra> (dokument se zapíše do složky
# KAŽDÉHO centra, do kterého je referencovaný)
#
# PŘÍSLUŠNOST KE STUDII = pole scopes[] (ne studies[]!).
# Dokument patří studii X na úrovni L, pokud má scope
# "L|X|..." — tj. byl reálně natažen reportem té studie/úrovně.
# studies[] je jen M:N reference (kam všude je přilinkovaný)
# a pro výběr „TMF studie X" se NEpoužívá.
#
# RYCHLÉ NASTAVENÍ (nahoře): STUDIES + OUTPUT_ROOT_TMPL + přepínače
# EXPORT (True/False u study/country/site).
#
# Stahuje jen deleted=False, downloaded=True, placeholder!=True,
# se seaweed_path. Idempotentní — existující soubory přeskakuje
# (resume), takže lze pouštět opakovaně pro „aktuální verzi".
#
# v1.2: výběr podle scopes[] (ne studies[]); více studií najednou
# (seznam STUDIES). (v1.0/v1.1 v TRASH/.)
#
# Spuštění:
# & "...\.venv\Scripts\python.exe" "...\export_from_seaweed_v1.2.py"
# ============================================================
import re
import sys
import urllib.request
from pathlib import Path
from pymongo import MongoClient, ASCENDING
# ====================================================================
# RYCHLÉ NASTAVENÍ
# ====================================================================
# Které studie sestavit (podle scopes[]). Klidně víc najednou.
STUDIES = [
"77242113UCO3001",
"77242113UCO3002",
"77242113CRD3001",
"42847922MDD3003",
]
# Kam: {study} se nahradí kódem studie. Pod tím vzniknou podadresáře
# dle úrovně (STUDY / COUNTRY / číslo centra).
OUTPUT_ROOT_TMPL = r"U:\{study}"
# Co stáhnout — přepni True/False:
EXPORT = {
"study": True, # -> <root>\STUDY\<Type>\<Subtype>\...
"country": True, # -> <root>\COUNTRY\<Type>\<Subtype>\...
"site": True, # -> <root>\<číslo centra>\<Type>\<Subtype>\...
}
OVERWRITE = False # True = přepsat i existující soubory
# ====================================================================
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "VTMF"
MONGO_COLL = "documents"
BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f]")
def log(msg):
print(msg, flush=True)
def clean(s):
s = BAD_CHARS_RE.sub("_", str(s or ""))
s = re.sub(r"\s+", " ", s)
s = re.sub(r"_{2,}", "_", s)
return s.strip(" ._")
def filename_for(doc):
"""'YYYY-MM-DD Description [VTMF-xxx] [v1.0].<přípona>'."""
ext = Path(doc.get("seaweed_path", "")).suffix
date = doc.get("date") or ""
date_prefix = (date + " ") if date else ""
version = f" [{doc['version']}]" if doc.get("version") else ""
desc = clean(doc.get("desc")) or clean(doc.get("vtmf"))
return f"{date_prefix}{desc} [{doc['vtmf']}]{version}{ext}"
def base_dirs_for(doc, level, root):
"""Seznam základních podadresářů (pod root) pro daný dokument.
study -> [STUDY], country -> [COUNTRY], site -> [<každé centrum>]."""
if level == "study":
return [root / "STUDY"]
if level == "country":
return [root / "COUNTRY"]
sites = [clean(s) for s in doc.get("sites", []) if clean(s)]
return [root / s for s in sites] if sites else [root / "SITE_nezname"]
def target_paths(doc, level, root):
fname = filename_for(doc)
typ = clean(doc.get("type")) or "_"
sub = clean(doc.get("subtype")) or "_"
return [base / typ / sub / fname for base in base_dirs_for(doc, level, root)]
def export_study_level(coll, study, level, root):
# příslušnost ke studii/úrovni přes scope "level|study|..."
scope_prefix = re.escape(f"{level}|{study}|")
q = {"scopes": {"$regex": f"^{scope_prefix}"},
"deleted": False, "downloaded": True,
"placeholder": {"$ne": True}, "seaweed_path": {"$ne": None}}
docs = list(coll.find(q).sort([("vtmf", ASCENDING), ("version", ASCENDING)]))
log(f" [{level}] dokumentů: {len(docs)}")
if not docs:
return 0, 0, 0, 0
written = skipped = failed = 0
total_bytes = 0
for n, doc in enumerate(docs, 1):
dests = target_paths(doc, level, root)
need = dests if OVERWRITE else [d for d in dests if not d.exists()]
if not need:
skipped += len(dests)
continue
try:
with urllib.request.urlopen(doc["seaweed_url"], timeout=120) as r:
data = r.read()
for dest in need:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
written += 1
total_bytes += len(data)
skipped += len(dests) - len(need)
kb = len(data) / 1024
size = f"{kb:.0f} KB" if kb < 1024 else f"{kb / 1024:.1f} MB"
extra = f" (+{len(need)} kopií)" if len(need) > 1 else ""
log(f" [{n}/{len(docs)}] {need[0].relative_to(root)} ({size}){extra}")
except Exception as e:
failed += 1
log(f" [!] {doc['_id']}: {e}")
return written, skipped, failed, total_bytes
def main():
coll = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)[MONGO_DB][MONGO_COLL]
levels = [lvl for lvl in ("study", "country", "site") if EXPORT.get(lvl)]
log(f"[i] Studie: {', '.join(STUDIES)}")
log(f"[i] Úrovně: {', '.join(levels) if levels else '(žádná)'}")
log(f"[i] Cíl: {OUTPUT_ROOT_TMPL}\n")
if not levels:
sys.exit(0)
gw = gs = gf = gb = 0
for study in STUDIES:
root = Path(OUTPUT_ROOT_TMPL.format(study=study))
# má studie přes scopes vůbec něco?
has = coll.count_documents({"scopes": {"$regex": f"^[a-z]+\\|{re.escape(study)}\\|"}})
log(f"=== {study} -> {root} (scoped dokumentů: {has}) ===")
if not has:
log(f"[i] {study}: přes scopes nic — pipeline pro tuto studii "
f"zatím neproběhla, přeskakuji.\n")
continue
root.mkdir(parents=True, exist_ok=True)
for level in levels:
w, s, f, b = export_study_level(coll, study, level, root)
gw += w; gs += s; gf += f; gb += b
log("")
mb = gb / 1024 / 1024
log(f"[ok] Hotovo: {gw} souborů zapsáno ({mb:.1f} MB), "
f"{gs} přeskočeno (už existuje), {gf} chyb.")
sys.exit(1 if gf else 0)
if __name__ == "__main__":
main()
-102
View File
@@ -1,102 +0,0 @@
# vtmf_pipeline_v1.3 — Kompletní V-TMF workflow (report → Mongo → download)
**Verze:** 1.3 · **Datum:** 2026-06-12
**Změny v1.1:** oprava tichého selhání — výjimka kteréhokoli kroku se
vypíše jako „PIPELINE SELHALA" + exit kód 2 (v1.0 končila zavádějícím
souhrnem „0 staženo, 0 chyb"). Export reportu robustnější: menu ⋯,
položka Export to Excel i tlačítko Export se hledají přes víc selektorů
a ve všech frames; při nenalezení se automaticky uloží diagnostika
stránky do debug/<čas>_report_* (screenshot, HTML všech frames, výpis
title/aria-label atributů) — z ní se dá určit přesný selektor.
**Změny v1.2:** selektory exportu ověřené na živém DOM (Claude in
Chrome; žádný iframe na celé stránce): menu ⋯ =
`.actionMenuContainer .dropDown.vv_dropdown_toggle button.vv-icon-button`
(button má prázdný title!); menu se načítá asynchronně (AJAX) →
po kliknutí se čeká na položku `a.ReportAction[data-action-name='ExcelExport']`;
„Data Only" = radio `name=requiredRadioField value=STANDARD`, defaultně
checked (pojistka přes .check()); tlačítko Export = React `<button>`
s emotion class hash → selektovat jen přes roli+text.
**Změny v1.3:** na konci běhu se prohlížeč i konzole zavřou
automaticky (žádné čekání na ENTER); interaktivní vstup zůstává jen
u 2FA a u ručně nezavřitelného dialogu.
Jeden běh skriptu udělá celé workflow pro studii 77242113UCO3001:
1. **Login** do vtmf.veevavault.com (persistentní profil
`vault_profile/`, J&J SSO, případné 2FA potvrdíte na telefonu
+ ENTER; údaje z `.env` v rootu projektu).
2. **Export reportu** „Document Inventory Report - Study Level"
(přímá URL s ID reportu `0RP000000000182` a filtrem studie
`0ST000000137008`) → menu ⋯ → Export to Excel → Data Only →
uloží se s timestampem do `WhatToDownload/`, po zpracování se
přesune do `WhatToDownload/Zpracovano/`.
3. **Parse + sync do MongoDB** — Tower `mongodb://192.168.1.76:27017`,
db **VTMF**, kolekce **documents**, klíč `_id = "VTMF-xxx|vY.Z"`
(VTMF číslo + verze, unikátní index na dvojici):
- nový dokument → založí se (first_seen, deleted=False,
downloaded=False),
- změna sledovaných polí (name, status, type, subtype, desc,
date, url, studies) → promítne se + záznam do `history[]`
(timestamp + old/new),
- dokument chybí v reportu → `deleted=True, deleted_at` a stažený
soubor se přejmenuje s ` [D]` před příponou,
- dokument se vrátí do reportu → `deleted=False` a ` [D]`
se ze souboru zase odebere.
Výsledná sada = záznamy s `deleted=False`.
4. **Stažení chybějících** — všechny `deleted=False, downloaded≠True`:
doc URL → Source File → uložení do
`U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\<Type>\<Subtype>\`
jako `YYYY-MM-DD Description [VTMF-xxx] [vY.Z].<skutečná přípona>`.
Výsledek (cesta, čas, případně chyba) se ihned zapisuje do Mongo —
běh jde kdykoli přerušit a příště naváže.
## Mongo schéma (kolekce documents)
```
_id: "VTMF-19077748|v1.0"
vtmf, version, url, name, status, type, subtype, desc, date, studies
first_seen, last_seen # kdy poprvé/naposledy v reportu
deleted, deleted_at # není ve výsledné sadě reportu
downloaded, file, downloaded_at
last_error, error_at # poslední chyba stahování
history: [{ts, changes: {pole: {old, new}}}]
```
## Migrace starého stavu
Při prvním běhu se `download_state.csv` (z download_vault v2.x)
jednorázově namigruje: záznamy `ok` se k odpovídajícímu VTMF zapíší
jako `downloaded=True` + cesta. CSV se přejmenuje na
`download_state.csv.imported`.
## Konfigurace (konstanty nahoře)
- `REPORT_URL` — ID reportu + filtr studie (pro jinou studii se mění
jen tato dvě ID)
- `LIMIT` — None = stáhnout vše zbývající; číslo = dávka na běh
- `MONGO_URI/DB/COLL`, `DOWNLOAD_ROOT`, `EXCEL_DIR`
- `TRACKED_FIELDS`, `MAX_ATTEMPTS`, `RETRY_PAUSE_MS`, `BETWEEN_DOCS_MS`
## Ověřené technické detaily (nesahat bez ověření)
- Maintenance dialog: zavírat POUZE přes `.ui-dialog a.ok.vv_button`
(křížek `.ui-dialog-titlebar-close` je display:none); objevuje se
se zpožděním → wait_for visible 8 s (home) / 2-4 s (jinde).
- Report Excel má rozbité deklarované rozměry → přímá iterace řádků.
- Document Name/Number/Status jsou =HYPERLINK vzorce → regex.
- Export kliknout právě jednou; 503/redirecty v network logu
ignorovat, rozhoduje expect_download.
## Spuštění
```powershell
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\vtmf_pipeline_v1.3.py"
```
Předchůdce: download_vault v1.xv2.1 (TRASH/).
-112
View File
@@ -1,112 +0,0 @@
# vtmf_pipeline_v1.4 — Kompletní V-TMF workflow (report → Mongo → download)
**Verze:** 1.4 · **Datum:** 2026-06-15
**Změny v1.1:** oprava tichého selhání — výjimka kteréhokoli kroku se
vypíše jako „PIPELINE SELHALA" + exit kód 2 (v1.0 končila zavádějícím
souhrnem „0 staženo, 0 chyb"). Export reportu robustnější: menu ⋯,
položka Export to Excel i tlačítko Export se hledají přes víc selektorů
a ve všech frames; při nenalezení se automaticky uloží diagnostika
stránky do debug/<čas>_report_* (screenshot, HTML všech frames, výpis
title/aria-label atributů) — z ní se dá určit přesný selektor.
**Změny v1.2:** selektory exportu ověřené na živém DOM (Claude in
Chrome; žádný iframe na celé stránce): menu ⋯ =
`.actionMenuContainer .dropDown.vv_dropdown_toggle button.vv-icon-button`
(button má prázdný title!); menu se načítá asynchronně (AJAX) →
po kliknutí se čeká na položku `a.ReportAction[data-action-name='ExcelExport']`;
„Data Only" = radio `name=requiredRadioField value=STANDARD`, defaultně
checked (pojistka přes .check()); tlačítko Export = React `<button>`
s emotion class hash → selektovat jen přes roli+text.
**Změny v1.3:** na konci běhu se prohlížeč i konzole zavřou
automaticky (žádné čekání na ENTER); interaktivní vstup zůstává jen
u 2FA a u ručně nezavřitelného dialogu.
**Změny v1.4:** detekce placeholder dokumentů — Vault zobrazuje text
„This placeholder has no content", dokument nemá žádný Source File ke
stažení. Při detekci se zapíše `placeholder=True, downloaded=True` do
Mongo a dokument se přeskočí bez chyby. Souhrn na konci běhu uvádí
počet placeholderů zvlášť.
Jeden běh skriptu udělá celé workflow pro studii 77242113UCO3001:
1. **Login** do vtmf.veevavault.com (persistentní profil
`vault_profile/`, J&J SSO, případné 2FA potvrdíte na telefonu
+ ENTER; údaje z `.env` v rootu projektu).
2. **Export reportu** „Document Inventory Report - Study Level"
(přímá URL s ID reportu `0RP000000000182` a filtrem studie
`0ST000000137008`) → menu ⋯ → Export to Excel → Data Only →
uloží se s timestampem do `WhatToDownload/`, po zpracování se
přesune do `WhatToDownload/Zpracovano/`.
3. **Parse + sync do MongoDB** — Tower `mongodb://192.168.1.76:27017`,
db **VTMF**, kolekce **documents**, klíč `_id = "VTMF-xxx|vY.Z"`
(VTMF číslo + verze, unikátní index na dvojici):
- nový dokument → založí se (first_seen, deleted=False,
downloaded=False),
- změna sledovaných polí (name, status, type, subtype, desc,
date, url, studies) → promítne se + záznam do `history[]`
(timestamp + old/new),
- dokument chybí v reportu → `deleted=True, deleted_at` a stažený
soubor se přejmenuje s ` [D]` před příponou,
- dokument se vrátí do reportu → `deleted=False` a ` [D]`
se ze souboru zase odebere.
Výsledná sada = záznamy s `deleted=False`.
4. **Stažení chybějících** — všechny `deleted=False, downloaded≠True`:
doc URL → Source File → uložení do
`U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\<Type>\<Subtype>\`
jako `YYYY-MM-DD Description [VTMF-xxx] [vY.Z].<skutečná přípona>`.
Výsledek (cesta, čas, případně chyba) se ihned zapisuje do Mongo —
běh jde kdykoli přerušit a příště naváže.
Placeholder dokumenty (stránka s textem „This placeholder has no
content") se přeskočí a označí `placeholder=True, downloaded=True`.
## Mongo schéma (kolekce documents)
```
_id: "VTMF-19077748|v1.0"
vtmf, version, url, name, status, type, subtype, desc, date, studies
first_seen, last_seen # kdy poprvé/naposledy v reportu
deleted, deleted_at # není ve výsledné sadě reportu
downloaded, file, downloaded_at
placeholder # True = Vault placeholder bez obsahu
last_error, error_at # poslední chyba stahování
history: [{ts, changes: {pole: {old, new}}}]
```
## Migrace starého stavu
Při prvním běhu se `download_state.csv` (z download_vault v2.x)
jednorázově namigruje: záznamy `ok` se k odpovídajícímu VTMF zapíší
jako `downloaded=True` + cesta. CSV se přejmenuje na
`download_state.csv.imported`.
## Konfigurace (konstanty nahoře)
- `REPORT_URL` — ID reportu + filtr studie (pro jinou studii se mění
jen tato dvě ID)
- `LIMIT` — None = stáhnout vše zbývající; číslo = dávka na běh
- `MONGO_URI/DB/COLL`, `DOWNLOAD_ROOT`, `EXCEL_DIR`
- `TRACKED_FIELDS`, `MAX_ATTEMPTS`, `RETRY_PAUSE_MS`, `BETWEEN_DOCS_MS`
## Ověřené technické detaily (nesahat bez ověření)
- Maintenance dialog: zavírat POUZE přes `.ui-dialog a.ok.vv_button`
(křížek `.ui-dialog-titlebar-close` je display:none); objevuje se
se zpožděním → wait_for visible 8 s (home) / 2-4 s (jinde).
- Report Excel má rozbité deklarované rozměry → přímá iterace řádků.
- Document Name/Number/Status jsou =HYPERLINK vzorce → regex.
- Export kliknout právě jednou; 503/redirecty v network logu
ignorovat, rozhoduje expect_download.
- Placeholder detekce: `page.locator("div.vv_placeholder_text")` (uvnitř
`div.vv_placeholder_pane > div.vv_placeholder_container > div.vv-placeholder-drag-and-drop-container`)
se testuje před hledáním Source File ikony — CSS selektor je spolehlivější
než text match.
## Spuštění
```powershell
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\vtmf_pipeline_v1.4.py"
```
Předchůdce: vtmf_pipeline_v1.3 (TRASH/).
-96
View File
@@ -1,96 +0,0 @@
# vtmf_pipeline_v1.5 — Kompletní V-TMF workflow (report → Mongo → download → SeaweedFS)
**Verze:** 1.5 · **Datum:** 2026-06-15
**Změny v1.5:** upload každého staženého dokumentu do SeaweedFS Filer
(`192.168.1.50:8888`, cesta `/vtmf-documents/ab/cd/<sha256>`).
SHA-256 content-addressed dedup — identický soubor se uloží jen jednou
(HEAD check → 404 → PUT; při 200 dedup hit). Chyba uploadu neblokuje
download ani zápis do Mongo — soubor zůstane na disku a pole
`sha256/seaweed_path/seaweed_url/seaweed_synced_at` zůstanou `null`
(lze doplnit backfillem). Souhrn na konci uvádí počet nově nahraných,
dedup hitů a případných chyb uploadu zvlášť.
_(Předchozí změny viz TRASH/vtmf_pipeline_v1.4.md)_
Jeden běh skriptu udělá celé workflow pro studii 77242113UCO3001:
1. **Login** do vtmf.veevavault.com (persistentní profil
`vault_profile/`, J&J SSO, případné 2FA potvrdíte na telefonu
+ ENTER; údaje z `.env` v rootu projektu).
2. **Export reportu** „Document Inventory Report - Study Level"
(přímá URL s ID reportu `0RP000000000182` a filtrem studie
`0ST000000137008`) → menu ⋯ → Export to Excel → Data Only →
uloží se s timestampem do `WhatToDownload/`, po zpracování se
přesune do `WhatToDownload/Zpracovano/`.
3. **Parse + sync do MongoDB** — Tower `mongodb://192.168.1.76:27017`,
db **VTMF**, kolekce **documents**, klíč `_id = "VTMF-xxx|vY.Z"`:
- nové dokumenty se založí,
- změny sledovaných polí se promítnou (+ `history[]`),
- dokumenty chybějící v reportu se označí `deleted=True`
a stažený soubor dostane ` [D]` před příponou,
- znovuobjevené se vzkřísí a ` [D]` se odebere.
4. **Stažení + SeaweedFS upload** — všechny `deleted=False, downloaded≠True`:
- Source File se uloží do
`U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\<Type>\<Subtype>\`
jako `YYYY-MM-DD Description [VTMF-xxx] [vY.Z].<přípona>`,
- soubor se přečte z disku, vypočítá se SHA-256, obsah se nahraje
do SeaweedFS na `/vtmf-documents/{sha256[:2]}/{sha256[2:4]}/{sha256}`,
- do Mongo se zapíše `downloaded=True, file, sha256, seaweed_path,
seaweed_url, seaweed_synced_at`; chyba SeaweedFS tyto fieldy
nechá `null` ale `downloaded=True` se zapíše (soubor je na disku).
- Placeholder dokumenty (`div.vv_placeholder_text` viditelný) se
přeskočí s `placeholder=True, downloaded=True`.
## Mongo schéma (kolekce documents)
```
_id: "VTMF-19077748|v1.0"
vtmf, version, url, name, status, type, subtype, desc, date, studies
first_seen, last_seen # kdy poprvé/naposledy v reportu
deleted, deleted_at # není ve výsledné sadě reportu
downloaded, file, downloaded_at
placeholder # True = Vault placeholder bez obsahu
sha256 # hex SHA-256 staženého souboru
seaweed_path # /vtmf-documents/ab/cd/<sha256>
seaweed_url # http://192.168.1.50:8888/vtmf-documents/...
seaweed_synced_at # kdy nahráno / null při chybě
last_error, error_at # poslední chyba stahování
history: [{ts, changes: {pole: {old, new}}}]
```
## SeaweedFS detaily
- **Filer**: `http://192.168.1.50:8888` (přímý PUT, žádný master assign)
- **Dedup**: HEAD → 404 → PUT; HEAD → 200 → dedup hit (vrátí `uploaded=False`)
- **Timeout**: HEAD 10 s, PUT 120 s (velké soubory)
- **MIME**: `mimetypes.guess_type()`, fallback `application/octet-stream`
- **Backfill**: dokumenty s `downloaded=True, seaweed_path=null` lze
dohnat samostatným skriptem (čte `file` z Mongo, nahraje, zapíše pola)
## Konfigurace (konstanty nahoře)
- `SEAWEED_FILER` — URL Filer serveru
- `SEAWEED_PREFIX` — prefix cesty (`/vtmf-documents`)
- `REPORT_URL` — ID reportu + filtr studie
- `LIMIT` — None = vše; číslo = dávka
- `MONGO_URI/DB/COLL`, `DOWNLOAD_ROOT`, `EXCEL_DIR`
- `TRACKED_FIELDS`, `MAX_ATTEMPTS`, `RETRY_PAUSE_MS`, `BETWEEN_DOCS_MS`
## Ověřené technické detaily (nesahat bez ověření)
- Maintenance dialog: zavírat POUZE přes `.ui-dialog a.ok.vv_button`
(křížek `.ui-dialog-titlebar-close` je display:none).
- Report Excel má rozbité deklarované rozměry → přímá iterace řádků.
- Document Name/Number/Status jsou =HYPERLINK vzorce → regex.
- Export kliknout právě jednou; rozhoduje `expect_download`.
- Placeholder detekce: `div.vv_placeholder_text` (uvnitř
`div.vv_placeholder_pane > div.vv_placeholder_container`).
## Spuštění
```powershell
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\vtmf_pipeline_v1.5.py"
```
Předchůdce: vtmf_pipeline_v1.4 (TRASH/).
-937
View File
@@ -1,937 +0,0 @@
# ============================================================
# vtmf_pipeline_v1.5.py
# Verze: 1.5
# Datum: 2026-06-15
# Popis: Kompletní workflow V-TMF (J&J Veeva Vault), studie
# 77242113UCO3001. Jeden běh udělá:
# 1) login do Vaultu (persistentní session + ruční 2FA),
# 2) export reportu "Document Inventory Report - Study
# Level" do Excelu (Data Only) do WhatToDownload/,
# 3) parse reportu a synchronizaci do MongoDB
# (Tower, db VTMF, kolekce documents,
# klíč = VTMF číslo + verze):
# - nové dokumenty se založí,
# - změny polí se promítnou (+ history[]),
# - dokumenty chybějící v reportu se označí
# deleted=True a stažený soubor dostane ' [D]',
# - znovuobjevené se vzkřísí a ' [D]' se odebere,
# 4) stažení všech dosud nestažených dokumentů do
# U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\
# <Type>\<Subtype>\"YYYY-MM-DD Description
# [VTMF-x] [v1.0].<přípona>" + zápis stavu do Mongo.
#
# Tracking stahování je KOMPLETNĚ v Mongo; starý
# download_state.csv se při prvním běhu jednorázově
# namigruje a přejmenuje na .imported.
#
# Vychází z download_vault_v2.1 (v TRASH/) — login, dialogy
# a stahování beze změny; nové jsou kroky 2 a 3.
#
# v1.1: oprava tichého selhání — chyba kteréhokoli kroku se teď
# hlasitě vypíše (a exit kód 2), místo aby běh skončil
# souhrnem "0 staženo, 0 chyb". Export reportu: více
# selektorů pro menu ⋯ i položku Export to Excel (včetně
# hledání ve všech frames) a při selhání automatický záchyt
# diagnostiky stránky do debug/ (screenshot + HTML frames).
# v1.2: selektory exportu OVĚŘENÉ na živém DOM (žádný iframe):
# menu ⋯ = .actionMenuContainer .dropDown.vv_dropdown_toggle
# button.vv-icon-button (title prázdný!); menu se načítá
# asynchronně -> čekat na položku; položka =
# a.ReportAction[data-action-name='ExcelExport']; Data Only =
# radio name=requiredRadioField value=STANDARD (default
# checked); Export = <button> role+text (emotion class hash,
# neselektovat podle tříd).
# v1.3: na konci běhu se prohlížeč i okno zavře automaticky
# (žádné čekání na ENTER) — vhodné pro bezobslužné běhy.
# Interaktivní vstupy zůstávají jen tam, kde jsou nutné
# (2FA, ručně nezavřitelný dialog).
# v1.4: detekce placeholder dokumentů — stránka s textem
# "This placeholder has no content" se přeskočí
# (placeholder=True, downloaded=True v Mongo), žádná chyba.
# v1.5: upload stažených dokumentů do SeaweedFS Filer
# (192.168.1.50:8888, cesta /vtmf-documents/ab/cd/<sha256>).
# SHA-256 content-addressed dedup — identický soubor se uloží
# jen jednou. Chyba uploadu neblokuje download; chybějící
# sha256/seaweed_path lze doplnit backfillem. Mongo nově ukládá:
# sha256, seaweed_path, seaweed_url, seaweed_synced_at.
# Souhrn běhu uvádí počet nově nahraných vs. dedup hitů.
#
# Heslo se NIKDY nedává natvrdo do skriptu — čte se z .env
# v rootu projektu Janssen (VAULT_USER / VAULT_PASS).
# ============================================================
import csv
import hashlib
import mimetypes
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
from pymongo import MongoClient, ASCENDING
# --- Konfigurace -------------------------------------------------------
LOGIN_URL = ("https://fedlogin.jnj.com/idp/eyJ2c2lkIjoiam5qX3ZlZXZhIn0/"
"startSSO.ping?PartnerSpId=janssenetmf.veevavault.com"
"&IdpAdapterId=CompIWALDAPEXTFORM"
"&TargetResource=https%3A%2F%2Fvtmf.veevavault.com%2F")
# Report Document Inventory Report - Study Level, filtr na studii
REPORT_URL = ("https://vtmf.veevavault.com/ui/#reporting/viewer/"
"0RP000000000182?study__v%2C%2C%2CIN=0ST000000137008")
VAULT_UI_PATTERN = "**vtmf.veevavault.com/ui**" # úspěšný vstup do Vaultu
SCRIPT_DIR = Path(__file__).resolve().parent
PROFILE_DIR = SCRIPT_DIR / "vault_profile" # perzistentní session
ENV_FILE = SCRIPT_DIR.parent / ".env" # root projektu Janssen
DEBUG_DIR = SCRIPT_DIR / "debug" # diagnostické výstupy
EXCEL_DIR = SCRIPT_DIR / "WhatToDownload" # stažené reporty
PROCESSED_DIR = EXCEL_DIR / "Zpracovano" # archiv zpracovaných
OLD_STATE_FILE = SCRIPT_DIR / "download_state.csv" # legacy CSV (migrace)
DOWNLOAD_ROOT = Path(r"U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001")
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "VTMF"
MONGO_COLL = "documents"
# Kolik dokumentů stáhnout v tomto běhu (None = všechny zbývající)
LIMIT = 0
# Pole reportu, jejichž změny se promítají a verzují do history[]
TRACKED_FIELDS = ("name", "status", "type", "subtype", "desc",
"date", "url", "studies")
MAX_ATTEMPTS = 2 # pokusy na jeden dokument
RETRY_PAUSE_MS = 5000 # pauza před opakováním
BETWEEN_DOCS_MS = 500 # pauza mezi dokumenty
SEAWEED_FILER = "http://192.168.1.50:8888"
SEAWEED_PREFIX = "/vtmf-documents"
class PlaceholderDocument(Exception):
"""Dokument existuje jen jako placeholder — "This placeholder has no content"."""
def log(msg):
print(msg, flush=True)
def load_env_file(path):
"""Načte KEY=VALUE řádky z .env do os.environ.
Už nastavené env proměnné mají přednost, .env je nepřepisuje."""
if not path.exists():
log(f"[!] .env nenalezen: {path}")
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip('"').strip("'")
if value and key not in os.environ:
os.environ[key] = value
ENV_SECTION_HEADER = "# --- Veeva Vault (J&J V-TMF) — VTMFDownloadFiles/download_vault ---"
ENV_KEYS = ("VAULT_USER", "VAULT_PASS")
def ensure_credentials():
"""Načte .env; pokud VAULT_USER/VAULT_PASS chybí, založí/doplní
v .env šablonu, vyzve uživatele k doplnění a ukončí skript."""
load_env_file(ENV_FILE)
if all(os.environ.get(k) for k in ENV_KEYS):
return
existing = ENV_FILE.read_text(encoding="utf-8") if ENV_FILE.exists() else ""
missing_lines = [f"{k}=" for k in ENV_KEYS
if not re.search(rf"^\s*{k}\s*=", existing, re.M)]
if not ENV_FILE.exists():
ENV_FILE.write_text(
"# .env — lokální přihlašovací údaje (NEVERZOVAT, je v .gitignore)\n\n"
+ ENV_SECTION_HEADER + "\n"
+ "\n".join(missing_lines) + "\n",
encoding="utf-8")
log(f"[i] Založil jsem nový .env: {ENV_FILE}")
elif missing_lines:
with open(ENV_FILE, "a", encoding="utf-8") as f:
f.write("\n" + ENV_SECTION_HEADER + "\n"
+ "\n".join(missing_lines) + "\n")
log(f"[i] Doplnil jsem chybějící řádky do .env: {ENV_FILE}")
print("\n" + "=" * 60)
print(" CHYBÍ PŘIHLAŠOVACÍ ÚDAJE.")
print(f" Doplň VAULT_USER a VAULT_PASS do souboru:")
print(f" {ENV_FILE}")
print(" a spusť skript znovu.")
print("=" * 60)
sys.exit(1)
# --- Parsování Excelu --------------------------------------------------
HYPERLINK_RE = re.compile(r'HYPERLINK\("([^"]+)"\s*,\s*"([^"]+)"\)')
VERSION_RE = re.compile(r"\((v[^)]+)\)\s*$")
# nepovolené znaky Windows názvů + řídicí znaky + unicode artefakt
BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f]")
def clean_filename(s):
"""Očistí string na platné jméno souboru/složky ve Windows."""
s = BAD_CHARS_RE.sub("_", str(s))
s = re.sub(r"\s+", " ", s) # vícenásobné mezery -> jedna
s = re.sub(r"_{2,}", "_", s) # vícenásobná podtržítka -> jedno
return s.strip(" ._") # okraje: mezery, tečky, podtržítka
def display_text(cell):
"""Zobrazený text buňky — u =HYPERLINK vzorce druhý argument."""
raw = str(cell.value or "").strip()
m = HYPERLINK_RE.search(raw)
return m.group(2).strip() if m else raw
def extract_doc_url(raw):
"""Z HYPERLINK hodnoty (nebo i rozbité URL) vytáhne čistou doc URL
ve tvaru https://<host>/ui/#doc_info/<id>/<major>/<minor>."""
m = re.search(r"(https://[^/\"]+/ui/#doc_info/\d+/\d+/\d+)", str(raw))
if not m:
raise ValueError(f"Nenašel jsem doc URL v: {raw!r}")
return m.group(1)
def read_documents_from_excel(path):
"""Načte dokumenty z daného .xlsx reportu. Vrací list dictů:
vtmf, version, url, name, status, type, subtype, desc, date, studies.
Document Name/Number/Status jsou =HYPERLINK vzorce — URL i text se
berou regexem. Report má rozbité deklarované rozměry, čte se
přímou iterací řádků."""
from openpyxl import load_workbook
log(f"[i] Parsování reportu: {path.name}")
wb = load_workbook(path, data_only=False) # potřebujeme vzorce
ws = wb[wb.sheetnames[0]]
rows = ws.iter_rows()
header = [c.value for c in next(rows)]
try:
i_num = header.index("Document Number")
i_name = header.index("Document Name")
i_status = header.index("Document Status")
i_type = header.index("Type")
i_sub = header.index("Subtype")
i_desc = header.index("Description")
i_date = header.index("Document Date")
i_study = header.index("Study")
except ValueError as e:
raise RuntimeError(f"V reportu chybí očekávaný sloupec: {e}")
docs, bad = [], []
for row in rows:
cell = row[i_num]
if cell.value is None:
continue
raw = str(cell.value)
m = HYPERLINK_RE.search(raw)
if m:
url_raw, vtmf = m.group(1), m.group(2)
elif cell.hyperlink: # pravý hyperlink místo vzorce
url_raw, vtmf = cell.hyperlink.target, raw
else:
bad.append(raw)
continue
try:
url = extract_doc_url(url_raw)
except ValueError:
bad.append(raw)
continue
name = display_text(row[i_name])
vm = VERSION_RE.search(name)
version = vm.group(1) if vm else "v?"
desc = clean_filename(display_text(row[i_desc]))
if not desc:
# fallback: Document Name bez koncové verze (jde zvlášť na konec)
desc = clean_filename(VERSION_RE.sub("", name))
date = row[i_date].value # datetime nebo None
docs.append({
"vtmf": vtmf.strip(),
"version": version,
"url": url,
"name": name,
"status": display_text(row[i_status]),
"type": clean_filename(display_text(row[i_type])),
"subtype": clean_filename(display_text(row[i_sub])),
"desc": desc,
"date": date if hasattr(date, "strftime") else None,
"studies": display_text(row[i_study]),
})
log(f"[i] Načteno {len(docs)} dokumentů"
+ (f", {len(bad)} řádků bez použitelné URL (přeskočeno)" if bad else ""))
return docs
def build_target_path(doc, suggested_filename):
"""Cílová cesta: DOWNLOAD_ROOT\\Type\\Subtype\\
'YYYY-MM-DD Description [VTMF-xxx] [v1.0].<skutečná přípona>'.
Datum/verze se vynechají, když nejsou k dispozici."""
ext = Path(suggested_filename).suffix # skutečná přípona vč. tečky
date_prefix = doc["date"].strftime("%Y-%m-%d") + " " if doc["date"] else ""
version = f" [{doc['version']}]" if doc.get("version") else ""
filename = f"{date_prefix}{doc['desc']} [{doc['vtmf']}]{version}{ext}"
return DOWNLOAD_ROOT / doc["type"] / doc["subtype"] / filename
def deleted_marker_path(path):
"""Jméno souboru s příznakem smazání: 'x.pdf' -> 'x [D].pdf'."""
p = Path(path)
return p.with_name(f"{p.stem} [D]{p.suffix}")
# --- MongoDB synchronizace ---------------------------------------------
def doc_key(vtmf, version):
return f"{vtmf}|{version}"
def get_collection():
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
client.admin.command("ping")
coll = client[MONGO_DB][MONGO_COLL]
coll.create_index([("vtmf", ASCENDING), ("version", ASCENDING)],
unique=True)
coll.create_index([("deleted", ASCENDING), ("downloaded", ASCENDING)])
return coll
def migrate_old_csv(coll):
"""Jednorázová migrace download_state.csv do Mongo: záznamy 'ok'
se zapíší jako downloaded=True k odpovídajícímu VTMF (aktuální,
nesmazané verzi). CSV se pak přejmenuje na .imported."""
if not OLD_STATE_FILE.exists():
return
migrated = 0
with open(OLD_STATE_FILE, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if row["result"] != "ok":
continue
r = coll.update_one(
{"vtmf": row["vtmf"], "deleted": False,
"downloaded": {"$ne": True}},
{"$set": {"downloaded": True, "file": row["file"],
"downloaded_at": row["timestamp"]}})
migrated += r.modified_count
OLD_STATE_FILE.rename(OLD_STATE_FILE.with_suffix(".csv.imported"))
log(f"[i] Migrace download_state.csv -> Mongo: {migrated} záznamů; "
f"CSV přejmenováno na .imported")
def sync_report_to_mongo(coll, docs):
"""Promítne aktuální report do kolekce documents.
Klíč = (vtmf, version). Nové založí, změny polí promítne
(s history[]), chybějící označí deleted + soubor přejmenuje
s ' [D]', znovuobjevené vzkřísí a ' [D]' odebere."""
now = datetime.now()
stats = {"new": 0, "updated": 0, "unchanged": 0,
"resurrected": 0, "marked_deleted": 0}
current_keys = set()
for d in docs:
key = doc_key(d["vtmf"], d["version"])
current_keys.add(key)
existing = coll.find_one({"_id": key})
if existing is None:
coll.insert_one({
"_id": key, **d,
"first_seen": now, "last_seen": now,
"deleted": False, "downloaded": False,
"file": None, "history": [],
})
stats["new"] += 1
continue
changes = {}
for fld in TRACKED_FIELDS:
if existing.get(fld) != d.get(fld):
changes[fld] = {"old": existing.get(fld),
"new": d.get(fld)}
update = {"$set": {**d, "last_seen": now, "deleted": False}}
if changes:
update["$push"] = {"history": {"ts": now, "changes": changes}}
stats["updated"] += 1
else:
stats["unchanged"] += 1
if existing.get("deleted"):
# dokument se do reportu vrátil -> odebrat [D] ze souboru
stats["resurrected"] += 1
stats["unchanged"] -= 0 # (počítá se výše jako updated/unchanged)
old_file = existing.get("file")
if old_file:
marked = deleted_marker_path(old_file)
if marked.exists() and not Path(old_file).exists():
marked.rename(old_file)
log(f"[i] {key}: soubor vrácen z ' [D]' zpět.")
update["$set"]["file"] = str(old_file)
coll.update_one({"_id": key}, update)
# dokumenty, které v aktuálním reportu nejsou -> deleted + ' [D]'
for rec in coll.find({"deleted": False}):
if rec["_id"] in current_keys:
continue
upd = {"deleted": True, "deleted_at": now}
f = rec.get("file")
if f and Path(f).exists():
marked = deleted_marker_path(f)
try:
Path(f).rename(marked)
upd["file"] = str(marked)
log(f"[i] {rec['_id']}: soubor označen ' [D]'.")
except OSError as e:
log(f"[!] {rec['_id']}: přejmenování na [D] selhalo: {e}")
coll.update_one({"_id": rec["_id"]},
{"$set": upd,
"$push": {"history": {"ts": now,
"changes": {"deleted": {
"old": False,
"new": True}}}}})
stats["marked_deleted"] += 1
log(f"[ok] Mongo sync: {stats['new']} nových, {stats['updated']} změněných, "
f"{stats['unchanged']} beze změny, {stats['resurrected']} obnovených, "
f"{stats['marked_deleted']} označených deleted.")
return stats
# --- Přihlášení --------------------------------------------------------
def submit_login_form(page, password_box):
"""Odešle login formulář. Zkouší postupně tlačítka Sign On / Login /
OK / submit input; když žádné nenajde, stiskne Enter v poli hesla."""
candidates = [
page.get_by_role("button", name=re.compile("sign\\s*on", re.I)),
page.get_by_role("button", name=re.compile("log\\s*in|sign\\s*in", re.I)),
page.locator("input[type='submit']"),
page.locator("button[type='submit']"),
page.get_by_role("button", name=re.compile("^ok$", re.I)),
]
for loc in candidates:
try:
if loc.count() and loc.first.is_visible():
label = (loc.first.inner_text() or
loc.first.get_attribute("value") or "submit").strip()
log(f"[i] Odesílám formulář tlačítkem '{label}'...")
loc.first.click()
return
except Exception:
continue
log("[i] Tlačítko nenalezeno, odesílám Enterem v poli hesla...")
password_box.press("Enter")
def login_if_needed(page):
"""Otevře login URL, vyplní jméno+heslo, detekuje 2FA a počká na
ruční potvrzení. Pokud perzistentní session žije, login přeskočí."""
log(f"[i] Otevírám přihlašovací URL...")
page.goto(LOGIN_URL, wait_until="domcontentloaded")
if "vtmf.veevavault.com/ui" in page.url:
log("[i] Už přihlášen (perzistentní session).")
return
user_box = page.locator("input[type='text']").first
try:
user_box.wait_for(timeout=8000)
except PWTimeout:
if "vtmf.veevavault.com/ui" in page.url:
log("[i] Přihlášen bez formuláře (session redirect).")
return
raise RuntimeError(
f"Nenašel jsem login formulář ani Vault. Aktuální URL: {page.url}")
username = os.environ["VAULT_USER"]
password = os.environ["VAULT_PASS"]
log("[i] Vyplňuji přihlašovací údaje...")
user_box.fill(username)
password_box = page.locator("input[type='password']").first
password_box.fill(password)
submit_login_form(page, password_box)
log("[i] Odeslán login, čekám na výsledek...")
try:
page.wait_for_url(VAULT_UI_PATTERN, timeout=15000)
log("[ok] Přihlášen rovnou (bez 2FA).")
return
except PWTimeout:
pass # nejsme ve Vaultu -> pravděpodobně 2FA výzva
err = page.locator("text=/invalid|incorrect|failed/i")
try:
if err.count() and err.first.is_visible():
raise RuntimeError(f"Login selhal: {err.first.inner_text().strip()}")
except PWTimeout:
pass
print("\n" + "=" * 60)
print(" VYŽADOVÁNO OVĚŘENÍ NA TELEFONU (2FA).")
print(" Potvrď přihlášení v mobilní aplikaci.")
print("=" * 60)
input(" Až to potvrdíš, stiskni ENTER pro pokračování... ")
page.wait_for_url(VAULT_UI_PATTERN, timeout=120000)
log("[ok] Přihlášení dokončeno.")
def verify_inside(page):
"""Ověří, že jsme uvnitř Vaultu (URL na /ui)."""
page.wait_for_url(VAULT_UI_PATTERN, timeout=30000)
log(f"[ok] Uvnitř Vaultu: {page.url}")
def dialog_visible(page):
"""True, pokud je na stránce viditelný jQuery UI dialog."""
try:
dlg = page.locator(".ui-dialog")
return bool(dlg.count() and dlg.first.is_visible())
except Exception:
return False
def save_page_debug(page, tag):
"""Uloží diagnostiku stránky: screenshot, HTML všech frames a výpis
kandidátů na tlačítka. Vrátí cestu složky."""
out = DEBUG_DIR / datetime.now().strftime(f"%Y-%m-%d_%H-%M-%S_{tag}")
out.mkdir(parents=True, exist_ok=True)
try:
page.screenshot(path=str(out / "screenshot.png"), full_page=False)
except Exception as e:
(out / "screenshot_error.txt").write_text(str(e), encoding="utf-8")
report = []
for i, frame in enumerate(page.frames):
report.append(f"=== frame[{i}] url={frame.url}")
try:
(out / f"frame_{i}.html").write_text(frame.content(),
encoding="utf-8")
for sel in (".ui-dialog", "a.ok.vv_button",
".ui-dialog-titlebar-close",
"button", "input[type='button']",
"[title]", "[aria-label]"):
n = frame.locator(sel).count()
if n:
report.append(f" {sel}: {n}x")
# výpis title/aria-label atributů — pomáhá najít menu ⋯
for attr in ("title", "aria-label"):
vals = frame.locator(f"[{attr}]").evaluate_all(
f"els => els.map(e => e.getAttribute('{attr}'))")
uniq = sorted({v for v in vals if v})[:80]
report.append(f" {attr}: {uniq}")
except Exception as e:
report.append(f" [chyba čtení framu: {e}]")
(out / "frames_report.txt").write_text("\n".join(report),
encoding="utf-8")
log(f"[!] Diagnostika stránky uložena do: {out}")
return out
# Viditelné OK tlačítko dialogu — je to <a>, ne <button>!
# Křížek .ui-dialog-titlebar-close je display:none → NEPOUŽÍVAT.
DIALOG_OK_SELECTOR = (".ui-dialog a.ok.vv_button, "
".vv_login_msg_dialog .vv_button.ok")
def dismiss_maintenance_popup(page, timeout=8000):
"""Zavře Veeva login/maintenance dialog kliknutím na viditelné OK
(<a class='ok vv_button'>). Dialog se objevuje SE ZPOŽDĚNÍM,
proto se na něj krátce čeká. Bezpečné volat vždy."""
ok = page.locator(DIALOG_OK_SELECTOR)
try:
ok.first.wait_for(state="visible", timeout=timeout)
except PWTimeout:
return False # okno se neobjevilo — pokračujeme
except Exception:
return False
closed = 0
for _ in range(5): # dialogy umí být ve frontě
try:
if ok.count() and ok.first.is_visible():
ok.first.click()
page.wait_for_timeout(300)
closed += 1
log("[i] Maintenance/login dialog zavřen (OK).")
continue
except Exception:
pass
break
if not dialog_visible(page):
return bool(closed)
page.keyboard.press("Escape")
page.wait_for_timeout(500)
log("[i] Zkusil jsem dialog zavřít klávesou Escape.")
if dialog_visible(page):
save_page_debug(page, "dialog")
print("\n" + "=" * 60)
print(" DIALOG SE NEPODAŘILO ZAVŘÍT AUTOMATICKY.")
print(" Zavři ho prosím ručně v prohlížeči.")
print("=" * 60)
input(" Po ručním zavření stiskni ENTER... ")
return bool(closed)
# --- Export reportu ----------------------------------------------------
def _first_visible(page, builders):
"""Vrátí (locator, popis) prvního viditelného kandidáta. Hledá na
hlavní stránce i ve všech frames."""
for frame in page.frames:
for build, desc in builders:
try:
loc = build(frame)
if loc.count() and loc.first.is_visible():
return loc.first, desc
except Exception:
continue
return None, None
def download_report(page):
"""Stáhne report (Export to Excel, Data Only) do WhatToDownload/
pod timestampovaným názvem. Vrátí cestu k souboru.
Při selhání uloží diagnostiku stránky do debug/ a vyhodí výjimku."""
log("[i] Otevírám report Document Inventory Report - Study Level...")
page.goto(REPORT_URL, wait_until="domcontentloaded")
dismiss_maintenance_popup(page, timeout=4000)
# report je hotový, když se objeví počet záznamů / statusy
try:
page.wait_for_selector("text=Returned", timeout=30000)
except PWTimeout:
try:
page.wait_for_selector("text=Document Status:", timeout=30000)
except PWTimeout:
save_page_debug(page, "report_load")
raise RuntimeError(
"Report se nenačetl (nenašel jsem 'Returned' ani "
"'Document Status:'). Diagnostika v debug/.")
log("[i] Report načten, otevírám menu akcí (⋯)...")
# Menu ⋯ (Actions): button bez title/aria-label uvnitř
# .actionMenuContainer (ověřeno na živém DOM, žádný iframe).
actions, desc = _first_visible(page, [
(lambda f: f.locator(
".actionMenuContainer .dropDown.vv_dropdown_toggle "
"button.vv-icon-button"), ".actionMenuContainer button (ověřený)"),
(lambda f: f.locator(".actionMenuContainer button"), ".actionMenuContainer button (volnější)"),
(lambda f: f.locator("button[title='Actions'], [aria-label='Actions']"), "title/aria-label Actions"),
])
if actions is None:
save_page_debug(page, "report_menu")
raise RuntimeError("Nenašel jsem menu akcí (⋯) na reportu. "
"Diagnostika v debug/.")
log(f"[i] Menu nalezeno přes: {desc}")
actions.click()
# Menu se načítá ASYNCHRONNĚ (data-loaded=false -> AJAX),
# počkat na položku, nečíst hned po kliknutí.
item = page.locator("a.ReportAction[data-action-name='ExcelExport']")
try:
item.first.wait_for(state="visible", timeout=15000)
except PWTimeout:
# fallback podle textu (kdyby se data atribut změnil)
item = page.get_by_text("Export to Excel", exact=True)
try:
item.first.wait_for(state="visible", timeout=5000)
except PWTimeout:
save_page_debug(page, "report_export_item")
raise RuntimeError("Menu se otevřelo, ale položku 'Export to "
"Excel' jsem nenašel. Diagnostika v debug/.")
log("[i] Klikám 'Export to Excel'...")
item.first.click()
log("[i] Dialog Excel Export Options...")
# 'Data Only' = radio value=STANDARD, defaultně checked; pojistka.
radio = page.locator("input[name='requiredRadioField'][value='STANDARD']")
try:
radio.first.wait_for(state="visible", timeout=10000)
if not radio.first.is_checked():
radio.first.check()
log("[i] Přepnuto na 'Data Only'.")
except PWTimeout:
log("[!] Radio 'Data Only' nenalezeno — spoléhám na default dialogu.")
# Export = <button> s textem Export (React dialog, emotion třídy —
# NEselektovat podle class hash, jen role+text).
export_btn = page.get_by_role("button", name="Export", exact=True)
try:
export_btn.first.wait_for(state="visible", timeout=10000)
except PWTimeout:
save_page_debug(page, "report_export_btn")
raise RuntimeError("Dialog exportu bez tlačítka Export. "
"Diagnostika v debug/.")
export_btn = export_btn.first
# Export kliknout PRÁVĚ jednou (vícenásobné kliky = duplikáty);
# 503/redirecty v network logu neřešit — rozhoduje expect_download
with page.expect_download(timeout=120000) as dl_info:
export_btn.click()
download = dl_info.value
EXCEL_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
dest = EXCEL_DIR / f"{ts} {download.suggested_filename}"
download.save_as(str(dest))
log(f"[ok] Report uložen: {dest}")
return dest
def archive_report(path):
"""Po úspěšném zpracování přesune report do Zpracovano/."""
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
target = PROCESSED_DIR / path.name
path.rename(target)
log(f"[i] Report archivován: {target}")
# --- SeaweedFS ---------------------------------------------------------
def _sw_path(sha256):
return f"{SEAWEED_PREFIX}/{sha256[:2]}/{sha256[2:4]}/{sha256}"
def seaweed_store(data, mime="application/octet-stream"):
"""Idempotentní upload do SeaweedFS Filer.
Vrací (path, url, uploaded): uploaded=False znamená dedup hit."""
sha256 = hashlib.sha256(data).hexdigest()
path = _sw_path(sha256)
url = SEAWEED_FILER + path
try:
urllib.request.urlopen(
urllib.request.Request(url, method="HEAD"), timeout=10)
return path, url, False # soubor už existuje
except urllib.error.HTTPError as e:
if e.code != 404:
raise
req = urllib.request.Request(
url, data=data, method="PUT",
headers={"Content-Type": mime})
urllib.request.urlopen(req, timeout=120)
return path, url, True
# --- Stažení dokumentů -------------------------------------------------
def find_source_file_button(page):
"""Najde ikonu Source File (list papíru se šipkou dolů, vpravo nahoře).
Více fallback selektorů — DOM se může lišit podle typu dokumentu."""
candidates = [
"[title='Source File']",
"[aria-label='Source File']",
]
for sel in candidates:
loc = page.locator(sel)
if loc.count():
return loc.first
loc = page.get_by_role("button", name=re.compile("Source File", re.I))
if loc.count():
return loc.first
return None
def download_source_file(page, doc):
vtmf = doc["vtmf"]
log(f"[i] Otevírám dokument {vtmf} ({doc.get('version', '')}) ...")
page.goto(doc["url"], wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=30000)
except PWTimeout:
log("[!] networkidle nenastal do 30 s, zkouším pokračovat...")
dismiss_maintenance_popup(page, timeout=2000)
ph = page.locator("div.vv_placeholder_text")
if ph.count() and ph.first.is_visible():
log(f"[i] {vtmf}: placeholder bez obsahu — přeskakuji.")
raise PlaceholderDocument(vtmf)
target = find_source_file_button(page)
if target is None:
raise RuntimeError(
f"Nenašel jsem ikonu 'Source File' na stránce dokumentu {vtmf}.")
log("[i] Klikám na Source File a čekám na download...")
with page.expect_download(timeout=60000) as dl_info:
target.click()
# Varianta s dropdownem (Source File + Viewable Rendition)
try:
item = page.get_by_role("menuitem",
name=re.compile("Source File", re.I))
if item.count() and item.first.is_visible():
log("[i] Otevřel se dropdown, vybírám 'Source File'...")
item.first.click()
except Exception:
pass
download = dl_info.value
dest = build_target_path(doc, download.suggested_filename)
dest.parent.mkdir(parents=True, exist_ok=True)
download.save_as(str(dest))
return dest
def download_missing(page, coll):
"""Stáhne všechny nesmazané dokumenty bez downloaded=True.
Výsledek každého se ihned zapíše do Mongo."""
todo = list(coll.find({"deleted": False, "downloaded": {"$ne": True}})
.sort([("vtmf", ASCENDING), ("version", ASCENDING)]))
if LIMIT:
todo = todo[:LIMIT]
log(f"\n[i] Ke stažení: {len(todo)} dokumentů"
+ (f" (LIMIT={LIMIT})" if LIMIT else ""))
ok_count, fail_count, placeholder_count = 0, 0, 0
sw_uploaded = sw_dedup = sw_failed = 0
for n, doc in enumerate(todo, 1):
key = doc["_id"]
log(f"\n--- [{n}/{len(todo)}] {key} | {doc['desc'][:70]}")
last_err = None
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
dest = download_source_file(page, doc)
# SeaweedFS upload (neblokuje při chybě)
sw_path = sw_url = sw_ts = sha256_hex = None
try:
data = dest.read_bytes()
size_kb = len(data) / 1024
size_str = f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
ext = dest.suffix.lstrip('.').upper()
log(f"[ok] Stazeno: {dest.name} ({size_str} {ext})")
mime = mimetypes.guess_type(dest.name)[0] or "application/octet-stream"
sw_path, sw_url, uploaded = seaweed_store(data, mime)
sha256_hex = hashlib.sha256(data).hexdigest()
sw_ts = datetime.now()
if uploaded:
sw_uploaded += 1
log(f"[ok] SeaweedFS: nahrano ({size_str}) -> {sw_path}")
else:
sw_dedup += 1
log(f"[i] SeaweedFS: dedup hit ({size_str}) -> {sw_path}")
except Exception as sw_err:
sw_failed += 1
log(f"[!] SeaweedFS upload selhal (soubor je na disku): {sw_err}")
coll.update_one({"_id": key}, {"$set": {
"downloaded": True, "file": str(dest),
"downloaded_at": datetime.now(),
"sha256": sha256_hex,
"seaweed_path": sw_path,
"seaweed_url": sw_url,
"seaweed_synced_at": sw_ts,
"last_error": None}})
ok_count += 1
last_err = None
break
except PlaceholderDocument:
coll.update_one({"_id": key}, {"$set": {
"downloaded": True, "placeholder": True,
"file": None, "downloaded_at": datetime.now(),
"last_error": None}})
placeholder_count += 1
last_err = None
break
except Exception as e:
last_err = e
log(f"[!] Pokus {attempt}/{MAX_ATTEMPTS} selhal: {e}")
if attempt < MAX_ATTEMPTS:
page.wait_for_timeout(RETRY_PAUSE_MS)
if last_err is not None:
coll.update_one({"_id": key}, {"$set": {
"last_error": str(last_err),
"error_at": datetime.now()}})
fail_count += 1
page.wait_for_timeout(BETWEEN_DOCS_MS)
return ok_count, fail_count, placeholder_count, sw_uploaded, sw_dedup, sw_failed
# --- Main --------------------------------------------------------------
def main():
ensure_credentials()
coll = get_collection()
log(f"[ok] Mongo připojeno: {MONGO_URI} / {MONGO_DB}.{MONGO_COLL}")
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(PROFILE_DIR),
headless=False,
accept_downloads=True,
no_viewport=True, # okno se chová nativně
args=["--start-maximized"],
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
ok_count = fail_count = placeholder_count = 0
sw_uploaded = sw_dedup = sw_failed = 0
pipeline_error = None
try:
# 1) login
login_if_needed(page)
verify_inside(page)
dismiss_maintenance_popup(page)
# 2) export reportu
report_path = download_report(page)
# 3) parse + sync do Mongo
docs = read_documents_from_excel(report_path)
if not docs:
raise RuntimeError("Report neobsahuje žádné dokumenty — "
"sync přeskočen, nic se nemaže.")
sync_report_to_mongo(coll, docs)
migrate_old_csv(coll)
archive_report(report_path)
# 4) stažení chybějících
DOWNLOAD_ROOT.mkdir(parents=True, exist_ok=True)
(ok_count, fail_count, placeholder_count,
sw_uploaded, sw_dedup, sw_failed) = download_missing(page, coll)
except KeyboardInterrupt:
log("\n[!] Přerušeno uživatelem — stav je v Mongo, příští běh naváže.")
except Exception as e:
pipeline_error = e
print("\n" + "=" * 60)
print(" PIPELINE SELHALA!")
print(f" {type(e).__name__}: {e}")
print("=" * 60)
finally:
total = coll.count_documents({})
have = coll.count_documents({"deleted": False, "downloaded": True})
active = coll.count_documents({"deleted": False})
sw_info = (f"SeaweedFS: {sw_uploaded} nových, {sw_dedup} dedup"
+ (f", {sw_failed} chyb uploadu" if sw_failed else ""))
log(f"\n[i] Výsledek běhu: {ok_count} staženo, "
f"{placeholder_count} placeholderů přeskočeno, {fail_count} chyb"
+ (f", PIPELINE SELHALA ({pipeline_error})" if pipeline_error else ".")
+ (f"\n[i] {sw_info}" if ok_count else ""))
log(f"[i] Mongo: {total} záznamů celkem, {active} aktivních, "
f"z toho staženo {have} ({active - have} zbývá).")
log("[i] Zavírám prohlížeč.")
ctx.close()
sys.exit(2 if pipeline_error else (1 if fail_count else 0))
if __name__ == "__main__":
main()
+14
View File
@@ -69,6 +69,20 @@ seaweed_path, seaweed_url, seaweed_synced_at # jediné umístění souboru
history: [{ts, changes:{pole:{old,new}}}]
```
## ⚠️ Příslušnost ke studii/úrovni = `scopes[]`, NE `studies[]`
Pravidlo platné pro každý dotaz i skript („co je ve VTMF na study/country/site
úrovni studie X"):
- **`scopes[]`** = `"<level>|<study>|<country>"` — odkud byl dokument reálně
natažen (který report/úroveň). **Tohle určuje příslušnost k TMF studie.**
- **`studies[]`** = jen M:N reference (kam všude je ve Vaultu přilinkovaný,
klidně 812 studií). Pro výběr „TMF studie X" se NEpoužívá.
Příklad: dokument sdílený s CRD3001/MDD3003 je má v `studies[]`, ale s
`scopes=['study|77242113UCO3001|']` patří do TMF UCO3001, ne CRD3001.
Export i jakýkoli reporting filtruje přes scopes (`^<level>\|<study>\|`).
## Scoped sync (řeší mazací háček)
Mazání už **nekouká na celou kolekci** (to by sync country reportu označil