z230
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# download_vault_v1.0 — Stažení source file z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 1.0 · **Datum:** 2026-06-12
|
||||
|
||||
Playwright skript pro stažení source-file dokumentů z Veeva Vault
|
||||
(vtmf.veevavault.com) s perzistentní session, ručním potvrzením 2FA
|
||||
na telefonu a přejmenováním staženého souboru podle VTMF čísla.
|
||||
|
||||
## Co skript dělá
|
||||
|
||||
1. Otevře viditelný maximalizovaný Chromium s perzistentním profilem
|
||||
(`vault_profile/` vedle skriptu) — session přežije běhy, takže po
|
||||
prvním přihlášení už se obvykle přihlašovat nemusí.
|
||||
2. Otevře J&J SSO login URL a vyplní jméno + heslo.
|
||||
3. Pokud SSO vyžaduje mobilní ověření (2FA), skript se **zastaví** a
|
||||
čeká, až ho potvrdíte v mobilní aplikaci a stisknete ENTER.
|
||||
4. Ověří, že jsme uvnitř Vaultu (URL `vtmf.veevavault.com/ui`), a zavře
|
||||
případné informační okno o údržbě.
|
||||
5. Otevře stránku dokumentu, klikne na ikonu **Source File** (vpravo
|
||||
nahoře, list papíru se šipkou dolů) a stažený soubor uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\` pod názvem
|
||||
`původní název [VTMF-XXXXXXXX].přípona`, např.
|
||||
`42847922MDD3003---Ongoing Third Party Oversight-31 May 2026 [VTMF-25690359].zip`
|
||||
|
||||
## Instalace
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Přihlašovací údaje
|
||||
|
||||
Heslo **není** ve skriptu. Skript čte proměnné prostředí:
|
||||
|
||||
```powershell
|
||||
$env:VAULT_USER = "vbuzalka"
|
||||
$env:VAULT_PASS = "..." # volitelné — bezpečnější je nezadávat
|
||||
```
|
||||
|
||||
Pokud proměnné chybí, skript se zeptá interaktivně (heslo přes
|
||||
`getpass`, nezobrazuje se). Doporučení: heslo zadávat interaktivně,
|
||||
neukládat ho do prostředí ani do souborů.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
python download_vault_v1.0.py
|
||||
```
|
||||
|
||||
**První běh:** zobrazí se login formulář, skript vyplní údaje, vy
|
||||
potvrdíte 2FA na telefonu a stisknete ENTER. Profil se uloží do
|
||||
`vault_profile/`, takže **další běhy** by měly jet rovnou bez
|
||||
přihlášení (dokud session nevyprší — pak se login + 2FA zopakuje).
|
||||
|
||||
Na konci běhu skript čeká na ENTER, teprve pak zavře prohlížeč
|
||||
(abyste si mohli stav zkontrolovat).
|
||||
|
||||
## Konfigurace (konstanty nahoře ve skriptu)
|
||||
|
||||
- `LOGIN_URL` — J&J SSO startSSO.ping URL cílící na vtmf.veevavault.com
|
||||
- `PROFILE_DIR` — perzistentní Chromium profil (`vault_profile/`)
|
||||
- `DOWNLOAD_DIR` — `U:\Dropbox\!!!Days\Downloads Z230`
|
||||
- `DOCUMENTS` — seznam dvojic `(url_dokumentu, vtmf_cislo)`; zatím jeden
|
||||
testovací dokument, připraveno na fázi 2 (načítání seznamu z Excelu)
|
||||
|
||||
## Známé body k dořešení (fáze 2)
|
||||
|
||||
- Ověřit selektor `[title='Source File']` na různých typech dokumentů.
|
||||
- Dokumenty s dropdownem (Source File + Viewable Rendition) — základní
|
||||
ošetření přes `menuitem` je ve skriptu, chce ověřit na reálném případu.
|
||||
- Načítání seznamu URL + VTMF čísel z Excelu (`openpyxl`) a smyčka přes
|
||||
všechny dokumenty (struktura `DOCUMENTS` je na to připravená).
|
||||
@@ -0,0 +1,226 @@
|
||||
# ============================================================
|
||||
# download_vault_v1.0.py
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-12
|
||||
# Popis: Stažení source-file dokumentu z Veeva Vault (J&J V-TMF).
|
||||
# Perzistentní Chromium profil (session přežije běhy),
|
||||
# login přes J&J SSO formulář, ruční potvrzení 2FA na
|
||||
# telefonu, stažení Source File a přejmenování podle
|
||||
# VTMF čísla do U:\Dropbox\!!!Days\Downloads Z230\.
|
||||
#
|
||||
# Heslo se NIKDY neukládá do skriptu — čte se z env proměnných
|
||||
# VAULT_USER / VAULT_PASS, případně se zadá interaktivně.
|
||||
# ============================================================
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from getpass import getpass
|
||||
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
|
||||
DOWNLOAD_DIR = Path(r"U:\Dropbox\!!!Days\Downloads Z230")
|
||||
|
||||
# Zatím jeden dokument; struktura připravená na pozdější seznam
|
||||
# (ve fázi 2 se naplní z Excelu).
|
||||
DOCUMENTS = [
|
||||
("https://vtmf.veevavault.com/ui/#doc_info/31824736/1/0", "VTMF-25690359"),
|
||||
]
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
# --- Přihlášení --------------------------------------------------------
|
||||
|
||||
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}")
|
||||
|
||||
username = os.environ.get("VAULT_USER") or input("WWID/username: ")
|
||||
password = os.environ.get("VAULT_PASS") or getpass("Network Password: ")
|
||||
if not username or not password:
|
||||
raise RuntimeError("Chybí přihlašovací údaje.")
|
||||
|
||||
log("[i] Vyplňuji přihlašovací údaje...")
|
||||
user_box.fill(username)
|
||||
page.locator("input[type='password']").first.fill(password)
|
||||
page.get_by_role("button", name=re.compile("Sign On", re.I)).click()
|
||||
|
||||
# --- 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 dismiss_maintenance_popup(page):
|
||||
"""Zavře informační okno o údržbě, pokud je zobrazené."""
|
||||
for name in ("OK", "Close", "Continue"):
|
||||
btn = page.get_by_role("button", name=re.compile(f"^{name}$", re.I))
|
||||
try:
|
||||
if btn.count() and btn.first.is_visible():
|
||||
btn.first.click()
|
||||
log(f"[i] Zavřel jsem dialog tlačítkem '{name}'.")
|
||||
page.wait_for_timeout(500)
|
||||
except Exception:
|
||||
pass # dialog není / mezitím zmizel — pokračujeme
|
||||
|
||||
|
||||
# --- Stažení -----------------------------------------------------------
|
||||
|
||||
def build_target_name(suggested, vtmf):
|
||||
"""Původní název + ' [VTMF-XXXXXX]' před příponou."""
|
||||
if "." in suggested:
|
||||
base, ext = suggested.rsplit(".", 1)
|
||||
return f"{base} [{vtmf}].{ext}"
|
||||
return f"{suggested} [{vtmf}]"
|
||||
|
||||
|
||||
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_url, 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...")
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
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
|
||||
|
||||
new_name = build_target_name(download.suggested_filename, vtmf)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = DOWNLOAD_DIR / new_name
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Uloženo: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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, failed = [], []
|
||||
try:
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
for doc_url, vtmf in DOCUMENTS:
|
||||
try:
|
||||
ok.append(download_source_file(page, doc_url, vtmf))
|
||||
except Exception as e:
|
||||
log(f"[CHYBA] {vtmf}: {e}")
|
||||
failed.append(vtmf)
|
||||
finally:
|
||||
log(f"\n[i] Hotovo: {len(ok)} staženo, {len(failed)} chyb"
|
||||
+ (f" ({', '.join(failed)})" if failed else ""))
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,80 @@
|
||||
# download_vault_v1.1 — Stažení source file z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 1.1 · **Datum:** 2026-06-12
|
||||
|
||||
**Změny v1.1:** přihlašovací údaje se čtou ze souboru `.env` v rootu
|
||||
projektu Janssen (`U:\PythonProject\Janssen\.env`, sekce Veeva Vault).
|
||||
|
||||
Playwright skript pro stažení source-file dokumentů z Veeva Vault
|
||||
(vtmf.veevavault.com) s perzistentní session, ručním potvrzením 2FA
|
||||
na telefonu a přejmenováním staženého souboru podle VTMF čísla.
|
||||
|
||||
## Co skript dělá
|
||||
|
||||
1. Otevře viditelný maximalizovaný Chromium s perzistentním profilem
|
||||
(`vault_profile/` vedle skriptu) — session přežije běhy, takže po
|
||||
prvním přihlášení už se obvykle přihlašovat nemusí.
|
||||
2. Otevře J&J SSO login URL a vyplní jméno + heslo.
|
||||
3. Pokud SSO vyžaduje mobilní ověření (2FA), skript se **zastaví** a
|
||||
čeká, až ho potvrdíte v mobilní aplikaci a stisknete ENTER.
|
||||
4. Ověří, že jsme uvnitř Vaultu (URL `vtmf.veevavault.com/ui`), a zavře
|
||||
případné informační okno o údržbě.
|
||||
5. Otevře stránku dokumentu, klikne na ikonu **Source File** (vpravo
|
||||
nahoře, list papíru se šipkou dolů) a stažený soubor uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\` pod názvem
|
||||
`původní název [VTMF-XXXXXXXX].přípona`, např.
|
||||
`42847922MDD3003---Ongoing Third Party Oversight-31 May 2026 [VTMF-25690359].zip`
|
||||
|
||||
## Instalace
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Přihlašovací údaje
|
||||
|
||||
Heslo **není** ve skriptu. Údaje se čtou v tomto pořadí:
|
||||
|
||||
1. **`.env` v rootu projektu** — `U:\PythonProject\Janssen\.env`,
|
||||
sekce Veeva Vault (doplňte hodnoty za `=`):
|
||||
|
||||
```
|
||||
VAULT_USER=vbuzalka
|
||||
VAULT_PASS=vaše-heslo
|
||||
```
|
||||
|
||||
Soubor je v `.gitignore`, do gitu se nedostane.
|
||||
2. Env proměnné `VAULT_USER` / `VAULT_PASS` (mají přednost před `.env`).
|
||||
3. Pokud nic z toho není nastaveno, skript se zeptá interaktivně
|
||||
(heslo přes `getpass`, nezobrazuje se).
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
python download_vault_v1.0.py
|
||||
```
|
||||
|
||||
**První běh:** zobrazí se login formulář, skript vyplní údaje, vy
|
||||
potvrdíte 2FA na telefonu a stisknete ENTER. Profil se uloží do
|
||||
`vault_profile/`, takže **další běhy** by měly jet rovnou bez
|
||||
přihlášení (dokud session nevyprší — pak se login + 2FA zopakuje).
|
||||
|
||||
Na konci běhu skript čeká na ENTER, teprve pak zavře prohlížeč
|
||||
(abyste si mohli stav zkontrolovat).
|
||||
|
||||
## Konfigurace (konstanty nahoře ve skriptu)
|
||||
|
||||
- `LOGIN_URL` — J&J SSO startSSO.ping URL cílící na vtmf.veevavault.com
|
||||
- `PROFILE_DIR` — perzistentní Chromium profil (`vault_profile/`)
|
||||
- `DOWNLOAD_DIR` — `U:\Dropbox\!!!Days\Downloads Z230`
|
||||
- `DOCUMENTS` — seznam dvojic `(url_dokumentu, vtmf_cislo)`; zatím jeden
|
||||
testovací dokument, připraveno na fázi 2 (načítání seznamu z Excelu)
|
||||
|
||||
## Známé body k dořešení (fáze 2)
|
||||
|
||||
- Ověřit selektor `[title='Source File']` na různých typech dokumentů.
|
||||
- Dokumenty s dropdownem (Source File + Viewable Rendition) — základní
|
||||
ošetření přes `menuitem` je ve skriptu, chce ověřit na reálném případu.
|
||||
- Načítání seznamu URL + VTMF čísel z Excelu (`openpyxl`) a smyčka přes
|
||||
všechny dokumenty (struktura `DOCUMENTS` je na to připravená).
|
||||
@@ -0,0 +1,247 @@
|
||||
# ============================================================
|
||||
# download_vault_v1.1.py
|
||||
# Verze: 1.1
|
||||
# Datum: 2026-06-12
|
||||
# Popis: Stažení source-file dokumentu z Veeva Vault (J&J V-TMF).
|
||||
# Perzistentní Chromium profil (session přežije běhy),
|
||||
# login přes J&J SSO formulář, ruční potvrzení 2FA na
|
||||
# telefonu, stažení Source File a přejmenování podle
|
||||
# VTMF čísla do U:\Dropbox\!!!Days\Downloads Z230\.
|
||||
#
|
||||
# v1.1: VAULT_USER / VAULT_PASS se čtou ze souboru .env v rootu
|
||||
# projektu Janssen (sekce Veeva Vault); env proměnné mají
|
||||
# přednost, při chybějících údajích se zeptá interaktivně.
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu.
|
||||
# ============================================================
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from getpass import getpass
|
||||
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
|
||||
DOWNLOAD_DIR = Path(r"U:\Dropbox\!!!Days\Downloads Z230")
|
||||
|
||||
# Zatím jeden dokument; struktura připravená na pozdější seznam
|
||||
# (ve fázi 2 se naplní z Excelu).
|
||||
DOCUMENTS = [
|
||||
("https://vtmf.veevavault.com/ui/#doc_info/31824736/1/0", "VTMF-25690359"),
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- Přihlášení --------------------------------------------------------
|
||||
|
||||
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é); jinak interaktivně
|
||||
username = os.environ.get("VAULT_USER") or input("WWID/username: ")
|
||||
password = os.environ.get("VAULT_PASS") or getpass("Network Password: ")
|
||||
if not username or not password:
|
||||
raise RuntimeError("Chybí přihlašovací údaje.")
|
||||
|
||||
log("[i] Vyplňuji přihlašovací údaje...")
|
||||
user_box.fill(username)
|
||||
page.locator("input[type='password']").first.fill(password)
|
||||
page.get_by_role("button", name=re.compile("Sign On", re.I)).click()
|
||||
|
||||
# --- 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 dismiss_maintenance_popup(page):
|
||||
"""Zavře informační okno o údržbě, pokud je zobrazené."""
|
||||
for name in ("OK", "Close", "Continue"):
|
||||
btn = page.get_by_role("button", name=re.compile(f"^{name}$", re.I))
|
||||
try:
|
||||
if btn.count() and btn.first.is_visible():
|
||||
btn.first.click()
|
||||
log(f"[i] Zavřel jsem dialog tlačítkem '{name}'.")
|
||||
page.wait_for_timeout(500)
|
||||
except Exception:
|
||||
pass # dialog není / mezitím zmizel — pokračujeme
|
||||
|
||||
|
||||
# --- Stažení -----------------------------------------------------------
|
||||
|
||||
def build_target_name(suggested, vtmf):
|
||||
"""Původní název + ' [VTMF-XXXXXX]' před příponou."""
|
||||
if "." in suggested:
|
||||
base, ext = suggested.rsplit(".", 1)
|
||||
return f"{base} [{vtmf}].{ext}"
|
||||
return f"{suggested} [{vtmf}]"
|
||||
|
||||
|
||||
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_url, 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...")
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
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
|
||||
|
||||
new_name = build_target_name(download.suggested_filename, vtmf)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = DOWNLOAD_DIR / new_name
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Uloženo: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
load_env_file(ENV_FILE)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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, failed = [], []
|
||||
try:
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
for doc_url, vtmf in DOCUMENTS:
|
||||
try:
|
||||
ok.append(download_source_file(page, doc_url, vtmf))
|
||||
except Exception as e:
|
||||
log(f"[CHYBA] {vtmf}: {e}")
|
||||
failed.append(vtmf)
|
||||
finally:
|
||||
log(f"\n[i] Hotovo: {len(ok)} staženo, {len(failed)} chyb"
|
||||
+ (f" ({', '.join(failed)})" if failed else ""))
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
# download_vault_v1.2 — Stažení source file z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 1.2 · **Datum:** 2026-06-12
|
||||
|
||||
**Změny v1.1:** přihlašovací údaje se čtou ze souboru `.env` v rootu
|
||||
projektu Janssen (`U:\PythonProject\Janssen\.env`, sekce Veeva Vault).
|
||||
**Změny v1.2:** když údaje chybí (typicky nový počítač — `.env` se přes
|
||||
Giteu nepřenáší), skript `.env` sám založí, případně do něj doplní
|
||||
chybějící řádky `VAULT_USER=`/`VAULT_PASS=`, vyzve k doplnění a skončí.
|
||||
|
||||
Playwright skript pro stažení source-file dokumentů z Veeva Vault
|
||||
(vtmf.veevavault.com) s perzistentní session, ručním potvrzením 2FA
|
||||
na telefonu a přejmenováním staženého souboru podle VTMF čísla.
|
||||
|
||||
## Co skript dělá
|
||||
|
||||
1. Otevře viditelný maximalizovaný Chromium s perzistentním profilem
|
||||
(`vault_profile/` vedle skriptu) — session přežije běhy, takže po
|
||||
prvním přihlášení už se obvykle přihlašovat nemusí.
|
||||
2. Otevře J&J SSO login URL a vyplní jméno + heslo.
|
||||
3. Pokud SSO vyžaduje mobilní ověření (2FA), skript se **zastaví** a
|
||||
čeká, až ho potvrdíte v mobilní aplikaci a stisknete ENTER.
|
||||
4. Ověří, že jsme uvnitř Vaultu (URL `vtmf.veevavault.com/ui`), a zavře
|
||||
případné informační okno o údržbě.
|
||||
5. Otevře stránku dokumentu, klikne na ikonu **Source File** (vpravo
|
||||
nahoře, list papíru se šipkou dolů) a stažený soubor uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\` pod názvem
|
||||
`původní název [VTMF-XXXXXXXX].přípona`, např.
|
||||
`42847922MDD3003---Ongoing Third Party Oversight-31 May 2026 [VTMF-25690359].zip`
|
||||
|
||||
## Instalace
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Přihlašovací údaje
|
||||
|
||||
Heslo **není** ve skriptu. Údaje se čtou v tomto pořadí:
|
||||
|
||||
1. **`.env` v rootu projektu** — `U:\PythonProject\Janssen\.env`,
|
||||
sekce Veeva Vault (doplňte hodnoty za `=`):
|
||||
|
||||
```
|
||||
VAULT_USER=vbuzalka
|
||||
VAULT_PASS=vaše-heslo
|
||||
```
|
||||
|
||||
Soubor je v `.gitignore`, do gitu se nedostane.
|
||||
2. Env proměnné `VAULT_USER` / `VAULT_PASS` (mají přednost před `.env`).
|
||||
|
||||
Pokud údaje chybí, skript `.env` založí / doplní v něm prázdné řádky
|
||||
`VAULT_USER=` a `VAULT_PASS=`, vypíše výzvu k doplnění a skončí —
|
||||
doplňte hodnoty a spusťte skript znovu.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\download_vault_v1.2.py"
|
||||
```
|
||||
|
||||
**První běh:** zobrazí se login formulář, skript vyplní údaje, vy
|
||||
potvrdíte 2FA na telefonu a stisknete ENTER. Profil se uloží do
|
||||
`vault_profile/`, takže **další běhy** by měly jet rovnou bez
|
||||
přihlášení (dokud session nevyprší — pak se login + 2FA zopakuje).
|
||||
|
||||
Na konci běhu skript čeká na ENTER, teprve pak zavře prohlížeč
|
||||
(abyste si mohli stav zkontrolovat).
|
||||
|
||||
## Konfigurace (konstanty nahoře ve skriptu)
|
||||
|
||||
- `LOGIN_URL` — J&J SSO startSSO.ping URL cílící na vtmf.veevavault.com
|
||||
- `PROFILE_DIR` — perzistentní Chromium profil (`vault_profile/`)
|
||||
- `DOWNLOAD_DIR` — `U:\Dropbox\!!!Days\Downloads Z230`
|
||||
- `DOCUMENTS` — seznam dvojic `(url_dokumentu, vtmf_cislo)`; zatím jeden
|
||||
testovací dokument, připraveno na fázi 2 (načítání seznamu z Excelu)
|
||||
|
||||
## Známé body k dořešení (fáze 2)
|
||||
|
||||
- Ověřit selektor `[title='Source File']` na různých typech dokumentů.
|
||||
- Dokumenty s dropdownem (Source File + Viewable Rendition) — základní
|
||||
ošetření přes `menuitem` je ve skriptu, chce ověřit na reálném případu.
|
||||
- Načítání seznamu URL + VTMF čísel z Excelu (`openpyxl`) a smyčka přes
|
||||
všechny dokumenty (struktura `DOCUMENTS` je na to připravená).
|
||||
@@ -0,0 +1,285 @@
|
||||
# ============================================================
|
||||
# download_vault_v1.2.py
|
||||
# Verze: 1.2
|
||||
# Datum: 2026-06-12
|
||||
# Popis: Stažení source-file dokumentu z Veeva Vault (J&J V-TMF).
|
||||
# Perzistentní Chromium profil (session přežije běhy),
|
||||
# login přes J&J SSO formulář, ruční potvrzení 2FA na
|
||||
# telefonu, stažení Source File a přejmenování podle
|
||||
# VTMF čísla do U:\Dropbox\!!!Days\Downloads Z230\.
|
||||
#
|
||||
# v1.1: VAULT_USER / VAULT_PASS se čtou ze souboru .env v rootu
|
||||
# projektu Janssen; env proměnné mají přednost.
|
||||
# v1.2: když údaje chybí (nový počítač — .env se přes Giteu
|
||||
# nepřenáší), skript .env založí / doplní v něm chybějící
|
||||
# řádky VAULT_USER=/VAULT_PASS=, vyzve k doplnění a skončí.
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu.
|
||||
# ============================================================
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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
|
||||
DOWNLOAD_DIR = Path(r"U:\Dropbox\!!!Days\Downloads Z230")
|
||||
|
||||
# Zatím jeden dokument; struktura připravená na pozdější seznam
|
||||
# (ve fázi 2 se naplní z Excelu).
|
||||
DOCUMENTS = [
|
||||
("https://vtmf.veevavault.com/ui/#doc_info/31824736/1/0", "VTMF-25690359"),
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# --- Přihlášení --------------------------------------------------------
|
||||
|
||||
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)
|
||||
page.locator("input[type='password']").first.fill(password)
|
||||
page.get_by_role("button", name=re.compile("Sign On", re.I)).click()
|
||||
|
||||
# --- 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 dismiss_maintenance_popup(page):
|
||||
"""Zavře informační okno o údržbě, pokud je zobrazené."""
|
||||
for name in ("OK", "Close", "Continue"):
|
||||
btn = page.get_by_role("button", name=re.compile(f"^{name}$", re.I))
|
||||
try:
|
||||
if btn.count() and btn.first.is_visible():
|
||||
btn.first.click()
|
||||
log(f"[i] Zavřel jsem dialog tlačítkem '{name}'.")
|
||||
page.wait_for_timeout(500)
|
||||
except Exception:
|
||||
pass # dialog není / mezitím zmizel — pokračujeme
|
||||
|
||||
|
||||
# --- Stažení -----------------------------------------------------------
|
||||
|
||||
def build_target_name(suggested, vtmf):
|
||||
"""Původní název + ' [VTMF-XXXXXX]' před příponou."""
|
||||
if "." in suggested:
|
||||
base, ext = suggested.rsplit(".", 1)
|
||||
return f"{base} [{vtmf}].{ext}"
|
||||
return f"{suggested} [{vtmf}]"
|
||||
|
||||
|
||||
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_url, 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...")
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
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
|
||||
|
||||
new_name = build_target_name(download.suggested_filename, vtmf)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = DOWNLOAD_DIR / new_name
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Uloženo: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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, failed = [], []
|
||||
try:
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
for doc_url, vtmf in DOCUMENTS:
|
||||
try:
|
||||
ok.append(download_source_file(page, doc_url, vtmf))
|
||||
except Exception as e:
|
||||
log(f"[CHYBA] {vtmf}: {e}")
|
||||
failed.append(vtmf)
|
||||
finally:
|
||||
log(f"\n[i] Hotovo: {len(ok)} staženo, {len(failed)} chyb"
|
||||
+ (f" ({', '.join(failed)})" if failed else ""))
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
# download_vault_v1.3 — Stažení source file z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 1.3 · **Datum:** 2026-06-12
|
||||
|
||||
**Změny v1.1:** přihlašovací údaje se čtou ze souboru `.env` v rootu
|
||||
projektu Janssen (`U:\PythonProject\Janssen\.env`, sekce Veeva Vault).
|
||||
**Změny v1.2:** když údaje chybí (typicky nový počítač — `.env` se přes
|
||||
Giteu nepřenáší), skript `.env` sám založí, případně do něj doplní
|
||||
chybějící řádky `VAULT_USER=`/`VAULT_PASS=`, vyzve k doplnění a skončí.
|
||||
**Změny v1.3:** robustnější odeslání login formuláře (Sign On / Login /
|
||||
submit input / Enter v poli hesla jako poslední fallback) a robustnější
|
||||
zavírání modálního okna o V-TMF maintenance — víc selektorů (button,
|
||||
input[type=button], odkaz) pro OK/Close/Continue a opakovaná kontrola
|
||||
3× po 1 s, protože okno se objevuje i se zpožděním po načtení stránky.
|
||||
|
||||
Playwright skript pro stažení source-file dokumentů z Veeva Vault
|
||||
(vtmf.veevavault.com) s perzistentní session, ručním potvrzením 2FA
|
||||
na telefonu a přejmenováním staženého souboru podle VTMF čísla.
|
||||
|
||||
## Co skript dělá
|
||||
|
||||
1. Otevře viditelný maximalizovaný Chromium s perzistentním profilem
|
||||
(`vault_profile/` vedle skriptu) — session přežije běhy, takže po
|
||||
prvním přihlášení už se obvykle přihlašovat nemusí.
|
||||
2. Otevře J&J SSO login URL a vyplní jméno + heslo.
|
||||
3. Pokud SSO vyžaduje mobilní ověření (2FA), skript se **zastaví** a
|
||||
čeká, až ho potvrdíte v mobilní aplikaci a stisknete ENTER.
|
||||
4. Ověří, že jsme uvnitř Vaultu (URL `vtmf.veevavault.com/ui`), a zavře
|
||||
případné informační okno o údržbě.
|
||||
5. Otevře stránku dokumentu, klikne na ikonu **Source File** (vpravo
|
||||
nahoře, list papíru se šipkou dolů) a stažený soubor uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\` pod názvem
|
||||
`původní název [VTMF-XXXXXXXX].přípona`, např.
|
||||
`42847922MDD3003---Ongoing Third Party Oversight-31 May 2026 [VTMF-25690359].zip`
|
||||
|
||||
## Instalace
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Přihlašovací údaje
|
||||
|
||||
Heslo **není** ve skriptu. Údaje se čtou v tomto pořadí:
|
||||
|
||||
1. **`.env` v rootu projektu** — `U:\PythonProject\Janssen\.env`,
|
||||
sekce Veeva Vault (doplňte hodnoty za `=`):
|
||||
|
||||
```
|
||||
VAULT_USER=vbuzalka
|
||||
VAULT_PASS=vaše-heslo
|
||||
```
|
||||
|
||||
Soubor je v `.gitignore`, do gitu se nedostane.
|
||||
2. Env proměnné `VAULT_USER` / `VAULT_PASS` (mají přednost před `.env`).
|
||||
|
||||
Pokud údaje chybí, skript `.env` založí / doplní v něm prázdné řádky
|
||||
`VAULT_USER=` a `VAULT_PASS=`, vypíše výzvu k doplnění a skončí —
|
||||
doplňte hodnoty a spusťte skript znovu.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\download_vault_v1.3.py"
|
||||
```
|
||||
|
||||
**První běh:** zobrazí se login formulář, skript vyplní údaje, vy
|
||||
potvrdíte 2FA na telefonu a stisknete ENTER. Profil se uloží do
|
||||
`vault_profile/`, takže **další běhy** by měly jet rovnou bez
|
||||
přihlášení (dokud session nevyprší — pak se login + 2FA zopakuje).
|
||||
|
||||
Na konci běhu skript čeká na ENTER, teprve pak zavře prohlížeč
|
||||
(abyste si mohli stav zkontrolovat).
|
||||
|
||||
## Konfigurace (konstanty nahoře ve skriptu)
|
||||
|
||||
- `LOGIN_URL` — J&J SSO startSSO.ping URL cílící na vtmf.veevavault.com
|
||||
- `PROFILE_DIR` — perzistentní Chromium profil (`vault_profile/`)
|
||||
- `DOWNLOAD_DIR` — `U:\Dropbox\!!!Days\Downloads Z230`
|
||||
- `DOCUMENTS` — seznam dvojic `(url_dokumentu, vtmf_cislo)`; zatím jeden
|
||||
testovací dokument, připraveno na fázi 2 (načítání seznamu z Excelu)
|
||||
|
||||
## Známé body k dořešení (fáze 2)
|
||||
|
||||
- Ověřit selektor `[title='Source File']` na různých typech dokumentů.
|
||||
- Dokumenty s dropdownem (Source File + Viewable Rendition) — základní
|
||||
ošetření přes `menuitem` je ve skriptu, chce ověřit na reálném případu.
|
||||
- Načítání seznamu URL + VTMF čísel z Excelu (`openpyxl`) a smyčka přes
|
||||
všechny dokumenty (struktura `DOCUMENTS` je na to připravená).
|
||||
@@ -0,0 +1,338 @@
|
||||
# ============================================================
|
||||
# download_vault_v1.3.py
|
||||
# Verze: 1.3
|
||||
# Datum: 2026-06-12
|
||||
# Popis: Stažení source-file dokumentu z Veeva Vault (J&J V-TMF).
|
||||
# Perzistentní Chromium profil (session přežije běhy),
|
||||
# login přes J&J SSO formulář, ruční potvrzení 2FA na
|
||||
# telefonu, stažení Source File a přejmenování podle
|
||||
# VTMF čísla do U:\Dropbox\!!!Days\Downloads Z230\.
|
||||
#
|
||||
# v1.1: VAULT_USER / VAULT_PASS se čtou ze souboru .env v rootu
|
||||
# projektu Janssen; env proměnné mají přednost.
|
||||
# v1.2: když údaje chybí (nový počítač — .env se přes Giteu
|
||||
# nepřenáší), skript .env založí / doplní v něm chybějící
|
||||
# řádky VAULT_USER=/VAULT_PASS=, vyzve k doplnění a skončí.
|
||||
# v1.3: robustnější odeslání login formuláře (Sign On / Login /
|
||||
# submit / Enter fallbacky) + robustnější zavírání modálního
|
||||
# okna o maintenance (Veeva ho zobrazuje s OK, ale nemusí to
|
||||
# být standardní <button>; okno se objevuje i se zpožděním).
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu.
|
||||
# ============================================================
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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
|
||||
DOWNLOAD_DIR = Path(r"U:\Dropbox\!!!Days\Downloads Z230")
|
||||
|
||||
# Zatím jeden dokument; struktura připravená na pozdější seznam
|
||||
# (ve fázi 2 se naplní z Excelu).
|
||||
DOCUMENTS = [
|
||||
("https://vtmf.veevavault.com/ui/#doc_info/31824736/1/0", "VTMF-25690359"),
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# --- 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 dismiss_maintenance_popup(page, attempts=3):
|
||||
"""Zavře modální okno (např. oznámení o V-TMF maintenance), pokud je
|
||||
zobrazené. Okno se může objevit se zpožděním po načtení stránky,
|
||||
proto se kontrola opakuje (attempts × 1 s). Tlačítko OK ve Veeva
|
||||
nemusí být standardní <button>, proto víc selektorů."""
|
||||
button_selectors = [
|
||||
# standardní tlačítka podle role/textu
|
||||
lambda name: page.get_by_role("button", name=re.compile(f"^{name}$", re.I)),
|
||||
# Veeva dialogy mívají <input type=button> nebo <a> místo <button>
|
||||
lambda name: page.locator(
|
||||
f"input[type='button'][value='{name}' i], "
|
||||
f"input[type='submit'][value='{name}' i]"),
|
||||
lambda name: page.locator(f"a:text-is('{name}')"),
|
||||
]
|
||||
for attempt in range(attempts):
|
||||
closed = False
|
||||
for name in ("OK", "Close", "Continue"):
|
||||
for make in button_selectors:
|
||||
try:
|
||||
btn = make(name)
|
||||
if btn.count() and btn.first.is_visible():
|
||||
btn.first.click()
|
||||
log(f"[i] Zavřel jsem modální okno tlačítkem '{name}'.")
|
||||
page.wait_for_timeout(500)
|
||||
closed = True
|
||||
break
|
||||
except Exception:
|
||||
continue # selektor nesedí / dialog mezitím zmizel
|
||||
if closed:
|
||||
break
|
||||
if closed:
|
||||
return True
|
||||
if attempt < attempts - 1:
|
||||
page.wait_for_timeout(1000) # okno se může teprve objevit
|
||||
return False
|
||||
|
||||
|
||||
# --- Stažení -----------------------------------------------------------
|
||||
|
||||
def build_target_name(suggested, vtmf):
|
||||
"""Původní název + ' [VTMF-XXXXXX]' před příponou."""
|
||||
if "." in suggested:
|
||||
base, ext = suggested.rsplit(".", 1)
|
||||
return f"{base} [{vtmf}].{ext}"
|
||||
return f"{suggested} [{vtmf}]"
|
||||
|
||||
|
||||
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_url, 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...")
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
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
|
||||
|
||||
new_name = build_target_name(download.suggested_filename, vtmf)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = DOWNLOAD_DIR / new_name
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Uloženo: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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, failed = [], []
|
||||
try:
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
for doc_url, vtmf in DOCUMENTS:
|
||||
try:
|
||||
ok.append(download_source_file(page, doc_url, vtmf))
|
||||
except Exception as e:
|
||||
log(f"[CHYBA] {vtmf}: {e}")
|
||||
failed.append(vtmf)
|
||||
finally:
|
||||
log(f"\n[i] Hotovo: {len(ok)} staženo, {len(failed)} chyb"
|
||||
+ (f" ({', '.join(failed)})" if failed else ""))
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
# download_vault_v1.4 — Stažení source file z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 1.4 · **Datum:** 2026-06-12
|
||||
|
||||
**Změny v1.1:** přihlašovací údaje se čtou ze souboru `.env` v rootu
|
||||
projektu Janssen (`U:\PythonProject\Janssen\.env`, sekce Veeva Vault).
|
||||
**Změny v1.2:** když údaje chybí (typicky nový počítač — `.env` se přes
|
||||
Giteu nepřenáší), skript `.env` sám založí, případně do něj doplní
|
||||
chybějící řádky `VAULT_USER=`/`VAULT_PASS=`, vyzve k doplnění a skončí.
|
||||
**Změny v1.3:** robustnější odeslání login formuláře (Sign On / Login /
|
||||
submit input / Enter v poli hesla jako poslední fallback).
|
||||
**Změny v1.4:** opravené zavírání maintenance okna. Je to jQuery UI
|
||||
dialog (`.vv_login_msg_dialog`) a jeho modré „OK" v patičce je
|
||||
**nefunkční** — jediné funkční zavření je křížek
|
||||
`.ui-dialog-titlebar-close` v titulku. Zavírá se ve smyčce (dialogy
|
||||
umí být ve frontě), kontrola se opakuje 3× po 1 s (okno se objevuje
|
||||
se zpožděním), záloha je klávesa Escape.
|
||||
|
||||
Playwright skript pro stažení source-file dokumentů z Veeva Vault
|
||||
(vtmf.veevavault.com) s perzistentní session, ručním potvrzením 2FA
|
||||
na telefonu a přejmenováním staženého souboru podle VTMF čísla.
|
||||
|
||||
## Co skript dělá
|
||||
|
||||
1. Otevře viditelný maximalizovaný Chromium s perzistentním profilem
|
||||
(`vault_profile/` vedle skriptu) — session přežije běhy, takže po
|
||||
prvním přihlášení už se obvykle přihlašovat nemusí.
|
||||
2. Otevře J&J SSO login URL a vyplní jméno + heslo.
|
||||
3. Pokud SSO vyžaduje mobilní ověření (2FA), skript se **zastaví** a
|
||||
čeká, až ho potvrdíte v mobilní aplikaci a stisknete ENTER.
|
||||
4. Ověří, že jsme uvnitř Vaultu (URL `vtmf.veevavault.com/ui`), a zavře
|
||||
případné informační okno o údržbě.
|
||||
5. Otevře stránku dokumentu, klikne na ikonu **Source File** (vpravo
|
||||
nahoře, list papíru se šipkou dolů) a stažený soubor uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\` pod názvem
|
||||
`původní název [VTMF-XXXXXXXX].přípona`, např.
|
||||
`42847922MDD3003---Ongoing Third Party Oversight-31 May 2026 [VTMF-25690359].zip`
|
||||
|
||||
## Instalace
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Přihlašovací údaje
|
||||
|
||||
Heslo **není** ve skriptu. Údaje se čtou v tomto pořadí:
|
||||
|
||||
1. **`.env` v rootu projektu** — `U:\PythonProject\Janssen\.env`,
|
||||
sekce Veeva Vault (doplňte hodnoty za `=`):
|
||||
|
||||
```
|
||||
VAULT_USER=vbuzalka
|
||||
VAULT_PASS=vaše-heslo
|
||||
```
|
||||
|
||||
Soubor je v `.gitignore`, do gitu se nedostane.
|
||||
2. Env proměnné `VAULT_USER` / `VAULT_PASS` (mají přednost před `.env`).
|
||||
|
||||
Pokud údaje chybí, skript `.env` založí / doplní v něm prázdné řádky
|
||||
`VAULT_USER=` a `VAULT_PASS=`, vypíše výzvu k doplnění a skončí —
|
||||
doplňte hodnoty a spusťte skript znovu.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\download_vault_v1.4.py"
|
||||
```
|
||||
|
||||
**První běh:** zobrazí se login formulář, skript vyplní údaje, vy
|
||||
potvrdíte 2FA na telefonu a stisknete ENTER. Profil se uloží do
|
||||
`vault_profile/`, takže **další běhy** by měly jet rovnou bez
|
||||
přihlášení (dokud session nevyprší — pak se login + 2FA zopakuje).
|
||||
|
||||
Na konci běhu skript čeká na ENTER, teprve pak zavře prohlížeč
|
||||
(abyste si mohli stav zkontrolovat).
|
||||
|
||||
## Konfigurace (konstanty nahoře ve skriptu)
|
||||
|
||||
- `LOGIN_URL` — J&J SSO startSSO.ping URL cílící na vtmf.veevavault.com
|
||||
- `PROFILE_DIR` — perzistentní Chromium profil (`vault_profile/`)
|
||||
- `DOWNLOAD_DIR` — `U:\Dropbox\!!!Days\Downloads Z230`
|
||||
- `DOCUMENTS` — seznam dvojic `(url_dokumentu, vtmf_cislo)`; zatím jeden
|
||||
testovací dokument, připraveno na fázi 2 (načítání seznamu z Excelu)
|
||||
|
||||
## Známé body k dořešení (fáze 2)
|
||||
|
||||
- Ověřit selektor `[title='Source File']` na různých typech dokumentů.
|
||||
- Dokumenty s dropdownem (Source File + Viewable Rendition) — základní
|
||||
ošetření přes `menuitem` je ve skriptu, chce ověřit na reálném případu.
|
||||
- Načítání seznamu URL + VTMF čísel z Excelu (`openpyxl`) a smyčka přes
|
||||
všechny dokumenty (struktura `DOCUMENTS` je na to připravená).
|
||||
@@ -0,0 +1,343 @@
|
||||
# ============================================================
|
||||
# download_vault_v1.4.py
|
||||
# Verze: 1.4
|
||||
# Datum: 2026-06-12
|
||||
# Popis: Stažení source-file dokumentu z Veeva Vault (J&J V-TMF).
|
||||
# Perzistentní Chromium profil (session přežije běhy),
|
||||
# login přes J&J SSO formulář, ruční potvrzení 2FA na
|
||||
# telefonu, stažení Source File a přejmenování podle
|
||||
# VTMF čísla do U:\Dropbox\!!!Days\Downloads Z230\.
|
||||
#
|
||||
# v1.1: VAULT_USER / VAULT_PASS se čtou ze souboru .env v rootu
|
||||
# projektu Janssen; env proměnné mají přednost.
|
||||
# v1.2: když údaje chybí (nový počítač — .env se přes Giteu
|
||||
# nepřenáší), skript .env založí / doplní v něm chybějící
|
||||
# řádky VAULT_USER=/VAULT_PASS=, vyzve k doplnění a skončí.
|
||||
# v1.3: robustnější odeslání login formuláře (Sign On / Login /
|
||||
# submit / Enter fallbacky).
|
||||
# v1.4: maintenance okno je jQuery UI dialog (.vv_login_msg_dialog);
|
||||
# jeho "OK" v patičce je nefunkční — jediné funkční zavření je
|
||||
# křížek .ui-dialog-titlebar-close. Zavírá se ve smyčce (dialogy
|
||||
# umí být ve frontě), s opakovanou kontrolou kvůli zpožděnému
|
||||
# zobrazení; záloha klávesa Escape.
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu.
|
||||
# ============================================================
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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
|
||||
DOWNLOAD_DIR = Path(r"U:\Dropbox\!!!Days\Downloads Z230")
|
||||
|
||||
# Zatím jeden dokument; struktura připravená na pozdější seznam
|
||||
# (ve fázi 2 se naplní z Excelu).
|
||||
DOCUMENTS = [
|
||||
("https://vtmf.veevavault.com/ui/#doc_info/31824736/1/0", "VTMF-25690359"),
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# --- 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 dismiss_maintenance_popup(page, attempts=3):
|
||||
"""Zavře Veeva login/maintenance jQuery-UI dialog(y), pokud jsou
|
||||
zobrazené. Modré 'OK' v patičce dialogu je nefunkční — jediné
|
||||
funkční zavření je křížek .ui-dialog-titlebar-close v titulku.
|
||||
Dialogy umí být ve frontě (zavírá se ve smyčce) a objevují se
|
||||
i se zpožděním po načtení stránky (attempts × 1 s). Bezpečné
|
||||
volat vždy — když okno není, tiše projde."""
|
||||
closed = 0
|
||||
for attempt in range(attempts):
|
||||
# zavírej křížkem, dokud nějaký dialog existuje (max 5 ve frontě)
|
||||
for _ in range(5):
|
||||
btn = page.locator(".ui-dialog-titlebar-close")
|
||||
try:
|
||||
if btn.count() and btn.first.is_visible():
|
||||
btn.first.click()
|
||||
page.wait_for_timeout(300)
|
||||
closed += 1
|
||||
log("[i] Zavřel jsem maintenance/login dialog (křížkem).")
|
||||
continue
|
||||
except Exception:
|
||||
pass # dialog mezitím zmizel — v pořádku
|
||||
break
|
||||
if closed:
|
||||
return True
|
||||
if attempt < attempts - 1:
|
||||
page.wait_for_timeout(1000) # okno se může teprve objevit
|
||||
|
||||
# záloha: jQuery UI dialog s closeOnEscape reaguje na Escape
|
||||
dialog = page.locator(".ui-dialog:visible")
|
||||
try:
|
||||
if dialog.count():
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(300)
|
||||
log("[i] Zkusil jsem dialog zavřít klávesou Escape.")
|
||||
except Exception:
|
||||
pass
|
||||
return bool(closed)
|
||||
|
||||
|
||||
# --- Stažení -----------------------------------------------------------
|
||||
|
||||
def build_target_name(suggested, vtmf):
|
||||
"""Původní název + ' [VTMF-XXXXXX]' před příponou."""
|
||||
if "." in suggested:
|
||||
base, ext = suggested.rsplit(".", 1)
|
||||
return f"{base} [{vtmf}].{ext}"
|
||||
return f"{suggested} [{vtmf}]"
|
||||
|
||||
|
||||
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_url, 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...")
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
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
|
||||
|
||||
new_name = build_target_name(download.suggested_filename, vtmf)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = DOWNLOAD_DIR / new_name
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Uloženo: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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, failed = [], []
|
||||
try:
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
for doc_url, vtmf in DOCUMENTS:
|
||||
try:
|
||||
ok.append(download_source_file(page, doc_url, vtmf))
|
||||
except Exception as e:
|
||||
log(f"[CHYBA] {vtmf}: {e}")
|
||||
failed.append(vtmf)
|
||||
finally:
|
||||
log(f"\n[i] Hotovo: {len(ok)} staženo, {len(failed)} chyb"
|
||||
+ (f" ({', '.join(failed)})" if failed else ""))
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
# download_vault_v1.5 — Stažení source file z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 1.5 · **Datum:** 2026-06-12
|
||||
|
||||
**Změny v1.1:** přihlašovací údaje se čtou ze souboru `.env` v rootu
|
||||
projektu Janssen (`U:\PythonProject\Janssen\.env`, sekce Veeva Vault).
|
||||
**Změny v1.2:** když údaje chybí (typicky nový počítač — `.env` se přes
|
||||
Giteu nepřenáší), skript `.env` sám založí, případně do něj doplní
|
||||
chybějící řádky `VAULT_USER=`/`VAULT_PASS=`, vyzve k doplnění a skončí.
|
||||
**Změny v1.3:** robustnější odeslání login formuláře (Sign On / Login /
|
||||
submit input / Enter v poli hesla jako poslední fallback).
|
||||
**Změny v1.4:** opravené zavírání maintenance okna. Je to jQuery UI
|
||||
dialog (`.vv_login_msg_dialog`) a jeho modré „OK" v patičce je
|
||||
**nefunkční** — jediné funkční zavření je křížek
|
||||
`.ui-dialog-titlebar-close` v titulku. Zavírá se ve smyčce (dialogy
|
||||
umí být ve frontě), kontrola se opakuje 3× po 1 s (okno se objevuje
|
||||
se zpožděním), záloha je klávesa Escape.
|
||||
**Změny v1.5:** dialog se hledá ve **všech frames** — Veeva renderuje
|
||||
části UI v iframech a selektor na hlavní stránce ho pak nevidí. Když
|
||||
se dialog nepodaří zavřít, skript uloží diagnostiku do
|
||||
`debug/<datum_čas>_dialog/` (screenshot, HTML všech frames, výpis
|
||||
kandidátů na tlačítka), požádá o ruční zavření a pokračuje — běh kvůli
|
||||
tomu nespadne a z diagnostiky se dá určit přesný selektor.
|
||||
|
||||
Playwright skript pro stažení source-file dokumentů z Veeva Vault
|
||||
(vtmf.veevavault.com) s perzistentní session, ručním potvrzením 2FA
|
||||
na telefonu a přejmenováním staženého souboru podle VTMF čísla.
|
||||
|
||||
## Co skript dělá
|
||||
|
||||
1. Otevře viditelný maximalizovaný Chromium s perzistentním profilem
|
||||
(`vault_profile/` vedle skriptu) — session přežije běhy, takže po
|
||||
prvním přihlášení už se obvykle přihlašovat nemusí.
|
||||
2. Otevře J&J SSO login URL a vyplní jméno + heslo.
|
||||
3. Pokud SSO vyžaduje mobilní ověření (2FA), skript se **zastaví** a
|
||||
čeká, až ho potvrdíte v mobilní aplikaci a stisknete ENTER.
|
||||
4. Ověří, že jsme uvnitř Vaultu (URL `vtmf.veevavault.com/ui`), a zavře
|
||||
případné informační okno o údržbě.
|
||||
5. Otevře stránku dokumentu, klikne na ikonu **Source File** (vpravo
|
||||
nahoře, list papíru se šipkou dolů) a stažený soubor uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\` pod názvem
|
||||
`původní název [VTMF-XXXXXXXX].přípona`, např.
|
||||
`42847922MDD3003---Ongoing Third Party Oversight-31 May 2026 [VTMF-25690359].zip`
|
||||
|
||||
## Instalace
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Přihlašovací údaje
|
||||
|
||||
Heslo **není** ve skriptu. Údaje se čtou v tomto pořadí:
|
||||
|
||||
1. **`.env` v rootu projektu** — `U:\PythonProject\Janssen\.env`,
|
||||
sekce Veeva Vault (doplňte hodnoty za `=`):
|
||||
|
||||
```
|
||||
VAULT_USER=vbuzalka
|
||||
VAULT_PASS=vaše-heslo
|
||||
```
|
||||
|
||||
Soubor je v `.gitignore`, do gitu se nedostane.
|
||||
2. Env proměnné `VAULT_USER` / `VAULT_PASS` (mají přednost před `.env`).
|
||||
|
||||
Pokud údaje chybí, skript `.env` založí / doplní v něm prázdné řádky
|
||||
`VAULT_USER=` a `VAULT_PASS=`, vypíše výzvu k doplnění a skončí —
|
||||
doplňte hodnoty a spusťte skript znovu.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\download_vault_v1.5.py"
|
||||
```
|
||||
|
||||
**První běh:** zobrazí se login formulář, skript vyplní údaje, vy
|
||||
potvrdíte 2FA na telefonu a stisknete ENTER. Profil se uloží do
|
||||
`vault_profile/`, takže **další běhy** by měly jet rovnou bez
|
||||
přihlášení (dokud session nevyprší — pak se login + 2FA zopakuje).
|
||||
|
||||
Na konci běhu skript čeká na ENTER, teprve pak zavře prohlížeč
|
||||
(abyste si mohli stav zkontrolovat).
|
||||
|
||||
## Konfigurace (konstanty nahoře ve skriptu)
|
||||
|
||||
- `LOGIN_URL` — J&J SSO startSSO.ping URL cílící na vtmf.veevavault.com
|
||||
- `PROFILE_DIR` — perzistentní Chromium profil (`vault_profile/`)
|
||||
- `DOWNLOAD_DIR` — `U:\Dropbox\!!!Days\Downloads Z230`
|
||||
- `DOCUMENTS` — seznam dvojic `(url_dokumentu, vtmf_cislo)`; zatím jeden
|
||||
testovací dokument, připraveno na fázi 2 (načítání seznamu z Excelu)
|
||||
|
||||
## Známé body k dořešení (fáze 2)
|
||||
|
||||
- Ověřit selektor `[title='Source File']` na různých typech dokumentů.
|
||||
- Dokumenty s dropdownem (Source File + Viewable Rendition) — základní
|
||||
ošetření přes `menuitem` je ve skriptu, chce ověřit na reálném případu.
|
||||
- Načítání seznamu URL + VTMF čísel z Excelu (`openpyxl`) a smyčka přes
|
||||
všechny dokumenty (struktura `DOCUMENTS` je na to připravená).
|
||||
@@ -0,0 +1,404 @@
|
||||
# ============================================================
|
||||
# download_vault_v1.5.py
|
||||
# Verze: 1.5
|
||||
# Datum: 2026-06-12
|
||||
# Popis: Stažení source-file dokumentu z Veeva Vault (J&J V-TMF).
|
||||
# Perzistentní Chromium profil (session přežije běhy),
|
||||
# login přes J&J SSO formulář, ruční potvrzení 2FA na
|
||||
# telefonu, stažení Source File a přejmenování podle
|
||||
# VTMF čísla do U:\Dropbox\!!!Days\Downloads Z230\.
|
||||
#
|
||||
# v1.1: VAULT_USER / VAULT_PASS se čtou ze souboru .env v rootu
|
||||
# projektu Janssen; env proměnné mají přednost.
|
||||
# v1.2: když údaje chybí (nový počítač — .env se přes Giteu
|
||||
# nepřenáší), skript .env založí / doplní v něm chybějící
|
||||
# řádky VAULT_USER=/VAULT_PASS=, vyzve k doplnění a skončí.
|
||||
# v1.3: robustnější odeslání login formuláře (Sign On / Login /
|
||||
# submit / Enter fallbacky).
|
||||
# v1.4: maintenance okno je jQuery UI dialog (.vv_login_msg_dialog);
|
||||
# jeho "OK" v patičce je nefunkční — jediné funkční zavření je
|
||||
# křížek .ui-dialog-titlebar-close. Zavírá se ve smyčce (dialogy
|
||||
# umí být ve frontě), s opakovanou kontrolou kvůli zpožděnému
|
||||
# zobrazení; záloha klávesa Escape.
|
||||
# v1.5: dialog se hledá ve VŠECH frames (Veeva renderuje části UI
|
||||
# v iframech — selektor na hlavní stránce ho pak nevidí).
|
||||
# Když dialog zůstane viditelný i po pokusech o zavření,
|
||||
# skript sám uloží diagnostiku do debug/<čas>/ (screenshot
|
||||
# + HTML všech frames + výpis kandidátů na tlačítka).
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu.
|
||||
# ============================================================
|
||||
|
||||
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
|
||||
DOWNLOAD_DIR = Path(r"U:\Dropbox\!!!Days\Downloads Z230")
|
||||
|
||||
# Zatím jeden dokument; struktura připravená na pozdější seznam
|
||||
# (ve fázi 2 se naplní z Excelu).
|
||||
DOCUMENTS = [
|
||||
("https://vtmf.veevavault.com/ui/#doc_info/31824736/1/0", "VTMF-25690359"),
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# --- 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_anywhere(page):
|
||||
"""Vrátí frame, ve kterém je viditelný jQuery UI dialog, jinak None.
|
||||
Prochází hlavní stránku i všechny iframy."""
|
||||
for frame in page.frames:
|
||||
try:
|
||||
dlg = frame.locator(".ui-dialog")
|
||||
if dlg.count() and dlg.first.is_visible():
|
||||
return frame
|
||||
except Exception:
|
||||
continue # frame se mezitím odpojil
|
||||
return None
|
||||
|
||||
|
||||
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", ".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
|
||||
|
||||
|
||||
def dismiss_maintenance_popup(page, attempts=3):
|
||||
"""Zavře Veeva login/maintenance jQuery-UI dialog(y), pokud jsou
|
||||
zobrazené. Modré 'OK' v patičce dialogu je nefunkční — jediné
|
||||
funkční zavření je křížek .ui-dialog-titlebar-close v titulku.
|
||||
Hledá se ve VŠECH frames (dialog může žít v iframe). Dialogy umí
|
||||
být ve frontě (zavírá se ve smyčce) a objevují se i se zpožděním
|
||||
po načtení stránky (attempts × 1 s). Bezpečné volat vždy."""
|
||||
closed = 0
|
||||
for attempt in range(attempts):
|
||||
# zavírej křížkem ve všech frames, dokud nějaký dialog existuje
|
||||
for _ in range(5):
|
||||
clicked = False
|
||||
for frame in page.frames:
|
||||
try:
|
||||
btn = frame.locator(".ui-dialog-titlebar-close")
|
||||
if btn.count() and btn.first.is_visible():
|
||||
btn.first.click()
|
||||
page.wait_for_timeout(300)
|
||||
closed += 1
|
||||
where = ("hlavní stránka" if frame is page.main_frame
|
||||
else f"iframe {frame.url[:60]}")
|
||||
log(f"[i] Zavřel jsem dialog křížkem ({where}).")
|
||||
clicked = True
|
||||
break
|
||||
except Exception:
|
||||
continue # frame/dialog mezitím zmizel
|
||||
if not clicked:
|
||||
break
|
||||
if closed and not dialog_visible_anywhere(page):
|
||||
return True
|
||||
if attempt < attempts - 1:
|
||||
page.wait_for_timeout(1000) # okno se může teprve objevit
|
||||
|
||||
# záloha: jQuery UI dialog s closeOnEscape reaguje na Escape
|
||||
if dialog_visible_anywhere(page):
|
||||
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
|
||||
stuck = dialog_visible_anywhere(page)
|
||||
if stuck:
|
||||
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 build_target_name(suggested, vtmf):
|
||||
"""Původní název + ' [VTMF-XXXXXX]' před příponou."""
|
||||
if "." in suggested:
|
||||
base, ext = suggested.rsplit(".", 1)
|
||||
return f"{base} [{vtmf}].{ext}"
|
||||
return f"{suggested} [{vtmf}]"
|
||||
|
||||
|
||||
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_url, 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...")
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
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
|
||||
|
||||
new_name = build_target_name(download.suggested_filename, vtmf)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = DOWNLOAD_DIR / new_name
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Uloženo: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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, failed = [], []
|
||||
try:
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
for doc_url, vtmf in DOCUMENTS:
|
||||
try:
|
||||
ok.append(download_source_file(page, doc_url, vtmf))
|
||||
except Exception as e:
|
||||
log(f"[CHYBA] {vtmf}: {e}")
|
||||
failed.append(vtmf)
|
||||
finally:
|
||||
log(f"\n[i] Hotovo: {len(ok)} staženo, {len(failed)} chyb"
|
||||
+ (f" ({', '.join(failed)})" if failed else ""))
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
# download_vault_v1.6 — Stažení source file z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 1.6 · **Datum:** 2026-06-12
|
||||
|
||||
**Změny v1.1:** přihlašovací údaje se čtou ze souboru `.env` v rootu
|
||||
projektu Janssen (`U:\PythonProject\Janssen\.env`, sekce Veeva Vault).
|
||||
**Změny v1.2:** když údaje chybí (typicky nový počítač — `.env` se přes
|
||||
Giteu nepřenáší), skript `.env` sám založí, případně do něj doplní
|
||||
chybějící řádky `VAULT_USER=`/`VAULT_PASS=`, vyzve k doplnění a skončí.
|
||||
**Změny v1.3:** robustnější odeslání login formuláře (Sign On / Login /
|
||||
submit input / Enter v poli hesla jako poslední fallback).
|
||||
**Změny v1.4:** opravené zavírání maintenance okna. Je to jQuery UI
|
||||
dialog (`.vv_login_msg_dialog`) a jeho modré „OK" v patičce je
|
||||
**nefunkční** — jediné funkční zavření je křížek
|
||||
`.ui-dialog-titlebar-close` v titulku. Zavírá se ve smyčce (dialogy
|
||||
umí být ve frontě), kontrola se opakuje 3× po 1 s (okno se objevuje
|
||||
se zpožděním), záloha je klávesa Escape.
|
||||
**Změny v1.5:** dialog se hledá ve **všech frames** — Veeva renderuje
|
||||
části UI v iframech a selektor na hlavní stránce ho pak nevidí. Když
|
||||
se dialog nepodaří zavřít, skript uloží diagnostiku do
|
||||
`debug/<datum_čas>_dialog/` (screenshot, HTML všech frames, výpis
|
||||
kandidátů na tlačítka), požádá o ruční zavření a pokračuje — běh kvůli
|
||||
tomu nespadne a z diagnostiky se dá určit přesný selektor.
|
||||
**Změny v1.6:** definitivní oprava zavírání dialogu, ověřená na živém
|
||||
DOM. Hypotézy z v1.4/v1.5 neplatí: žádný iframe (dialog je přímo
|
||||
v `<body>`) a křížek `.ui-dialog-titlebar-close` je `display:none`
|
||||
(0×0) — Playwright na něj správně odmítá klikat. Funkční cíl je
|
||||
viditelné modré OK, které je `<a class="ok vv_button">`, selektor
|
||||
`.ui-dialog a.ok.vv_button`. Dialog se po příchodu na home objevuje
|
||||
se zpožděním, proto se na něj čeká přes `wait_for(state="visible")`
|
||||
(8 s na home, 2 s na stránkách dokumentů). Záloha Escape a záchyt
|
||||
diagnostiky do `debug/` zůstávají.
|
||||
|
||||
Playwright skript pro stažení source-file dokumentů z Veeva Vault
|
||||
(vtmf.veevavault.com) s perzistentní session, ručním potvrzením 2FA
|
||||
na telefonu a přejmenováním staženého souboru podle VTMF čísla.
|
||||
|
||||
## Co skript dělá
|
||||
|
||||
1. Otevře viditelný maximalizovaný Chromium s perzistentním profilem
|
||||
(`vault_profile/` vedle skriptu) — session přežije běhy, takže po
|
||||
prvním přihlášení už se obvykle přihlašovat nemusí.
|
||||
2. Otevře J&J SSO login URL a vyplní jméno + heslo.
|
||||
3. Pokud SSO vyžaduje mobilní ověření (2FA), skript se **zastaví** a
|
||||
čeká, až ho potvrdíte v mobilní aplikaci a stisknete ENTER.
|
||||
4. Ověří, že jsme uvnitř Vaultu (URL `vtmf.veevavault.com/ui`), a zavře
|
||||
případné informační okno o údržbě.
|
||||
5. Otevře stránku dokumentu, klikne na ikonu **Source File** (vpravo
|
||||
nahoře, list papíru se šipkou dolů) a stažený soubor uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\` pod názvem
|
||||
`původní název [VTMF-XXXXXXXX].přípona`, např.
|
||||
`42847922MDD3003---Ongoing Third Party Oversight-31 May 2026 [VTMF-25690359].zip`
|
||||
|
||||
## Instalace
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Přihlašovací údaje
|
||||
|
||||
Heslo **není** ve skriptu. Údaje se čtou v tomto pořadí:
|
||||
|
||||
1. **`.env` v rootu projektu** — `U:\PythonProject\Janssen\.env`,
|
||||
sekce Veeva Vault (doplňte hodnoty za `=`):
|
||||
|
||||
```
|
||||
VAULT_USER=vbuzalka
|
||||
VAULT_PASS=vaše-heslo
|
||||
```
|
||||
|
||||
Soubor je v `.gitignore`, do gitu se nedostane.
|
||||
2. Env proměnné `VAULT_USER` / `VAULT_PASS` (mají přednost před `.env`).
|
||||
|
||||
Pokud údaje chybí, skript `.env` založí / doplní v něm prázdné řádky
|
||||
`VAULT_USER=` a `VAULT_PASS=`, vypíše výzvu k doplnění a skončí —
|
||||
doplňte hodnoty a spusťte skript znovu.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\download_vault_v1.6.py"
|
||||
```
|
||||
|
||||
**První běh:** zobrazí se login formulář, skript vyplní údaje, vy
|
||||
potvrdíte 2FA na telefonu a stisknete ENTER. Profil se uloží do
|
||||
`vault_profile/`, takže **další běhy** by měly jet rovnou bez
|
||||
přihlášení (dokud session nevyprší — pak se login + 2FA zopakuje).
|
||||
|
||||
Na konci běhu skript čeká na ENTER, teprve pak zavře prohlížeč
|
||||
(abyste si mohli stav zkontrolovat).
|
||||
|
||||
## Konfigurace (konstanty nahoře ve skriptu)
|
||||
|
||||
- `LOGIN_URL` — J&J SSO startSSO.ping URL cílící na vtmf.veevavault.com
|
||||
- `PROFILE_DIR` — perzistentní Chromium profil (`vault_profile/`)
|
||||
- `DOWNLOAD_DIR` — `U:\Dropbox\!!!Days\Downloads Z230`
|
||||
- `DOCUMENTS` — seznam dvojic `(url_dokumentu, vtmf_cislo)`; zatím jeden
|
||||
testovací dokument, připraveno na fázi 2 (načítání seznamu z Excelu)
|
||||
|
||||
## Známé body k dořešení (fáze 2)
|
||||
|
||||
- Ověřit selektor `[title='Source File']` na různých typech dokumentů.
|
||||
- Dokumenty s dropdownem (Source File + Viewable Rendition) — základní
|
||||
ošetření přes `menuitem` je ve skriptu, chce ověřit na reálném případu.
|
||||
- Načítání seznamu URL + VTMF čísel z Excelu (`openpyxl`) a smyčka přes
|
||||
všechny dokumenty (struktura `DOCUMENTS` je na to připravená).
|
||||
@@ -0,0 +1,408 @@
|
||||
# ============================================================
|
||||
# download_vault_v1.6.py
|
||||
# Verze: 1.6
|
||||
# Datum: 2026-06-12
|
||||
# Popis: Stažení source-file dokumentu z Veeva Vault (J&J V-TMF).
|
||||
# Perzistentní Chromium profil (session přežije běhy),
|
||||
# login přes J&J SSO formulář, ruční potvrzení 2FA na
|
||||
# telefonu, stažení Source File a přejmenování podle
|
||||
# VTMF čísla do U:\Dropbox\!!!Days\Downloads Z230\.
|
||||
#
|
||||
# v1.1: VAULT_USER / VAULT_PASS se čtou ze souboru .env v rootu
|
||||
# projektu Janssen; env proměnné mají přednost.
|
||||
# v1.2: když údaje chybí (nový počítač — .env se přes Giteu
|
||||
# nepřenáší), skript .env založí / doplní v něm chybějící
|
||||
# řádky VAULT_USER=/VAULT_PASS=, vyzve k doplnění a skončí.
|
||||
# v1.3: robustnější odeslání login formuláře (Sign On / Login /
|
||||
# submit / Enter fallbacky).
|
||||
# v1.4: maintenance okno je jQuery UI dialog (.vv_login_msg_dialog);
|
||||
# jeho "OK" v patičce je nefunkční — jediné funkční zavření je
|
||||
# křížek .ui-dialog-titlebar-close. Zavírá se ve smyčce (dialogy
|
||||
# umí být ve frontě), s opakovanou kontrolou kvůli zpožděnému
|
||||
# zobrazení; záloha klávesa Escape.
|
||||
# v1.5: hledání ve všech frames + automatický záchyt diagnostiky
|
||||
# do debug/<čas>/ když se dialog nepodaří zavřít.
|
||||
# v1.6: DEFINITIVNÍ oprava zavírání (ověřeno na živém DOM):
|
||||
# žádný iframe — dialog je přímo v <body>. Křížek
|
||||
# .ui-dialog-titlebar-close je display:none (0×0), Playwright
|
||||
# na něj správně odmítá klikat. Funkční cíl je viditelné
|
||||
# <a class="ok vv_button"> → ".ui-dialog a.ok.vv_button".
|
||||
# Dialog se po příchodu na home objevuje SE ZPOŽDĚNÍM, proto
|
||||
# wait_for(state="visible") s timeoutem místo okamžité kontroly.
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu.
|
||||
# ============================================================
|
||||
|
||||
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
|
||||
DOWNLOAD_DIR = Path(r"U:\Dropbox\!!!Days\Downloads Z230")
|
||||
|
||||
# Zatím jeden dokument; struktura připravená na pozdější seznam
|
||||
# (ve fázi 2 se naplní z Excelu).
|
||||
DOCUMENTS = [
|
||||
("https://vtmf.veevavault.com/ui/#doc_info/31824736/1/0", "VTMF-25690359"),
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# --- 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 build_target_name(suggested, vtmf):
|
||||
"""Původní název + ' [VTMF-XXXXXX]' před příponou."""
|
||||
if "." in suggested:
|
||||
base, ext = suggested.rsplit(".", 1)
|
||||
return f"{base} [{vtmf}].{ext}"
|
||||
return f"{suggested} [{vtmf}]"
|
||||
|
||||
|
||||
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_url, 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
|
||||
|
||||
new_name = build_target_name(download.suggested_filename, vtmf)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = DOWNLOAD_DIR / new_name
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Uloženo: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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, failed = [], []
|
||||
try:
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
for doc_url, vtmf in DOCUMENTS:
|
||||
try:
|
||||
ok.append(download_source_file(page, doc_url, vtmf))
|
||||
except Exception as e:
|
||||
log(f"[CHYBA] {vtmf}: {e}")
|
||||
failed.append(vtmf)
|
||||
finally:
|
||||
log(f"\n[i] Hotovo: {len(ok)} staženo, {len(failed)} chyb"
|
||||
+ (f" ({', '.join(failed)})" if failed else ""))
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
# download_vault_v2.0 — Dávkové stažení source files z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 2.0 · **Datum:** 2026-06-12
|
||||
|
||||
Playwright skript pro dávkové stažení source-file dokumentů z Veeva
|
||||
Vault (vtmf.veevavault.com) podle Excelu, s perzistentní session,
|
||||
ručním potvrzením 2FA, průběžným stavem (resume) a přejmenováním
|
||||
souborů podle VTMF čísla.
|
||||
|
||||
**Změny v2.0 oproti v1.x** (jednodokumentové verze v TRASH/):
|
||||
seznam dokumentů se čte z Excelu ve `WhatToDownload/`, průběžný stav
|
||||
v `download_state.csv` (přerušený běh naváže, hotové se přeskakují),
|
||||
retry 2× na dokument, volitelný filtr Document Status.
|
||||
|
||||
## Vstupní Excel
|
||||
|
||||
Skript vezme **nejnovější `.xlsx`** ze složky `WhatToDownload/`
|
||||
(typicky „Document Inventory Report - Study Level.xlsx" z Vaultu).
|
||||
Očekávané sloupce: `Document Name`, `Document Number`,
|
||||
`Document Status`. Sloupec Document Number obsahuje vzorec
|
||||
`=HYPERLINK("https://vtmf.veevavault.com/ui/#doc_info/<id>/<maj>/<min>", "VTMF-XXXXXXXX")`
|
||||
— skript z něj regexem bere přímou doc URL i VTMF číslo (ošetřen je
|
||||
i „pravý" hyperlink přes cell.hyperlink a oříznutí rozbité URL).
|
||||
|
||||
Pozor: report z Vaultu má rozbité deklarované rozměry listu (tváří se
|
||||
jako 1×1) — skript proto iteruje řádky přímo a nespoléhá na
|
||||
max_row/max_column.
|
||||
|
||||
## Co skript dělá
|
||||
|
||||
1. Načte přihlašovací údaje z `.env` v rootu projektu
|
||||
(`VAULT_USER`/`VAULT_PASS`); když chybí, založí/doplní šablonu
|
||||
v `.env`, vyzve k doplnění a skončí.
|
||||
2. Načte dokumenty z Excelu, odečte už stažené podle
|
||||
`download_state.csv`.
|
||||
3. Otevře viditelný maximalizovaný Chromium s perzistentním profilem
|
||||
(`vault_profile/`), přihlásí se přes J&J SSO; případné 2FA
|
||||
potvrdíte na telefonu a stisknete ENTER.
|
||||
4. Zavře maintenance/login dialog — kliká na viditelné OK
|
||||
(`.ui-dialog a.ok.vv_button`; křížek v titulku je display:none
|
||||
a nejde na něj klikat). Dialog se objevuje se zpožděním, čeká se
|
||||
na něj 8 s na home, 2 s na stránkách dokumentů.
|
||||
5. Pro každý dokument: otevře doc URL, klikne na ikonu **Source
|
||||
File**, stažený soubor uloží do `U:\Dropbox\!!!Days\Downloads Z230\`
|
||||
jako `původní název [VTMF-XXXXXXXX].přípona`. Při chybě 1 retry
|
||||
po 5 s; výsledek se ihned zapíše do `download_state.csv`.
|
||||
6. Na konci vypíše souhrn (staženo/chyb) a čeká na ENTER.
|
||||
|
||||
## Průběžný stav a navázání
|
||||
|
||||
`download_state.csv` (vedle skriptu): sloupce `vtmf, result, file,
|
||||
timestamp`. Dokumenty s `result=ok` se při dalším běhu přeskakují;
|
||||
chybové se zkusí znovu. Běh jde kdykoli přerušit (Ctrl+C nebo
|
||||
zavření okna) — stav se zapisuje po každém dokumentu.
|
||||
Pro úplně nový běh od nuly soubor smažte (resp. přesuňte do TRASH/).
|
||||
|
||||
## Konfigurace (konstanty nahoře ve skriptu)
|
||||
|
||||
- `EXCEL_DIR` — `WhatToDownload/` (bere se nejnovější .xlsx)
|
||||
- `STATE_FILE` — `download_state.csv`
|
||||
- `DOWNLOAD_DIR` — `U:\Dropbox\!!!Days\Downloads Z230`
|
||||
- `STATUS_FILTER` — `None` = stahovat vše; např. `{"Approved"}`
|
||||
pro jen schválené dokumenty
|
||||
- `MAX_ATTEMPTS` / `RETRY_PAUSE_MS` / `BETWEEN_DOCS_MS` — retry a pauzy
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\download_vault_v2.0.py"
|
||||
```
|
||||
|
||||
Společný venv projektu už obsahuje playwright i Chromium; jinak
|
||||
`pip install -r requirements.txt` a `playwright install chromium`.
|
||||
@@ -0,0 +1,543 @@
|
||||
# ============================================================
|
||||
# download_vault_v2.0.py
|
||||
# Verze: 2.0
|
||||
# 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, stažení Source File a přejmenování
|
||||
# podle VTMF čísla do U:\Dropbox\!!!Days\Downloads Z230\.
|
||||
#
|
||||
# v1.x: jeden dokument natvrdo v konstantě (historie v TRASH/).
|
||||
# v2.0: seznam dokumentů se čte z Excelu (Document Inventory
|
||||
# Report) ve WhatToDownload/ — sloupec Document Number
|
||||
# obsahuje =HYPERLINK("<doc URL>", "VTMF-...."), odkud se
|
||||
# regexem bere přímá doc_info URL i VTMF číslo. Pozn.:
|
||||
# report má rozbité deklarované rozměry, openpyxl proto
|
||||
# čte přes vlastní iteraci, ne max_row/max_column.
|
||||
# Průběžný stav v download_state.csv — hotové dokumenty se
|
||||
# při dalším běhu přeskakují, běh jde kdykoli přerušit.
|
||||
# Retry 2x na dokument, volitelný filtr Document Status.
|
||||
#
|
||||
# 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_DIR = Path(r"U:\Dropbox\!!!Days\Downloads Z230")
|
||||
|
||||
# Filtr podle Document Status (např. {"Approved"}); None = stahovat vše
|
||||
STATUS_FILTER = None
|
||||
|
||||
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 --------------------------------
|
||||
|
||||
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)
|
||||
|
||||
|
||||
HYPERLINK_RE = re.compile(r'HYPERLINK\("([^"]+)"\s*,\s*"([^"]+)"\)')
|
||||
|
||||
|
||||
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 read_documents_from_excel():
|
||||
"""Načte dokumenty z nejnovějšího .xlsx ve WhatToDownload/.
|
||||
Vrací list dictů: vtmf, url, name, status.
|
||||
Document Number je =HYPERLINK("url", "VTMF-...") — URL i VTMF se
|
||||
berou regexem; ošetřen je i 'pravý' hyperlink (cell.hyperlink)."""
|
||||
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")
|
||||
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
|
||||
docs.append({
|
||||
"vtmf": vtmf.strip(),
|
||||
"url": url,
|
||||
# Document Name i Status mohou být také HYPERLINK vzorce
|
||||
"name": display_text(row[i_name]),
|
||||
"status": display_text(row[i_status]),
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- 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 build_target_name(suggested, vtmf):
|
||||
"""Původní název + ' [VTMF-XXXXXX]' před příponou."""
|
||||
if "." in suggested:
|
||||
base, ext = suggested.rsplit(".", 1)
|
||||
return f"{base} [{vtmf}].{ext}"
|
||||
return f"{suggested} [{vtmf}]"
|
||||
|
||||
|
||||
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_url, 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
|
||||
|
||||
new_name = build_target_name(download.suggested_filename, vtmf)
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = DOWNLOAD_DIR / new_name
|
||||
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)}, ke stažení: {len(todo)}")
|
||||
if not todo:
|
||||
log("[ok] Vše už je staženo, není co dělat.")
|
||||
return
|
||||
|
||||
DOWNLOAD_DIR.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['name'][:70]}")
|
||||
last_err = None
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
dest = download_source_file(page, doc["url"], vtmf)
|
||||
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()
|
||||
@@ -0,0 +1,61 @@
|
||||
# download_vault_v2.1 — Dávkové stažení source files z Veeva Vault (J&J V-TMF)
|
||||
|
||||
**Verze:** 2.1 · **Datum:** 2026-06-12
|
||||
|
||||
Playwright skript pro dávkové stažení source-file dokumentů z Veeva
|
||||
Vault (vtmf.veevavault.com) podle Excelu, s perzistentní session,
|
||||
ručním potvrzením 2FA, průběžným stavem (resume) a ukládáním do
|
||||
adresářového stromu Type\Subtype s pojmenováním podle Description.
|
||||
|
||||
**Změny v2.1 oproti v2.0** (starší verze v TRASH/):
|
||||
- cílová složka `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` (165 řádků ho nemá → bez prefixu)
|
||||
- Description sanitizovaný na platné jméno (nepovolené znaky → `_`,
|
||||
sjednocené mezery/podtržítka, ořezané okraje); 16 prázdných
|
||||
Description → fallback na Document Name bez koncové verze
|
||||
- verze z konce `Document Name` `(vX.Y)` → `[vX.Y]` (u všech 1959)
|
||||
- přípona skutečná ze staženého souboru
|
||||
- `LIMIT = 10` — počet dokumentů na jeden běh (None = všechny zbývající);
|
||||
kombinuje se s resume: další běh navazuje na download_state.csv
|
||||
- všech 1959 cílových cest ověřeno jako unikátní (suchý test)
|
||||
|
||||
## Vstupní Excel
|
||||
|
||||
Nejnovější `.xlsx` ze `WhatToDownload/` („Document Inventory Report -
|
||||
Study Level.xlsx" z Vaultu). Potřebné sloupce: Document Name, Document
|
||||
Number, Document Status, Type, Subtype, Description, Document Date.
|
||||
Document Name/Number/Status jsou `=HYPERLINK(...)` vzorce — URL
|
||||
(`/ui/#doc_info/<id>/<maj>/<min>`) a zobrazený text se berou regexem.
|
||||
Pozor: report má rozbité deklarované rozměry listu — čte se přímou
|
||||
iterací řádků, ne přes max_row/max_column.
|
||||
|
||||
## Běh
|
||||
|
||||
1. Údaje z `.env` v rootu projektu (`VAULT_USER`/`VAULT_PASS`);
|
||||
chybí-li, skript šablonu založí/doplní, vyzve a skončí.
|
||||
2. Načte Excel, odečte hotové z `download_state.csv`, vezme
|
||||
prvních `LIMIT` zbývajících.
|
||||
3. Chromium s perzistentním profilem (`vault_profile/`), J&J SSO
|
||||
login, 2FA potvrdíte na telefonu + ENTER.
|
||||
4. Maintenance dialog zavírá přes viditelné OK
|
||||
(`.ui-dialog a.ok.vv_button` — křížek je display:none!), čeká se
|
||||
na něj 8 s na home, 2 s na doc stránkách.
|
||||
5. Každý dokument: doc URL → ikona Source File → uložení do stromu
|
||||
pod novým jménem. Retry 2× po 5 s, výsledek ihned do
|
||||
`download_state.csv` (`vtmf,result,file,timestamp`).
|
||||
6. Souhrn a ENTER pro zavření.
|
||||
|
||||
Pro úplně nový běh od nuly smazat/odsunout `download_state.csv`.
|
||||
|
||||
## Konfigurace (konstanty nahoře)
|
||||
|
||||
`DOWNLOAD_ROOT`, `LIMIT` (10), `STATUS_FILTER` (None = vše),
|
||||
`MAX_ATTEMPTS`/`RETRY_PAUSE_MS`/`BETWEEN_DOCS_MS`, `EXCEL_DIR`,
|
||||
`STATE_FILE`.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\download_vault_v2.1.py"
|
||||
```
|
||||
@@ -0,0 +1,583 @@
|
||||
# ============================================================
|
||||
# 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()
|
||||
@@ -0,0 +1,78 @@
|
||||
# vtmf_pipeline_v1.0 — Kompletní V-TMF workflow (report → Mongo → download)
|
||||
|
||||
**Verze:** 1.0 · **Datum:** 2026-06-12
|
||||
|
||||
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.0.py"
|
||||
```
|
||||
|
||||
Předchůdce: download_vault v1.x–v2.1 (TRASH/).
|
||||
@@ -0,0 +1,748 @@
|
||||
# ============================================================
|
||||
# vtmf_pipeline_v1.0.py
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-12
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
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 = 10
|
||||
# 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
|
||||
|
||||
|
||||
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_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'>). 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_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("=" * 60)
|
||||
input(" Po ručním zavření stiskni ENTER... ")
|
||||
return bool(closed)
|
||||
|
||||
|
||||
# --- Export reportu ----------------------------------------------------
|
||||
|
||||
def download_report(page):
|
||||
"""Stáhne report (Export to Excel, Data Only) do WhatToDownload/
|
||||
pod timestampovaným názvem. Vrátí cestu k souboru."""
|
||||
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:
|
||||
page.wait_for_selector("text=Document Status:", timeout=30000)
|
||||
log("[i] Report načten, otevírám menu akcí (⋯)...")
|
||||
|
||||
# menu tří teček vpravo nahoře u nadpisu reportu
|
||||
actions = page.locator("button[title='Actions'], .report-actions button")
|
||||
if not actions.count():
|
||||
# fallback: poslední tlačítko v hlavičce reportu
|
||||
actions = page.locator(".vv-report-header button")
|
||||
actions.last.click()
|
||||
|
||||
page.get_by_text("Export to Excel", exact=True).click()
|
||||
log("[i] Dialog Excel Export Options...")
|
||||
|
||||
# 'Data Only' bývá default, ale pro jistotu explicitně
|
||||
try:
|
||||
page.get_by_text("Data Only").first.click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Export kliknout PRÁVĚ jednou (vícenásobné kliky = duplikáty)
|
||||
with page.expect_download(timeout=120000) as dl_info:
|
||||
page.get_by_role("button", name="Export").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}")
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
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))
|
||||
log(f"[ok] Uloženo: {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 = 0, 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)
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "file": str(dest),
|
||||
"downloaded_at": datetime.now(),
|
||||
"last_error": None}})
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
# --- 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 = 0
|
||||
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 = download_missing(page, coll)
|
||||
except KeyboardInterrupt:
|
||||
log("\n[!] Přerušeno uživatelem — stav je v Mongo, příští běh naváže.")
|
||||
finally:
|
||||
total = coll.count_documents({})
|
||||
have = coll.count_documents({"deleted": False, "downloaded": True})
|
||||
active = coll.count_documents({"deleted": False})
|
||||
log(f"\n[i] Výsledek běhu: {ok_count} staženo, {fail_count} chyb.")
|
||||
log(f"[i] Mongo: {total} záznamů celkem, {active} aktivních, "
|
||||
f"z toho staženo {have} ({active - have} zbývá).")
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(1 if fail_count else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
# vtmf_pipeline_v1.1 — Kompletní V-TMF workflow (report → Mongo → download)
|
||||
|
||||
**Verze:** 1.1 · **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.
|
||||
|
||||
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.1.py"
|
||||
```
|
||||
|
||||
Předchůdce: download_vault v1.x–v2.1 (TRASH/).
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
# ============================================================
|
||||
# vtmf_pipeline_v1.1.py
|
||||
# Verze: 1.1
|
||||
# Datum: 2026-06-12
|
||||
# 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).
|
||||
#
|
||||
# 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
|
||||
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 = 10
|
||||
# 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
|
||||
|
||||
|
||||
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 tří teček vpravo nahoře u nadpisu reportu — víc kandidátů
|
||||
actions, desc = _first_visible(page, [
|
||||
(lambda f: f.locator("button[title='Actions']"), "button[title=Actions]"),
|
||||
(lambda f: f.locator("[aria-label='Actions']"), "[aria-label=Actions]"),
|
||||
(lambda f: f.locator("button[title='All Actions']"), "button[title=All Actions]"),
|
||||
(lambda f: f.locator("[aria-label='All Actions']"), "[aria-label=All Actions]"),
|
||||
(lambda f: f.locator(".report-actions button"), ".report-actions button"),
|
||||
(lambda f: f.locator(".vv-report-header button"), ".vv-report-header button"),
|
||||
(lambda f: f.get_by_role("button", name=re.compile("actions", re.I)), "role=button name~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()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
item, desc = _first_visible(page, [
|
||||
(lambda f: f.get_by_text("Export to Excel", exact=True), "text exact"),
|
||||
(lambda f: f.get_by_role("menuitem", name=re.compile("export to excel", re.I)), "menuitem"),
|
||||
(lambda f: f.get_by_text(re.compile("export to excel", re.I)), "text regex"),
|
||||
])
|
||||
if item is None:
|
||||
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(f"[i] Klikám 'Export to Excel' ({desc})...")
|
||||
item.click()
|
||||
log("[i] Dialog Excel Export Options...")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 'Data Only' bývá default, ale pro jistotu explicitně
|
||||
try:
|
||||
only = page.get_by_text("Data Only")
|
||||
if only.count() and only.first.is_visible():
|
||||
only.first.click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
export_btn, desc = _first_visible(page, [
|
||||
(lambda f: f.get_by_role("button", name="Export"), "role=button Export"),
|
||||
(lambda f: f.locator("input[type='button'][value='Export'], input[type='submit'][value='Export']"), "input value=Export"),
|
||||
(lambda f: f.locator("a.vv_button:has-text('Export')"), "a.vv_button Export"),
|
||||
])
|
||||
if export_btn is None:
|
||||
save_page_debug(page, "report_export_btn")
|
||||
raise RuntimeError("Dialog exportu bez tlačítka Export. "
|
||||
"Diagnostika v debug/.")
|
||||
# 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}")
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
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))
|
||||
log(f"[ok] Uloženo: {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 = 0, 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)
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "file": str(dest),
|
||||
"downloaded_at": datetime.now(),
|
||||
"last_error": None}})
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
# --- 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 = 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 = 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})
|
||||
log(f"\n[i] Výsledek běhu: {ok_count} staženo, {fail_count} chyb"
|
||||
+ (f", PIPELINE SELHALA ({pipeline_error})"
|
||||
if pipeline_error else "."))
|
||||
log(f"[i] Mongo: {total} záznamů celkem, {active} aktivních, "
|
||||
f"z toho staženo {have} ({active - have} zbývá).")
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(2 if pipeline_error else (1 if fail_count else 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
# vtmf_pipeline_v1.2 — Kompletní V-TMF workflow (report → Mongo → download)
|
||||
|
||||
**Verze:** 1.2 · **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.
|
||||
|
||||
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.2.py"
|
||||
```
|
||||
|
||||
Předchůdce: download_vault v1.x–v2.1 (TRASH/).
|
||||
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
# ============================================================
|
||||
# vtmf_pipeline_v1.2.py
|
||||
# Verze: 1.2
|
||||
# Datum: 2026-06-12
|
||||
# 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).
|
||||
#
|
||||
# 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
|
||||
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 = 10
|
||||
# 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
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
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))
|
||||
log(f"[ok] Uloženo: {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 = 0, 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)
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "file": str(dest),
|
||||
"downloaded_at": datetime.now(),
|
||||
"last_error": None}})
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
# --- 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 = 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 = 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})
|
||||
log(f"\n[i] Výsledek běhu: {ok_count} staženo, {fail_count} chyb"
|
||||
+ (f", PIPELINE SELHALA ({pipeline_error})"
|
||||
if pipeline_error else "."))
|
||||
log(f"[i] Mongo: {total} záznamů celkem, {active} aktivních, "
|
||||
f"z toho staženo {have} ({active - have} zbývá).")
|
||||
input("ENTER pro zavření prohlížeče...")
|
||||
ctx.close()
|
||||
sys.exit(2 if pipeline_error else (1 if fail_count else 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
vtmf,result,file,timestamp
|
||||
VTMF-19077748,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Third Parties\Third Party Oversight\2023-05-16 Teckro SOP list associated with work performed with IPE [VTMF-19077748] [v1.0].pdf,2026-06-12T13:32:43
|
||||
VTMF-18982659,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Third Parties\Third Party Oversight\2023-03-22 List of QD Solutions SOP list associated with work performed with IPE [VTMF-18982659] [v1.0].pdf,2026-06-12T13:32:46
|
||||
VTMF-19008643,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Central Trial Documents\Subject Documents\2023-05-03 Janssen Institution Level_Cover Letter_Greenphire US Site Stipend and Travel Assistance Program Acknowledgement Form [VTMF-19008643] [v1.0].pdf,2026-06-12T13:32:50
|
||||
VTMF-19008639,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Central Trial Documents\Subject Documents\2026-02-04 Janssen Institution Level_Cover Letter_Greenphire ClinCard Message Templates V10 Jan2022 [VTMF-19008639] [v2.0].pdf,2026-06-12T13:32:57
|
||||
VTMF-22648123,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Trial Management\Trial Oversight\2025-02-05 CSC Timely Filing Metrics Review_4Q2024_V#1 [VTMF-22648123] [v1.0].pdf,2026-06-12T13:33:01
|
||||
VTMF-23162872,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Trial Management\Trial Oversight\2025-04-07 CSC Timely Filing Metrics Review_1Q2025_V#1 [VTMF-23162872] [v1.0].pdf,2026-06-12T13:33:05
|
||||
VTMF-22056820,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Trial Management\Trial Oversight\2024-11-12 CSC Timely Filing Metrics Review_Aug-Oct 2024_V#1 [VTMF-22056820] [v1.0].pdf,2026-06-12T13:33:08
|
||||
VTMF-24352418,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Trial Management\Trial Oversight\2025-10-27 CSC Timely Filing Metrics Review - 3Q2025 [VTMF-24352418] [v1.0].pdf,2026-06-12T13:33:12
|
||||
VTMF-5690421,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Central Trial Documents\Trial Documents\IB_en_JNJ-42847922_2025_ed14 [VTMF-5690421] [v18.0].pdf,2026-06-12T13:33:16
|
||||
VTMF-22657978,ok,U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\Central Trial Documents\Trial Documents\IB_Ed[13]-Addendum-JNJ-42847922-AAA-1524042 [VTMF-22657978] [v1.0].pdf,2026-06-12T13:33:20
|
||||
@@ -0,0 +1 @@
|
||||
playwright
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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.x–v2.1 (TRASH/).
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
# ============================================================
|
||||
# vtmf_pipeline_v1.3.py
|
||||
# Verze: 1.3
|
||||
# Datum: 2026-06-12
|
||||
# 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).
|
||||
#
|
||||
# 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
|
||||
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
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
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))
|
||||
log(f"[ok] Uloženo: {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 = 0, 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)
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "file": str(dest),
|
||||
"downloaded_at": datetime.now(),
|
||||
"last_error": None}})
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
# --- 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 = 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 = 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})
|
||||
log(f"\n[i] Výsledek běhu: {ok_count} staženo, {fail_count} chyb"
|
||||
+ (f", PIPELINE SELHALA ({pipeline_error})"
|
||||
if pipeline_error 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()
|
||||
Reference in New Issue
Block a user