584 lines
23 KiB
Python
584 lines
23 KiB
Python
# ============================================================
|
||
# 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()
|