z230
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# ============================================================
|
||||
# seaweed_backfill_v1.0.py
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-15
|
||||
# Popis: Jednorázový backfill — nahraje do SeaweedFS Filer
|
||||
# všechny dokumenty z VTMF.documents, které jsou na disku
|
||||
# (downloaded=True, file!=null) ale ještě nemají seaweed_path.
|
||||
# Placeholdery a záznamy bez souboru přeskočí.
|
||||
# Lze spustit opakovaně — HEAD check zajistí dedup,
|
||||
# přerušení kdykoli naváže příště.
|
||||
# ============================================================
|
||||
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
MONGO_DB = "VTMF"
|
||||
MONGO_COLL = "documents"
|
||||
|
||||
SEAWEED_FILER = "http://192.168.1.50:8888"
|
||||
SEAWEED_PREFIX = "/vtmf-documents"
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def sw_path(sha256):
|
||||
return f"{SEAWEED_PREFIX}/{sha256[:2]}/{sha256[2:4]}/{sha256}"
|
||||
|
||||
|
||||
def seaweed_store(data, mime="application/octet-stream"):
|
||||
"""HEAD check + PUT. Vrací (path, url, uploaded)."""
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
path = sw_path(sha256)
|
||||
url = SEAWEED_FILER + path
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(url, method="HEAD"), timeout=10)
|
||||
return path, url, False # dedup hit
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(url, data=data, method="PUT",
|
||||
headers={"Content-Type": mime}),
|
||||
timeout=120)
|
||||
return path, url, True
|
||||
|
||||
|
||||
def main():
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
coll = client[MONGO_DB][MONGO_COLL]
|
||||
log(f"[ok] Mongo připojeno: {MONGO_URI} / {MONGO_DB}.{MONGO_COLL}")
|
||||
|
||||
query = {
|
||||
"downloaded": True,
|
||||
"placeholder": {"$ne": True},
|
||||
"seaweed_path": None,
|
||||
"file": {"$ne": None},
|
||||
}
|
||||
todo = list(coll.find(query).sort([("vtmf", ASCENDING), ("version", ASCENDING)]))
|
||||
log(f"[i] Ke zpracování: {len(todo)} dokumentů\n")
|
||||
|
||||
uploaded = dedup = skipped = failed = 0
|
||||
|
||||
for n, doc in enumerate(todo, 1):
|
||||
key = doc["_id"]
|
||||
path = doc.get("file")
|
||||
log(f"[{n}/{len(todo)}] {key}")
|
||||
|
||||
if not path or not Path(path).exists():
|
||||
log(f" [!] Soubor nenalezen na disku: {path} — přeskočeno.")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
data = Path(path).read_bytes()
|
||||
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
|
||||
sw_p, sw_url, was_new = seaweed_store(data, mime)
|
||||
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"sha256": sha256_hex,
|
||||
"seaweed_path": sw_p,
|
||||
"seaweed_url": sw_url,
|
||||
"seaweed_synced_at": datetime.now(),
|
||||
}})
|
||||
|
||||
if was_new:
|
||||
uploaded += 1
|
||||
log(f" [ok] Nahráno → {sw_p}")
|
||||
else:
|
||||
dedup += 1
|
||||
log(f" [i] Dedup hit → {sw_p}")
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
log(f" [!] Chyba: {e}")
|
||||
|
||||
log(f"\n{'='*60}")
|
||||
log(f" Hotovo: {uploaded} nahráno, {dedup} dedup, "
|
||||
f"{skipped} bez souboru, {failed} chyb.")
|
||||
log(f"{'='*60}")
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,112 @@
|
||||
# vtmf_pipeline_v1.4 — Kompletní V-TMF workflow (report → Mongo → download)
|
||||
|
||||
**Verze:** 1.4 · **Datum:** 2026-06-15
|
||||
|
||||
**Změny v1.1:** oprava tichého selhání — výjimka kteréhokoli kroku se
|
||||
vypíše jako „PIPELINE SELHALA" + exit kód 2 (v1.0 končila zavádějícím
|
||||
souhrnem „0 staženo, 0 chyb"). Export reportu robustnější: menu ⋯,
|
||||
položka Export to Excel i tlačítko Export se hledají přes víc selektorů
|
||||
a ve všech frames; při nenalezení se automaticky uloží diagnostika
|
||||
stránky do debug/<čas>_report_* (screenshot, HTML všech frames, výpis
|
||||
title/aria-label atributů) — z ní se dá určit přesný selektor.
|
||||
|
||||
**Změny v1.2:** selektory exportu ověřené na živém DOM (Claude in
|
||||
Chrome; žádný iframe na celé stránce): menu ⋯ =
|
||||
`.actionMenuContainer .dropDown.vv_dropdown_toggle button.vv-icon-button`
|
||||
(button má prázdný title!); menu se načítá asynchronně (AJAX) →
|
||||
po kliknutí se čeká na položku `a.ReportAction[data-action-name='ExcelExport']`;
|
||||
„Data Only" = radio `name=requiredRadioField value=STANDARD`, defaultně
|
||||
checked (pojistka přes .check()); tlačítko Export = React `<button>`
|
||||
s emotion class hash → selektovat jen přes roli+text.
|
||||
|
||||
**Změny v1.3:** na konci běhu se prohlížeč i konzole zavřou
|
||||
automaticky (žádné čekání na ENTER); interaktivní vstup zůstává jen
|
||||
u 2FA a u ručně nezavřitelného dialogu.
|
||||
|
||||
**Změny v1.4:** detekce placeholder dokumentů — Vault zobrazuje text
|
||||
„This placeholder has no content", dokument nemá žádný Source File ke
|
||||
stažení. Při detekci se zapíše `placeholder=True, downloaded=True` do
|
||||
Mongo a dokument se přeskočí bez chyby. Souhrn na konci běhu uvádí
|
||||
počet placeholderů zvlášť.
|
||||
|
||||
Jeden běh skriptu udělá celé workflow pro studii 77242113UCO3001:
|
||||
|
||||
1. **Login** do vtmf.veevavault.com (persistentní profil
|
||||
`vault_profile/`, J&J SSO, případné 2FA potvrdíte na telefonu
|
||||
+ ENTER; údaje z `.env` v rootu projektu).
|
||||
2. **Export reportu** „Document Inventory Report - Study Level"
|
||||
(přímá URL s ID reportu `0RP000000000182` a filtrem studie
|
||||
`0ST000000137008`) → menu ⋯ → Export to Excel → Data Only →
|
||||
uloží se s timestampem do `WhatToDownload/`, po zpracování se
|
||||
přesune do `WhatToDownload/Zpracovano/`.
|
||||
3. **Parse + sync do MongoDB** — Tower `mongodb://192.168.1.76:27017`,
|
||||
db **VTMF**, kolekce **documents**, klíč `_id = "VTMF-xxx|vY.Z"`
|
||||
(VTMF číslo + verze, unikátní index na dvojici):
|
||||
- nový dokument → založí se (first_seen, deleted=False,
|
||||
downloaded=False),
|
||||
- změna sledovaných polí (name, status, type, subtype, desc,
|
||||
date, url, studies) → promítne se + záznam do `history[]`
|
||||
(timestamp + old/new),
|
||||
- dokument chybí v reportu → `deleted=True, deleted_at` a stažený
|
||||
soubor se přejmenuje s ` [D]` před příponou,
|
||||
- dokument se vrátí do reportu → `deleted=False` a ` [D]`
|
||||
se ze souboru zase odebere.
|
||||
Výsledná sada = záznamy s `deleted=False`.
|
||||
4. **Stažení chybějících** — všechny `deleted=False, downloaded≠True`:
|
||||
doc URL → Source File → uložení do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\<Type>\<Subtype>\`
|
||||
jako `YYYY-MM-DD Description [VTMF-xxx] [vY.Z].<skutečná přípona>`.
|
||||
Výsledek (cesta, čas, případně chyba) se ihned zapisuje do Mongo —
|
||||
běh jde kdykoli přerušit a příště naváže.
|
||||
Placeholder dokumenty (stránka s textem „This placeholder has no
|
||||
content") se přeskočí a označí `placeholder=True, downloaded=True`.
|
||||
|
||||
## Mongo schéma (kolekce documents)
|
||||
|
||||
```
|
||||
_id: "VTMF-19077748|v1.0"
|
||||
vtmf, version, url, name, status, type, subtype, desc, date, studies
|
||||
first_seen, last_seen # kdy poprvé/naposledy v reportu
|
||||
deleted, deleted_at # není ve výsledné sadě reportu
|
||||
downloaded, file, downloaded_at
|
||||
placeholder # True = Vault placeholder bez obsahu
|
||||
last_error, error_at # poslední chyba stahování
|
||||
history: [{ts, changes: {pole: {old, new}}}]
|
||||
```
|
||||
|
||||
## Migrace starého stavu
|
||||
|
||||
Při prvním běhu se `download_state.csv` (z download_vault v2.x)
|
||||
jednorázově namigruje: záznamy `ok` se k odpovídajícímu VTMF zapíší
|
||||
jako `downloaded=True` + cesta. CSV se přejmenuje na
|
||||
`download_state.csv.imported`.
|
||||
|
||||
## Konfigurace (konstanty nahoře)
|
||||
|
||||
- `REPORT_URL` — ID reportu + filtr studie (pro jinou studii se mění
|
||||
jen tato dvě ID)
|
||||
- `LIMIT` — None = stáhnout vše zbývající; číslo = dávka na běh
|
||||
- `MONGO_URI/DB/COLL`, `DOWNLOAD_ROOT`, `EXCEL_DIR`
|
||||
- `TRACKED_FIELDS`, `MAX_ATTEMPTS`, `RETRY_PAUSE_MS`, `BETWEEN_DOCS_MS`
|
||||
|
||||
## Ověřené technické detaily (nesahat bez ověření)
|
||||
|
||||
- Maintenance dialog: zavírat POUZE přes `.ui-dialog a.ok.vv_button`
|
||||
(křížek `.ui-dialog-titlebar-close` je display:none); objevuje se
|
||||
se zpožděním → wait_for visible 8 s (home) / 2-4 s (jinde).
|
||||
- Report Excel má rozbité deklarované rozměry → přímá iterace řádků.
|
||||
- Document Name/Number/Status jsou =HYPERLINK vzorce → regex.
|
||||
- Export kliknout právě jednou; 503/redirecty v network logu
|
||||
ignorovat, rozhoduje expect_download.
|
||||
- Placeholder detekce: `page.locator("div.vv_placeholder_text")` (uvnitř
|
||||
`div.vv_placeholder_pane > div.vv_placeholder_container > div.vv-placeholder-drag-and-drop-container`)
|
||||
se testuje před hledáním Source File ikony — CSS selektor je spolehlivější
|
||||
než text match.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\vtmf_pipeline_v1.4.py"
|
||||
```
|
||||
|
||||
Předchůdce: vtmf_pipeline_v1.3 (TRASH/).
|
||||
@@ -0,0 +1,864 @@
|
||||
# ============================================================
|
||||
# vtmf_pipeline_v1.4.py
|
||||
# Verze: 1.4
|
||||
# Datum: 2026-06-15
|
||||
# Popis: Kompletní workflow V-TMF (J&J Veeva Vault), studie
|
||||
# 77242113UCO3001. Jeden běh udělá:
|
||||
# 1) login do Vaultu (persistentní session + ruční 2FA),
|
||||
# 2) export reportu "Document Inventory Report - Study
|
||||
# Level" do Excelu (Data Only) do WhatToDownload/,
|
||||
# 3) parse reportu a synchronizaci do MongoDB
|
||||
# (Tower, db VTMF, kolekce documents,
|
||||
# klíč = VTMF číslo + verze):
|
||||
# - nové dokumenty se založí,
|
||||
# - změny polí se promítnou (+ history[]),
|
||||
# - dokumenty chybějící v reportu se označí
|
||||
# deleted=True a stažený soubor dostane ' [D]',
|
||||
# - znovuobjevené se vzkřísí a ' [D]' se odebere,
|
||||
# 4) stažení všech dosud nestažených dokumentů do
|
||||
# U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\
|
||||
# <Type>\<Subtype>\"YYYY-MM-DD Description
|
||||
# [VTMF-x] [v1.0].<přípona>" + zápis stavu do Mongo.
|
||||
#
|
||||
# Tracking stahování je KOMPLETNĚ v Mongo; starý
|
||||
# download_state.csv se při prvním běhu jednorázově
|
||||
# namigruje a přejmenuje na .imported.
|
||||
#
|
||||
# Vychází z download_vault_v2.1 (v TRASH/) — login, dialogy
|
||||
# a stahování beze změny; nové jsou kroky 2 a 3.
|
||||
#
|
||||
# v1.1: oprava tichého selhání — chyba kteréhokoli kroku se teď
|
||||
# hlasitě vypíše (a exit kód 2), místo aby běh skončil
|
||||
# souhrnem "0 staženo, 0 chyb". Export reportu: více
|
||||
# selektorů pro menu ⋯ i položku Export to Excel (včetně
|
||||
# hledání ve všech frames) a při selhání automatický záchyt
|
||||
# diagnostiky stránky do debug/ (screenshot + HTML frames).
|
||||
# v1.2: selektory exportu OVĚŘENÉ na živém DOM (žádný iframe):
|
||||
# menu ⋯ = .actionMenuContainer .dropDown.vv_dropdown_toggle
|
||||
# button.vv-icon-button (title prázdný!); menu se načítá
|
||||
# asynchronně -> čekat na položku; položka =
|
||||
# a.ReportAction[data-action-name='ExcelExport']; Data Only =
|
||||
# radio name=requiredRadioField value=STANDARD (default
|
||||
# checked); Export = <button> role+text (emotion class hash,
|
||||
# neselektovat podle tříd).
|
||||
# v1.3: na konci běhu se prohlížeč i okno zavře automaticky
|
||||
# (žádné čekání na ENTER) — vhodné pro bezobslužné běhy.
|
||||
# Interaktivní vstupy zůstávají jen tam, kde jsou nutné
|
||||
# (2FA, ručně nezavřitelný dialog).
|
||||
# v1.4: detekce placeholder dokumentů — stránka s textem
|
||||
# "This placeholder has no content" se přeskočí
|
||||
# (placeholder=True, downloaded=True v Mongo), žádná chyba.
|
||||
#
|
||||
# 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
|
||||
|
||||
|
||||
class PlaceholderDocument(Exception):
|
||||
"""Dokument existuje jen jako placeholder — "This placeholder has no content"."""
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def load_env_file(path):
|
||||
"""Načte KEY=VALUE řádky z .env do os.environ.
|
||||
Už nastavené env proměnné mají přednost, .env je nepřepisuje."""
|
||||
if not path.exists():
|
||||
log(f"[!] .env nenalezen: {path}")
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
if value and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
ENV_SECTION_HEADER = "# --- Veeva Vault (J&J V-TMF) — VTMFDownloadFiles/download_vault ---"
|
||||
ENV_KEYS = ("VAULT_USER", "VAULT_PASS")
|
||||
|
||||
|
||||
def ensure_credentials():
|
||||
"""Načte .env; pokud VAULT_USER/VAULT_PASS chybí, založí/doplní
|
||||
v .env šablonu, vyzve uživatele k doplnění a ukončí skript."""
|
||||
load_env_file(ENV_FILE)
|
||||
if all(os.environ.get(k) for k in ENV_KEYS):
|
||||
return
|
||||
|
||||
existing = ENV_FILE.read_text(encoding="utf-8") if ENV_FILE.exists() else ""
|
||||
missing_lines = [f"{k}=" for k in ENV_KEYS
|
||||
if not re.search(rf"^\s*{k}\s*=", existing, re.M)]
|
||||
|
||||
if not ENV_FILE.exists():
|
||||
ENV_FILE.write_text(
|
||||
"# .env — lokální přihlašovací údaje (NEVERZOVAT, je v .gitignore)\n\n"
|
||||
+ ENV_SECTION_HEADER + "\n"
|
||||
+ "\n".join(missing_lines) + "\n",
|
||||
encoding="utf-8")
|
||||
log(f"[i] Založil jsem nový .env: {ENV_FILE}")
|
||||
elif missing_lines:
|
||||
with open(ENV_FILE, "a", encoding="utf-8") as f:
|
||||
f.write("\n" + ENV_SECTION_HEADER + "\n"
|
||||
+ "\n".join(missing_lines) + "\n")
|
||||
log(f"[i] Doplnil jsem chybějící řádky do .env: {ENV_FILE}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" CHYBÍ PŘIHLAŠOVACÍ ÚDAJE.")
|
||||
print(f" Doplň VAULT_USER a VAULT_PASS do souboru:")
|
||||
print(f" {ENV_FILE}")
|
||||
print(" a spusť skript znovu.")
|
||||
print("=" * 60)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- Parsování Excelu --------------------------------------------------
|
||||
|
||||
HYPERLINK_RE = re.compile(r'HYPERLINK\("([^"]+)"\s*,\s*"([^"]+)"\)')
|
||||
VERSION_RE = re.compile(r"\((v[^)]+)\)\s*$")
|
||||
# nepovolené znaky Windows názvů + řídicí znaky + unicode artefakt �
|
||||
BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f�]")
|
||||
|
||||
|
||||
def clean_filename(s):
|
||||
"""Očistí string na platné jméno souboru/složky ve Windows."""
|
||||
s = BAD_CHARS_RE.sub("_", str(s))
|
||||
s = re.sub(r"\s+", " ", s) # vícenásobné mezery -> jedna
|
||||
s = re.sub(r"_{2,}", "_", s) # vícenásobná podtržítka -> jedno
|
||||
return s.strip(" ._") # okraje: mezery, tečky, podtržítka
|
||||
|
||||
|
||||
def display_text(cell):
|
||||
"""Zobrazený text buňky — u =HYPERLINK vzorce druhý argument."""
|
||||
raw = str(cell.value or "").strip()
|
||||
m = HYPERLINK_RE.search(raw)
|
||||
return m.group(2).strip() if m else raw
|
||||
|
||||
|
||||
def extract_doc_url(raw):
|
||||
"""Z HYPERLINK hodnoty (nebo i rozbité URL) vytáhne čistou doc URL
|
||||
ve tvaru https://<host>/ui/#doc_info/<id>/<major>/<minor>."""
|
||||
m = re.search(r"(https://[^/\"]+/ui/#doc_info/\d+/\d+/\d+)", str(raw))
|
||||
if not m:
|
||||
raise ValueError(f"Nenašel jsem doc URL v: {raw!r}")
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def read_documents_from_excel(path):
|
||||
"""Načte dokumenty z daného .xlsx reportu. Vrací list dictů:
|
||||
vtmf, version, url, name, status, type, subtype, desc, date, studies.
|
||||
Document Name/Number/Status jsou =HYPERLINK vzorce — URL i text se
|
||||
berou regexem. Report má rozbité deklarované rozměry, čte se
|
||||
přímou iterací řádků."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
log(f"[i] Parsování reportu: {path.name}")
|
||||
wb = load_workbook(path, data_only=False) # potřebujeme vzorce
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
|
||||
rows = ws.iter_rows()
|
||||
header = [c.value for c in next(rows)]
|
||||
try:
|
||||
i_num = header.index("Document Number")
|
||||
i_name = header.index("Document Name")
|
||||
i_status = header.index("Document Status")
|
||||
i_type = header.index("Type")
|
||||
i_sub = header.index("Subtype")
|
||||
i_desc = header.index("Description")
|
||||
i_date = header.index("Document Date")
|
||||
i_study = header.index("Study")
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"V reportu chybí očekávaný sloupec: {e}")
|
||||
|
||||
docs, bad = [], []
|
||||
for row in rows:
|
||||
cell = row[i_num]
|
||||
if cell.value is None:
|
||||
continue
|
||||
raw = str(cell.value)
|
||||
m = HYPERLINK_RE.search(raw)
|
||||
if m:
|
||||
url_raw, vtmf = m.group(1), m.group(2)
|
||||
elif cell.hyperlink: # pravý hyperlink místo vzorce
|
||||
url_raw, vtmf = cell.hyperlink.target, raw
|
||||
else:
|
||||
bad.append(raw)
|
||||
continue
|
||||
try:
|
||||
url = extract_doc_url(url_raw)
|
||||
except ValueError:
|
||||
bad.append(raw)
|
||||
continue
|
||||
|
||||
name = display_text(row[i_name])
|
||||
vm = VERSION_RE.search(name)
|
||||
version = vm.group(1) if vm else "v?"
|
||||
|
||||
desc = clean_filename(display_text(row[i_desc]))
|
||||
if not desc:
|
||||
# fallback: Document Name bez koncové verze (jde zvlášť na konec)
|
||||
desc = clean_filename(VERSION_RE.sub("", name))
|
||||
|
||||
date = row[i_date].value # datetime nebo None
|
||||
docs.append({
|
||||
"vtmf": vtmf.strip(),
|
||||
"version": version,
|
||||
"url": url,
|
||||
"name": name,
|
||||
"status": display_text(row[i_status]),
|
||||
"type": clean_filename(display_text(row[i_type])),
|
||||
"subtype": clean_filename(display_text(row[i_sub])),
|
||||
"desc": desc,
|
||||
"date": date if hasattr(date, "strftime") else None,
|
||||
"studies": display_text(row[i_study]),
|
||||
})
|
||||
|
||||
log(f"[i] Načteno {len(docs)} dokumentů"
|
||||
+ (f", {len(bad)} řádků bez použitelné URL (přeskočeno)" if bad else ""))
|
||||
return docs
|
||||
|
||||
|
||||
def build_target_path(doc, suggested_filename):
|
||||
"""Cílová cesta: DOWNLOAD_ROOT\\Type\\Subtype\\
|
||||
'YYYY-MM-DD Description [VTMF-xxx] [v1.0].<skutečná přípona>'.
|
||||
Datum/verze se vynechají, když nejsou k dispozici."""
|
||||
ext = Path(suggested_filename).suffix # skutečná přípona vč. tečky
|
||||
date_prefix = doc["date"].strftime("%Y-%m-%d") + " " if doc["date"] else ""
|
||||
version = f" [{doc['version']}]" if doc.get("version") else ""
|
||||
filename = f"{date_prefix}{doc['desc']} [{doc['vtmf']}]{version}{ext}"
|
||||
return DOWNLOAD_ROOT / doc["type"] / doc["subtype"] / filename
|
||||
|
||||
|
||||
def deleted_marker_path(path):
|
||||
"""Jméno souboru s příznakem smazání: 'x.pdf' -> 'x [D].pdf'."""
|
||||
p = Path(path)
|
||||
return p.with_name(f"{p.stem} [D]{p.suffix}")
|
||||
|
||||
|
||||
# --- MongoDB synchronizace ---------------------------------------------
|
||||
|
||||
def doc_key(vtmf, version):
|
||||
return f"{vtmf}|{version}"
|
||||
|
||||
|
||||
def get_collection():
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
coll = client[MONGO_DB][MONGO_COLL]
|
||||
coll.create_index([("vtmf", ASCENDING), ("version", ASCENDING)],
|
||||
unique=True)
|
||||
coll.create_index([("deleted", ASCENDING), ("downloaded", ASCENDING)])
|
||||
return coll
|
||||
|
||||
|
||||
def migrate_old_csv(coll):
|
||||
"""Jednorázová migrace download_state.csv do Mongo: záznamy 'ok'
|
||||
se zapíší jako downloaded=True k odpovídajícímu VTMF (aktuální,
|
||||
nesmazané verzi). CSV se pak přejmenuje na .imported."""
|
||||
if not OLD_STATE_FILE.exists():
|
||||
return
|
||||
migrated = 0
|
||||
with open(OLD_STATE_FILE, newline="", encoding="utf-8") as f:
|
||||
for row in csv.DictReader(f):
|
||||
if row["result"] != "ok":
|
||||
continue
|
||||
r = coll.update_one(
|
||||
{"vtmf": row["vtmf"], "deleted": False,
|
||||
"downloaded": {"$ne": True}},
|
||||
{"$set": {"downloaded": True, "file": row["file"],
|
||||
"downloaded_at": row["timestamp"]}})
|
||||
migrated += r.modified_count
|
||||
OLD_STATE_FILE.rename(OLD_STATE_FILE.with_suffix(".csv.imported"))
|
||||
log(f"[i] Migrace download_state.csv -> Mongo: {migrated} záznamů; "
|
||||
f"CSV přejmenováno na .imported")
|
||||
|
||||
|
||||
def sync_report_to_mongo(coll, docs):
|
||||
"""Promítne aktuální report do kolekce documents.
|
||||
Klíč = (vtmf, version). Nové založí, změny polí promítne
|
||||
(s history[]), chybějící označí deleted + soubor přejmenuje
|
||||
s ' [D]', znovuobjevené vzkřísí a ' [D]' odebere."""
|
||||
now = datetime.now()
|
||||
stats = {"new": 0, "updated": 0, "unchanged": 0,
|
||||
"resurrected": 0, "marked_deleted": 0}
|
||||
current_keys = set()
|
||||
|
||||
for d in docs:
|
||||
key = doc_key(d["vtmf"], d["version"])
|
||||
current_keys.add(key)
|
||||
existing = coll.find_one({"_id": key})
|
||||
if existing is None:
|
||||
coll.insert_one({
|
||||
"_id": key, **d,
|
||||
"first_seen": now, "last_seen": now,
|
||||
"deleted": False, "downloaded": False,
|
||||
"file": None, "history": [],
|
||||
})
|
||||
stats["new"] += 1
|
||||
continue
|
||||
|
||||
changes = {}
|
||||
for fld in TRACKED_FIELDS:
|
||||
if existing.get(fld) != d.get(fld):
|
||||
changes[fld] = {"old": existing.get(fld),
|
||||
"new": d.get(fld)}
|
||||
update = {"$set": {**d, "last_seen": now, "deleted": False}}
|
||||
if changes:
|
||||
update["$push"] = {"history": {"ts": now, "changes": changes}}
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
|
||||
if existing.get("deleted"):
|
||||
# dokument se do reportu vrátil -> odebrat [D] ze souboru
|
||||
stats["resurrected"] += 1
|
||||
stats["unchanged"] -= 0 # (počítá se výše jako updated/unchanged)
|
||||
old_file = existing.get("file")
|
||||
if old_file:
|
||||
marked = deleted_marker_path(old_file)
|
||||
if marked.exists() and not Path(old_file).exists():
|
||||
marked.rename(old_file)
|
||||
log(f"[i] {key}: soubor vrácen z ' [D]' zpět.")
|
||||
update["$set"]["file"] = str(old_file)
|
||||
coll.update_one({"_id": key}, update)
|
||||
|
||||
# dokumenty, které v aktuálním reportu nejsou -> deleted + ' [D]'
|
||||
for rec in coll.find({"deleted": False}):
|
||||
if rec["_id"] in current_keys:
|
||||
continue
|
||||
upd = {"deleted": True, "deleted_at": now}
|
||||
f = rec.get("file")
|
||||
if f and Path(f).exists():
|
||||
marked = deleted_marker_path(f)
|
||||
try:
|
||||
Path(f).rename(marked)
|
||||
upd["file"] = str(marked)
|
||||
log(f"[i] {rec['_id']}: soubor označen ' [D]'.")
|
||||
except OSError as e:
|
||||
log(f"[!] {rec['_id']}: přejmenování na [D] selhalo: {e}")
|
||||
coll.update_one({"_id": rec["_id"]},
|
||||
{"$set": upd,
|
||||
"$push": {"history": {"ts": now,
|
||||
"changes": {"deleted": {
|
||||
"old": False,
|
||||
"new": True}}}}})
|
||||
stats["marked_deleted"] += 1
|
||||
|
||||
log(f"[ok] Mongo sync: {stats['new']} nových, {stats['updated']} změněných, "
|
||||
f"{stats['unchanged']} beze změny, {stats['resurrected']} obnovených, "
|
||||
f"{stats['marked_deleted']} označených deleted.")
|
||||
return stats
|
||||
|
||||
|
||||
# --- Přihlášení --------------------------------------------------------
|
||||
|
||||
def submit_login_form(page, password_box):
|
||||
"""Odešle login formulář. Zkouší postupně tlačítka Sign On / Login /
|
||||
OK / submit input; když žádné nenajde, stiskne Enter v poli hesla."""
|
||||
candidates = [
|
||||
page.get_by_role("button", name=re.compile("sign\\s*on", re.I)),
|
||||
page.get_by_role("button", name=re.compile("log\\s*in|sign\\s*in", re.I)),
|
||||
page.locator("input[type='submit']"),
|
||||
page.locator("button[type='submit']"),
|
||||
page.get_by_role("button", name=re.compile("^ok$", re.I)),
|
||||
]
|
||||
for loc in candidates:
|
||||
try:
|
||||
if loc.count() and loc.first.is_visible():
|
||||
label = (loc.first.inner_text() or
|
||||
loc.first.get_attribute("value") or "submit").strip()
|
||||
log(f"[i] Odesílám formulář tlačítkem '{label}'...")
|
||||
loc.first.click()
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
log("[i] Tlačítko nenalezeno, odesílám Enterem v poli hesla...")
|
||||
password_box.press("Enter")
|
||||
|
||||
|
||||
def login_if_needed(page):
|
||||
"""Otevře login URL, vyplní jméno+heslo, detekuje 2FA a počká na
|
||||
ruční potvrzení. Pokud perzistentní session žije, login přeskočí."""
|
||||
log(f"[i] Otevírám přihlašovací URL...")
|
||||
page.goto(LOGIN_URL, wait_until="domcontentloaded")
|
||||
|
||||
if "vtmf.veevavault.com/ui" in page.url:
|
||||
log("[i] Už přihlášen (perzistentní session).")
|
||||
return
|
||||
|
||||
user_box = page.locator("input[type='text']").first
|
||||
try:
|
||||
user_box.wait_for(timeout=8000)
|
||||
except PWTimeout:
|
||||
if "vtmf.veevavault.com/ui" in page.url:
|
||||
log("[i] Přihlášen bez formuláře (session redirect).")
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Nenašel jsem login formulář ani Vault. Aktuální URL: {page.url}")
|
||||
|
||||
username = os.environ["VAULT_USER"]
|
||||
password = os.environ["VAULT_PASS"]
|
||||
|
||||
log("[i] Vyplňuji přihlašovací údaje...")
|
||||
user_box.fill(username)
|
||||
password_box = page.locator("input[type='password']").first
|
||||
password_box.fill(password)
|
||||
submit_login_form(page, password_box)
|
||||
|
||||
log("[i] Odeslán login, čekám na výsledek...")
|
||||
try:
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=15000)
|
||||
log("[ok] Přihlášen rovnou (bez 2FA).")
|
||||
return
|
||||
except PWTimeout:
|
||||
pass # nejsme ve Vaultu -> pravděpodobně 2FA výzva
|
||||
|
||||
err = page.locator("text=/invalid|incorrect|failed/i")
|
||||
try:
|
||||
if err.count() and err.first.is_visible():
|
||||
raise RuntimeError(f"Login selhal: {err.first.inner_text().strip()}")
|
||||
except PWTimeout:
|
||||
pass
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" VYŽADOVÁNO OVĚŘENÍ NA TELEFONU (2FA).")
|
||||
print(" Potvrď přihlášení v mobilní aplikaci.")
|
||||
print("=" * 60)
|
||||
input(" Až to potvrdíš, stiskni ENTER pro pokračování... ")
|
||||
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=120000)
|
||||
log("[ok] Přihlášení dokončeno.")
|
||||
|
||||
|
||||
def verify_inside(page):
|
||||
"""Ověří, že jsme uvnitř Vaultu (URL na /ui)."""
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=30000)
|
||||
log(f"[ok] Uvnitř Vaultu: {page.url}")
|
||||
|
||||
|
||||
def dialog_visible(page):
|
||||
"""True, pokud je na stránce viditelný jQuery UI dialog."""
|
||||
try:
|
||||
dlg = page.locator(".ui-dialog")
|
||||
return bool(dlg.count() and dlg.first.is_visible())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def save_page_debug(page, tag):
|
||||
"""Uloží diagnostiku stránky: screenshot, HTML všech frames a výpis
|
||||
kandidátů na tlačítka. Vrátí cestu složky."""
|
||||
out = DEBUG_DIR / datetime.now().strftime(f"%Y-%m-%d_%H-%M-%S_{tag}")
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
page.screenshot(path=str(out / "screenshot.png"), full_page=False)
|
||||
except Exception as e:
|
||||
(out / "screenshot_error.txt").write_text(str(e), encoding="utf-8")
|
||||
report = []
|
||||
for i, frame in enumerate(page.frames):
|
||||
report.append(f"=== frame[{i}] url={frame.url}")
|
||||
try:
|
||||
(out / f"frame_{i}.html").write_text(frame.content(),
|
||||
encoding="utf-8")
|
||||
for sel in (".ui-dialog", "a.ok.vv_button",
|
||||
".ui-dialog-titlebar-close",
|
||||
"button", "input[type='button']",
|
||||
"[title]", "[aria-label]"):
|
||||
n = frame.locator(sel).count()
|
||||
if n:
|
||||
report.append(f" {sel}: {n}x")
|
||||
# výpis title/aria-label atributů — pomáhá najít menu ⋯
|
||||
for attr in ("title", "aria-label"):
|
||||
vals = frame.locator(f"[{attr}]").evaluate_all(
|
||||
f"els => els.map(e => e.getAttribute('{attr}'))")
|
||||
uniq = sorted({v for v in vals if v})[:80]
|
||||
report.append(f" {attr}: {uniq}")
|
||||
except Exception as e:
|
||||
report.append(f" [chyba čtení framu: {e}]")
|
||||
(out / "frames_report.txt").write_text("\n".join(report),
|
||||
encoding="utf-8")
|
||||
log(f"[!] Diagnostika stránky uložena do: {out}")
|
||||
return out
|
||||
|
||||
|
||||
# Viditelné OK tlačítko dialogu — je to <a>, ne <button>!
|
||||
# Křížek .ui-dialog-titlebar-close je display:none → NEPOUŽÍVAT.
|
||||
DIALOG_OK_SELECTOR = (".ui-dialog a.ok.vv_button, "
|
||||
".vv_login_msg_dialog .vv_button.ok")
|
||||
|
||||
|
||||
def dismiss_maintenance_popup(page, timeout=8000):
|
||||
"""Zavře Veeva login/maintenance dialog kliknutím na viditelné OK
|
||||
(<a class='ok vv_button'>). Dialog se objevuje SE ZPOŽDĚNÍM,
|
||||
proto se na něj krátce čeká. Bezpečné volat vždy."""
|
||||
ok = page.locator(DIALOG_OK_SELECTOR)
|
||||
try:
|
||||
ok.first.wait_for(state="visible", timeout=timeout)
|
||||
except PWTimeout:
|
||||
return False # okno se neobjevilo — pokračujeme
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
closed = 0
|
||||
for _ in range(5): # dialogy umí být ve frontě
|
||||
try:
|
||||
if ok.count() and ok.first.is_visible():
|
||||
ok.first.click()
|
||||
page.wait_for_timeout(300)
|
||||
closed += 1
|
||||
log("[i] Maintenance/login dialog zavřen (OK).")
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if not dialog_visible(page):
|
||||
return bool(closed)
|
||||
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(500)
|
||||
log("[i] Zkusil jsem dialog zavřít klávesou Escape.")
|
||||
|
||||
if dialog_visible(page):
|
||||
save_page_debug(page, "dialog")
|
||||
print("\n" + "=" * 60)
|
||||
print(" DIALOG SE NEPODAŘILO ZAVŘÍT AUTOMATICKY.")
|
||||
print(" Zavři ho prosím ručně v prohlížeči.")
|
||||
print("=" * 60)
|
||||
input(" Po ručním zavření stiskni ENTER... ")
|
||||
return bool(closed)
|
||||
|
||||
|
||||
# --- Export reportu ----------------------------------------------------
|
||||
|
||||
def _first_visible(page, builders):
|
||||
"""Vrátí (locator, popis) prvního viditelného kandidáta. Hledá na
|
||||
hlavní stránce i ve všech frames."""
|
||||
for frame in page.frames:
|
||||
for build, desc in builders:
|
||||
try:
|
||||
loc = build(frame)
|
||||
if loc.count() and loc.first.is_visible():
|
||||
return loc.first, desc
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def download_report(page):
|
||||
"""Stáhne report (Export to Excel, Data Only) do WhatToDownload/
|
||||
pod timestampovaným názvem. Vrátí cestu k souboru.
|
||||
Při selhání uloží diagnostiku stránky do debug/ a vyhodí výjimku."""
|
||||
log("[i] Otevírám report Document Inventory Report - Study Level...")
|
||||
page.goto(REPORT_URL, wait_until="domcontentloaded")
|
||||
dismiss_maintenance_popup(page, timeout=4000)
|
||||
|
||||
# report je hotový, když se objeví počet záznamů / statusy
|
||||
try:
|
||||
page.wait_for_selector("text=Returned", timeout=30000)
|
||||
except PWTimeout:
|
||||
try:
|
||||
page.wait_for_selector("text=Document Status:", timeout=30000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_load")
|
||||
raise RuntimeError(
|
||||
"Report se nenačetl (nenašel jsem 'Returned' ani "
|
||||
"'Document Status:'). Diagnostika v debug/.")
|
||||
log("[i] Report načten, otevírám menu akcí (⋯)...")
|
||||
|
||||
# Menu ⋯ (Actions): button bez title/aria-label uvnitř
|
||||
# .actionMenuContainer (ověřeno na živém DOM, žádný iframe).
|
||||
actions, desc = _first_visible(page, [
|
||||
(lambda f: f.locator(
|
||||
".actionMenuContainer .dropDown.vv_dropdown_toggle "
|
||||
"button.vv-icon-button"), ".actionMenuContainer button (ověřený)"),
|
||||
(lambda f: f.locator(".actionMenuContainer button"), ".actionMenuContainer button (volnější)"),
|
||||
(lambda f: f.locator("button[title='Actions'], [aria-label='Actions']"), "title/aria-label Actions"),
|
||||
])
|
||||
if actions is None:
|
||||
save_page_debug(page, "report_menu")
|
||||
raise RuntimeError("Nenašel jsem menu akcí (⋯) na reportu. "
|
||||
"Diagnostika v debug/.")
|
||||
log(f"[i] Menu nalezeno přes: {desc}")
|
||||
actions.click()
|
||||
|
||||
# Menu se načítá ASYNCHRONNĚ (data-loaded=false -> AJAX),
|
||||
# počkat na položku, nečíst hned po kliknutí.
|
||||
item = page.locator("a.ReportAction[data-action-name='ExcelExport']")
|
||||
try:
|
||||
item.first.wait_for(state="visible", timeout=15000)
|
||||
except PWTimeout:
|
||||
# fallback podle textu (kdyby se data atribut změnil)
|
||||
item = page.get_by_text("Export to Excel", exact=True)
|
||||
try:
|
||||
item.first.wait_for(state="visible", timeout=5000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_export_item")
|
||||
raise RuntimeError("Menu se otevřelo, ale položku 'Export to "
|
||||
"Excel' jsem nenašel. Diagnostika v debug/.")
|
||||
log("[i] Klikám 'Export to Excel'...")
|
||||
item.first.click()
|
||||
log("[i] Dialog Excel Export Options...")
|
||||
|
||||
# 'Data Only' = radio value=STANDARD, defaultně checked; pojistka.
|
||||
radio = page.locator("input[name='requiredRadioField'][value='STANDARD']")
|
||||
try:
|
||||
radio.first.wait_for(state="visible", timeout=10000)
|
||||
if not radio.first.is_checked():
|
||||
radio.first.check()
|
||||
log("[i] Přepnuto na 'Data Only'.")
|
||||
except PWTimeout:
|
||||
log("[!] Radio 'Data Only' nenalezeno — spoléhám na default dialogu.")
|
||||
|
||||
# Export = <button> s textem Export (React dialog, emotion třídy —
|
||||
# NEselektovat podle class hash, jen role+text).
|
||||
export_btn = page.get_by_role("button", name="Export", exact=True)
|
||||
try:
|
||||
export_btn.first.wait_for(state="visible", timeout=10000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_export_btn")
|
||||
raise RuntimeError("Dialog exportu bez tlačítka Export. "
|
||||
"Diagnostika v debug/.")
|
||||
export_btn = export_btn.first
|
||||
# Export kliknout PRÁVĚ jednou (vícenásobné kliky = duplikáty);
|
||||
# 503/redirecty v network logu neřešit — rozhoduje expect_download
|
||||
with page.expect_download(timeout=120000) as dl_info:
|
||||
export_btn.click()
|
||||
download = dl_info.value
|
||||
|
||||
EXCEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
dest = EXCEL_DIR / f"{ts} {download.suggested_filename}"
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Report uložen: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
def archive_report(path):
|
||||
"""Po úspěšném zpracování přesune report do Zpracovano/."""
|
||||
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
target = PROCESSED_DIR / path.name
|
||||
path.rename(target)
|
||||
log(f"[i] Report archivován: {target}")
|
||||
|
||||
|
||||
# --- Stažení dokumentů -------------------------------------------------
|
||||
|
||||
def find_source_file_button(page):
|
||||
"""Najde ikonu Source File (list papíru se šipkou dolů, vpravo nahoře).
|
||||
Více fallback selektorů — DOM se může lišit podle typu dokumentu."""
|
||||
candidates = [
|
||||
"[title='Source File']",
|
||||
"[aria-label='Source File']",
|
||||
]
|
||||
for sel in candidates:
|
||||
loc = page.locator(sel)
|
||||
if loc.count():
|
||||
return loc.first
|
||||
loc = page.get_by_role("button", name=re.compile("Source File", re.I))
|
||||
if loc.count():
|
||||
return loc.first
|
||||
return None
|
||||
|
||||
|
||||
def download_source_file(page, doc):
|
||||
vtmf = doc["vtmf"]
|
||||
log(f"[i] Otevírám dokument {vtmf} ({doc.get('version', '')}) ...")
|
||||
page.goto(doc["url"], wait_until="domcontentloaded")
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=30000)
|
||||
except PWTimeout:
|
||||
log("[!] networkidle nenastal do 30 s, zkouším pokračovat...")
|
||||
dismiss_maintenance_popup(page, timeout=2000)
|
||||
|
||||
ph = page.locator("div.vv_placeholder_text")
|
||||
if ph.count() and ph.first.is_visible():
|
||||
log(f"[i] {vtmf}: placeholder bez obsahu — přeskakuji.")
|
||||
raise PlaceholderDocument(vtmf)
|
||||
|
||||
target = find_source_file_button(page)
|
||||
if target is None:
|
||||
raise RuntimeError(
|
||||
f"Nenašel jsem ikonu 'Source File' na stránce dokumentu {vtmf}.")
|
||||
|
||||
log("[i] Klikám na Source File a čekám na download...")
|
||||
with page.expect_download(timeout=60000) as dl_info:
|
||||
target.click()
|
||||
# Varianta s dropdownem (Source File + Viewable Rendition)
|
||||
try:
|
||||
item = page.get_by_role("menuitem",
|
||||
name=re.compile("Source File", re.I))
|
||||
if item.count() and item.first.is_visible():
|
||||
log("[i] Otevřel se dropdown, vybírám 'Source File'...")
|
||||
item.first.click()
|
||||
except Exception:
|
||||
pass
|
||||
download = dl_info.value
|
||||
|
||||
dest = build_target_path(doc, download.suggested_filename)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
download.save_as(str(dest))
|
||||
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, placeholder_count = 0, 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 PlaceholderDocument:
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "placeholder": True,
|
||||
"file": None, "downloaded_at": datetime.now(),
|
||||
"last_error": None}})
|
||||
placeholder_count += 1
|
||||
last_err = None
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
log(f"[!] Pokus {attempt}/{MAX_ATTEMPTS} selhal: {e}")
|
||||
if attempt < MAX_ATTEMPTS:
|
||||
page.wait_for_timeout(RETRY_PAUSE_MS)
|
||||
if last_err is not None:
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"last_error": str(last_err),
|
||||
"error_at": datetime.now()}})
|
||||
fail_count += 1
|
||||
page.wait_for_timeout(BETWEEN_DOCS_MS)
|
||||
return ok_count, fail_count, placeholder_count
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
coll = get_collection()
|
||||
log(f"[ok] Mongo připojeno: {MONGO_URI} / {MONGO_DB}.{MONGO_COLL}")
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
headless=False,
|
||||
accept_downloads=True,
|
||||
no_viewport=True, # okno se chová nativně
|
||||
args=["--start-maximized"],
|
||||
)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
ok_count = fail_count = placeholder_count = 0
|
||||
pipeline_error = None
|
||||
try:
|
||||
# 1) login
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
# 2) export reportu
|
||||
report_path = download_report(page)
|
||||
|
||||
# 3) parse + sync do Mongo
|
||||
docs = read_documents_from_excel(report_path)
|
||||
if not docs:
|
||||
raise RuntimeError("Report neobsahuje žádné dokumenty — "
|
||||
"sync přeskočen, nic se nemaže.")
|
||||
sync_report_to_mongo(coll, docs)
|
||||
migrate_old_csv(coll)
|
||||
archive_report(report_path)
|
||||
|
||||
# 4) stažení chybějících
|
||||
DOWNLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
ok_count, fail_count, placeholder_count = 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, "
|
||||
f"{placeholder_count} placeholderů přeskoč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()
|
||||
@@ -0,0 +1,96 @@
|
||||
# vtmf_pipeline_v1.5 — Kompletní V-TMF workflow (report → Mongo → download → SeaweedFS)
|
||||
|
||||
**Verze:** 1.5 · **Datum:** 2026-06-15
|
||||
|
||||
**Změny v1.5:** upload každého staženého dokumentu do SeaweedFS Filer
|
||||
(`192.168.1.50:8888`, cesta `/vtmf-documents/ab/cd/<sha256>`).
|
||||
SHA-256 content-addressed dedup — identický soubor se uloží jen jednou
|
||||
(HEAD check → 404 → PUT; při 200 dedup hit). Chyba uploadu neblokuje
|
||||
download ani zápis do Mongo — soubor zůstane na disku a pole
|
||||
`sha256/seaweed_path/seaweed_url/seaweed_synced_at` zůstanou `null`
|
||||
(lze doplnit backfillem). Souhrn na konci uvádí počet nově nahraných,
|
||||
dedup hitů a případných chyb uploadu zvlášť.
|
||||
|
||||
_(Předchozí změny viz TRASH/vtmf_pipeline_v1.4.md)_
|
||||
|
||||
Jeden běh skriptu udělá celé workflow pro studii 77242113UCO3001:
|
||||
|
||||
1. **Login** do vtmf.veevavault.com (persistentní profil
|
||||
`vault_profile/`, J&J SSO, případné 2FA potvrdíte na telefonu
|
||||
+ ENTER; údaje z `.env` v rootu projektu).
|
||||
2. **Export reportu** „Document Inventory Report - Study Level"
|
||||
(přímá URL s ID reportu `0RP000000000182` a filtrem studie
|
||||
`0ST000000137008`) → menu ⋯ → Export to Excel → Data Only →
|
||||
uloží se s timestampem do `WhatToDownload/`, po zpracování se
|
||||
přesune do `WhatToDownload/Zpracovano/`.
|
||||
3. **Parse + sync do MongoDB** — Tower `mongodb://192.168.1.76:27017`,
|
||||
db **VTMF**, kolekce **documents**, klíč `_id = "VTMF-xxx|vY.Z"`:
|
||||
- nové dokumenty se založí,
|
||||
- změny sledovaných polí se promítnou (+ `history[]`),
|
||||
- dokumenty chybějící v reportu se označí `deleted=True`
|
||||
a stažený soubor dostane ` [D]` před příponou,
|
||||
- znovuobjevené se vzkřísí a ` [D]` se odebere.
|
||||
4. **Stažení + SeaweedFS upload** — všechny `deleted=False, downloaded≠True`:
|
||||
- Source File se uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\<Type>\<Subtype>\`
|
||||
jako `YYYY-MM-DD Description [VTMF-xxx] [vY.Z].<přípona>`,
|
||||
- soubor se přečte z disku, vypočítá se SHA-256, obsah se nahraje
|
||||
do SeaweedFS na `/vtmf-documents/{sha256[:2]}/{sha256[2:4]}/{sha256}`,
|
||||
- do Mongo se zapíše `downloaded=True, file, sha256, seaweed_path,
|
||||
seaweed_url, seaweed_synced_at`; chyba SeaweedFS tyto fieldy
|
||||
nechá `null` ale `downloaded=True` se zapíše (soubor je na disku).
|
||||
- Placeholder dokumenty (`div.vv_placeholder_text` viditelný) se
|
||||
přeskočí s `placeholder=True, downloaded=True`.
|
||||
|
||||
## Mongo schéma (kolekce documents)
|
||||
|
||||
```
|
||||
_id: "VTMF-19077748|v1.0"
|
||||
vtmf, version, url, name, status, type, subtype, desc, date, studies
|
||||
first_seen, last_seen # kdy poprvé/naposledy v reportu
|
||||
deleted, deleted_at # není ve výsledné sadě reportu
|
||||
downloaded, file, downloaded_at
|
||||
placeholder # True = Vault placeholder bez obsahu
|
||||
sha256 # hex SHA-256 staženého souboru
|
||||
seaweed_path # /vtmf-documents/ab/cd/<sha256>
|
||||
seaweed_url # http://192.168.1.50:8888/vtmf-documents/...
|
||||
seaweed_synced_at # kdy nahráno / null při chybě
|
||||
last_error, error_at # poslední chyba stahování
|
||||
history: [{ts, changes: {pole: {old, new}}}]
|
||||
```
|
||||
|
||||
## SeaweedFS detaily
|
||||
|
||||
- **Filer**: `http://192.168.1.50:8888` (přímý PUT, žádný master assign)
|
||||
- **Dedup**: HEAD → 404 → PUT; HEAD → 200 → dedup hit (vrátí `uploaded=False`)
|
||||
- **Timeout**: HEAD 10 s, PUT 120 s (velké soubory)
|
||||
- **MIME**: `mimetypes.guess_type()`, fallback `application/octet-stream`
|
||||
- **Backfill**: dokumenty s `downloaded=True, seaweed_path=null` lze
|
||||
dohnat samostatným skriptem (čte `file` z Mongo, nahraje, zapíše pola)
|
||||
|
||||
## Konfigurace (konstanty nahoře)
|
||||
|
||||
- `SEAWEED_FILER` — URL Filer serveru
|
||||
- `SEAWEED_PREFIX` — prefix cesty (`/vtmf-documents`)
|
||||
- `REPORT_URL` — ID reportu + filtr studie
|
||||
- `LIMIT` — None = vše; číslo = dávka
|
||||
- `MONGO_URI/DB/COLL`, `DOWNLOAD_ROOT`, `EXCEL_DIR`
|
||||
- `TRACKED_FIELDS`, `MAX_ATTEMPTS`, `RETRY_PAUSE_MS`, `BETWEEN_DOCS_MS`
|
||||
|
||||
## Ověřené technické detaily (nesahat bez ověření)
|
||||
|
||||
- Maintenance dialog: zavírat POUZE přes `.ui-dialog a.ok.vv_button`
|
||||
(křížek `.ui-dialog-titlebar-close` je display:none).
|
||||
- Report Excel má rozbité deklarované rozměry → přímá iterace řádků.
|
||||
- Document Name/Number/Status jsou =HYPERLINK vzorce → regex.
|
||||
- Export kliknout právě jednou; rozhoduje `expect_download`.
|
||||
- Placeholder detekce: `div.vv_placeholder_text` (uvnitř
|
||||
`div.vv_placeholder_pane > div.vv_placeholder_container`).
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\vtmf_pipeline_v1.5.py"
|
||||
```
|
||||
|
||||
Předchůdce: vtmf_pipeline_v1.4 (TRASH/).
|
||||
@@ -0,0 +1,937 @@
|
||||
# ============================================================
|
||||
# vtmf_pipeline_v1.5.py
|
||||
# Verze: 1.5
|
||||
# Datum: 2026-06-15
|
||||
# Popis: Kompletní workflow V-TMF (J&J Veeva Vault), studie
|
||||
# 77242113UCO3001. Jeden běh udělá:
|
||||
# 1) login do Vaultu (persistentní session + ruční 2FA),
|
||||
# 2) export reportu "Document Inventory Report - Study
|
||||
# Level" do Excelu (Data Only) do WhatToDownload/,
|
||||
# 3) parse reportu a synchronizaci do MongoDB
|
||||
# (Tower, db VTMF, kolekce documents,
|
||||
# klíč = VTMF číslo + verze):
|
||||
# - nové dokumenty se založí,
|
||||
# - změny polí se promítnou (+ history[]),
|
||||
# - dokumenty chybějící v reportu se označí
|
||||
# deleted=True a stažený soubor dostane ' [D]',
|
||||
# - znovuobjevené se vzkřísí a ' [D]' se odebere,
|
||||
# 4) stažení všech dosud nestažených dokumentů do
|
||||
# U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\
|
||||
# <Type>\<Subtype>\"YYYY-MM-DD Description
|
||||
# [VTMF-x] [v1.0].<přípona>" + zápis stavu do Mongo.
|
||||
#
|
||||
# Tracking stahování je KOMPLETNĚ v Mongo; starý
|
||||
# download_state.csv se při prvním běhu jednorázově
|
||||
# namigruje a přejmenuje na .imported.
|
||||
#
|
||||
# Vychází z download_vault_v2.1 (v TRASH/) — login, dialogy
|
||||
# a stahování beze změny; nové jsou kroky 2 a 3.
|
||||
#
|
||||
# v1.1: oprava tichého selhání — chyba kteréhokoli kroku se teď
|
||||
# hlasitě vypíše (a exit kód 2), místo aby běh skončil
|
||||
# souhrnem "0 staženo, 0 chyb". Export reportu: více
|
||||
# selektorů pro menu ⋯ i položku Export to Excel (včetně
|
||||
# hledání ve všech frames) a při selhání automatický záchyt
|
||||
# diagnostiky stránky do debug/ (screenshot + HTML frames).
|
||||
# v1.2: selektory exportu OVĚŘENÉ na živém DOM (žádný iframe):
|
||||
# menu ⋯ = .actionMenuContainer .dropDown.vv_dropdown_toggle
|
||||
# button.vv-icon-button (title prázdný!); menu se načítá
|
||||
# asynchronně -> čekat na položku; položka =
|
||||
# a.ReportAction[data-action-name='ExcelExport']; Data Only =
|
||||
# radio name=requiredRadioField value=STANDARD (default
|
||||
# checked); Export = <button> role+text (emotion class hash,
|
||||
# neselektovat podle tříd).
|
||||
# v1.3: na konci běhu se prohlížeč i okno zavře automaticky
|
||||
# (žádné čekání na ENTER) — vhodné pro bezobslužné běhy.
|
||||
# Interaktivní vstupy zůstávají jen tam, kde jsou nutné
|
||||
# (2FA, ručně nezavřitelný dialog).
|
||||
# v1.4: detekce placeholder dokumentů — stránka s textem
|
||||
# "This placeholder has no content" se přeskočí
|
||||
# (placeholder=True, downloaded=True v Mongo), žádná chyba.
|
||||
# v1.5: upload stažených dokumentů do SeaweedFS Filer
|
||||
# (192.168.1.50:8888, cesta /vtmf-documents/ab/cd/<sha256>).
|
||||
# SHA-256 content-addressed dedup — identický soubor se uloží
|
||||
# jen jednou. Chyba uploadu neblokuje download; chybějící
|
||||
# sha256/seaweed_path lze doplnit backfillem. Mongo nově ukládá:
|
||||
# sha256, seaweed_path, seaweed_url, seaweed_synced_at.
|
||||
# Souhrn běhu uvádí počet nově nahraných vs. dedup hitů.
|
||||
#
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu — čte se z .env
|
||||
# v rootu projektu Janssen (VAULT_USER / VAULT_PASS).
|
||||
# ============================================================
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
|
||||
# --- Konfigurace -------------------------------------------------------
|
||||
|
||||
LOGIN_URL = ("https://fedlogin.jnj.com/idp/eyJ2c2lkIjoiam5qX3ZlZXZhIn0/"
|
||||
"startSSO.ping?PartnerSpId=janssenetmf.veevavault.com"
|
||||
"&IdpAdapterId=CompIWALDAPEXTFORM"
|
||||
"&TargetResource=https%3A%2F%2Fvtmf.veevavault.com%2F")
|
||||
|
||||
# Report Document Inventory Report - Study Level, filtr na studii
|
||||
REPORT_URL = ("https://vtmf.veevavault.com/ui/#reporting/viewer/"
|
||||
"0RP000000000182?study__v%2C%2C%2CIN=0ST000000137008")
|
||||
|
||||
VAULT_UI_PATTERN = "**vtmf.veevavault.com/ui**" # úspěšný vstup do Vaultu
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PROFILE_DIR = SCRIPT_DIR / "vault_profile" # perzistentní session
|
||||
ENV_FILE = SCRIPT_DIR.parent / ".env" # root projektu Janssen
|
||||
DEBUG_DIR = SCRIPT_DIR / "debug" # diagnostické výstupy
|
||||
EXCEL_DIR = SCRIPT_DIR / "WhatToDownload" # stažené reporty
|
||||
PROCESSED_DIR = EXCEL_DIR / "Zpracovano" # archiv zpracovaných
|
||||
OLD_STATE_FILE = SCRIPT_DIR / "download_state.csv" # legacy CSV (migrace)
|
||||
DOWNLOAD_ROOT = Path(r"U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001")
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
MONGO_DB = "VTMF"
|
||||
MONGO_COLL = "documents"
|
||||
|
||||
# Kolik dokumentů stáhnout v tomto běhu (None = všechny zbývající)
|
||||
LIMIT = 0
|
||||
# Pole reportu, jejichž změny se promítají a verzují do history[]
|
||||
TRACKED_FIELDS = ("name", "status", "type", "subtype", "desc",
|
||||
"date", "url", "studies")
|
||||
|
||||
MAX_ATTEMPTS = 2 # pokusy na jeden dokument
|
||||
RETRY_PAUSE_MS = 5000 # pauza před opakováním
|
||||
BETWEEN_DOCS_MS = 500 # pauza mezi dokumenty
|
||||
|
||||
SEAWEED_FILER = "http://192.168.1.50:8888"
|
||||
SEAWEED_PREFIX = "/vtmf-documents"
|
||||
|
||||
|
||||
class PlaceholderDocument(Exception):
|
||||
"""Dokument existuje jen jako placeholder — "This placeholder has no content"."""
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def load_env_file(path):
|
||||
"""Načte KEY=VALUE řádky z .env do os.environ.
|
||||
Už nastavené env proměnné mají přednost, .env je nepřepisuje."""
|
||||
if not path.exists():
|
||||
log(f"[!] .env nenalezen: {path}")
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
if value and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
ENV_SECTION_HEADER = "# --- Veeva Vault (J&J V-TMF) — VTMFDownloadFiles/download_vault ---"
|
||||
ENV_KEYS = ("VAULT_USER", "VAULT_PASS")
|
||||
|
||||
|
||||
def ensure_credentials():
|
||||
"""Načte .env; pokud VAULT_USER/VAULT_PASS chybí, založí/doplní
|
||||
v .env šablonu, vyzve uživatele k doplnění a ukončí skript."""
|
||||
load_env_file(ENV_FILE)
|
||||
if all(os.environ.get(k) for k in ENV_KEYS):
|
||||
return
|
||||
|
||||
existing = ENV_FILE.read_text(encoding="utf-8") if ENV_FILE.exists() else ""
|
||||
missing_lines = [f"{k}=" for k in ENV_KEYS
|
||||
if not re.search(rf"^\s*{k}\s*=", existing, re.M)]
|
||||
|
||||
if not ENV_FILE.exists():
|
||||
ENV_FILE.write_text(
|
||||
"# .env — lokální přihlašovací údaje (NEVERZOVAT, je v .gitignore)\n\n"
|
||||
+ ENV_SECTION_HEADER + "\n"
|
||||
+ "\n".join(missing_lines) + "\n",
|
||||
encoding="utf-8")
|
||||
log(f"[i] Založil jsem nový .env: {ENV_FILE}")
|
||||
elif missing_lines:
|
||||
with open(ENV_FILE, "a", encoding="utf-8") as f:
|
||||
f.write("\n" + ENV_SECTION_HEADER + "\n"
|
||||
+ "\n".join(missing_lines) + "\n")
|
||||
log(f"[i] Doplnil jsem chybějící řádky do .env: {ENV_FILE}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" CHYBÍ PŘIHLAŠOVACÍ ÚDAJE.")
|
||||
print(f" Doplň VAULT_USER a VAULT_PASS do souboru:")
|
||||
print(f" {ENV_FILE}")
|
||||
print(" a spusť skript znovu.")
|
||||
print("=" * 60)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- Parsování Excelu --------------------------------------------------
|
||||
|
||||
HYPERLINK_RE = re.compile(r'HYPERLINK\("([^"]+)"\s*,\s*"([^"]+)"\)')
|
||||
VERSION_RE = re.compile(r"\((v[^)]+)\)\s*$")
|
||||
# nepovolené znaky Windows názvů + řídicí znaky + unicode artefakt �
|
||||
BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f�]")
|
||||
|
||||
|
||||
def clean_filename(s):
|
||||
"""Očistí string na platné jméno souboru/složky ve Windows."""
|
||||
s = BAD_CHARS_RE.sub("_", str(s))
|
||||
s = re.sub(r"\s+", " ", s) # vícenásobné mezery -> jedna
|
||||
s = re.sub(r"_{2,}", "_", s) # vícenásobná podtržítka -> jedno
|
||||
return s.strip(" ._") # okraje: mezery, tečky, podtržítka
|
||||
|
||||
|
||||
def display_text(cell):
|
||||
"""Zobrazený text buňky — u =HYPERLINK vzorce druhý argument."""
|
||||
raw = str(cell.value or "").strip()
|
||||
m = HYPERLINK_RE.search(raw)
|
||||
return m.group(2).strip() if m else raw
|
||||
|
||||
|
||||
def extract_doc_url(raw):
|
||||
"""Z HYPERLINK hodnoty (nebo i rozbité URL) vytáhne čistou doc URL
|
||||
ve tvaru https://<host>/ui/#doc_info/<id>/<major>/<minor>."""
|
||||
m = re.search(r"(https://[^/\"]+/ui/#doc_info/\d+/\d+/\d+)", str(raw))
|
||||
if not m:
|
||||
raise ValueError(f"Nenašel jsem doc URL v: {raw!r}")
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def read_documents_from_excel(path):
|
||||
"""Načte dokumenty z daného .xlsx reportu. Vrací list dictů:
|
||||
vtmf, version, url, name, status, type, subtype, desc, date, studies.
|
||||
Document Name/Number/Status jsou =HYPERLINK vzorce — URL i text se
|
||||
berou regexem. Report má rozbité deklarované rozměry, čte se
|
||||
přímou iterací řádků."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
log(f"[i] Parsování reportu: {path.name}")
|
||||
wb = load_workbook(path, data_only=False) # potřebujeme vzorce
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
|
||||
rows = ws.iter_rows()
|
||||
header = [c.value for c in next(rows)]
|
||||
try:
|
||||
i_num = header.index("Document Number")
|
||||
i_name = header.index("Document Name")
|
||||
i_status = header.index("Document Status")
|
||||
i_type = header.index("Type")
|
||||
i_sub = header.index("Subtype")
|
||||
i_desc = header.index("Description")
|
||||
i_date = header.index("Document Date")
|
||||
i_study = header.index("Study")
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"V reportu chybí očekávaný sloupec: {e}")
|
||||
|
||||
docs, bad = [], []
|
||||
for row in rows:
|
||||
cell = row[i_num]
|
||||
if cell.value is None:
|
||||
continue
|
||||
raw = str(cell.value)
|
||||
m = HYPERLINK_RE.search(raw)
|
||||
if m:
|
||||
url_raw, vtmf = m.group(1), m.group(2)
|
||||
elif cell.hyperlink: # pravý hyperlink místo vzorce
|
||||
url_raw, vtmf = cell.hyperlink.target, raw
|
||||
else:
|
||||
bad.append(raw)
|
||||
continue
|
||||
try:
|
||||
url = extract_doc_url(url_raw)
|
||||
except ValueError:
|
||||
bad.append(raw)
|
||||
continue
|
||||
|
||||
name = display_text(row[i_name])
|
||||
vm = VERSION_RE.search(name)
|
||||
version = vm.group(1) if vm else "v?"
|
||||
|
||||
desc = clean_filename(display_text(row[i_desc]))
|
||||
if not desc:
|
||||
# fallback: Document Name bez koncové verze (jde zvlášť na konec)
|
||||
desc = clean_filename(VERSION_RE.sub("", name))
|
||||
|
||||
date = row[i_date].value # datetime nebo None
|
||||
docs.append({
|
||||
"vtmf": vtmf.strip(),
|
||||
"version": version,
|
||||
"url": url,
|
||||
"name": name,
|
||||
"status": display_text(row[i_status]),
|
||||
"type": clean_filename(display_text(row[i_type])),
|
||||
"subtype": clean_filename(display_text(row[i_sub])),
|
||||
"desc": desc,
|
||||
"date": date if hasattr(date, "strftime") else None,
|
||||
"studies": display_text(row[i_study]),
|
||||
})
|
||||
|
||||
log(f"[i] Načteno {len(docs)} dokumentů"
|
||||
+ (f", {len(bad)} řádků bez použitelné URL (přeskočeno)" if bad else ""))
|
||||
return docs
|
||||
|
||||
|
||||
def build_target_path(doc, suggested_filename):
|
||||
"""Cílová cesta: DOWNLOAD_ROOT\\Type\\Subtype\\
|
||||
'YYYY-MM-DD Description [VTMF-xxx] [v1.0].<skutečná přípona>'.
|
||||
Datum/verze se vynechají, když nejsou k dispozici."""
|
||||
ext = Path(suggested_filename).suffix # skutečná přípona vč. tečky
|
||||
date_prefix = doc["date"].strftime("%Y-%m-%d") + " " if doc["date"] else ""
|
||||
version = f" [{doc['version']}]" if doc.get("version") else ""
|
||||
filename = f"{date_prefix}{doc['desc']} [{doc['vtmf']}]{version}{ext}"
|
||||
return DOWNLOAD_ROOT / doc["type"] / doc["subtype"] / filename
|
||||
|
||||
|
||||
def deleted_marker_path(path):
|
||||
"""Jméno souboru s příznakem smazání: 'x.pdf' -> 'x [D].pdf'."""
|
||||
p = Path(path)
|
||||
return p.with_name(f"{p.stem} [D]{p.suffix}")
|
||||
|
||||
|
||||
# --- MongoDB synchronizace ---------------------------------------------
|
||||
|
||||
def doc_key(vtmf, version):
|
||||
return f"{vtmf}|{version}"
|
||||
|
||||
|
||||
def get_collection():
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
coll = client[MONGO_DB][MONGO_COLL]
|
||||
coll.create_index([("vtmf", ASCENDING), ("version", ASCENDING)],
|
||||
unique=True)
|
||||
coll.create_index([("deleted", ASCENDING), ("downloaded", ASCENDING)])
|
||||
return coll
|
||||
|
||||
|
||||
def migrate_old_csv(coll):
|
||||
"""Jednorázová migrace download_state.csv do Mongo: záznamy 'ok'
|
||||
se zapíší jako downloaded=True k odpovídajícímu VTMF (aktuální,
|
||||
nesmazané verzi). CSV se pak přejmenuje na .imported."""
|
||||
if not OLD_STATE_FILE.exists():
|
||||
return
|
||||
migrated = 0
|
||||
with open(OLD_STATE_FILE, newline="", encoding="utf-8") as f:
|
||||
for row in csv.DictReader(f):
|
||||
if row["result"] != "ok":
|
||||
continue
|
||||
r = coll.update_one(
|
||||
{"vtmf": row["vtmf"], "deleted": False,
|
||||
"downloaded": {"$ne": True}},
|
||||
{"$set": {"downloaded": True, "file": row["file"],
|
||||
"downloaded_at": row["timestamp"]}})
|
||||
migrated += r.modified_count
|
||||
OLD_STATE_FILE.rename(OLD_STATE_FILE.with_suffix(".csv.imported"))
|
||||
log(f"[i] Migrace download_state.csv -> Mongo: {migrated} záznamů; "
|
||||
f"CSV přejmenováno na .imported")
|
||||
|
||||
|
||||
def sync_report_to_mongo(coll, docs):
|
||||
"""Promítne aktuální report do kolekce documents.
|
||||
Klíč = (vtmf, version). Nové založí, změny polí promítne
|
||||
(s history[]), chybějící označí deleted + soubor přejmenuje
|
||||
s ' [D]', znovuobjevené vzkřísí a ' [D]' odebere."""
|
||||
now = datetime.now()
|
||||
stats = {"new": 0, "updated": 0, "unchanged": 0,
|
||||
"resurrected": 0, "marked_deleted": 0}
|
||||
current_keys = set()
|
||||
|
||||
for d in docs:
|
||||
key = doc_key(d["vtmf"], d["version"])
|
||||
current_keys.add(key)
|
||||
existing = coll.find_one({"_id": key})
|
||||
if existing is None:
|
||||
coll.insert_one({
|
||||
"_id": key, **d,
|
||||
"first_seen": now, "last_seen": now,
|
||||
"deleted": False, "downloaded": False,
|
||||
"file": None, "history": [],
|
||||
})
|
||||
stats["new"] += 1
|
||||
continue
|
||||
|
||||
changes = {}
|
||||
for fld in TRACKED_FIELDS:
|
||||
if existing.get(fld) != d.get(fld):
|
||||
changes[fld] = {"old": existing.get(fld),
|
||||
"new": d.get(fld)}
|
||||
update = {"$set": {**d, "last_seen": now, "deleted": False}}
|
||||
if changes:
|
||||
update["$push"] = {"history": {"ts": now, "changes": changes}}
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
|
||||
if existing.get("deleted"):
|
||||
# dokument se do reportu vrátil -> odebrat [D] ze souboru
|
||||
stats["resurrected"] += 1
|
||||
stats["unchanged"] -= 0 # (počítá se výše jako updated/unchanged)
|
||||
old_file = existing.get("file")
|
||||
if old_file:
|
||||
marked = deleted_marker_path(old_file)
|
||||
if marked.exists() and not Path(old_file).exists():
|
||||
marked.rename(old_file)
|
||||
log(f"[i] {key}: soubor vrácen z ' [D]' zpět.")
|
||||
update["$set"]["file"] = str(old_file)
|
||||
coll.update_one({"_id": key}, update)
|
||||
|
||||
# dokumenty, které v aktuálním reportu nejsou -> deleted + ' [D]'
|
||||
for rec in coll.find({"deleted": False}):
|
||||
if rec["_id"] in current_keys:
|
||||
continue
|
||||
upd = {"deleted": True, "deleted_at": now}
|
||||
f = rec.get("file")
|
||||
if f and Path(f).exists():
|
||||
marked = deleted_marker_path(f)
|
||||
try:
|
||||
Path(f).rename(marked)
|
||||
upd["file"] = str(marked)
|
||||
log(f"[i] {rec['_id']}: soubor označen ' [D]'.")
|
||||
except OSError as e:
|
||||
log(f"[!] {rec['_id']}: přejmenování na [D] selhalo: {e}")
|
||||
coll.update_one({"_id": rec["_id"]},
|
||||
{"$set": upd,
|
||||
"$push": {"history": {"ts": now,
|
||||
"changes": {"deleted": {
|
||||
"old": False,
|
||||
"new": True}}}}})
|
||||
stats["marked_deleted"] += 1
|
||||
|
||||
log(f"[ok] Mongo sync: {stats['new']} nových, {stats['updated']} změněných, "
|
||||
f"{stats['unchanged']} beze změny, {stats['resurrected']} obnovených, "
|
||||
f"{stats['marked_deleted']} označených deleted.")
|
||||
return stats
|
||||
|
||||
|
||||
# --- Přihlášení --------------------------------------------------------
|
||||
|
||||
def submit_login_form(page, password_box):
|
||||
"""Odešle login formulář. Zkouší postupně tlačítka Sign On / Login /
|
||||
OK / submit input; když žádné nenajde, stiskne Enter v poli hesla."""
|
||||
candidates = [
|
||||
page.get_by_role("button", name=re.compile("sign\\s*on", re.I)),
|
||||
page.get_by_role("button", name=re.compile("log\\s*in|sign\\s*in", re.I)),
|
||||
page.locator("input[type='submit']"),
|
||||
page.locator("button[type='submit']"),
|
||||
page.get_by_role("button", name=re.compile("^ok$", re.I)),
|
||||
]
|
||||
for loc in candidates:
|
||||
try:
|
||||
if loc.count() and loc.first.is_visible():
|
||||
label = (loc.first.inner_text() or
|
||||
loc.first.get_attribute("value") or "submit").strip()
|
||||
log(f"[i] Odesílám formulář tlačítkem '{label}'...")
|
||||
loc.first.click()
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
log("[i] Tlačítko nenalezeno, odesílám Enterem v poli hesla...")
|
||||
password_box.press("Enter")
|
||||
|
||||
|
||||
def login_if_needed(page):
|
||||
"""Otevře login URL, vyplní jméno+heslo, detekuje 2FA a počká na
|
||||
ruční potvrzení. Pokud perzistentní session žije, login přeskočí."""
|
||||
log(f"[i] Otevírám přihlašovací URL...")
|
||||
page.goto(LOGIN_URL, wait_until="domcontentloaded")
|
||||
|
||||
if "vtmf.veevavault.com/ui" in page.url:
|
||||
log("[i] Už přihlášen (perzistentní session).")
|
||||
return
|
||||
|
||||
user_box = page.locator("input[type='text']").first
|
||||
try:
|
||||
user_box.wait_for(timeout=8000)
|
||||
except PWTimeout:
|
||||
if "vtmf.veevavault.com/ui" in page.url:
|
||||
log("[i] Přihlášen bez formuláře (session redirect).")
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Nenašel jsem login formulář ani Vault. Aktuální URL: {page.url}")
|
||||
|
||||
username = os.environ["VAULT_USER"]
|
||||
password = os.environ["VAULT_PASS"]
|
||||
|
||||
log("[i] Vyplňuji přihlašovací údaje...")
|
||||
user_box.fill(username)
|
||||
password_box = page.locator("input[type='password']").first
|
||||
password_box.fill(password)
|
||||
submit_login_form(page, password_box)
|
||||
|
||||
log("[i] Odeslán login, čekám na výsledek...")
|
||||
try:
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=15000)
|
||||
log("[ok] Přihlášen rovnou (bez 2FA).")
|
||||
return
|
||||
except PWTimeout:
|
||||
pass # nejsme ve Vaultu -> pravděpodobně 2FA výzva
|
||||
|
||||
err = page.locator("text=/invalid|incorrect|failed/i")
|
||||
try:
|
||||
if err.count() and err.first.is_visible():
|
||||
raise RuntimeError(f"Login selhal: {err.first.inner_text().strip()}")
|
||||
except PWTimeout:
|
||||
pass
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" VYŽADOVÁNO OVĚŘENÍ NA TELEFONU (2FA).")
|
||||
print(" Potvrď přihlášení v mobilní aplikaci.")
|
||||
print("=" * 60)
|
||||
input(" Až to potvrdíš, stiskni ENTER pro pokračování... ")
|
||||
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=120000)
|
||||
log("[ok] Přihlášení dokončeno.")
|
||||
|
||||
|
||||
def verify_inside(page):
|
||||
"""Ověří, že jsme uvnitř Vaultu (URL na /ui)."""
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=30000)
|
||||
log(f"[ok] Uvnitř Vaultu: {page.url}")
|
||||
|
||||
|
||||
def dialog_visible(page):
|
||||
"""True, pokud je na stránce viditelný jQuery UI dialog."""
|
||||
try:
|
||||
dlg = page.locator(".ui-dialog")
|
||||
return bool(dlg.count() and dlg.first.is_visible())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def save_page_debug(page, tag):
|
||||
"""Uloží diagnostiku stránky: screenshot, HTML všech frames a výpis
|
||||
kandidátů na tlačítka. Vrátí cestu složky."""
|
||||
out = DEBUG_DIR / datetime.now().strftime(f"%Y-%m-%d_%H-%M-%S_{tag}")
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
page.screenshot(path=str(out / "screenshot.png"), full_page=False)
|
||||
except Exception as e:
|
||||
(out / "screenshot_error.txt").write_text(str(e), encoding="utf-8")
|
||||
report = []
|
||||
for i, frame in enumerate(page.frames):
|
||||
report.append(f"=== frame[{i}] url={frame.url}")
|
||||
try:
|
||||
(out / f"frame_{i}.html").write_text(frame.content(),
|
||||
encoding="utf-8")
|
||||
for sel in (".ui-dialog", "a.ok.vv_button",
|
||||
".ui-dialog-titlebar-close",
|
||||
"button", "input[type='button']",
|
||||
"[title]", "[aria-label]"):
|
||||
n = frame.locator(sel).count()
|
||||
if n:
|
||||
report.append(f" {sel}: {n}x")
|
||||
# výpis title/aria-label atributů — pomáhá najít menu ⋯
|
||||
for attr in ("title", "aria-label"):
|
||||
vals = frame.locator(f"[{attr}]").evaluate_all(
|
||||
f"els => els.map(e => e.getAttribute('{attr}'))")
|
||||
uniq = sorted({v for v in vals if v})[:80]
|
||||
report.append(f" {attr}: {uniq}")
|
||||
except Exception as e:
|
||||
report.append(f" [chyba čtení framu: {e}]")
|
||||
(out / "frames_report.txt").write_text("\n".join(report),
|
||||
encoding="utf-8")
|
||||
log(f"[!] Diagnostika stránky uložena do: {out}")
|
||||
return out
|
||||
|
||||
|
||||
# Viditelné OK tlačítko dialogu — je to <a>, ne <button>!
|
||||
# Křížek .ui-dialog-titlebar-close je display:none → NEPOUŽÍVAT.
|
||||
DIALOG_OK_SELECTOR = (".ui-dialog a.ok.vv_button, "
|
||||
".vv_login_msg_dialog .vv_button.ok")
|
||||
|
||||
|
||||
def dismiss_maintenance_popup(page, timeout=8000):
|
||||
"""Zavře Veeva login/maintenance dialog kliknutím na viditelné OK
|
||||
(<a class='ok vv_button'>). Dialog se objevuje SE ZPOŽDĚNÍM,
|
||||
proto se na něj krátce čeká. Bezpečné volat vždy."""
|
||||
ok = page.locator(DIALOG_OK_SELECTOR)
|
||||
try:
|
||||
ok.first.wait_for(state="visible", timeout=timeout)
|
||||
except PWTimeout:
|
||||
return False # okno se neobjevilo — pokračujeme
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
closed = 0
|
||||
for _ in range(5): # dialogy umí být ve frontě
|
||||
try:
|
||||
if ok.count() and ok.first.is_visible():
|
||||
ok.first.click()
|
||||
page.wait_for_timeout(300)
|
||||
closed += 1
|
||||
log("[i] Maintenance/login dialog zavřen (OK).")
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if not dialog_visible(page):
|
||||
return bool(closed)
|
||||
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(500)
|
||||
log("[i] Zkusil jsem dialog zavřít klávesou Escape.")
|
||||
|
||||
if dialog_visible(page):
|
||||
save_page_debug(page, "dialog")
|
||||
print("\n" + "=" * 60)
|
||||
print(" DIALOG SE NEPODAŘILO ZAVŘÍT AUTOMATICKY.")
|
||||
print(" Zavři ho prosím ručně v prohlížeči.")
|
||||
print("=" * 60)
|
||||
input(" Po ručním zavření stiskni ENTER... ")
|
||||
return bool(closed)
|
||||
|
||||
|
||||
# --- Export reportu ----------------------------------------------------
|
||||
|
||||
def _first_visible(page, builders):
|
||||
"""Vrátí (locator, popis) prvního viditelného kandidáta. Hledá na
|
||||
hlavní stránce i ve všech frames."""
|
||||
for frame in page.frames:
|
||||
for build, desc in builders:
|
||||
try:
|
||||
loc = build(frame)
|
||||
if loc.count() and loc.first.is_visible():
|
||||
return loc.first, desc
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def download_report(page):
|
||||
"""Stáhne report (Export to Excel, Data Only) do WhatToDownload/
|
||||
pod timestampovaným názvem. Vrátí cestu k souboru.
|
||||
Při selhání uloží diagnostiku stránky do debug/ a vyhodí výjimku."""
|
||||
log("[i] Otevírám report Document Inventory Report - Study Level...")
|
||||
page.goto(REPORT_URL, wait_until="domcontentloaded")
|
||||
dismiss_maintenance_popup(page, timeout=4000)
|
||||
|
||||
# report je hotový, když se objeví počet záznamů / statusy
|
||||
try:
|
||||
page.wait_for_selector("text=Returned", timeout=30000)
|
||||
except PWTimeout:
|
||||
try:
|
||||
page.wait_for_selector("text=Document Status:", timeout=30000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_load")
|
||||
raise RuntimeError(
|
||||
"Report se nenačetl (nenašel jsem 'Returned' ani "
|
||||
"'Document Status:'). Diagnostika v debug/.")
|
||||
log("[i] Report načten, otevírám menu akcí (⋯)...")
|
||||
|
||||
# Menu ⋯ (Actions): button bez title/aria-label uvnitř
|
||||
# .actionMenuContainer (ověřeno na živém DOM, žádný iframe).
|
||||
actions, desc = _first_visible(page, [
|
||||
(lambda f: f.locator(
|
||||
".actionMenuContainer .dropDown.vv_dropdown_toggle "
|
||||
"button.vv-icon-button"), ".actionMenuContainer button (ověřený)"),
|
||||
(lambda f: f.locator(".actionMenuContainer button"), ".actionMenuContainer button (volnější)"),
|
||||
(lambda f: f.locator("button[title='Actions'], [aria-label='Actions']"), "title/aria-label Actions"),
|
||||
])
|
||||
if actions is None:
|
||||
save_page_debug(page, "report_menu")
|
||||
raise RuntimeError("Nenašel jsem menu akcí (⋯) na reportu. "
|
||||
"Diagnostika v debug/.")
|
||||
log(f"[i] Menu nalezeno přes: {desc}")
|
||||
actions.click()
|
||||
|
||||
# Menu se načítá ASYNCHRONNĚ (data-loaded=false -> AJAX),
|
||||
# počkat na položku, nečíst hned po kliknutí.
|
||||
item = page.locator("a.ReportAction[data-action-name='ExcelExport']")
|
||||
try:
|
||||
item.first.wait_for(state="visible", timeout=15000)
|
||||
except PWTimeout:
|
||||
# fallback podle textu (kdyby se data atribut změnil)
|
||||
item = page.get_by_text("Export to Excel", exact=True)
|
||||
try:
|
||||
item.first.wait_for(state="visible", timeout=5000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_export_item")
|
||||
raise RuntimeError("Menu se otevřelo, ale položku 'Export to "
|
||||
"Excel' jsem nenašel. Diagnostika v debug/.")
|
||||
log("[i] Klikám 'Export to Excel'...")
|
||||
item.first.click()
|
||||
log("[i] Dialog Excel Export Options...")
|
||||
|
||||
# 'Data Only' = radio value=STANDARD, defaultně checked; pojistka.
|
||||
radio = page.locator("input[name='requiredRadioField'][value='STANDARD']")
|
||||
try:
|
||||
radio.first.wait_for(state="visible", timeout=10000)
|
||||
if not radio.first.is_checked():
|
||||
radio.first.check()
|
||||
log("[i] Přepnuto na 'Data Only'.")
|
||||
except PWTimeout:
|
||||
log("[!] Radio 'Data Only' nenalezeno — spoléhám na default dialogu.")
|
||||
|
||||
# Export = <button> s textem Export (React dialog, emotion třídy —
|
||||
# NEselektovat podle class hash, jen role+text).
|
||||
export_btn = page.get_by_role("button", name="Export", exact=True)
|
||||
try:
|
||||
export_btn.first.wait_for(state="visible", timeout=10000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_export_btn")
|
||||
raise RuntimeError("Dialog exportu bez tlačítka Export. "
|
||||
"Diagnostika v debug/.")
|
||||
export_btn = export_btn.first
|
||||
# Export kliknout PRÁVĚ jednou (vícenásobné kliky = duplikáty);
|
||||
# 503/redirecty v network logu neřešit — rozhoduje expect_download
|
||||
with page.expect_download(timeout=120000) as dl_info:
|
||||
export_btn.click()
|
||||
download = dl_info.value
|
||||
|
||||
EXCEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
dest = EXCEL_DIR / f"{ts} {download.suggested_filename}"
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Report uložen: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
def archive_report(path):
|
||||
"""Po úspěšném zpracování přesune report do Zpracovano/."""
|
||||
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
target = PROCESSED_DIR / path.name
|
||||
path.rename(target)
|
||||
log(f"[i] Report archivován: {target}")
|
||||
|
||||
|
||||
# --- SeaweedFS ---------------------------------------------------------
|
||||
|
||||
def _sw_path(sha256):
|
||||
return f"{SEAWEED_PREFIX}/{sha256[:2]}/{sha256[2:4]}/{sha256}"
|
||||
|
||||
|
||||
def seaweed_store(data, mime="application/octet-stream"):
|
||||
"""Idempotentní upload do SeaweedFS Filer.
|
||||
Vrací (path, url, uploaded): uploaded=False znamená dedup hit."""
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
path = _sw_path(sha256)
|
||||
url = SEAWEED_FILER + path
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(url, method="HEAD"), timeout=10)
|
||||
return path, url, False # soubor už existuje
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="PUT",
|
||||
headers={"Content-Type": mime})
|
||||
urllib.request.urlopen(req, timeout=120)
|
||||
return path, url, True
|
||||
|
||||
|
||||
# --- Stažení dokumentů -------------------------------------------------
|
||||
|
||||
def find_source_file_button(page):
|
||||
"""Najde ikonu Source File (list papíru se šipkou dolů, vpravo nahoře).
|
||||
Více fallback selektorů — DOM se může lišit podle typu dokumentu."""
|
||||
candidates = [
|
||||
"[title='Source File']",
|
||||
"[aria-label='Source File']",
|
||||
]
|
||||
for sel in candidates:
|
||||
loc = page.locator(sel)
|
||||
if loc.count():
|
||||
return loc.first
|
||||
loc = page.get_by_role("button", name=re.compile("Source File", re.I))
|
||||
if loc.count():
|
||||
return loc.first
|
||||
return None
|
||||
|
||||
|
||||
def download_source_file(page, doc):
|
||||
vtmf = doc["vtmf"]
|
||||
log(f"[i] Otevírám dokument {vtmf} ({doc.get('version', '')}) ...")
|
||||
page.goto(doc["url"], wait_until="domcontentloaded")
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=30000)
|
||||
except PWTimeout:
|
||||
log("[!] networkidle nenastal do 30 s, zkouším pokračovat...")
|
||||
dismiss_maintenance_popup(page, timeout=2000)
|
||||
|
||||
ph = page.locator("div.vv_placeholder_text")
|
||||
if ph.count() and ph.first.is_visible():
|
||||
log(f"[i] {vtmf}: placeholder bez obsahu — přeskakuji.")
|
||||
raise PlaceholderDocument(vtmf)
|
||||
|
||||
target = find_source_file_button(page)
|
||||
if target is None:
|
||||
raise RuntimeError(
|
||||
f"Nenašel jsem ikonu 'Source File' na stránce dokumentu {vtmf}.")
|
||||
|
||||
log("[i] Klikám na Source File a čekám na download...")
|
||||
with page.expect_download(timeout=60000) as dl_info:
|
||||
target.click()
|
||||
# Varianta s dropdownem (Source File + Viewable Rendition)
|
||||
try:
|
||||
item = page.get_by_role("menuitem",
|
||||
name=re.compile("Source File", re.I))
|
||||
if item.count() and item.first.is_visible():
|
||||
log("[i] Otevřel se dropdown, vybírám 'Source File'...")
|
||||
item.first.click()
|
||||
except Exception:
|
||||
pass
|
||||
download = dl_info.value
|
||||
|
||||
dest = build_target_path(doc, download.suggested_filename)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
download.save_as(str(dest))
|
||||
return dest
|
||||
|
||||
|
||||
def download_missing(page, coll):
|
||||
"""Stáhne všechny nesmazané dokumenty bez downloaded=True.
|
||||
Výsledek každého se ihned zapíše do Mongo."""
|
||||
todo = list(coll.find({"deleted": False, "downloaded": {"$ne": True}})
|
||||
.sort([("vtmf", ASCENDING), ("version", ASCENDING)]))
|
||||
if LIMIT:
|
||||
todo = todo[:LIMIT]
|
||||
log(f"\n[i] Ke stažení: {len(todo)} dokumentů"
|
||||
+ (f" (LIMIT={LIMIT})" if LIMIT else ""))
|
||||
|
||||
ok_count, fail_count, placeholder_count = 0, 0, 0
|
||||
sw_uploaded = sw_dedup = sw_failed = 0
|
||||
for n, doc in enumerate(todo, 1):
|
||||
key = doc["_id"]
|
||||
log(f"\n--- [{n}/{len(todo)}] {key} | {doc['desc'][:70]}")
|
||||
last_err = None
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
dest = download_source_file(page, doc)
|
||||
|
||||
# SeaweedFS upload (neblokuje při chybě)
|
||||
sw_path = sw_url = sw_ts = sha256_hex = None
|
||||
try:
|
||||
data = dest.read_bytes()
|
||||
size_kb = len(data) / 1024
|
||||
size_str = f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
||||
ext = dest.suffix.lstrip('.').upper()
|
||||
log(f"[ok] Stazeno: {dest.name} ({size_str} {ext})")
|
||||
mime = mimetypes.guess_type(dest.name)[0] or "application/octet-stream"
|
||||
sw_path, sw_url, uploaded = seaweed_store(data, mime)
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
sw_ts = datetime.now()
|
||||
if uploaded:
|
||||
sw_uploaded += 1
|
||||
log(f"[ok] SeaweedFS: nahrano ({size_str}) -> {sw_path}")
|
||||
else:
|
||||
sw_dedup += 1
|
||||
log(f"[i] SeaweedFS: dedup hit ({size_str}) -> {sw_path}")
|
||||
except Exception as sw_err:
|
||||
sw_failed += 1
|
||||
log(f"[!] SeaweedFS upload selhal (soubor je na disku): {sw_err}")
|
||||
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "file": str(dest),
|
||||
"downloaded_at": datetime.now(),
|
||||
"sha256": sha256_hex,
|
||||
"seaweed_path": sw_path,
|
||||
"seaweed_url": sw_url,
|
||||
"seaweed_synced_at": sw_ts,
|
||||
"last_error": None}})
|
||||
ok_count += 1
|
||||
last_err = None
|
||||
break
|
||||
except PlaceholderDocument:
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "placeholder": True,
|
||||
"file": None, "downloaded_at": datetime.now(),
|
||||
"last_error": None}})
|
||||
placeholder_count += 1
|
||||
last_err = None
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
log(f"[!] Pokus {attempt}/{MAX_ATTEMPTS} selhal: {e}")
|
||||
if attempt < MAX_ATTEMPTS:
|
||||
page.wait_for_timeout(RETRY_PAUSE_MS)
|
||||
if last_err is not None:
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"last_error": str(last_err),
|
||||
"error_at": datetime.now()}})
|
||||
fail_count += 1
|
||||
page.wait_for_timeout(BETWEEN_DOCS_MS)
|
||||
return ok_count, fail_count, placeholder_count, sw_uploaded, sw_dedup, sw_failed
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
coll = get_collection()
|
||||
log(f"[ok] Mongo připojeno: {MONGO_URI} / {MONGO_DB}.{MONGO_COLL}")
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
headless=False,
|
||||
accept_downloads=True,
|
||||
no_viewport=True, # okno se chová nativně
|
||||
args=["--start-maximized"],
|
||||
)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
ok_count = fail_count = placeholder_count = 0
|
||||
sw_uploaded = sw_dedup = sw_failed = 0
|
||||
pipeline_error = None
|
||||
try:
|
||||
# 1) login
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
# 2) export reportu
|
||||
report_path = download_report(page)
|
||||
|
||||
# 3) parse + sync do Mongo
|
||||
docs = read_documents_from_excel(report_path)
|
||||
if not docs:
|
||||
raise RuntimeError("Report neobsahuje žádné dokumenty — "
|
||||
"sync přeskočen, nic se nemaže.")
|
||||
sync_report_to_mongo(coll, docs)
|
||||
migrate_old_csv(coll)
|
||||
archive_report(report_path)
|
||||
|
||||
# 4) stažení chybějících
|
||||
DOWNLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
(ok_count, fail_count, placeholder_count,
|
||||
sw_uploaded, sw_dedup, sw_failed) = download_missing(page, coll)
|
||||
except KeyboardInterrupt:
|
||||
log("\n[!] Přerušeno uživatelem — stav je v Mongo, příští běh naváže.")
|
||||
except Exception as e:
|
||||
pipeline_error = e
|
||||
print("\n" + "=" * 60)
|
||||
print(" PIPELINE SELHALA!")
|
||||
print(f" {type(e).__name__}: {e}")
|
||||
print("=" * 60)
|
||||
finally:
|
||||
total = coll.count_documents({})
|
||||
have = coll.count_documents({"deleted": False, "downloaded": True})
|
||||
active = coll.count_documents({"deleted": False})
|
||||
sw_info = (f"SeaweedFS: {sw_uploaded} nových, {sw_dedup} dedup"
|
||||
+ (f", {sw_failed} chyb uploadu" if sw_failed else ""))
|
||||
log(f"\n[i] Výsledek běhu: {ok_count} staženo, "
|
||||
f"{placeholder_count} placeholderů přeskočeno, {fail_count} chyb"
|
||||
+ (f", PIPELINE SELHALA ({pipeline_error})" if pipeline_error else ".")
|
||||
+ (f"\n[i] {sw_info}" if ok_count else ""))
|
||||
log(f"[i] Mongo: {total} záznamů celkem, {active} aktivních, "
|
||||
f"z toho staženo {have} ({active - have} zbývá).")
|
||||
log("[i] Zavírám prohlížeč.")
|
||||
ctx.close()
|
||||
sys.exit(2 if pipeline_error else (1 if fail_count else 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,215 @@
|
||||
# ============================================================
|
||||
# migrate_to_v16.py
|
||||
# Verze: 1.1
|
||||
# Datum: 2026-06-15
|
||||
# Popis: Jednorázová migrace stávajících STUDY-level dat
|
||||
# (nasbíraných pipeline v1.3–v1.5) na schéma v1.6.
|
||||
#
|
||||
# v1.6 ukládá dokumenty JEN do SeaweedFS (žádný Dropbox),
|
||||
# klíč = číslo dokumentu + verze. Dvě fáze:
|
||||
#
|
||||
# [mongo] Re-parse NEJNOVĚJŠÍHO archivovaného study reportu
|
||||
# (WhatToDownload/Zpracovano/*Study Level*.xlsx)
|
||||
# v1.6 parserem a obohacení existujících dokumentů
|
||||
# o nová pole (level, levels[], scopes[], studies[],
|
||||
# countries=[], sites=[], classification,
|
||||
# process_name, external_system_name, created_by,
|
||||
# last_modified_by, version_created_by).
|
||||
# NESAHÁ na download stav (downloaded, sha256,
|
||||
# seaweed_*, history, first_seen).
|
||||
#
|
||||
# [seaweed] Překlíčování SeaweedFS ze starých SHA cest na nové
|
||||
# /vtmf-documents/<vtmf>/<verze>.<přípona>. Zdroj
|
||||
# bajtů = stávající soubor na disku (pole file), jako
|
||||
# fallback GET ze staré SHA cesty. Po úspěchu: oprava
|
||||
# seaweed_path/url + sha256 v Mongo, smazání staré SHA
|
||||
# cesty a ODEBRÁNÍ pole file z Mongo (Dropbox se už
|
||||
# nepoužívá; fyzické soubory v Dropboxu pak můžeš
|
||||
# smazat ručně).
|
||||
#
|
||||
# DEFAULT je DRY-RUN. Ostře až s --apply. Idempotentní.
|
||||
#
|
||||
# Použití:
|
||||
# python migrate_to_v16.py # dry-run, vše
|
||||
# python migrate_to_v16.py --apply # ostře, vše
|
||||
# python migrate_to_v16.py --phase mongo --apply
|
||||
# python migrate_to_v16.py --phase seaweed --apply
|
||||
# ============================================================
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import mimetypes
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PIPE_FILE = SCRIPT_DIR / "vtmf_pipeline_v1.6.py"
|
||||
|
||||
# starý SHA-256 content-addressed tvar cesty (k odstranění z SeaweedFS)
|
||||
OLD_SHA_PATH_RE = re.compile(r"^/vtmf-documents/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def load_pipeline():
|
||||
spec = importlib.util.spec_from_file_location("vtmf_pipeline_v16", PIPE_FILE)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def http_get(url):
|
||||
with urllib.request.urlopen(url, timeout=120) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def seaweed_delete(url):
|
||||
try:
|
||||
urllib.request.urlopen(urllib.request.Request(url, method="DELETE"), timeout=30)
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code in (404, 204, 200)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# --- Fáze MONGO --------------------------------------------------------
|
||||
|
||||
def phase_mongo(mod, coll, apply):
|
||||
zp = SCRIPT_DIR / "WhatToDownload" / "Zpracovano"
|
||||
reports = sorted(zp.glob("*Study Level*.xlsx"))
|
||||
if not reports:
|
||||
log("[!] Nenašel jsem žádný archivovaný study report — fáze mongo přeskočena.")
|
||||
return
|
||||
newest = reports[-1]
|
||||
log(f"[i] [mongo] Re-parse: {newest.name}")
|
||||
docs = mod.read_documents_from_excel(newest, "study")
|
||||
docs = [d for d in docs if mod.TARGET_STUDY in d["studies"]]
|
||||
log(f"[i] [mongo] {len(docs)} dokumentů study-level {mod.TARGET_STUDY}.")
|
||||
|
||||
sk = f"study|{mod.TARGET_STUDY}|"
|
||||
enriched = missing = 0
|
||||
for d in docs:
|
||||
key = mod.doc_key(d["vtmf"], d["version"])
|
||||
if not coll.find_one({"_id": key}, {"_id": 1}):
|
||||
missing += 1
|
||||
if missing <= 10:
|
||||
log(f" [!] V Mongo chybí {key} (přeskočeno).")
|
||||
continue
|
||||
set_fields = {
|
||||
"level": "study", "url": d["url"], "name": d["name"],
|
||||
"status": d["status"], "type": d["type"], "subtype": d["subtype"],
|
||||
"classification": d["classification"], "desc": d["desc"],
|
||||
"process_name": d["process_name"],
|
||||
"external_system_name": d["external_system_name"],
|
||||
"created_by": d["created_by"], "last_modified_by": d["last_modified_by"],
|
||||
"version_created_by": d["version_created_by"], "date": d["date"],
|
||||
"studies": d["studies"], "countries": [], "sites": [],
|
||||
}
|
||||
if apply:
|
||||
coll.update_one({"_id": key}, {
|
||||
"$set": set_fields,
|
||||
"$addToSet": {"scopes": sk, "levels": "study"},
|
||||
})
|
||||
enriched += 1
|
||||
|
||||
log(f"[{'APPLY' if apply else 'DRY'}] [mongo] Obohaceno {enriched} dokumentů"
|
||||
+ (f", {missing} v Mongo chybělo." if missing else "."))
|
||||
|
||||
|
||||
# --- Fáze SEAWEED ------------------------------------------------------
|
||||
|
||||
def phase_seaweed(mod, coll, apply):
|
||||
q = {"downloaded": True, "placeholder": {"$ne": True}, "file": {"$ne": None}}
|
||||
docs = list(coll.find(q))
|
||||
log(f"[i] [seaweed] Kandidátů (s polem file): {len(docs)}")
|
||||
|
||||
uploaded = old_deleted = unset = missing = err = already = 0
|
||||
for doc in docs:
|
||||
key = doc["_id"]
|
||||
src = Path(doc["file"])
|
||||
ext = src.suffix
|
||||
new_path = mod.seaweed_path(doc["vtmf"], doc["version"], ext)
|
||||
old_path = doc.get("seaweed_path")
|
||||
old_is_sha = bool(old_path and OLD_SHA_PATH_RE.match(old_path))
|
||||
|
||||
if old_path == new_path:
|
||||
already += 1
|
||||
if apply: # jen dorovnat: zahodit file
|
||||
coll.update_one({"_id": key}, {"$unset": {"file": ""}})
|
||||
unset += 1
|
||||
continue
|
||||
|
||||
if not apply:
|
||||
note = f" (smazat starou {old_path})" if old_is_sha else ""
|
||||
log(f" PUT {new_path}{note} (+ unset file)")
|
||||
continue
|
||||
|
||||
# zdroj bajtů: disk, fallback GET ze staré SHA cesty
|
||||
try:
|
||||
if src.exists():
|
||||
data = src.read_bytes()
|
||||
elif old_is_sha:
|
||||
data = http_get(mod.SEAWEED_FILER + old_path)
|
||||
else:
|
||||
missing += 1
|
||||
if missing <= 10:
|
||||
log(f" [!] {key}: zdroj nedostupný (soubor i SHA chybí).")
|
||||
continue
|
||||
|
||||
mime = mimetypes.guess_type("f" + ext)[0] or "application/octet-stream"
|
||||
sw_path, sw_url = mod.seaweed_store(doc["vtmf"], doc["version"], ext, data, mime)
|
||||
coll.update_one({"_id": key}, {
|
||||
"$set": {"seaweed_path": sw_path, "seaweed_url": sw_url,
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"seaweed_synced_at": datetime.now()},
|
||||
"$unset": {"file": ""}})
|
||||
uploaded += 1
|
||||
unset += 1
|
||||
if old_is_sha and old_path != sw_path:
|
||||
if seaweed_delete(mod.SEAWEED_FILER + old_path):
|
||||
old_deleted += 1
|
||||
except Exception as e:
|
||||
err += 1
|
||||
log(f" [!] {key}: SeaweedFS selhal: {e}")
|
||||
|
||||
log(f"[{'APPLY' if apply else 'DRY'}] [seaweed] Překlíčováno {uploaded}, "
|
||||
f"už na nové cestě {already}, starých SHA smazáno {old_deleted}, "
|
||||
f"pole file odebráno {unset}, chybí zdroj {missing}, chyb {err}.")
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Migrace VTMF dat na schéma v1.6")
|
||||
ap.add_argument("--phase", choices=["mongo", "seaweed", "all"], default="all")
|
||||
ap.add_argument("--apply", action="store_true",
|
||||
help="ostrý běh (bez něj jen DRY-RUN)")
|
||||
args = ap.parse_args()
|
||||
|
||||
mode = "APPLY (ostře)" if args.apply else "DRY-RUN (nic se nemění)"
|
||||
log(f"=== Migrace na v1.6 — fáze: {args.phase} — režim: {mode} ===\n")
|
||||
|
||||
mod = load_pipeline()
|
||||
_, coll, _ = mod.get_db()
|
||||
log(f"[ok] Mongo: {mod.MONGO_URI} / {mod.MONGO_DB}.{mod.MONGO_COLL}\n")
|
||||
|
||||
if args.phase in ("mongo", "all"):
|
||||
phase_mongo(mod, coll, args.apply)
|
||||
log("")
|
||||
if args.phase in ("seaweed", "all"):
|
||||
phase_seaweed(mod, coll, args.apply)
|
||||
log("")
|
||||
|
||||
log("=== DRY-RUN hotov. Pro ostrý běh přidej --apply. ==="
|
||||
if not args.apply else "=== Migrace dokončena. ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
# ============================================================
|
||||
# seaweed_backfill_v1.1.py
|
||||
# Verze: 1.1
|
||||
# Datum: 2026-06-15
|
||||
# v1.1: retry 3x s 5s pauzou při HTTP 5xx (přechodná chyba serveru)
|
||||
# Popis: Jednorázový backfill — nahraje do SeaweedFS Filer
|
||||
# všechny dokumenty z VTMF.documents, které jsou na disku
|
||||
# (downloaded=True, file!=null) ale ještě nemají seaweed_path.
|
||||
# Placeholdery a záznamy bez souboru přeskočí.
|
||||
# Lze spustit opakovaně — HEAD check zajistí dedup,
|
||||
# přerušení kdykoli naváže příště.
|
||||
# ============================================================
|
||||
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
MONGO_DB = "VTMF"
|
||||
MONGO_COLL = "documents"
|
||||
|
||||
SEAWEED_FILER = "http://192.168.1.50:8888"
|
||||
SEAWEED_PREFIX = "/vtmf-documents"
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def sw_path(sha256):
|
||||
return f"{SEAWEED_PREFIX}/{sha256[:2]}/{sha256[2:4]}/{sha256}"
|
||||
|
||||
|
||||
MAX_ATTEMPTS = 3
|
||||
RETRY_PAUSE = 5 # sekund mezi pokusy při 5xx
|
||||
|
||||
|
||||
def seaweed_store(data, mime="application/octet-stream"):
|
||||
"""HEAD check + PUT s retry při 5xx. Vrací (path, url, uploaded)."""
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
path = sw_path(sha256)
|
||||
url = SEAWEED_FILER + path
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(url, method="HEAD"), timeout=10)
|
||||
return path, url, False # dedup hit
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
|
||||
last_err = None
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(url, data=data, method="PUT",
|
||||
headers={"Content-Type": mime}),
|
||||
timeout=120)
|
||||
return path, url, True
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code < 500:
|
||||
raise # 4xx — nema smysl opakovat
|
||||
last_err = e
|
||||
if attempt < MAX_ATTEMPTS:
|
||||
log(f" [!] HTTP {e.code} (pokus {attempt}/{MAX_ATTEMPTS}), čekám {RETRY_PAUSE}s...")
|
||||
time.sleep(RETRY_PAUSE)
|
||||
raise last_err
|
||||
|
||||
|
||||
def main():
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
coll = client[MONGO_DB][MONGO_COLL]
|
||||
log(f"[ok] Mongo připojeno: {MONGO_URI} / {MONGO_DB}.{MONGO_COLL}")
|
||||
|
||||
query = {
|
||||
"downloaded": True,
|
||||
"placeholder": {"$ne": True},
|
||||
"seaweed_path": None,
|
||||
"file": {"$ne": None},
|
||||
}
|
||||
todo = list(coll.find(query).sort([("vtmf", ASCENDING), ("version", ASCENDING)]))
|
||||
log(f"[i] Ke zpracování: {len(todo)} dokumentů\n")
|
||||
|
||||
uploaded = dedup = skipped = failed = 0
|
||||
|
||||
for n, doc in enumerate(todo, 1):
|
||||
key = doc["_id"]
|
||||
path = doc.get("file")
|
||||
|
||||
if not path or not Path(path).exists():
|
||||
log(f"[{n}/{len(todo)}] {key} [!] Soubor nenalezen na disku — přeskočeno.")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
data = Path(path).read_bytes()
|
||||
size_kb = len(data) / 1024
|
||||
size_str = f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
||||
log(f"[{n}/{len(todo)}] {key} ({size_str} {Path(path).suffix.lstrip('.').upper()}) {doc.get('desc', '')[:60]}")
|
||||
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
|
||||
sw_p, sw_url, was_new = seaweed_store(data, mime)
|
||||
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"sha256": sha256_hex,
|
||||
"seaweed_path": sw_p,
|
||||
"seaweed_url": sw_url,
|
||||
"seaweed_synced_at": datetime.now(),
|
||||
}})
|
||||
|
||||
if was_new:
|
||||
uploaded += 1
|
||||
log(f" [ok] Nahráno ({size_str}) → {sw_p}")
|
||||
else:
|
||||
dedup += 1
|
||||
log(f" [i] Dedup hit ({size_str}) → {sw_p}")
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
log(f" [!] Chyba: {e}")
|
||||
|
||||
log(f"\n{'='*60}")
|
||||
log(f" Hotovo: {uploaded} nahráno, {dedup} dedup, "
|
||||
f"{skipped} bez souboru, {failed} chyb.")
|
||||
log(f"{'='*60}")
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Rychlý test SeaweedFS Filer (port 8888) — PUT / HEAD / GET / DELETE."""
|
||||
import hashlib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
FILER = "http://192.168.1.50:8888"
|
||||
PAYLOAD = b"SeaweedFS VTMF test " + b"x" * 1000
|
||||
SHA256 = hashlib.sha256(PAYLOAD).hexdigest()
|
||||
PATH = f"/vtmf-documents/_test/{SHA256[:8]}"
|
||||
URL = FILER + PATH
|
||||
|
||||
|
||||
def req(method, data=None):
|
||||
r = urllib.request.Request(URL, method=method, data=data,
|
||||
headers={"Content-Type": "text/plain"} if data else {})
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=10) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, b""
|
||||
|
||||
|
||||
print(f"Filer: {FILER}")
|
||||
print(f"Path: {PATH}\n")
|
||||
|
||||
status, _ = req("PUT", PAYLOAD)
|
||||
assert status in (200, 201), f"PUT selhal: {status}"
|
||||
print(f"[ok] PUT → {status}")
|
||||
|
||||
status, _ = req("HEAD")
|
||||
assert status == 200, f"HEAD selhal: {status}"
|
||||
print(f"[ok] HEAD → {status}")
|
||||
|
||||
status, body = req("GET")
|
||||
assert status == 200 and body == PAYLOAD, f"GET selhal: {status}, délka={len(body)}"
|
||||
print(f"[ok] GET → {status}, {len(body)} B")
|
||||
|
||||
status, _ = req("DELETE")
|
||||
assert status in (200, 204), f"DELETE selhal: {status}"
|
||||
print(f"[ok] DELETE → {status}")
|
||||
|
||||
status, _ = req("HEAD")
|
||||
assert status == 404, f"Po DELETE HEAD vrátil {status}, čekal 404"
|
||||
print(f"[ok] HEAD po DELETE → 404 (soubor odstraněn)\n")
|
||||
|
||||
print("SeaweedFS Filer OK.")
|
||||
@@ -0,0 +1,112 @@
|
||||
# vtmf_pipeline_v1.4 — Kompletní V-TMF workflow (report → Mongo → download)
|
||||
|
||||
**Verze:** 1.4 · **Datum:** 2026-06-15
|
||||
|
||||
**Změny v1.1:** oprava tichého selhání — výjimka kteréhokoli kroku se
|
||||
vypíše jako „PIPELINE SELHALA" + exit kód 2 (v1.0 končila zavádějícím
|
||||
souhrnem „0 staženo, 0 chyb"). Export reportu robustnější: menu ⋯,
|
||||
položka Export to Excel i tlačítko Export se hledají přes víc selektorů
|
||||
a ve všech frames; při nenalezení se automaticky uloží diagnostika
|
||||
stránky do debug/<čas>_report_* (screenshot, HTML všech frames, výpis
|
||||
title/aria-label atributů) — z ní se dá určit přesný selektor.
|
||||
|
||||
**Změny v1.2:** selektory exportu ověřené na živém DOM (Claude in
|
||||
Chrome; žádný iframe na celé stránce): menu ⋯ =
|
||||
`.actionMenuContainer .dropDown.vv_dropdown_toggle button.vv-icon-button`
|
||||
(button má prázdný title!); menu se načítá asynchronně (AJAX) →
|
||||
po kliknutí se čeká na položku `a.ReportAction[data-action-name='ExcelExport']`;
|
||||
„Data Only" = radio `name=requiredRadioField value=STANDARD`, defaultně
|
||||
checked (pojistka přes .check()); tlačítko Export = React `<button>`
|
||||
s emotion class hash → selektovat jen přes roli+text.
|
||||
|
||||
**Změny v1.3:** na konci běhu se prohlížeč i konzole zavřou
|
||||
automaticky (žádné čekání na ENTER); interaktivní vstup zůstává jen
|
||||
u 2FA a u ručně nezavřitelného dialogu.
|
||||
|
||||
**Změny v1.4:** detekce placeholder dokumentů — Vault zobrazuje text
|
||||
„This placeholder has no content", dokument nemá žádný Source File ke
|
||||
stažení. Při detekci se zapíše `placeholder=True, downloaded=True` do
|
||||
Mongo a dokument se přeskočí bez chyby. Souhrn na konci běhu uvádí
|
||||
počet placeholderů zvlášť.
|
||||
|
||||
Jeden běh skriptu udělá celé workflow pro studii 77242113UCO3001:
|
||||
|
||||
1. **Login** do vtmf.veevavault.com (persistentní profil
|
||||
`vault_profile/`, J&J SSO, případné 2FA potvrdíte na telefonu
|
||||
+ ENTER; údaje z `.env` v rootu projektu).
|
||||
2. **Export reportu** „Document Inventory Report - Study Level"
|
||||
(přímá URL s ID reportu `0RP000000000182` a filtrem studie
|
||||
`0ST000000137008`) → menu ⋯ → Export to Excel → Data Only →
|
||||
uloží se s timestampem do `WhatToDownload/`, po zpracování se
|
||||
přesune do `WhatToDownload/Zpracovano/`.
|
||||
3. **Parse + sync do MongoDB** — Tower `mongodb://192.168.1.76:27017`,
|
||||
db **VTMF**, kolekce **documents**, klíč `_id = "VTMF-xxx|vY.Z"`
|
||||
(VTMF číslo + verze, unikátní index na dvojici):
|
||||
- nový dokument → založí se (first_seen, deleted=False,
|
||||
downloaded=False),
|
||||
- změna sledovaných polí (name, status, type, subtype, desc,
|
||||
date, url, studies) → promítne se + záznam do `history[]`
|
||||
(timestamp + old/new),
|
||||
- dokument chybí v reportu → `deleted=True, deleted_at` a stažený
|
||||
soubor se přejmenuje s ` [D]` před příponou,
|
||||
- dokument se vrátí do reportu → `deleted=False` a ` [D]`
|
||||
se ze souboru zase odebere.
|
||||
Výsledná sada = záznamy s `deleted=False`.
|
||||
4. **Stažení chybějících** — všechny `deleted=False, downloaded≠True`:
|
||||
doc URL → Source File → uložení do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\<Type>\<Subtype>\`
|
||||
jako `YYYY-MM-DD Description [VTMF-xxx] [vY.Z].<skutečná přípona>`.
|
||||
Výsledek (cesta, čas, případně chyba) se ihned zapisuje do Mongo —
|
||||
běh jde kdykoli přerušit a příště naváže.
|
||||
Placeholder dokumenty (stránka s textem „This placeholder has no
|
||||
content") se přeskočí a označí `placeholder=True, downloaded=True`.
|
||||
|
||||
## Mongo schéma (kolekce documents)
|
||||
|
||||
```
|
||||
_id: "VTMF-19077748|v1.0"
|
||||
vtmf, version, url, name, status, type, subtype, desc, date, studies
|
||||
first_seen, last_seen # kdy poprvé/naposledy v reportu
|
||||
deleted, deleted_at # není ve výsledné sadě reportu
|
||||
downloaded, file, downloaded_at
|
||||
placeholder # True = Vault placeholder bez obsahu
|
||||
last_error, error_at # poslední chyba stahování
|
||||
history: [{ts, changes: {pole: {old, new}}}]
|
||||
```
|
||||
|
||||
## Migrace starého stavu
|
||||
|
||||
Při prvním běhu se `download_state.csv` (z download_vault v2.x)
|
||||
jednorázově namigruje: záznamy `ok` se k odpovídajícímu VTMF zapíší
|
||||
jako `downloaded=True` + cesta. CSV se přejmenuje na
|
||||
`download_state.csv.imported`.
|
||||
|
||||
## Konfigurace (konstanty nahoře)
|
||||
|
||||
- `REPORT_URL` — ID reportu + filtr studie (pro jinou studii se mění
|
||||
jen tato dvě ID)
|
||||
- `LIMIT` — None = stáhnout vše zbývající; číslo = dávka na běh
|
||||
- `MONGO_URI/DB/COLL`, `DOWNLOAD_ROOT`, `EXCEL_DIR`
|
||||
- `TRACKED_FIELDS`, `MAX_ATTEMPTS`, `RETRY_PAUSE_MS`, `BETWEEN_DOCS_MS`
|
||||
|
||||
## Ověřené technické detaily (nesahat bez ověření)
|
||||
|
||||
- Maintenance dialog: zavírat POUZE přes `.ui-dialog a.ok.vv_button`
|
||||
(křížek `.ui-dialog-titlebar-close` je display:none); objevuje se
|
||||
se zpožděním → wait_for visible 8 s (home) / 2-4 s (jinde).
|
||||
- Report Excel má rozbité deklarované rozměry → přímá iterace řádků.
|
||||
- Document Name/Number/Status jsou =HYPERLINK vzorce → regex.
|
||||
- Export kliknout právě jednou; 503/redirecty v network logu
|
||||
ignorovat, rozhoduje expect_download.
|
||||
- Placeholder detekce: `page.locator("div.vv_placeholder_text")` (uvnitř
|
||||
`div.vv_placeholder_pane > div.vv_placeholder_container > div.vv-placeholder-drag-and-drop-container`)
|
||||
se testuje před hledáním Source File ikony — CSS selektor je spolehlivější
|
||||
než text match.
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\vtmf_pipeline_v1.4.py"
|
||||
```
|
||||
|
||||
Předchůdce: vtmf_pipeline_v1.3 (TRASH/).
|
||||
@@ -0,0 +1,96 @@
|
||||
# vtmf_pipeline_v1.5 — Kompletní V-TMF workflow (report → Mongo → download → SeaweedFS)
|
||||
|
||||
**Verze:** 1.5 · **Datum:** 2026-06-15
|
||||
|
||||
**Změny v1.5:** upload každého staženého dokumentu do SeaweedFS Filer
|
||||
(`192.168.1.50:8888`, cesta `/vtmf-documents/ab/cd/<sha256>`).
|
||||
SHA-256 content-addressed dedup — identický soubor se uloží jen jednou
|
||||
(HEAD check → 404 → PUT; při 200 dedup hit). Chyba uploadu neblokuje
|
||||
download ani zápis do Mongo — soubor zůstane na disku a pole
|
||||
`sha256/seaweed_path/seaweed_url/seaweed_synced_at` zůstanou `null`
|
||||
(lze doplnit backfillem). Souhrn na konci uvádí počet nově nahraných,
|
||||
dedup hitů a případných chyb uploadu zvlášť.
|
||||
|
||||
_(Předchozí změny viz TRASH/vtmf_pipeline_v1.4.md)_
|
||||
|
||||
Jeden běh skriptu udělá celé workflow pro studii 77242113UCO3001:
|
||||
|
||||
1. **Login** do vtmf.veevavault.com (persistentní profil
|
||||
`vault_profile/`, J&J SSO, případné 2FA potvrdíte na telefonu
|
||||
+ ENTER; údaje z `.env` v rootu projektu).
|
||||
2. **Export reportu** „Document Inventory Report - Study Level"
|
||||
(přímá URL s ID reportu `0RP000000000182` a filtrem studie
|
||||
`0ST000000137008`) → menu ⋯ → Export to Excel → Data Only →
|
||||
uloží se s timestampem do `WhatToDownload/`, po zpracování se
|
||||
přesune do `WhatToDownload/Zpracovano/`.
|
||||
3. **Parse + sync do MongoDB** — Tower `mongodb://192.168.1.76:27017`,
|
||||
db **VTMF**, kolekce **documents**, klíč `_id = "VTMF-xxx|vY.Z"`:
|
||||
- nové dokumenty se založí,
|
||||
- změny sledovaných polí se promítnou (+ `history[]`),
|
||||
- dokumenty chybějící v reportu se označí `deleted=True`
|
||||
a stažený soubor dostane ` [D]` před příponou,
|
||||
- znovuobjevené se vzkřísí a ` [D]` se odebere.
|
||||
4. **Stažení + SeaweedFS upload** — všechny `deleted=False, downloaded≠True`:
|
||||
- Source File se uloží do
|
||||
`U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\<Type>\<Subtype>\`
|
||||
jako `YYYY-MM-DD Description [VTMF-xxx] [vY.Z].<přípona>`,
|
||||
- soubor se přečte z disku, vypočítá se SHA-256, obsah se nahraje
|
||||
do SeaweedFS na `/vtmf-documents/{sha256[:2]}/{sha256[2:4]}/{sha256}`,
|
||||
- do Mongo se zapíše `downloaded=True, file, sha256, seaweed_path,
|
||||
seaweed_url, seaweed_synced_at`; chyba SeaweedFS tyto fieldy
|
||||
nechá `null` ale `downloaded=True` se zapíše (soubor je na disku).
|
||||
- Placeholder dokumenty (`div.vv_placeholder_text` viditelný) se
|
||||
přeskočí s `placeholder=True, downloaded=True`.
|
||||
|
||||
## Mongo schéma (kolekce documents)
|
||||
|
||||
```
|
||||
_id: "VTMF-19077748|v1.0"
|
||||
vtmf, version, url, name, status, type, subtype, desc, date, studies
|
||||
first_seen, last_seen # kdy poprvé/naposledy v reportu
|
||||
deleted, deleted_at # není ve výsledné sadě reportu
|
||||
downloaded, file, downloaded_at
|
||||
placeholder # True = Vault placeholder bez obsahu
|
||||
sha256 # hex SHA-256 staženého souboru
|
||||
seaweed_path # /vtmf-documents/ab/cd/<sha256>
|
||||
seaweed_url # http://192.168.1.50:8888/vtmf-documents/...
|
||||
seaweed_synced_at # kdy nahráno / null při chybě
|
||||
last_error, error_at # poslední chyba stahování
|
||||
history: [{ts, changes: {pole: {old, new}}}]
|
||||
```
|
||||
|
||||
## SeaweedFS detaily
|
||||
|
||||
- **Filer**: `http://192.168.1.50:8888` (přímý PUT, žádný master assign)
|
||||
- **Dedup**: HEAD → 404 → PUT; HEAD → 200 → dedup hit (vrátí `uploaded=False`)
|
||||
- **Timeout**: HEAD 10 s, PUT 120 s (velké soubory)
|
||||
- **MIME**: `mimetypes.guess_type()`, fallback `application/octet-stream`
|
||||
- **Backfill**: dokumenty s `downloaded=True, seaweed_path=null` lze
|
||||
dohnat samostatným skriptem (čte `file` z Mongo, nahraje, zapíše pola)
|
||||
|
||||
## Konfigurace (konstanty nahoře)
|
||||
|
||||
- `SEAWEED_FILER` — URL Filer serveru
|
||||
- `SEAWEED_PREFIX` — prefix cesty (`/vtmf-documents`)
|
||||
- `REPORT_URL` — ID reportu + filtr studie
|
||||
- `LIMIT` — None = vše; číslo = dávka
|
||||
- `MONGO_URI/DB/COLL`, `DOWNLOAD_ROOT`, `EXCEL_DIR`
|
||||
- `TRACKED_FIELDS`, `MAX_ATTEMPTS`, `RETRY_PAUSE_MS`, `BETWEEN_DOCS_MS`
|
||||
|
||||
## Ověřené technické detaily (nesahat bez ověření)
|
||||
|
||||
- Maintenance dialog: zavírat POUZE přes `.ui-dialog a.ok.vv_button`
|
||||
(křížek `.ui-dialog-titlebar-close` je display:none).
|
||||
- Report Excel má rozbité deklarované rozměry → přímá iterace řádků.
|
||||
- Document Name/Number/Status jsou =HYPERLINK vzorce → regex.
|
||||
- Export kliknout právě jednou; rozhoduje `expect_download`.
|
||||
- Placeholder detekce: `div.vv_placeholder_text` (uvnitř
|
||||
`div.vv_placeholder_pane > div.vv_placeholder_container`).
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\vtmf_pipeline_v1.5.py"
|
||||
```
|
||||
|
||||
Předchůdce: vtmf_pipeline_v1.4 (TRASH/).
|
||||
@@ -0,0 +1,937 @@
|
||||
# ============================================================
|
||||
# vtmf_pipeline_v1.5.py
|
||||
# Verze: 1.5
|
||||
# Datum: 2026-06-15
|
||||
# Popis: Kompletní workflow V-TMF (J&J Veeva Vault), studie
|
||||
# 77242113UCO3001. Jeden běh udělá:
|
||||
# 1) login do Vaultu (persistentní session + ruční 2FA),
|
||||
# 2) export reportu "Document Inventory Report - Study
|
||||
# Level" do Excelu (Data Only) do WhatToDownload/,
|
||||
# 3) parse reportu a synchronizaci do MongoDB
|
||||
# (Tower, db VTMF, kolekce documents,
|
||||
# klíč = VTMF číslo + verze):
|
||||
# - nové dokumenty se založí,
|
||||
# - změny polí se promítnou (+ history[]),
|
||||
# - dokumenty chybějící v reportu se označí
|
||||
# deleted=True a stažený soubor dostane ' [D]',
|
||||
# - znovuobjevené se vzkřísí a ' [D]' se odebere,
|
||||
# 4) stažení všech dosud nestažených dokumentů do
|
||||
# U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001\
|
||||
# <Type>\<Subtype>\"YYYY-MM-DD Description
|
||||
# [VTMF-x] [v1.0].<přípona>" + zápis stavu do Mongo.
|
||||
#
|
||||
# Tracking stahování je KOMPLETNĚ v Mongo; starý
|
||||
# download_state.csv se při prvním běhu jednorázově
|
||||
# namigruje a přejmenuje na .imported.
|
||||
#
|
||||
# Vychází z download_vault_v2.1 (v TRASH/) — login, dialogy
|
||||
# a stahování beze změny; nové jsou kroky 2 a 3.
|
||||
#
|
||||
# v1.1: oprava tichého selhání — chyba kteréhokoli kroku se teď
|
||||
# hlasitě vypíše (a exit kód 2), místo aby běh skončil
|
||||
# souhrnem "0 staženo, 0 chyb". Export reportu: více
|
||||
# selektorů pro menu ⋯ i položku Export to Excel (včetně
|
||||
# hledání ve všech frames) a při selhání automatický záchyt
|
||||
# diagnostiky stránky do debug/ (screenshot + HTML frames).
|
||||
# v1.2: selektory exportu OVĚŘENÉ na živém DOM (žádný iframe):
|
||||
# menu ⋯ = .actionMenuContainer .dropDown.vv_dropdown_toggle
|
||||
# button.vv-icon-button (title prázdný!); menu se načítá
|
||||
# asynchronně -> čekat na položku; položka =
|
||||
# a.ReportAction[data-action-name='ExcelExport']; Data Only =
|
||||
# radio name=requiredRadioField value=STANDARD (default
|
||||
# checked); Export = <button> role+text (emotion class hash,
|
||||
# neselektovat podle tříd).
|
||||
# v1.3: na konci běhu se prohlížeč i okno zavře automaticky
|
||||
# (žádné čekání na ENTER) — vhodné pro bezobslužné běhy.
|
||||
# Interaktivní vstupy zůstávají jen tam, kde jsou nutné
|
||||
# (2FA, ručně nezavřitelný dialog).
|
||||
# v1.4: detekce placeholder dokumentů — stránka s textem
|
||||
# "This placeholder has no content" se přeskočí
|
||||
# (placeholder=True, downloaded=True v Mongo), žádná chyba.
|
||||
# v1.5: upload stažených dokumentů do SeaweedFS Filer
|
||||
# (192.168.1.50:8888, cesta /vtmf-documents/ab/cd/<sha256>).
|
||||
# SHA-256 content-addressed dedup — identický soubor se uloží
|
||||
# jen jednou. Chyba uploadu neblokuje download; chybějící
|
||||
# sha256/seaweed_path lze doplnit backfillem. Mongo nově ukládá:
|
||||
# sha256, seaweed_path, seaweed_url, seaweed_synced_at.
|
||||
# Souhrn běhu uvádí počet nově nahraných vs. dedup hitů.
|
||||
#
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu — čte se z .env
|
||||
# v rootu projektu Janssen (VAULT_USER / VAULT_PASS).
|
||||
# ============================================================
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
|
||||
# --- Konfigurace -------------------------------------------------------
|
||||
|
||||
LOGIN_URL = ("https://fedlogin.jnj.com/idp/eyJ2c2lkIjoiam5qX3ZlZXZhIn0/"
|
||||
"startSSO.ping?PartnerSpId=janssenetmf.veevavault.com"
|
||||
"&IdpAdapterId=CompIWALDAPEXTFORM"
|
||||
"&TargetResource=https%3A%2F%2Fvtmf.veevavault.com%2F")
|
||||
|
||||
# Report Document Inventory Report - Study Level, filtr na studii
|
||||
REPORT_URL = ("https://vtmf.veevavault.com/ui/#reporting/viewer/"
|
||||
"0RP000000000182?study__v%2C%2C%2CIN=0ST000000137008")
|
||||
|
||||
VAULT_UI_PATTERN = "**vtmf.veevavault.com/ui**" # úspěšný vstup do Vaultu
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PROFILE_DIR = SCRIPT_DIR / "vault_profile" # perzistentní session
|
||||
ENV_FILE = SCRIPT_DIR.parent / ".env" # root projektu Janssen
|
||||
DEBUG_DIR = SCRIPT_DIR / "debug" # diagnostické výstupy
|
||||
EXCEL_DIR = SCRIPT_DIR / "WhatToDownload" # stažené reporty
|
||||
PROCESSED_DIR = EXCEL_DIR / "Zpracovano" # archiv zpracovaných
|
||||
OLD_STATE_FILE = SCRIPT_DIR / "download_state.csv" # legacy CSV (migrace)
|
||||
DOWNLOAD_ROOT = Path(r"U:\Dropbox\!!!Days\Downloads Z230\VTMF-77242113UCO3001")
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
MONGO_DB = "VTMF"
|
||||
MONGO_COLL = "documents"
|
||||
|
||||
# Kolik dokumentů stáhnout v tomto běhu (None = všechny zbývající)
|
||||
LIMIT = 0
|
||||
# Pole reportu, jejichž změny se promítají a verzují do history[]
|
||||
TRACKED_FIELDS = ("name", "status", "type", "subtype", "desc",
|
||||
"date", "url", "studies")
|
||||
|
||||
MAX_ATTEMPTS = 2 # pokusy na jeden dokument
|
||||
RETRY_PAUSE_MS = 5000 # pauza před opakováním
|
||||
BETWEEN_DOCS_MS = 500 # pauza mezi dokumenty
|
||||
|
||||
SEAWEED_FILER = "http://192.168.1.50:8888"
|
||||
SEAWEED_PREFIX = "/vtmf-documents"
|
||||
|
||||
|
||||
class PlaceholderDocument(Exception):
|
||||
"""Dokument existuje jen jako placeholder — "This placeholder has no content"."""
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def load_env_file(path):
|
||||
"""Načte KEY=VALUE řádky z .env do os.environ.
|
||||
Už nastavené env proměnné mají přednost, .env je nepřepisuje."""
|
||||
if not path.exists():
|
||||
log(f"[!] .env nenalezen: {path}")
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
if value and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
ENV_SECTION_HEADER = "# --- Veeva Vault (J&J V-TMF) — VTMFDownloadFiles/download_vault ---"
|
||||
ENV_KEYS = ("VAULT_USER", "VAULT_PASS")
|
||||
|
||||
|
||||
def ensure_credentials():
|
||||
"""Načte .env; pokud VAULT_USER/VAULT_PASS chybí, založí/doplní
|
||||
v .env šablonu, vyzve uživatele k doplnění a ukončí skript."""
|
||||
load_env_file(ENV_FILE)
|
||||
if all(os.environ.get(k) for k in ENV_KEYS):
|
||||
return
|
||||
|
||||
existing = ENV_FILE.read_text(encoding="utf-8") if ENV_FILE.exists() else ""
|
||||
missing_lines = [f"{k}=" for k in ENV_KEYS
|
||||
if not re.search(rf"^\s*{k}\s*=", existing, re.M)]
|
||||
|
||||
if not ENV_FILE.exists():
|
||||
ENV_FILE.write_text(
|
||||
"# .env — lokální přihlašovací údaje (NEVERZOVAT, je v .gitignore)\n\n"
|
||||
+ ENV_SECTION_HEADER + "\n"
|
||||
+ "\n".join(missing_lines) + "\n",
|
||||
encoding="utf-8")
|
||||
log(f"[i] Založil jsem nový .env: {ENV_FILE}")
|
||||
elif missing_lines:
|
||||
with open(ENV_FILE, "a", encoding="utf-8") as f:
|
||||
f.write("\n" + ENV_SECTION_HEADER + "\n"
|
||||
+ "\n".join(missing_lines) + "\n")
|
||||
log(f"[i] Doplnil jsem chybějící řádky do .env: {ENV_FILE}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" CHYBÍ PŘIHLAŠOVACÍ ÚDAJE.")
|
||||
print(f" Doplň VAULT_USER a VAULT_PASS do souboru:")
|
||||
print(f" {ENV_FILE}")
|
||||
print(" a spusť skript znovu.")
|
||||
print("=" * 60)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- Parsování Excelu --------------------------------------------------
|
||||
|
||||
HYPERLINK_RE = re.compile(r'HYPERLINK\("([^"]+)"\s*,\s*"([^"]+)"\)')
|
||||
VERSION_RE = re.compile(r"\((v[^)]+)\)\s*$")
|
||||
# nepovolené znaky Windows názvů + řídicí znaky + unicode artefakt �
|
||||
BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f�]")
|
||||
|
||||
|
||||
def clean_filename(s):
|
||||
"""Očistí string na platné jméno souboru/složky ve Windows."""
|
||||
s = BAD_CHARS_RE.sub("_", str(s))
|
||||
s = re.sub(r"\s+", " ", s) # vícenásobné mezery -> jedna
|
||||
s = re.sub(r"_{2,}", "_", s) # vícenásobná podtržítka -> jedno
|
||||
return s.strip(" ._") # okraje: mezery, tečky, podtržítka
|
||||
|
||||
|
||||
def display_text(cell):
|
||||
"""Zobrazený text buňky — u =HYPERLINK vzorce druhý argument."""
|
||||
raw = str(cell.value or "").strip()
|
||||
m = HYPERLINK_RE.search(raw)
|
||||
return m.group(2).strip() if m else raw
|
||||
|
||||
|
||||
def extract_doc_url(raw):
|
||||
"""Z HYPERLINK hodnoty (nebo i rozbité URL) vytáhne čistou doc URL
|
||||
ve tvaru https://<host>/ui/#doc_info/<id>/<major>/<minor>."""
|
||||
m = re.search(r"(https://[^/\"]+/ui/#doc_info/\d+/\d+/\d+)", str(raw))
|
||||
if not m:
|
||||
raise ValueError(f"Nenašel jsem doc URL v: {raw!r}")
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def read_documents_from_excel(path):
|
||||
"""Načte dokumenty z daného .xlsx reportu. Vrací list dictů:
|
||||
vtmf, version, url, name, status, type, subtype, desc, date, studies.
|
||||
Document Name/Number/Status jsou =HYPERLINK vzorce — URL i text se
|
||||
berou regexem. Report má rozbité deklarované rozměry, čte se
|
||||
přímou iterací řádků."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
log(f"[i] Parsování reportu: {path.name}")
|
||||
wb = load_workbook(path, data_only=False) # potřebujeme vzorce
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
|
||||
rows = ws.iter_rows()
|
||||
header = [c.value for c in next(rows)]
|
||||
try:
|
||||
i_num = header.index("Document Number")
|
||||
i_name = header.index("Document Name")
|
||||
i_status = header.index("Document Status")
|
||||
i_type = header.index("Type")
|
||||
i_sub = header.index("Subtype")
|
||||
i_desc = header.index("Description")
|
||||
i_date = header.index("Document Date")
|
||||
i_study = header.index("Study")
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"V reportu chybí očekávaný sloupec: {e}")
|
||||
|
||||
docs, bad = [], []
|
||||
for row in rows:
|
||||
cell = row[i_num]
|
||||
if cell.value is None:
|
||||
continue
|
||||
raw = str(cell.value)
|
||||
m = HYPERLINK_RE.search(raw)
|
||||
if m:
|
||||
url_raw, vtmf = m.group(1), m.group(2)
|
||||
elif cell.hyperlink: # pravý hyperlink místo vzorce
|
||||
url_raw, vtmf = cell.hyperlink.target, raw
|
||||
else:
|
||||
bad.append(raw)
|
||||
continue
|
||||
try:
|
||||
url = extract_doc_url(url_raw)
|
||||
except ValueError:
|
||||
bad.append(raw)
|
||||
continue
|
||||
|
||||
name = display_text(row[i_name])
|
||||
vm = VERSION_RE.search(name)
|
||||
version = vm.group(1) if vm else "v?"
|
||||
|
||||
desc = clean_filename(display_text(row[i_desc]))
|
||||
if not desc:
|
||||
# fallback: Document Name bez koncové verze (jde zvlášť na konec)
|
||||
desc = clean_filename(VERSION_RE.sub("", name))
|
||||
|
||||
date = row[i_date].value # datetime nebo None
|
||||
docs.append({
|
||||
"vtmf": vtmf.strip(),
|
||||
"version": version,
|
||||
"url": url,
|
||||
"name": name,
|
||||
"status": display_text(row[i_status]),
|
||||
"type": clean_filename(display_text(row[i_type])),
|
||||
"subtype": clean_filename(display_text(row[i_sub])),
|
||||
"desc": desc,
|
||||
"date": date if hasattr(date, "strftime") else None,
|
||||
"studies": display_text(row[i_study]),
|
||||
})
|
||||
|
||||
log(f"[i] Načteno {len(docs)} dokumentů"
|
||||
+ (f", {len(bad)} řádků bez použitelné URL (přeskočeno)" if bad else ""))
|
||||
return docs
|
||||
|
||||
|
||||
def build_target_path(doc, suggested_filename):
|
||||
"""Cílová cesta: DOWNLOAD_ROOT\\Type\\Subtype\\
|
||||
'YYYY-MM-DD Description [VTMF-xxx] [v1.0].<skutečná přípona>'.
|
||||
Datum/verze se vynechají, když nejsou k dispozici."""
|
||||
ext = Path(suggested_filename).suffix # skutečná přípona vč. tečky
|
||||
date_prefix = doc["date"].strftime("%Y-%m-%d") + " " if doc["date"] else ""
|
||||
version = f" [{doc['version']}]" if doc.get("version") else ""
|
||||
filename = f"{date_prefix}{doc['desc']} [{doc['vtmf']}]{version}{ext}"
|
||||
return DOWNLOAD_ROOT / doc["type"] / doc["subtype"] / filename
|
||||
|
||||
|
||||
def deleted_marker_path(path):
|
||||
"""Jméno souboru s příznakem smazání: 'x.pdf' -> 'x [D].pdf'."""
|
||||
p = Path(path)
|
||||
return p.with_name(f"{p.stem} [D]{p.suffix}")
|
||||
|
||||
|
||||
# --- MongoDB synchronizace ---------------------------------------------
|
||||
|
||||
def doc_key(vtmf, version):
|
||||
return f"{vtmf}|{version}"
|
||||
|
||||
|
||||
def get_collection():
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
coll = client[MONGO_DB][MONGO_COLL]
|
||||
coll.create_index([("vtmf", ASCENDING), ("version", ASCENDING)],
|
||||
unique=True)
|
||||
coll.create_index([("deleted", ASCENDING), ("downloaded", ASCENDING)])
|
||||
return coll
|
||||
|
||||
|
||||
def migrate_old_csv(coll):
|
||||
"""Jednorázová migrace download_state.csv do Mongo: záznamy 'ok'
|
||||
se zapíší jako downloaded=True k odpovídajícímu VTMF (aktuální,
|
||||
nesmazané verzi). CSV se pak přejmenuje na .imported."""
|
||||
if not OLD_STATE_FILE.exists():
|
||||
return
|
||||
migrated = 0
|
||||
with open(OLD_STATE_FILE, newline="", encoding="utf-8") as f:
|
||||
for row in csv.DictReader(f):
|
||||
if row["result"] != "ok":
|
||||
continue
|
||||
r = coll.update_one(
|
||||
{"vtmf": row["vtmf"], "deleted": False,
|
||||
"downloaded": {"$ne": True}},
|
||||
{"$set": {"downloaded": True, "file": row["file"],
|
||||
"downloaded_at": row["timestamp"]}})
|
||||
migrated += r.modified_count
|
||||
OLD_STATE_FILE.rename(OLD_STATE_FILE.with_suffix(".csv.imported"))
|
||||
log(f"[i] Migrace download_state.csv -> Mongo: {migrated} záznamů; "
|
||||
f"CSV přejmenováno na .imported")
|
||||
|
||||
|
||||
def sync_report_to_mongo(coll, docs):
|
||||
"""Promítne aktuální report do kolekce documents.
|
||||
Klíč = (vtmf, version). Nové založí, změny polí promítne
|
||||
(s history[]), chybějící označí deleted + soubor přejmenuje
|
||||
s ' [D]', znovuobjevené vzkřísí a ' [D]' odebere."""
|
||||
now = datetime.now()
|
||||
stats = {"new": 0, "updated": 0, "unchanged": 0,
|
||||
"resurrected": 0, "marked_deleted": 0}
|
||||
current_keys = set()
|
||||
|
||||
for d in docs:
|
||||
key = doc_key(d["vtmf"], d["version"])
|
||||
current_keys.add(key)
|
||||
existing = coll.find_one({"_id": key})
|
||||
if existing is None:
|
||||
coll.insert_one({
|
||||
"_id": key, **d,
|
||||
"first_seen": now, "last_seen": now,
|
||||
"deleted": False, "downloaded": False,
|
||||
"file": None, "history": [],
|
||||
})
|
||||
stats["new"] += 1
|
||||
continue
|
||||
|
||||
changes = {}
|
||||
for fld in TRACKED_FIELDS:
|
||||
if existing.get(fld) != d.get(fld):
|
||||
changes[fld] = {"old": existing.get(fld),
|
||||
"new": d.get(fld)}
|
||||
update = {"$set": {**d, "last_seen": now, "deleted": False}}
|
||||
if changes:
|
||||
update["$push"] = {"history": {"ts": now, "changes": changes}}
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
|
||||
if existing.get("deleted"):
|
||||
# dokument se do reportu vrátil -> odebrat [D] ze souboru
|
||||
stats["resurrected"] += 1
|
||||
stats["unchanged"] -= 0 # (počítá se výše jako updated/unchanged)
|
||||
old_file = existing.get("file")
|
||||
if old_file:
|
||||
marked = deleted_marker_path(old_file)
|
||||
if marked.exists() and not Path(old_file).exists():
|
||||
marked.rename(old_file)
|
||||
log(f"[i] {key}: soubor vrácen z ' [D]' zpět.")
|
||||
update["$set"]["file"] = str(old_file)
|
||||
coll.update_one({"_id": key}, update)
|
||||
|
||||
# dokumenty, které v aktuálním reportu nejsou -> deleted + ' [D]'
|
||||
for rec in coll.find({"deleted": False}):
|
||||
if rec["_id"] in current_keys:
|
||||
continue
|
||||
upd = {"deleted": True, "deleted_at": now}
|
||||
f = rec.get("file")
|
||||
if f and Path(f).exists():
|
||||
marked = deleted_marker_path(f)
|
||||
try:
|
||||
Path(f).rename(marked)
|
||||
upd["file"] = str(marked)
|
||||
log(f"[i] {rec['_id']}: soubor označen ' [D]'.")
|
||||
except OSError as e:
|
||||
log(f"[!] {rec['_id']}: přejmenování na [D] selhalo: {e}")
|
||||
coll.update_one({"_id": rec["_id"]},
|
||||
{"$set": upd,
|
||||
"$push": {"history": {"ts": now,
|
||||
"changes": {"deleted": {
|
||||
"old": False,
|
||||
"new": True}}}}})
|
||||
stats["marked_deleted"] += 1
|
||||
|
||||
log(f"[ok] Mongo sync: {stats['new']} nových, {stats['updated']} změněných, "
|
||||
f"{stats['unchanged']} beze změny, {stats['resurrected']} obnovených, "
|
||||
f"{stats['marked_deleted']} označených deleted.")
|
||||
return stats
|
||||
|
||||
|
||||
# --- Přihlášení --------------------------------------------------------
|
||||
|
||||
def submit_login_form(page, password_box):
|
||||
"""Odešle login formulář. Zkouší postupně tlačítka Sign On / Login /
|
||||
OK / submit input; když žádné nenajde, stiskne Enter v poli hesla."""
|
||||
candidates = [
|
||||
page.get_by_role("button", name=re.compile("sign\\s*on", re.I)),
|
||||
page.get_by_role("button", name=re.compile("log\\s*in|sign\\s*in", re.I)),
|
||||
page.locator("input[type='submit']"),
|
||||
page.locator("button[type='submit']"),
|
||||
page.get_by_role("button", name=re.compile("^ok$", re.I)),
|
||||
]
|
||||
for loc in candidates:
|
||||
try:
|
||||
if loc.count() and loc.first.is_visible():
|
||||
label = (loc.first.inner_text() or
|
||||
loc.first.get_attribute("value") or "submit").strip()
|
||||
log(f"[i] Odesílám formulář tlačítkem '{label}'...")
|
||||
loc.first.click()
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
log("[i] Tlačítko nenalezeno, odesílám Enterem v poli hesla...")
|
||||
password_box.press("Enter")
|
||||
|
||||
|
||||
def login_if_needed(page):
|
||||
"""Otevře login URL, vyplní jméno+heslo, detekuje 2FA a počká na
|
||||
ruční potvrzení. Pokud perzistentní session žije, login přeskočí."""
|
||||
log(f"[i] Otevírám přihlašovací URL...")
|
||||
page.goto(LOGIN_URL, wait_until="domcontentloaded")
|
||||
|
||||
if "vtmf.veevavault.com/ui" in page.url:
|
||||
log("[i] Už přihlášen (perzistentní session).")
|
||||
return
|
||||
|
||||
user_box = page.locator("input[type='text']").first
|
||||
try:
|
||||
user_box.wait_for(timeout=8000)
|
||||
except PWTimeout:
|
||||
if "vtmf.veevavault.com/ui" in page.url:
|
||||
log("[i] Přihlášen bez formuláře (session redirect).")
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Nenašel jsem login formulář ani Vault. Aktuální URL: {page.url}")
|
||||
|
||||
username = os.environ["VAULT_USER"]
|
||||
password = os.environ["VAULT_PASS"]
|
||||
|
||||
log("[i] Vyplňuji přihlašovací údaje...")
|
||||
user_box.fill(username)
|
||||
password_box = page.locator("input[type='password']").first
|
||||
password_box.fill(password)
|
||||
submit_login_form(page, password_box)
|
||||
|
||||
log("[i] Odeslán login, čekám na výsledek...")
|
||||
try:
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=15000)
|
||||
log("[ok] Přihlášen rovnou (bez 2FA).")
|
||||
return
|
||||
except PWTimeout:
|
||||
pass # nejsme ve Vaultu -> pravděpodobně 2FA výzva
|
||||
|
||||
err = page.locator("text=/invalid|incorrect|failed/i")
|
||||
try:
|
||||
if err.count() and err.first.is_visible():
|
||||
raise RuntimeError(f"Login selhal: {err.first.inner_text().strip()}")
|
||||
except PWTimeout:
|
||||
pass
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" VYŽADOVÁNO OVĚŘENÍ NA TELEFONU (2FA).")
|
||||
print(" Potvrď přihlášení v mobilní aplikaci.")
|
||||
print("=" * 60)
|
||||
input(" Až to potvrdíš, stiskni ENTER pro pokračování... ")
|
||||
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=120000)
|
||||
log("[ok] Přihlášení dokončeno.")
|
||||
|
||||
|
||||
def verify_inside(page):
|
||||
"""Ověří, že jsme uvnitř Vaultu (URL na /ui)."""
|
||||
page.wait_for_url(VAULT_UI_PATTERN, timeout=30000)
|
||||
log(f"[ok] Uvnitř Vaultu: {page.url}")
|
||||
|
||||
|
||||
def dialog_visible(page):
|
||||
"""True, pokud je na stránce viditelný jQuery UI dialog."""
|
||||
try:
|
||||
dlg = page.locator(".ui-dialog")
|
||||
return bool(dlg.count() and dlg.first.is_visible())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def save_page_debug(page, tag):
|
||||
"""Uloží diagnostiku stránky: screenshot, HTML všech frames a výpis
|
||||
kandidátů na tlačítka. Vrátí cestu složky."""
|
||||
out = DEBUG_DIR / datetime.now().strftime(f"%Y-%m-%d_%H-%M-%S_{tag}")
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
page.screenshot(path=str(out / "screenshot.png"), full_page=False)
|
||||
except Exception as e:
|
||||
(out / "screenshot_error.txt").write_text(str(e), encoding="utf-8")
|
||||
report = []
|
||||
for i, frame in enumerate(page.frames):
|
||||
report.append(f"=== frame[{i}] url={frame.url}")
|
||||
try:
|
||||
(out / f"frame_{i}.html").write_text(frame.content(),
|
||||
encoding="utf-8")
|
||||
for sel in (".ui-dialog", "a.ok.vv_button",
|
||||
".ui-dialog-titlebar-close",
|
||||
"button", "input[type='button']",
|
||||
"[title]", "[aria-label]"):
|
||||
n = frame.locator(sel).count()
|
||||
if n:
|
||||
report.append(f" {sel}: {n}x")
|
||||
# výpis title/aria-label atributů — pomáhá najít menu ⋯
|
||||
for attr in ("title", "aria-label"):
|
||||
vals = frame.locator(f"[{attr}]").evaluate_all(
|
||||
f"els => els.map(e => e.getAttribute('{attr}'))")
|
||||
uniq = sorted({v for v in vals if v})[:80]
|
||||
report.append(f" {attr}: {uniq}")
|
||||
except Exception as e:
|
||||
report.append(f" [chyba čtení framu: {e}]")
|
||||
(out / "frames_report.txt").write_text("\n".join(report),
|
||||
encoding="utf-8")
|
||||
log(f"[!] Diagnostika stránky uložena do: {out}")
|
||||
return out
|
||||
|
||||
|
||||
# Viditelné OK tlačítko dialogu — je to <a>, ne <button>!
|
||||
# Křížek .ui-dialog-titlebar-close je display:none → NEPOUŽÍVAT.
|
||||
DIALOG_OK_SELECTOR = (".ui-dialog a.ok.vv_button, "
|
||||
".vv_login_msg_dialog .vv_button.ok")
|
||||
|
||||
|
||||
def dismiss_maintenance_popup(page, timeout=8000):
|
||||
"""Zavře Veeva login/maintenance dialog kliknutím na viditelné OK
|
||||
(<a class='ok vv_button'>). Dialog se objevuje SE ZPOŽDĚNÍM,
|
||||
proto se na něj krátce čeká. Bezpečné volat vždy."""
|
||||
ok = page.locator(DIALOG_OK_SELECTOR)
|
||||
try:
|
||||
ok.first.wait_for(state="visible", timeout=timeout)
|
||||
except PWTimeout:
|
||||
return False # okno se neobjevilo — pokračujeme
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
closed = 0
|
||||
for _ in range(5): # dialogy umí být ve frontě
|
||||
try:
|
||||
if ok.count() and ok.first.is_visible():
|
||||
ok.first.click()
|
||||
page.wait_for_timeout(300)
|
||||
closed += 1
|
||||
log("[i] Maintenance/login dialog zavřen (OK).")
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if not dialog_visible(page):
|
||||
return bool(closed)
|
||||
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(500)
|
||||
log("[i] Zkusil jsem dialog zavřít klávesou Escape.")
|
||||
|
||||
if dialog_visible(page):
|
||||
save_page_debug(page, "dialog")
|
||||
print("\n" + "=" * 60)
|
||||
print(" DIALOG SE NEPODAŘILO ZAVŘÍT AUTOMATICKY.")
|
||||
print(" Zavři ho prosím ručně v prohlížeči.")
|
||||
print("=" * 60)
|
||||
input(" Po ručním zavření stiskni ENTER... ")
|
||||
return bool(closed)
|
||||
|
||||
|
||||
# --- Export reportu ----------------------------------------------------
|
||||
|
||||
def _first_visible(page, builders):
|
||||
"""Vrátí (locator, popis) prvního viditelného kandidáta. Hledá na
|
||||
hlavní stránce i ve všech frames."""
|
||||
for frame in page.frames:
|
||||
for build, desc in builders:
|
||||
try:
|
||||
loc = build(frame)
|
||||
if loc.count() and loc.first.is_visible():
|
||||
return loc.first, desc
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def download_report(page):
|
||||
"""Stáhne report (Export to Excel, Data Only) do WhatToDownload/
|
||||
pod timestampovaným názvem. Vrátí cestu k souboru.
|
||||
Při selhání uloží diagnostiku stránky do debug/ a vyhodí výjimku."""
|
||||
log("[i] Otevírám report Document Inventory Report - Study Level...")
|
||||
page.goto(REPORT_URL, wait_until="domcontentloaded")
|
||||
dismiss_maintenance_popup(page, timeout=4000)
|
||||
|
||||
# report je hotový, když se objeví počet záznamů / statusy
|
||||
try:
|
||||
page.wait_for_selector("text=Returned", timeout=30000)
|
||||
except PWTimeout:
|
||||
try:
|
||||
page.wait_for_selector("text=Document Status:", timeout=30000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_load")
|
||||
raise RuntimeError(
|
||||
"Report se nenačetl (nenašel jsem 'Returned' ani "
|
||||
"'Document Status:'). Diagnostika v debug/.")
|
||||
log("[i] Report načten, otevírám menu akcí (⋯)...")
|
||||
|
||||
# Menu ⋯ (Actions): button bez title/aria-label uvnitř
|
||||
# .actionMenuContainer (ověřeno na živém DOM, žádný iframe).
|
||||
actions, desc = _first_visible(page, [
|
||||
(lambda f: f.locator(
|
||||
".actionMenuContainer .dropDown.vv_dropdown_toggle "
|
||||
"button.vv-icon-button"), ".actionMenuContainer button (ověřený)"),
|
||||
(lambda f: f.locator(".actionMenuContainer button"), ".actionMenuContainer button (volnější)"),
|
||||
(lambda f: f.locator("button[title='Actions'], [aria-label='Actions']"), "title/aria-label Actions"),
|
||||
])
|
||||
if actions is None:
|
||||
save_page_debug(page, "report_menu")
|
||||
raise RuntimeError("Nenašel jsem menu akcí (⋯) na reportu. "
|
||||
"Diagnostika v debug/.")
|
||||
log(f"[i] Menu nalezeno přes: {desc}")
|
||||
actions.click()
|
||||
|
||||
# Menu se načítá ASYNCHRONNĚ (data-loaded=false -> AJAX),
|
||||
# počkat na položku, nečíst hned po kliknutí.
|
||||
item = page.locator("a.ReportAction[data-action-name='ExcelExport']")
|
||||
try:
|
||||
item.first.wait_for(state="visible", timeout=15000)
|
||||
except PWTimeout:
|
||||
# fallback podle textu (kdyby se data atribut změnil)
|
||||
item = page.get_by_text("Export to Excel", exact=True)
|
||||
try:
|
||||
item.first.wait_for(state="visible", timeout=5000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_export_item")
|
||||
raise RuntimeError("Menu se otevřelo, ale položku 'Export to "
|
||||
"Excel' jsem nenašel. Diagnostika v debug/.")
|
||||
log("[i] Klikám 'Export to Excel'...")
|
||||
item.first.click()
|
||||
log("[i] Dialog Excel Export Options...")
|
||||
|
||||
# 'Data Only' = radio value=STANDARD, defaultně checked; pojistka.
|
||||
radio = page.locator("input[name='requiredRadioField'][value='STANDARD']")
|
||||
try:
|
||||
radio.first.wait_for(state="visible", timeout=10000)
|
||||
if not radio.first.is_checked():
|
||||
radio.first.check()
|
||||
log("[i] Přepnuto na 'Data Only'.")
|
||||
except PWTimeout:
|
||||
log("[!] Radio 'Data Only' nenalezeno — spoléhám na default dialogu.")
|
||||
|
||||
# Export = <button> s textem Export (React dialog, emotion třídy —
|
||||
# NEselektovat podle class hash, jen role+text).
|
||||
export_btn = page.get_by_role("button", name="Export", exact=True)
|
||||
try:
|
||||
export_btn.first.wait_for(state="visible", timeout=10000)
|
||||
except PWTimeout:
|
||||
save_page_debug(page, "report_export_btn")
|
||||
raise RuntimeError("Dialog exportu bez tlačítka Export. "
|
||||
"Diagnostika v debug/.")
|
||||
export_btn = export_btn.first
|
||||
# Export kliknout PRÁVĚ jednou (vícenásobné kliky = duplikáty);
|
||||
# 503/redirecty v network logu neřešit — rozhoduje expect_download
|
||||
with page.expect_download(timeout=120000) as dl_info:
|
||||
export_btn.click()
|
||||
download = dl_info.value
|
||||
|
||||
EXCEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
dest = EXCEL_DIR / f"{ts} {download.suggested_filename}"
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Report uložen: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
def archive_report(path):
|
||||
"""Po úspěšném zpracování přesune report do Zpracovano/."""
|
||||
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
target = PROCESSED_DIR / path.name
|
||||
path.rename(target)
|
||||
log(f"[i] Report archivován: {target}")
|
||||
|
||||
|
||||
# --- SeaweedFS ---------------------------------------------------------
|
||||
|
||||
def _sw_path(sha256):
|
||||
return f"{SEAWEED_PREFIX}/{sha256[:2]}/{sha256[2:4]}/{sha256}"
|
||||
|
||||
|
||||
def seaweed_store(data, mime="application/octet-stream"):
|
||||
"""Idempotentní upload do SeaweedFS Filer.
|
||||
Vrací (path, url, uploaded): uploaded=False znamená dedup hit."""
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
path = _sw_path(sha256)
|
||||
url = SEAWEED_FILER + path
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(url, method="HEAD"), timeout=10)
|
||||
return path, url, False # soubor už existuje
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="PUT",
|
||||
headers={"Content-Type": mime})
|
||||
urllib.request.urlopen(req, timeout=120)
|
||||
return path, url, True
|
||||
|
||||
|
||||
# --- Stažení dokumentů -------------------------------------------------
|
||||
|
||||
def find_source_file_button(page):
|
||||
"""Najde ikonu Source File (list papíru se šipkou dolů, vpravo nahoře).
|
||||
Více fallback selektorů — DOM se může lišit podle typu dokumentu."""
|
||||
candidates = [
|
||||
"[title='Source File']",
|
||||
"[aria-label='Source File']",
|
||||
]
|
||||
for sel in candidates:
|
||||
loc = page.locator(sel)
|
||||
if loc.count():
|
||||
return loc.first
|
||||
loc = page.get_by_role("button", name=re.compile("Source File", re.I))
|
||||
if loc.count():
|
||||
return loc.first
|
||||
return None
|
||||
|
||||
|
||||
def download_source_file(page, doc):
|
||||
vtmf = doc["vtmf"]
|
||||
log(f"[i] Otevírám dokument {vtmf} ({doc.get('version', '')}) ...")
|
||||
page.goto(doc["url"], wait_until="domcontentloaded")
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=30000)
|
||||
except PWTimeout:
|
||||
log("[!] networkidle nenastal do 30 s, zkouším pokračovat...")
|
||||
dismiss_maintenance_popup(page, timeout=2000)
|
||||
|
||||
ph = page.locator("div.vv_placeholder_text")
|
||||
if ph.count() and ph.first.is_visible():
|
||||
log(f"[i] {vtmf}: placeholder bez obsahu — přeskakuji.")
|
||||
raise PlaceholderDocument(vtmf)
|
||||
|
||||
target = find_source_file_button(page)
|
||||
if target is None:
|
||||
raise RuntimeError(
|
||||
f"Nenašel jsem ikonu 'Source File' na stránce dokumentu {vtmf}.")
|
||||
|
||||
log("[i] Klikám na Source File a čekám na download...")
|
||||
with page.expect_download(timeout=60000) as dl_info:
|
||||
target.click()
|
||||
# Varianta s dropdownem (Source File + Viewable Rendition)
|
||||
try:
|
||||
item = page.get_by_role("menuitem",
|
||||
name=re.compile("Source File", re.I))
|
||||
if item.count() and item.first.is_visible():
|
||||
log("[i] Otevřel se dropdown, vybírám 'Source File'...")
|
||||
item.first.click()
|
||||
except Exception:
|
||||
pass
|
||||
download = dl_info.value
|
||||
|
||||
dest = build_target_path(doc, download.suggested_filename)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
download.save_as(str(dest))
|
||||
return dest
|
||||
|
||||
|
||||
def download_missing(page, coll):
|
||||
"""Stáhne všechny nesmazané dokumenty bez downloaded=True.
|
||||
Výsledek každého se ihned zapíše do Mongo."""
|
||||
todo = list(coll.find({"deleted": False, "downloaded": {"$ne": True}})
|
||||
.sort([("vtmf", ASCENDING), ("version", ASCENDING)]))
|
||||
if LIMIT:
|
||||
todo = todo[:LIMIT]
|
||||
log(f"\n[i] Ke stažení: {len(todo)} dokumentů"
|
||||
+ (f" (LIMIT={LIMIT})" if LIMIT else ""))
|
||||
|
||||
ok_count, fail_count, placeholder_count = 0, 0, 0
|
||||
sw_uploaded = sw_dedup = sw_failed = 0
|
||||
for n, doc in enumerate(todo, 1):
|
||||
key = doc["_id"]
|
||||
log(f"\n--- [{n}/{len(todo)}] {key} | {doc['desc'][:70]}")
|
||||
last_err = None
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
dest = download_source_file(page, doc)
|
||||
|
||||
# SeaweedFS upload (neblokuje při chybě)
|
||||
sw_path = sw_url = sw_ts = sha256_hex = None
|
||||
try:
|
||||
data = dest.read_bytes()
|
||||
size_kb = len(data) / 1024
|
||||
size_str = f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
||||
ext = dest.suffix.lstrip('.').upper()
|
||||
log(f"[ok] Stazeno: {dest.name} ({size_str} {ext})")
|
||||
mime = mimetypes.guess_type(dest.name)[0] or "application/octet-stream"
|
||||
sw_path, sw_url, uploaded = seaweed_store(data, mime)
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
sw_ts = datetime.now()
|
||||
if uploaded:
|
||||
sw_uploaded += 1
|
||||
log(f"[ok] SeaweedFS: nahrano ({size_str}) -> {sw_path}")
|
||||
else:
|
||||
sw_dedup += 1
|
||||
log(f"[i] SeaweedFS: dedup hit ({size_str}) -> {sw_path}")
|
||||
except Exception as sw_err:
|
||||
sw_failed += 1
|
||||
log(f"[!] SeaweedFS upload selhal (soubor je na disku): {sw_err}")
|
||||
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "file": str(dest),
|
||||
"downloaded_at": datetime.now(),
|
||||
"sha256": sha256_hex,
|
||||
"seaweed_path": sw_path,
|
||||
"seaweed_url": sw_url,
|
||||
"seaweed_synced_at": sw_ts,
|
||||
"last_error": None}})
|
||||
ok_count += 1
|
||||
last_err = None
|
||||
break
|
||||
except PlaceholderDocument:
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "placeholder": True,
|
||||
"file": None, "downloaded_at": datetime.now(),
|
||||
"last_error": None}})
|
||||
placeholder_count += 1
|
||||
last_err = None
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
log(f"[!] Pokus {attempt}/{MAX_ATTEMPTS} selhal: {e}")
|
||||
if attempt < MAX_ATTEMPTS:
|
||||
page.wait_for_timeout(RETRY_PAUSE_MS)
|
||||
if last_err is not None:
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"last_error": str(last_err),
|
||||
"error_at": datetime.now()}})
|
||||
fail_count += 1
|
||||
page.wait_for_timeout(BETWEEN_DOCS_MS)
|
||||
return ok_count, fail_count, placeholder_count, sw_uploaded, sw_dedup, sw_failed
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
coll = get_collection()
|
||||
log(f"[ok] Mongo připojeno: {MONGO_URI} / {MONGO_DB}.{MONGO_COLL}")
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
headless=False,
|
||||
accept_downloads=True,
|
||||
no_viewport=True, # okno se chová nativně
|
||||
args=["--start-maximized"],
|
||||
)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
ok_count = fail_count = placeholder_count = 0
|
||||
sw_uploaded = sw_dedup = sw_failed = 0
|
||||
pipeline_error = None
|
||||
try:
|
||||
# 1) login
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
# 2) export reportu
|
||||
report_path = download_report(page)
|
||||
|
||||
# 3) parse + sync do Mongo
|
||||
docs = read_documents_from_excel(report_path)
|
||||
if not docs:
|
||||
raise RuntimeError("Report neobsahuje žádné dokumenty — "
|
||||
"sync přeskočen, nic se nemaže.")
|
||||
sync_report_to_mongo(coll, docs)
|
||||
migrate_old_csv(coll)
|
||||
archive_report(report_path)
|
||||
|
||||
# 4) stažení chybějících
|
||||
DOWNLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
(ok_count, fail_count, placeholder_count,
|
||||
sw_uploaded, sw_dedup, sw_failed) = download_missing(page, coll)
|
||||
except KeyboardInterrupt:
|
||||
log("\n[!] Přerušeno uživatelem — stav je v Mongo, příští běh naváže.")
|
||||
except Exception as e:
|
||||
pipeline_error = e
|
||||
print("\n" + "=" * 60)
|
||||
print(" PIPELINE SELHALA!")
|
||||
print(f" {type(e).__name__}: {e}")
|
||||
print("=" * 60)
|
||||
finally:
|
||||
total = coll.count_documents({})
|
||||
have = coll.count_documents({"deleted": False, "downloaded": True})
|
||||
active = coll.count_documents({"deleted": False})
|
||||
sw_info = (f"SeaweedFS: {sw_uploaded} nových, {sw_dedup} dedup"
|
||||
+ (f", {sw_failed} chyb uploadu" if sw_failed else ""))
|
||||
log(f"\n[i] Výsledek běhu: {ok_count} staženo, "
|
||||
f"{placeholder_count} placeholderů přeskočeno, {fail_count} chyb"
|
||||
+ (f", PIPELINE SELHALA ({pipeline_error})" if pipeline_error else ".")
|
||||
+ (f"\n[i] {sw_info}" if ok_count else ""))
|
||||
log(f"[i] Mongo: {total} záznamů celkem, {active} aktivních, "
|
||||
f"z toho staženo {have} ({active - have} zbývá).")
|
||||
log("[i] Zavírám prohlížeč.")
|
||||
ctx.close()
|
||||
sys.exit(2 if pipeline_error else (1 if fail_count else 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
# vtmf_pipeline_v1.6 — V-TMF workflow přes 3 úrovně (STUDY / COUNTRY / SITE)
|
||||
|
||||
**Verze:** 1.6 · **Datum:** 2026-06-15
|
||||
|
||||
## Co je nové proti v1.5
|
||||
|
||||
v1.5 stahovala jen **study-level** dokumenty jedné studie do ploché
|
||||
`<Type>\<Subtype>` struktury. v1.6 řeší celou hierarchii VTMF
|
||||
**STUDY → COUNTRY → SITE** a sdílený (M:N) charakter dokumentů.
|
||||
|
||||
**Klíčové poznatky z reportů:**
|
||||
- Dokument je do studií/zemí/center jen **referencovaný** (M:N) — např.
|
||||
Master Confidentiality Agreement v nemocnici je jeden dokument
|
||||
referencovaný do všech studií i center té nemocnice. Reference ≠ kopie.
|
||||
- Sloupce `Study`, `Study Country`, `Site` jsou **comma-separated seznamy**.
|
||||
- Tři reporty = tři **úrovně** dokumentu. Aby byl TMF kompletní, musí se
|
||||
stáhnout všechny tři.
|
||||
- Country i site report filtrují **jen na zemi** (CZ), ne na studii →
|
||||
empiricky vrací 100 % dokumentů navázaných na UCO3001, ořez na studii je
|
||||
pojistka (no-op).
|
||||
- Study report má 15 sloupců (+ `Document Date`), country/site 17
|
||||
(+ `Created By`, `Study Country`, `Site`; bez `Document Date`).
|
||||
|
||||
## Konfigurace REPORTS
|
||||
|
||||
```python
|
||||
TARGET_STUDY = "77242113UCO3001"
|
||||
REPORTS = [
|
||||
{"level":"study", "study":TARGET_STUDY, "country":None,
|
||||
"url":".../0RP000000000182?study__v...IN=0ST000000137008"},
|
||||
{"level":"country", "study":TARGET_STUDY, "country":"Czech Republic",
|
||||
"url":".../0RP000000000319?study_country__v...IN=0SC00000017T056"},
|
||||
{"level":"site", "study":TARGET_STUDY, "country":"Czech Republic",
|
||||
"url":".../0RP000000000762?study_country__v...EQ=0SC00000017T056"},
|
||||
]
|
||||
```
|
||||
Jiná studie / země = jen úprava ID v URL + TARGET_STUDY.
|
||||
|
||||
## Tok jednoho běhu
|
||||
|
||||
1. **Login** (persistentní profil, J&J SSO, 2FA na telefonu).
|
||||
2. Pro **každý report** v `REPORTS`:
|
||||
- export do Excelu (Data Only) → `WhatToDownload/<ts> <level> ...xlsx`,
|
||||
- parse (zobecněný parser, sloupce podle názvu),
|
||||
- ořez na `TARGET_STUDY` (řádek se bere jen pokud má studii v `studies`),
|
||||
- **scoped sync** do Mongo,
|
||||
- archiv reportu do `Zpracovano/`.
|
||||
3. **Jeden průchod stažení** všech `deleted=False, downloaded≠True`
|
||||
na disk i do SeaweedFS.
|
||||
|
||||
## Mongo schéma (kolekce documents)
|
||||
|
||||
```
|
||||
_id: "VTMF-9108777|v2.0" # číslo dokumentu | verze
|
||||
vtmf, version, url, level # level = study|country|site (pro cestu)
|
||||
levels: ["site"] # všechny úrovně, kde se objevil
|
||||
scopes: ["site|77242113UCO3001|Czech Republic", ...] # pro scoped mazání
|
||||
name, status, type, subtype, classification, desc
|
||||
process_name, external_system_name
|
||||
created_by, last_modified_by, version_created_by
|
||||
date # YYYY-MM-DD (Document/Approval/Version date)
|
||||
studies: ["77242113UCO3001", ...] # comma-split sloupce reportu
|
||||
countries: ["Czech Republic", ...]
|
||||
sites: ["BH5-CZ10001", ...]
|
||||
first_seen, last_seen, deleted, deleted_at
|
||||
downloaded, downloaded_at, placeholder # žádné pole file (Dropbox zrušen)
|
||||
sha256 # kontrolní součet (NE cesta)
|
||||
seaweed_path, seaweed_url, seaweed_synced_at # jediné umístění souboru
|
||||
history: [{ts, changes:{pole:{old,new}}}]
|
||||
```
|
||||
|
||||
## Scoped sync (řeší mazací háček)
|
||||
|
||||
Mazání už **nekouká na celou kolekci** (to by sync country reportu označil
|
||||
study/site dokumenty jako smazané). Každý report má
|
||||
`scope = "<level>|<study>|<country>"`; dokument nese pole `scopes[]`.
|
||||
- dokument v reportu → `$addToSet` scope,
|
||||
- dokument, který z **tohoto** scope zmizel → scope se odebere; teprve když
|
||||
nemá **žádný** scope → `deleted=True` + soubor ` [D]`.
|
||||
|
||||
## Evidence reportů — kolekce report_runs
|
||||
|
||||
```
|
||||
level, study, country, url, scope, exported_at, file, row_count, doc_keys[]
|
||||
```
|
||||
Umožní ukázat „co přesně bylo v reportu" a slouží jako audit.
|
||||
|
||||
## Úložiště = JEN SeaweedFS (žádný Dropbox/disk)
|
||||
|
||||
Dokumenty se stahují z Vaultu přes **dočasný soubor Playwrightu** rovnou do
|
||||
SeaweedFS Fileru — na disk/Dropbox se nic neukládá. Klíč = číslo dokumentu
|
||||
+ verze:
|
||||
|
||||
```
|
||||
/vtmf-documents/<vtmf>/<verze>.<přípona>
|
||||
např. /vtmf-documents/VTMF-9108777/v2.0.pdf
|
||||
```
|
||||
Žádné SHA cesty, žádný content dedup, žádné hardlinky. SHA-256 se počítá a
|
||||
ukládá do Mongo jen jako kontrolní součet. Která úroveň / země / centra =
|
||||
pole `level` / `countries[]` / `sites[]` v Mongo.
|
||||
|
||||
Aktuální verzi čehokoli do Dropboxu (nebo kamkoli jinam) zařídí samostatný
|
||||
export skript ze SeaweedFS — pipeline se tím nezdržuje.
|
||||
|
||||
## Migrace stávajících dat → migrate_to_v16.py
|
||||
|
||||
Stávající study-level data (v1.3–v1.5) převede na schéma v1.6. Dvě fáze,
|
||||
**default DRY-RUN**, ostře s `--apply`:
|
||||
|
||||
- `--phase mongo` — re-parse nejnovějšího archivu study reportu v1.6
|
||||
parserem → obohatí ~1692 dokumentů o nová pole (level, scopes[],
|
||||
studies[], countries=[], sites=[], classification, …). Nesahá na
|
||||
download stav.
|
||||
- `--phase seaweed` — překlíčuje SeaweedFS ze starých SHA cest na nové
|
||||
`<vtmf>/<verze>` (~1637 souborů; zdroj bajtů = stávající soubor na disku,
|
||||
fallback GET ze SHA cesty), opraví `seaweed_path/url` + `sha256`, smaže
|
||||
staré SHA objekty a odebere pole `file` z Mongo. Fyzické soubory
|
||||
v Dropboxu pak můžeš smazat ručně.
|
||||
|
||||
```powershell
|
||||
# náhled
|
||||
& "...\.venv\Scripts\python.exe" "...\migrate_to_v16.py"
|
||||
# ostře
|
||||
& "...\.venv\Scripts\python.exe" "...\migrate_to_v16.py" --apply
|
||||
```
|
||||
|
||||
## Spuštění pipeline
|
||||
|
||||
```powershell
|
||||
& "U:\PythonProject\Janssen\.venv\Scripts\python.exe" "U:\PythonProject\Janssen\VTMFDownloadFiles\vtmf_pipeline_v1.6.py"
|
||||
```
|
||||
|
||||
Předchůdce: vtmf_pipeline_v1.5 (TRASH/).
|
||||
```
|
||||
@@ -0,0 +1,937 @@
|
||||
# ============================================================
|
||||
# vtmf_pipeline_v1.6.py
|
||||
# Verze: 1.6
|
||||
# Datum: 2026-06-15
|
||||
# Popis: Kompletní workflow V-TMF (J&J Veeva Vault) pro studii
|
||||
# 77242113UCO3001 přes VŠECHNY TŘI ÚROVNĚ dokumentů
|
||||
# (STUDY / COUNTRY / SITE). Jeden běh udělá pro každý
|
||||
# report ze seznamu REPORTS:
|
||||
# 1) login do Vaultu (persistentní session + ruční 2FA),
|
||||
# 2) export reportu do Excelu (Data Only) do WhatToDownload/,
|
||||
# 3) parse + scoped sync do MongoDB (db VTMF, kolekce
|
||||
# documents; klíč _id = "číslo|verze"),
|
||||
# a nakonec jeden průchod stažení všech dosud nestažených
|
||||
# dokumentů PŘÍMO do SeaweedFS (žádný Dropbox/disk).
|
||||
#
|
||||
# ZÁSADNÍ ZMĚNY proti v1.5:
|
||||
#
|
||||
# • Hierarchie dokumentů ve VTMF je STUDY -> COUNTRY -> SITE.
|
||||
# Dokument je do studií/zemí/center jen REFERENCOVANÝ (M:N) —
|
||||
# např. Master Confidentiality Agreement v nemocnici je jeden
|
||||
# dokument referencovaný do všech studií i center té nemocnice.
|
||||
# Proto: jeden dokument = jeden záznam = jeden SeaweedFS objekt;
|
||||
# příslušnost je jen metadatová pole studies[]/countries[]/sites[].
|
||||
#
|
||||
# • REPORTS = seznam (level, study, country, url). Country i site
|
||||
# report filtrují jen na zemi (CZ), ne na studii -> při ukládání
|
||||
# se row bere jen pokud cílová studie je v jeho Study sloupci
|
||||
# (prakticky no-op, vše vrácené UCO3001 obsahuje).
|
||||
#
|
||||
# • Zobecněný parser: study report má 15 sloupců (+ Document Date),
|
||||
# country/site mají 17 (+ Created By, Study Country, Site; bez
|
||||
# Document Date). Sloupce se hledají podle NÁZVU, datum má
|
||||
# fallback Document Date -> Approval Complete Date -> Version
|
||||
# Creation Date. Study/Study Country/Site se parsují na pole.
|
||||
#
|
||||
# • Scoped sync: mazání už NEkouká na celou kolekci. Každý report
|
||||
# má scope = (level|study|country); dokument nese pole scopes[].
|
||||
# Když z reportu daného scope zmizí, scope se odebere; teprve
|
||||
# když nemá žádný scope -> deleted=True.
|
||||
#
|
||||
# • Evidence reportů: kolekce report_runs (level, study, country,
|
||||
# url, exported_at, file, row_count, doc_keys).
|
||||
#
|
||||
# • ÚLOŽIŠTĚ = JEN SeaweedFS, klíč číslo dokumentu + verze:
|
||||
# /vtmf-documents/<vtmf>/<verze>.<přípona>
|
||||
# Žádné ukládání dokumentů na disk/Dropbox — stahují se přes
|
||||
# dočasný soubor Playwrightu rovnou do Fileru. SHA-256 se počítá
|
||||
# a ukládá do Mongo jen jako kontrolní součet. (Aktuální verzi
|
||||
# čehokoli do Dropboxu zařídí samostatný export skript ze SeaweedFS.)
|
||||
#
|
||||
# Heslo se NIKDY nedává natvrdo do skriptu — čte se z .env
|
||||
# v rootu projektu Janssen (VAULT_USER / VAULT_PASS).
|
||||
#
|
||||
# Migrace stávajících study-level dat na toto schéma: migrate_to_v16.py
|
||||
# Předchůdce: vtmf_pipeline_v1.5 (v TRASH/).
|
||||
# ============================================================
|
||||
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
|
||||
# --- Konfigurace -------------------------------------------------------
|
||||
|
||||
LOGIN_URL = ("https://fedlogin.jnj.com/idp/eyJ2c2lkIjoiam5qX3ZlZXZhIn0/"
|
||||
"startSSO.ping?PartnerSpId=janssenetmf.veevavault.com"
|
||||
"&IdpAdapterId=CompIWALDAPEXTFORM"
|
||||
"&TargetResource=https%3A%2F%2Fvtmf.veevavault.com%2F")
|
||||
|
||||
# Studie, jejíž TMF stavíme (cíl ořezu country/site reportů).
|
||||
TARGET_STUDY = "77242113UCO3001"
|
||||
|
||||
# ====================================================================
|
||||
# SEZNAM REPORTŮ KE ZPRACOVÁNÍ
|
||||
# --------------------------------------------------------------------
|
||||
# Každý řádek = jeden report. Pole:
|
||||
# enabled = True/False -> přepni na False a report se v dalším běhu
|
||||
# NEnačte (zůstane v seznamu jako dokumentace)
|
||||
# name = popisek do logu (co to je za report)
|
||||
# level = "study" | "country" | "site" (úroveň + scope)
|
||||
# study = kód cílové studie (scope + ořez na tuto studii)
|
||||
# country = země scope (None u study-level)
|
||||
# url = přímý odkaz na report viewer ve Vaultu
|
||||
#
|
||||
# Přidání jiné studie = prostě dopiš další 3 řádky s jejím kódem
|
||||
# a URL; běh je zpracuje vedle stávajících.
|
||||
# ====================================================================
|
||||
REPORTS = [
|
||||
{"enabled": True, "name": "UCO3001 — STUDY level",
|
||||
"level": "study", "study": TARGET_STUDY, "country": None,
|
||||
"url": "https://vtmf.veevavault.com/ui/#reporting/viewer/"
|
||||
"0RP000000000182?study__v%2C%2C%2CIN=0ST000000137008"},
|
||||
|
||||
{"enabled": True, "name": "UCO3001 — COUNTRY level (Czech Republic)",
|
||||
"level": "country", "study": TARGET_STUDY, "country": "Czech Republic",
|
||||
"url": "https://vtmf.veevavault.com/ui/#reporting/viewer/"
|
||||
"0RP000000000319?study_country__v%2C%2C%2CIN=0SC00000017T056"},
|
||||
|
||||
{"enabled": False, "name": "UCO3001 — SITE level (all sites in Czech Republic)",
|
||||
"level": "site", "study": TARGET_STUDY, "country": "Czech Republic",
|
||||
"url": "https://vtmf.veevavault.com/ui/#reporting/viewer/"
|
||||
"0RP000000000762?study_country__v%2C%2C%2CEQ=0SC00000017T056"},
|
||||
]
|
||||
|
||||
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 (jen Excel)
|
||||
PROCESSED_DIR = EXCEL_DIR / "Zpracovano" # archiv zpracovaných
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
MONGO_DB = "VTMF"
|
||||
MONGO_COLL = "documents"
|
||||
RUNS_COLL = "report_runs"
|
||||
|
||||
# Kolik dokumentů stáhnout v tomto běhu (None = všechny zbývající)
|
||||
LIMIT = None
|
||||
# Pole, jejichž změny se verzují do history[]
|
||||
TRACKED_FIELDS = ("name", "status", "type", "subtype", "classification",
|
||||
"desc", "date", "url", "studies", "countries", "sites",
|
||||
"level")
|
||||
|
||||
MAX_ATTEMPTS = 2 # pokusy na jeden dokument
|
||||
RETRY_PAUSE_MS = 5000 # pauza před opakováním
|
||||
BETWEEN_DOCS_MS = 500 # pauza mezi dokumenty
|
||||
|
||||
SEAWEED_FILER = "http://192.168.1.50:8888"
|
||||
SEAWEED_PREFIX = "/vtmf-documents"
|
||||
|
||||
|
||||
class PlaceholderDocument(Exception):
|
||||
"""Dokument existuje jen jako placeholder — "This placeholder has no content"."""
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def load_env_file(path):
|
||||
"""Načte KEY=VALUE řádky z .env do os.environ.
|
||||
Už nastavené env proměnné mají přednost, .env je nepřepisuje."""
|
||||
if not path.exists():
|
||||
log(f"[!] .env nenalezen: {path}")
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
if value and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
ENV_SECTION_HEADER = "# --- Veeva Vault (J&J V-TMF) — VTMFDownloadFiles/download_vault ---"
|
||||
ENV_KEYS = ("VAULT_USER", "VAULT_PASS")
|
||||
|
||||
|
||||
def ensure_credentials():
|
||||
"""Načte .env; pokud VAULT_USER/VAULT_PASS chybí, založí/doplní
|
||||
v .env šablonu, vyzve uživatele k doplnění a ukončí skript."""
|
||||
load_env_file(ENV_FILE)
|
||||
if all(os.environ.get(k) for k in ENV_KEYS):
|
||||
return
|
||||
|
||||
existing = ENV_FILE.read_text(encoding="utf-8") if ENV_FILE.exists() else ""
|
||||
missing_lines = [f"{k}=" for k in ENV_KEYS
|
||||
if not re.search(rf"^\s*{k}\s*=", existing, re.M)]
|
||||
|
||||
if not ENV_FILE.exists():
|
||||
ENV_FILE.write_text(
|
||||
"# .env — lokální přihlašovací údaje (NEVERZOVAT, je v .gitignore)\n\n"
|
||||
+ ENV_SECTION_HEADER + "\n"
|
||||
+ "\n".join(missing_lines) + "\n",
|
||||
encoding="utf-8")
|
||||
log(f"[i] Založil jsem nový .env: {ENV_FILE}")
|
||||
elif missing_lines:
|
||||
with open(ENV_FILE, "a", encoding="utf-8") as f:
|
||||
f.write("\n" + ENV_SECTION_HEADER + "\n"
|
||||
+ "\n".join(missing_lines) + "\n")
|
||||
log(f"[i] Doplnil jsem chybějící řádky do .env: {ENV_FILE}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" CHYBÍ PŘIHLAŠOVACÍ ÚDAJE.")
|
||||
print(f" Doplň VAULT_USER a VAULT_PASS do souboru:")
|
||||
print(f" {ENV_FILE}")
|
||||
print(" a spusť skript znovu.")
|
||||
print("=" * 60)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- Parsování Excelu --------------------------------------------------
|
||||
|
||||
HYPERLINK_RE = re.compile(r'HYPERLINK\("([^"]+)"\s*,\s*"([^"]+)"\)')
|
||||
VERSION_RE = re.compile(r"\((v[^)]+)\)\s*$")
|
||||
DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")
|
||||
# nepovolené znaky názvů + řídicí znaky + unicode artefakt �
|
||||
BAD_CHARS_RE = re.compile(r"[<>:\"/\\|?*\x00-\x1f�]")
|
||||
|
||||
|
||||
def clean_text(s):
|
||||
"""Očistí string na rozumný název (bez nepovolených znaků)."""
|
||||
s = BAD_CHARS_RE.sub("_", str(s))
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
s = re.sub(r"_{2,}", "_", s)
|
||||
return s.strip(" ._")
|
||||
|
||||
|
||||
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 split_multi(text):
|
||||
"""Comma-separated seznam -> list (strip, bez prázdných, dedup pořadí)."""
|
||||
out, seen = [], set()
|
||||
for part in str(text or "").split(","):
|
||||
p = part.strip()
|
||||
if p and p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def cell_date(cell):
|
||||
"""Z buňky vytáhne datum jako 'YYYY-MM-DD' (datetime i string), nebo ''."""
|
||||
v = cell.value if cell is not None else None
|
||||
if hasattr(v, "strftime"):
|
||||
return v.strftime("%Y-%m-%d")
|
||||
m = DATE_RE.search(str(v or ""))
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
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, level):
|
||||
"""Načte dokumenty z .xlsx reportu dané úrovně (study/country/site).
|
||||
Sloupce se hledají podle NÁZVU (study má 15, country/site 17).
|
||||
Document Name/Number jsou =HYPERLINK vzorce -> URL i text regexem.
|
||||
Report má rozbité deklarované rozměry -> přímá iterace řádků."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
log(f"[i] Parsování reportu ({level}): {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)]
|
||||
idx = {h: i for i, h in enumerate(header) if h is not None}
|
||||
|
||||
required = ("Document Number", "Document Name", "Document Status",
|
||||
"Type", "Subtype", "Description", "Study")
|
||||
missing = [c for c in required if c not in idx]
|
||||
if missing:
|
||||
raise RuntimeError(f"V reportu chybí očekávané sloupce: {missing}")
|
||||
|
||||
i_num, i_name = idx["Document Number"], idx["Document Name"]
|
||||
i_status, i_type, i_sub = idx["Document Status"], idx["Type"], idx["Subtype"]
|
||||
i_desc, i_study = idx["Description"], idx["Study"]
|
||||
i_class = idx.get("Classification")
|
||||
i_proc = idx.get("Process Name")
|
||||
i_extsys = idx.get("External System Name")
|
||||
i_created = idx.get("Created By")
|
||||
i_modby = idx.get("Last Modified By")
|
||||
i_verby = idx.get("Version Created By")
|
||||
i_country = idx.get("Study Country")
|
||||
i_site = idx.get("Site")
|
||||
i_date_cols = [idx.get(c) for c in
|
||||
("Document Date", "Approval Complete Date", "Version Creation Date")
|
||||
if idx.get(c) is not None]
|
||||
|
||||
def g(row, i):
|
||||
return display_text(row[i]) if i is not None else ""
|
||||
|
||||
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:
|
||||
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_text(g(row, i_desc))
|
||||
if not desc:
|
||||
desc = clean_text(VERSION_RE.sub("", name))
|
||||
|
||||
date = ""
|
||||
for i_d in i_date_cols:
|
||||
date = cell_date(row[i_d])
|
||||
if date:
|
||||
break
|
||||
|
||||
docs.append({
|
||||
"vtmf": vtmf.strip(),
|
||||
"version": version,
|
||||
"url": url,
|
||||
"level": level,
|
||||
"name": name,
|
||||
"status": g(row, i_status),
|
||||
"type": clean_text(g(row, i_type)),
|
||||
"subtype": clean_text(g(row, i_sub)),
|
||||
"classification": g(row, i_class),
|
||||
"desc": desc,
|
||||
"process_name": g(row, i_proc),
|
||||
"external_system_name": g(row, i_extsys),
|
||||
"created_by": g(row, i_created),
|
||||
"last_modified_by": g(row, i_modby),
|
||||
"version_created_by": g(row, i_verby),
|
||||
"date": date,
|
||||
"studies": split_multi(g(row, i_study)),
|
||||
"countries": split_multi(g(row, i_country)) if i_country is not None else [],
|
||||
"sites": split_multi(g(row, i_site)) if i_site is not None else [],
|
||||
})
|
||||
|
||||
log(f"[i] Načteno {len(docs)} dokumentů"
|
||||
+ (f", {len(bad)} řádků bez použitelné URL (přeskočeno)" if bad else ""))
|
||||
return docs
|
||||
|
||||
|
||||
# --- MongoDB synchronizace ---------------------------------------------
|
||||
|
||||
def doc_key(vtmf, version):
|
||||
return f"{vtmf}|{version}"
|
||||
|
||||
|
||||
def scope_key(report):
|
||||
return f"{report['level']}|{report['study']}|{report.get('country') or ''}"
|
||||
|
||||
|
||||
def get_db():
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
db = client[MONGO_DB]
|
||||
coll = db[MONGO_COLL]
|
||||
coll.create_index([("vtmf", ASCENDING), ("version", ASCENDING)], unique=True)
|
||||
coll.create_index([("deleted", ASCENDING), ("downloaded", ASCENDING)])
|
||||
coll.create_index([("scopes", ASCENDING)])
|
||||
coll.create_index([("studies", ASCENDING)])
|
||||
coll.create_index([("sites", ASCENDING)])
|
||||
coll.create_index([("level", ASCENDING)])
|
||||
runs = db[RUNS_COLL]
|
||||
runs.create_index([("level", ASCENDING), ("study", ASCENDING),
|
||||
("country", ASCENDING), ("exported_at", ASCENDING)])
|
||||
return db, coll, runs
|
||||
|
||||
|
||||
def sync_report_to_mongo(coll, runs, docs, report, report_file):
|
||||
"""Promítne report daného scope do kolekce documents.
|
||||
- nové založí, změny polí promítne (+ history[]),
|
||||
- každému dokumentu přidá scope do scopes[] (a level do levels[]),
|
||||
- dokument, který z TOHOTO scope zmizel, ztratí tento scope;
|
||||
bez jakéhokoli scope -> deleted=True.
|
||||
Scoped mazání = sync jednoho reportu NIKDY neoznačí dokumenty
|
||||
jiného scope (study/country/site) jako smazané. Žádné souborové
|
||||
operace (úložiště je SeaweedFS)."""
|
||||
now = datetime.now()
|
||||
sk = scope_key(report)
|
||||
stats = {"new": 0, "updated": 0, "unchanged": 0,
|
||||
"resurrected": 0, "scope_removed": 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,
|
||||
"levels": [d["level"]], "scopes": [sk],
|
||||
"first_seen": now, "last_seen": now,
|
||||
"deleted": False, "downloaded": False,
|
||||
"seaweed_path": 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},
|
||||
"$addToSet": {"scopes": sk, "levels": d["level"]}}
|
||||
if changes:
|
||||
update["$push"] = {"history": {"ts": now, "changes": changes}}
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
if existing.get("deleted"):
|
||||
stats["resurrected"] += 1
|
||||
coll.update_one({"_id": key}, update)
|
||||
|
||||
# dokumenty dříve v TOMTO scope, které v reportu chybí -> odebrat scope
|
||||
for rec in coll.find({"scopes": sk, "_id": {"$nin": list(current_keys)}}):
|
||||
remaining = [s for s in rec.get("scopes", []) if s != sk]
|
||||
upd = {"scopes": remaining}
|
||||
op = {"$set": upd}
|
||||
stats["scope_removed"] += 1
|
||||
if not remaining: # už nikde -> smazáno
|
||||
upd["deleted"] = True
|
||||
upd["deleted_at"] = now
|
||||
op["$push"] = {"history": {"ts": now,
|
||||
"changes": {"deleted": {"old": False, "new": True}}}}
|
||||
stats["marked_deleted"] += 1
|
||||
coll.update_one({"_id": rec["_id"]}, op)
|
||||
|
||||
runs.insert_one({
|
||||
"level": report["level"], "study": report["study"],
|
||||
"country": report.get("country"), "url": report["url"],
|
||||
"scope": sk, "exported_at": now,
|
||||
"file": str(report_file), "row_count": len(docs),
|
||||
"doc_keys": sorted(current_keys),
|
||||
})
|
||||
|
||||
log(f"[ok] Mongo sync [{sk}]: {stats['new']} nových, {stats['updated']} změněných, "
|
||||
f"{stats['unchanged']} beze změny, {stats['resurrected']} obnovených, "
|
||||
f"{stats['scope_removed']} odebrán scope ({stats['marked_deleted']} úplně smazáno).")
|
||||
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")
|
||||
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
|
||||
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, report):
|
||||
"""Stáhne daný 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(f"\n[i] === Report {report['level'].upper()} "
|
||||
f"({report.get('country') or report['study']}) ===")
|
||||
log("[i] Otevírám report...")
|
||||
page.goto(report["url"], wait_until="domcontentloaded")
|
||||
dismiss_maintenance_popup(page, timeout=4000)
|
||||
|
||||
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, f"report_load_{report['level']}")
|
||||
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í (⋯)...")
|
||||
|
||||
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, f"report_menu_{report['level']}")
|
||||
raise RuntimeError("Nenašel jsem menu akcí (⋯) na reportu. Diagnostika v debug/.")
|
||||
log(f"[i] Menu nalezeno přes: {desc}")
|
||||
actions.click()
|
||||
|
||||
item = page.locator("a.ReportAction[data-action-name='ExcelExport']")
|
||||
try:
|
||||
item.first.wait_for(state="visible", timeout=15000)
|
||||
except PWTimeout:
|
||||
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, f"report_export_item_{report['level']}")
|
||||
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...")
|
||||
|
||||
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_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, f"report_export_btn_{report['level']}")
|
||||
raise RuntimeError("Dialog exportu bez tlačítka Export. Diagnostika v debug/.")
|
||||
export_btn = export_btn.first
|
||||
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} {report['level']} {download.suggested_filename}"
|
||||
download.save_as(str(dest))
|
||||
log(f"[ok] Report uložen: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
def archive_report(path):
|
||||
"""Po úspěšném zpracování přesune report do Zpracovano/."""
|
||||
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
target = PROCESSED_DIR / path.name
|
||||
path.rename(target)
|
||||
log(f"[i] Report archivován: {target}")
|
||||
|
||||
|
||||
# --- SeaweedFS ---------------------------------------------------------
|
||||
|
||||
def seaweed_path(vtmf, version, ext):
|
||||
"""Cesta podle identity dokumentu: /vtmf-documents/<vtmf>/<verze><ext>."""
|
||||
ver = version or "vunknown"
|
||||
return f"{SEAWEED_PREFIX}/{vtmf}/{ver}{ext}"
|
||||
|
||||
|
||||
def seaweed_store(vtmf, version, ext, data, mime="application/octet-stream"):
|
||||
"""Upload do SeaweedFS Filer pod cestou <vtmf>/<verze><ext>.
|
||||
Vrací (path, url)."""
|
||||
path = seaweed_path(vtmf, version, ext)
|
||||
url = SEAWEED_FILER + path
|
||||
req = urllib.request.Request(url, data=data, method="PUT",
|
||||
headers={"Content-Type": mime})
|
||||
urllib.request.urlopen(req, timeout=120)
|
||||
return path, url
|
||||
|
||||
|
||||
# --- Stažení dokumentů -------------------------------------------------
|
||||
|
||||
def find_source_file_button(page):
|
||||
"""Najde ikonu Source File (list papíru se šipkou dolů, vpravo nahoře)."""
|
||||
for sel in ("[title='Source File']", "[aria-label='Source File']"):
|
||||
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_bytes(page, doc):
|
||||
"""Otevře dokument, stáhne Source File do dočasného souboru Playwrightu
|
||||
a vrátí (data: bytes, ext: str). Žádné trvalé uložení na disk.
|
||||
PlaceholderDocument když dokument nemá obsah."""
|
||||
vtmf = doc["vtmf"]
|
||||
log(f"[i] Otevírám dokument {vtmf} ({doc.get('version', '')}) ...")
|
||||
page.goto(doc["url"], wait_until="domcontentloaded")
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=30000)
|
||||
except PWTimeout:
|
||||
log("[!] networkidle nenastal do 30 s, zkouším pokračovat...")
|
||||
dismiss_maintenance_popup(page, timeout=2000)
|
||||
|
||||
ph = page.locator("div.vv_placeholder_text")
|
||||
if ph.count() and ph.first.is_visible():
|
||||
log(f"[i] {vtmf}: placeholder bez obsahu — přeskakuji.")
|
||||
raise PlaceholderDocument(vtmf)
|
||||
|
||||
target = find_source_file_button(page)
|
||||
if target is None:
|
||||
raise RuntimeError(
|
||||
f"Nenašel jsem ikonu 'Source File' na stránce dokumentu {vtmf}.")
|
||||
|
||||
log("[i] Klikám na Source File a čekám na download...")
|
||||
with page.expect_download(timeout=60000) as dl_info:
|
||||
target.click()
|
||||
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
|
||||
|
||||
ext = Path(download.suggested_filename).suffix
|
||||
tmp = download.path() # dočasný soubor Playwrightu
|
||||
data = Path(tmp).read_bytes()
|
||||
return data, ext
|
||||
|
||||
|
||||
def download_missing(page, coll):
|
||||
"""Stáhne všechny nesmazané dokumenty bez downloaded=True PŘÍMO do
|
||||
SeaweedFS (žádný disk). Výsledek každého se ihned zapíše do Mongo."""
|
||||
todo = list(coll.find({"deleted": False, "downloaded": {"$ne": True}})
|
||||
.sort([("level", ASCENDING), ("vtmf", ASCENDING),
|
||||
("version", ASCENDING)]))
|
||||
if LIMIT:
|
||||
todo = todo[:LIMIT]
|
||||
log(f"\n[i] Ke stažení: {len(todo)} dokumentů"
|
||||
+ (f" (LIMIT={LIMIT})" if LIMIT else ""))
|
||||
|
||||
ok_count = fail_count = placeholder_count = 0
|
||||
for n, doc in enumerate(todo, 1):
|
||||
key = doc["_id"]
|
||||
log(f"\n--- [{n}/{len(todo)}] {key} | {doc.get('level', '?')} | {doc['desc'][:60]}")
|
||||
last_err = None
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
data, ext = download_source_bytes(page, doc)
|
||||
size_kb = len(data) / 1024
|
||||
size_str = f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
mime = mimetypes.guess_type("f" + ext)[0] or "application/octet-stream"
|
||||
sw_path, sw_url = seaweed_store(doc["vtmf"], doc["version"], ext, data, mime)
|
||||
log(f"[ok] {size_str} -> SeaweedFS {sw_path}")
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True,
|
||||
"downloaded_at": datetime.now(),
|
||||
"sha256": sha256_hex,
|
||||
"seaweed_path": sw_path, "seaweed_url": sw_url,
|
||||
"seaweed_synced_at": datetime.now(),
|
||||
"last_error": None}})
|
||||
ok_count += 1
|
||||
last_err = None
|
||||
break
|
||||
except PlaceholderDocument:
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"downloaded": True, "placeholder": True,
|
||||
"downloaded_at": datetime.now(), "last_error": None}})
|
||||
placeholder_count += 1
|
||||
last_err = None
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
log(f"[!] Pokus {attempt}/{MAX_ATTEMPTS} selhal: {e}")
|
||||
if attempt < MAX_ATTEMPTS:
|
||||
page.wait_for_timeout(RETRY_PAUSE_MS)
|
||||
if last_err is not None:
|
||||
coll.update_one({"_id": key}, {"$set": {
|
||||
"last_error": str(last_err), "error_at": datetime.now()}})
|
||||
fail_count += 1
|
||||
page.wait_for_timeout(BETWEEN_DOCS_MS)
|
||||
return ok_count, fail_count, placeholder_count
|
||||
|
||||
|
||||
# --- Main --------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ensure_credentials()
|
||||
db, coll, runs = get_db()
|
||||
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,
|
||||
args=["--start-maximized"],
|
||||
)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
ok_count = fail_count = placeholder_count = 0
|
||||
pipeline_error = None
|
||||
try:
|
||||
# 1) login
|
||||
login_if_needed(page)
|
||||
verify_inside(page)
|
||||
dismiss_maintenance_popup(page)
|
||||
|
||||
# 2+3) pro každý ZAPNUTÝ report: export -> parse -> scoped sync
|
||||
log("\n[i] Plán reportů:")
|
||||
for r in REPORTS:
|
||||
flag = "ZAP" if r.get("enabled", True) else "VYP"
|
||||
log(f" [{flag}] {r.get('name', r['level'])}")
|
||||
for report in REPORTS:
|
||||
if not report.get("enabled", True):
|
||||
log(f"\n[i] Přeskakuji (enabled=False): {report.get('name', report['level'])}")
|
||||
continue
|
||||
report_path = download_report(page, report)
|
||||
docs = read_documents_from_excel(report_path, report["level"])
|
||||
before = len(docs)
|
||||
docs = [d for d in docs if report["study"] in d["studies"]]
|
||||
if before != len(docs):
|
||||
log(f"[i] Ořez na {report['study']}: {len(docs)}/{before} řádků.")
|
||||
if not docs:
|
||||
log(f"[!] Report {report['level']} prázdný (po ořezu) — "
|
||||
f"sync přeskočen, nic se nemaže.")
|
||||
archive_report(report_path)
|
||||
continue
|
||||
sync_report_to_mongo(coll, runs, docs, report, report_path)
|
||||
archive_report(report_path)
|
||||
|
||||
# 4) jeden průchod stažení všeho nestaženého do SeaweedFS
|
||||
ok_count, fail_count, placeholder_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({})
|
||||
active = coll.count_documents({"deleted": False})
|
||||
have = coll.count_documents({"deleted": False, "downloaded": True})
|
||||
log(f"\n[i] Výsledek běhu: {ok_count} staženo, "
|
||||
f"{placeholder_count} placeholderů, {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 v SeaweedFS {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