Files
2026-06-12 15:29:57 +02:00

584 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ============================================================
# 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\
# <Type>\<Subtype>\ ; jméno souboru:
# "YYYY-MM-DD Description [VTMF-xxxxxxxx] [v1.0].<přípona>"
# — 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://<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():
"""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].<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["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 <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'>; ověřeno na živém DOM — křížek v titulku
je display:none a Playwright na něj správně odmítá klikat).
Dialog se objevuje SE ZPOŽDĚNÍM po načtení stránky, proto se na
něj krátce čeká (timeout). Bezpečné volat vždy — když se okno
neobjeví, tiše projde."""
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 # dialog mezitím zmizel — v pořádku
break
if not dialog_visible(page):
return bool(closed)
# záloha: jQuery UI dialog s closeOnEscape reaguje na Escape
page.keyboard.press("Escape")
page.wait_for_timeout(500)
log("[i] Zkusil jsem dialog zavřít klávesou Escape.")
# dialog pořád viditelný -> zachytit diagnostiku pro analýzu
if dialog_visible(page):
save_dialog_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(" (Diagnostika uložena, viz cesta výše.)")
print("=" * 60)
input(" Po ručním zavření stiskni ENTER... ")
return bool(closed)
# --- Stažení -----------------------------------------------------------
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
# fallback: tlačítko/role s názvem Source File
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} ...")
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...")
# na stránkách dokumentů dialog obvykle není — čekat jen krátce
dismiss_maintenance_popup(page, timeout=2000)
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):
# pokud klik otevřel menu místo downloadu, klikni na položku menu.
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))
log(f"[ok] Uloženo: {dest}")
return dest
# --- Main --------------------------------------------------------------
def main():
ensure_credentials()
docs = read_documents_from_excel()
state = load_state()
done = {v for v, r in state.items() if r == "ok"}
todo = [d for d in docs if d["vtmf"] not in done]
log(f"[i] Hotovo z dřívějška: {len(done)}, zbývá celkem: {len(todo)}")
if not todo:
log("[ok] Vše už je staženo, není co dělat.")
return
if LIMIT:
todo = todo[:LIMIT]
log(f"[i] LIMIT={LIMIT}: v tomto běhu stáhnu {len(todo)} dokumentů.")
DOWNLOAD_ROOT.mkdir(parents=True, exist_ok=True)
ok_count, fail_count = 0, 0
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()
try:
login_if_needed(page)
verify_inside(page)
dismiss_maintenance_popup(page)
for n, doc in enumerate(todo, 1):
vtmf = doc["vtmf"]
log(f"\n--- [{n}/{len(todo)}] {vtmf} | {doc['desc'][:70]}")
last_err = None
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
dest = download_source_file(page, doc)
append_state(vtmf, "ok", str(dest))
ok_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:
append_state(vtmf, f"error: {last_err}")
fail_count += 1
page.wait_for_timeout(BETWEEN_DOCS_MS)
except KeyboardInterrupt:
log("\n[!] Přerušeno uživatelem — stav je uložen, příští běh naváže.")
finally:
log(f"\n[i] Výsledek běhu: {ok_count} staženo, {fail_count} chyb."
f" Stav: {STATE_FILE}")
input("ENTER pro zavření prohlížeče...")
ctx.close()
sys.exit(1 if fail_count else 0)
if __name__ == "__main__":
main()