# ============================================================ # download_vault_v2.1.py # Verze: 2.1 # Datum: 2026-06-12 # Popis: Dávkové stažení source-file dokumentů z Veeva Vault # (J&J V-TMF) podle Excelu ve WhatToDownload/. Perzistentní # Chromium profil, login přes J&J SSO + ruční 2FA, zavírání # maintenance dialogu, ukládání do stromu Type\Subtype # s pojmenováním podle Description. # # v2.0: seznam z Excelu (HYPERLINK → doc URL + VTMF), resume přes # download_state.csv, retry 2×, STATUS_FILTER. # v2.1: cíl U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\ # \\ ; jméno souboru: # "YYYY-MM-DD Description [VTMF-xxxxxxxx] [v1.0]." # — datum z Document Date (když chybí, vynechá se), Description # sanitizovaný (fallback Document Name bez koncové verze), # verze z konce Document Name "(vX.Y)" → "[vX.Y]", přípona # skutečná ze staženého souboru. LIMIT = počet dokumentů # na jeden běh (None = vše), kombinuje se s resume. # # Heslo se NIKDY nedává natvrdo do skriptu — čte se z .env # v rootu projektu Janssen (VAULT_USER / VAULT_PASS). # ============================================================ import csv import os import re import sys from datetime import datetime from pathlib import Path from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout # --- 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") 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" # vstupní Excel(y) STATE_FILE = SCRIPT_DIR / "download_state.csv" # průběžný stav běhu DOWNLOAD_ROOT = Path(r"U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001") # Filtr podle Document Status (např. {"Approved"}); None = stahovat vše STATUS_FILTER = None # Kolik dokumentů stáhnout v tomto běhu (None = všechny zbývající) LIMIT = 10 MAX_ATTEMPTS = 2 # pokusy na jeden dokument RETRY_PAUSE_MS = 5000 # pauza před opakováním BETWEEN_DOCS_MS = 500 # pauza mezi dokumenty 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 "" # doplnit jen řádky, které v souboru úplně chybí (prázdné hodnoty nechat) 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) # --- Načtení seznamu dokumentů z 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:///ui/#doc_info///.""" 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(): """Načte dokumenty z nejnovějšího .xlsx ve WhatToDownload/. Vrací list dictů: vtmf, url, name, status, type, subtype, desc (sanitizovaný, s fallbackem), date (datetime|None), version. Document Number je =HYPERLINK("url", "VTMF-...") — URL i VTMF se berou regexem; ošetřen je i 'pravý' hyperlink (cell.hyperlink). Pozn.: report má rozbité deklarované rozměry, čte se přímou iterací.""" from openpyxl import load_workbook excels = sorted(EXCEL_DIR.glob("*.xlsx"), key=lambda p: p.stat().st_mtime, reverse=True) excels = [p for p in excels if not p.name.startswith("~$")] if not excels: raise RuntimeError(f"Ve složce {EXCEL_DIR} není žádný .xlsx.") src = excels[0] log(f"[i] Čtu seznam dokumentů z: {src.name}") wb = load_workbook(src, 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") except ValueError as e: raise RuntimeError(f"V Excelu 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 "" 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(), "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, "version": version, }) log(f"[i] Načteno {len(docs)} dokumentů" + (f", {len(bad)} řádků bez použitelné URL (přeskočeno)" if bad else "")) if STATUS_FILTER: before = len(docs) docs = [d for d in docs if d["status"] in STATUS_FILTER] log(f"[i] Filtr Document Status {sorted(STATUS_FILTER)}: " f"{len(docs)} z {before}") return docs def build_target_path(doc, suggested_filename): """Cílová cesta: DOWNLOAD_ROOT\\Type\\Subtype\\ 'YYYY-MM-DD Description [VTMF-xxx] [v1.0].'. 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["version"] else "" filename = f"{date_prefix}{doc['desc']} [{doc['vtmf']}]{version}{ext}" return DOWNLOAD_ROOT / doc["type"] / doc["subtype"] / filename # --- Průběžný stav (resume) -------------------------------------------- STATE_FIELDS = ("vtmf", "result", "file", "timestamp") def load_state(): """Vrátí dict vtmf -> result z download_state.csv (poslední záznam vyhrává — neúspěch se při dalším běhu zkouší znovu).""" state = {} if STATE_FILE.exists(): with open(STATE_FILE, newline="", encoding="utf-8") as f: for row in csv.DictReader(f): state[row["vtmf"]] = row["result"] return state def append_state(vtmf, result, file=""): """Připíše výsledek dokumentu do stavového CSV (ihned, kvůli možnosti běh kdykoli přerušit).""" new = not STATE_FILE.exists() with open(STATE_FILE, "a", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=STATE_FIELDS) if new: w.writeheader() w.writerow({"vtmf": vtmf, "result": result, "file": file, "timestamp": datetime.now().isoformat(timespec="seconds")}) # --- 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") # Perzistentní session může rovnou přesměrovat do Vaultu if "vtmf.veevavault.com/ui" in page.url: log("[i] Už přihlášen (perzistentní session).") return # SSO redirecty chvíli trvají — čekáme buď na login formulář, # nebo na samovolný vstup do Vaultu. 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}") # údaje z .env v rootu projektu (případně env proměnné); # jejich existenci už ověřil ensure_credentials() na startu 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) # --- Detekce 2FA / čekání na vstup do Vaultu --- 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 # Pojistka: pokud formulář hlásí chybu (špatné heslo), nečekat na 2FA 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_dialog_debug(page, tag): """Uloží diagnostiku neumlčitelného dialogu: screenshot, HTML všech frames a výpis kandidátů na zavírací 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']"): n = frame.locator(sel).count() if n: report.append(f" {sel}: {n}x") 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 dialogu uložena do: {out}") return out # Viditelné OK tlačítko dialogu — je to , ne