z230
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
# Název: download_equeries_report_v1.1.py
|
||||
# Verze: 1.1
|
||||
# Datum: 2026-05-28
|
||||
# Popis: Stahuje eQuery reporty ze Covance/Labcorp XSP pro studii 36940.
|
||||
# Stahuje 2 soubory: všechny eQueries + pouze nezodpovězené.
|
||||
# Výstup: timestampované CSV soubory do adresáře Source/.
|
||||
# Prohlížeč spouštěn maximalizovaný.
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
EMAIL = "vbuzalka@its.jnj.com"
|
||||
PASSWORD = "%zT3Wqfc9)cWua5"
|
||||
LOGIN_URL = "https://xsp.covance.com/"
|
||||
SITES = "%5B%22930551%22,%22930556%22,%22930525%22,%22930549%22,%22930543%22,%22930547%22,%22930555%22,%22930557%22,%22930539%22,%22930536%22,%22930553%22,%22930531%22%5D"
|
||||
REPORT_URL = f"https://xsp.labcorp.com/sponsor/study/36940/activity-reports/documents/equery?site={SITES}"
|
||||
REPORT_URL_UNRESPONDED = f"https://xsp.labcorp.com/sponsor/study/36940/activity-reports/documents/equery?site={SITES}&unrespondedOnly=true"
|
||||
OUT_DIR = r"U:\PythonProject\Janssen\Covance_UCO3001\Source"
|
||||
PROFILE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "browser_profile")
|
||||
|
||||
|
||||
def login(page):
|
||||
page.goto(LOGIN_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
if not page.get_by_label("Email").is_visible():
|
||||
print(f"Session aktivni, prihlasen: {page.url}")
|
||||
return
|
||||
page.get_by_label("Email").fill(EMAIL)
|
||||
page.get_by_role("button", name="Next").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.get_by_label("Password").fill(PASSWORD)
|
||||
page.get_by_role("button", name="Verify").click()
|
||||
page.wait_for_url(lambda url: "code=" not in url, timeout=60000)
|
||||
page.wait_for_load_state("networkidle", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
print(f"Prihlaseni OK: {page.url}")
|
||||
|
||||
|
||||
def export_csv(page, url, filename):
|
||||
page.goto(url)
|
||||
page.wait_for_load_state("networkidle", timeout=60000)
|
||||
print(f"Report nacteny: {page.url}")
|
||||
page.locator("ag-export").get_by_role("button", name="more_horiz").click()
|
||||
with page.expect_download(timeout=60000) as dl:
|
||||
page.get_by_text("Export to CSV").click()
|
||||
dest = os.path.join(OUT_DIR, filename)
|
||||
dl.value.save_as(dest)
|
||||
print(f"Stazeno: {dest}")
|
||||
|
||||
|
||||
def download(page):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
export_csv(page, REPORT_URL,
|
||||
f"{timestamp} sponsor-study-36940-activity-reports-documents-equery.csv")
|
||||
export_csv(page, REPORT_URL_UNRESPONDED,
|
||||
f"{timestamp} sponsor-study-36940-activity-reports-documents-equery_unresponded_only.csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with sync_playwright() as p:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=PROFILE_DIR,
|
||||
headless=False,
|
||||
args=["--disable-blink-features=AutomationControlled", "--start-maximized"],
|
||||
no_viewport=True,
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
accept_downloads=True,
|
||||
)
|
||||
context.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
page = context.new_page()
|
||||
login(page)
|
||||
download(page)
|
||||
context.close()
|
||||
@@ -0,0 +1,238 @@
|
||||
# download_test_results_v1.0.py — dokumentace
|
||||
|
||||
**Verze:** 1.0 · **Datum:** 2026-05-29
|
||||
**Umístění:** `U:\PythonProject\Janssen\Covance_UCO3001\download_test_results_v1.0.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. Účel
|
||||
|
||||
Automatické stažení reportu **Standard Test Results** z portálu Labcorp Sponsor
|
||||
Portal (`xsp.labcorp.com`) pro studii **77242113UCO3001** (interní study ID
|
||||
**36940**). Skript projde **všech 12 center** studie, u každého vyexportuje grid
|
||||
do CSV a uloží ho timestampovaný do adresáře `Source/`.
|
||||
|
||||
> Pozn.: Stahuje se záložka **Standard** (ne Microbiology). URL končí
|
||||
> `/standard-test-results`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Spuštění
|
||||
|
||||
```bat
|
||||
U:\PythonProject\Janssen\.venv\Scripts\python.exe ^
|
||||
U:\PythonProject\Janssen\Covance_UCO3001\download_test_results_v1.0.py
|
||||
```
|
||||
|
||||
- Prohlížeč běží **viditelně** (`headless=False`), maximalizovaný.
|
||||
- Používá **persistent profile** (`browser_profile/` vedle skriptu) — session
|
||||
(přihlášení) přežívá mezi spuštěními.
|
||||
|
||||
---
|
||||
|
||||
## 3. Konfigurace (konstanty v hlavičce skriptu)
|
||||
|
||||
| Konstanta | Hodnota / význam |
|
||||
|---|---|
|
||||
| `EMAIL` | `vbuzalka@its.jnj.com` (login přes iMedidata/OKTA na xsp.covance.com) |
|
||||
| `PASSWORD` | heslo k účtu (uložené přímo v kódu) |
|
||||
| `LOGIN_URL` | `https://xsp.covance.com/` — po přihlášení redirect na xsp.labcorp.com |
|
||||
| `OUT_DIR` | `U:\PythonProject\Janssen\Covance_UCO3001\Source` |
|
||||
| `PROFILE_DIR` | `browser_profile/` vedle skriptu (persistent Chromium profil) |
|
||||
| `STUDY` | `36940` (interní study ID pro 77242113UCO3001) |
|
||||
| `SITE_IDS` | seznam 12 interních čísel center — viz níže |
|
||||
|
||||
### Interní čísla center (SITE_IDS)
|
||||
|
||||
```
|
||||
930551, 930556, 930525, 930549, 930543, 930547,
|
||||
930555, 930557, 930539, 930536, 930553, 930531
|
||||
```
|
||||
|
||||
> **Zdroj:** převzato z `download_equeries_report_v1.1.py` (proměnná `SITES`).
|
||||
> Jsou to **interní ID** Labcorpu, **nikoli** čísla center typu CZ10001.
|
||||
> V URL test-results se používá právě toto interní ID:
|
||||
> `…/test-results/{SITE_ID}/standard-test-results`.
|
||||
|
||||
### Generování REPORTS (DRY)
|
||||
|
||||
`REPORTS` se sestaví automaticky z `SITE_IDS` — URL i název souboru mají vzor
|
||||
napsaný jen jednou. **Přidání/odebrání centra = úprava seznamu `SITE_IDS`.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Výstupní soubory
|
||||
|
||||
Formát názvu (timestamp + popisný název):
|
||||
|
||||
```
|
||||
{YYYY-MM-DD_HHMMSS} sponsor-study-36940-test-results-{SITE_ID}-standard.csv
|
||||
```
|
||||
|
||||
Příklad:
|
||||
```
|
||||
2026-05-29_125710 sponsor-study-36940-test-results-930557-standard.csv
|
||||
```
|
||||
|
||||
- Timestamp se generuje pro **každý** report zvlášť (v okamžiku exportu).
|
||||
- Staré soubory se **nikdy nemažou** (verzování přes timestamp).
|
||||
- Browser dočasný název se na disk neukládá — `expect_download` zachytí download
|
||||
event a `save_as()` ho uloží pod naším názvem.
|
||||
|
||||
---
|
||||
|
||||
## 5. Průběh skriptu (kroky + logging)
|
||||
|
||||
Každý krok se loguje s časem (`[HH:MM:SS]`, `flush=True` → vypisuje průběžně).
|
||||
|
||||
| Fáze | Co dělá |
|
||||
|---|---|
|
||||
| START | spustí prohlížeč |
|
||||
| LOGIN | otevře login; **pokud je session aktivní, přihlášení přeskočí** |
|
||||
| Pro každé centrum: | |
|
||||
| KROK 1/5 | navigace na report URL |
|
||||
| KROK 2/5 | čeká na řádky gridu `.ag-row` **nebo** prázdný grid; pak stabilizace počtu |
|
||||
| KROK 3/5 | klik na viditelné tři tečky (`more_horiz`) → otevře menu |
|
||||
| KROK 4/5 | klik na viditelné „Export to CSV" → zachytí download |
|
||||
| KROK 5/5 | uloží soubor do `OUT_DIR` |
|
||||
| KONEC | souhrn `hotovo X/12` + seznam selhaných center |
|
||||
|
||||
### Příklad výstupu
|
||||
|
||||
```
|
||||
[12:56:35] START: prohlizec spusten.
|
||||
[12:56:35] LOGIN: otviram login stranku...
|
||||
[12:56:49] LOGIN: prihlaseni OK (...)
|
||||
[12:56:49] === Centrum 930557 (studie 36940) ===
|
||||
[12:56:59] KROK 1/5: stranka nactena (...)
|
||||
[12:57:05] KROK 2/5: radky se objevily, cekam na stabilizaci poctu...
|
||||
[12:57:06] ...kontrola #1: 153 radku
|
||||
[12:57:08] ...kontrola #2: 153 radku
|
||||
[12:57:10] KROK 2/5: data stabilni (153 radku v gridu).
|
||||
[12:57:10] KROK 3/5: menu otevreno.
|
||||
[12:57:11] KROK 4/5: stahovani zachyceno, ukladam soubor...
|
||||
[12:57:11] KROK 5/5: HOTOVO -> ...\2026-05-29_125710 sponsor-study-36940-test-results-930557-standard.csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Klíčové technické poznatky (PROČ to tak je) — ověřeno přes Chrome DevTools
|
||||
|
||||
Stránka test-results je **Angular SPA** s knihovnou **MDL** a tabulkou **AG Grid**.
|
||||
Tyto detaily byly zjištěny živou inspekcí DOM (Claude in Chrome MCP), ne hádáním:
|
||||
|
||||
### 6.1 Čekání na data = řádky AG Gridu
|
||||
- Grid je `<covance-ag-grid>` → `<ag-grid-angular>` (AG Grid).
|
||||
- Data jsou načtena, jakmile se objeví řádky **`div.ag-row`** (count jde z 0 → N;
|
||||
pro 930557 to bylo 153 řádků).
|
||||
- **Řádky jsou `position-absolute`** (virtuální render AG Gridu) → Playwright je
|
||||
**nepovažuje za „visible"**. Proto:
|
||||
- ❌ `wait_for_selector("div.ag-row")` (default `state="visible"`) **timeoutuje**
|
||||
i když řádky existují.
|
||||
- ✅ čekat na **přítomnost v DOM**:
|
||||
`wait_for_function("() => document.querySelectorAll('div.ag-row').length > 0")`.
|
||||
- Stabilizace: počet řádků se čte opakovaně co 2 s, dokud se 2× po sobě neshodne.
|
||||
|
||||
### 6.2 „Fetching Data" / spinner — POZOR, NEPLATÍ pro tuto stránku
|
||||
- Na test-results stránce **NENÍ** text „Fetching Data" (ten je na *samples*
|
||||
reportu, jiná stránka!).
|
||||
- Je tu element `<loading-bar>`, ale ten jen krátce problikne `<mdl-progress>`
|
||||
při route-loadingu, **ne** během načítání dat gridu → nelze na něj spoléhat.
|
||||
- Spolehlivý signál je výhradně **objevení `.ag-row`**.
|
||||
|
||||
### 6.3 Tři tečky (export) — na stránce jsou DVA `<ag-export>`
|
||||
- Na stránce existují **2× `<ag-export>`**: jeden **skrytý**, jeden viditelný.
|
||||
- Existují **3× ikona `more_horiz`**: 1 mimo export (toolbar), 1 ve skrytém
|
||||
ag-export, 1 ve viditelném.
|
||||
- Proto je nutný filtr na viditelnost:
|
||||
✅ `page.locator("ag-export button:visible", has_text="more_horiz").first.click()`
|
||||
|
||||
### 6.4 „Export to CSV" — v DOM jsou DVĚ položky
|
||||
- Po otevření menu existují **2× `mdl-menu-item` „Export to CSV"** (jedna skrytá
|
||||
z neviditelného ag-export, jedna viditelná). Stejně tak 2× „Export to Excel".
|
||||
- Proto:
|
||||
✅ `page.locator("mdl-menu-item:visible", has_text="Export to CSV").first.click()`
|
||||
- ❌ `get_by_text("Export to CSV")` → **strict mode violation** (2 elementy).
|
||||
|
||||
### 6.5 Prázdné centrum (ověřeno přes Chrome MCP na centru 930551)
|
||||
- AG Grid při 0 záznamech zobrazí no-rows overlay s textem **„No Data"**.
|
||||
- Struktura: `.ag-overlay` → `.ag-overlay-panel` →
|
||||
**`.ag-overlay-no-rows-wrapper`** → `<span>No Data</span>`.
|
||||
- ⚠️ Třída **NENÍ** `.ag-overlay-no-rows-center` (to byl chybný předpoklad,
|
||||
na který detekce nikdy nezabrala) — správně je **`.ag-overlay-no-rows-wrapper`**.
|
||||
- ⚠️ Text „No Data" **NENÍ** v `.ag-body-viewport` (ten je prázdný,
|
||||
`height: 1px`) — je v samostatném overlay sourozenci.
|
||||
- ⚠️ Na stránce jsou **2 overlaye** (jeden skrytý, jeden viditelný) — stejně
|
||||
jako u `ag-export`. Proto kontrola **viditelnosti** `offsetParent !== null`.
|
||||
- Detekce (KROK 2):
|
||||
```js
|
||||
() => {
|
||||
if (document.querySelectorAll('div.ag-row').length > 0) return false;
|
||||
return [...document.querySelectorAll('.ag-overlay-no-rows-wrapper')]
|
||||
.some(e => e.offsetParent !== null);
|
||||
}
|
||||
```
|
||||
- KROK 2 čeká na `.ag-row` **NEBO** tuto detekci → centrum bez dat
|
||||
necheká zbytečně 120 s a export se přeskočí.
|
||||
|
||||
---
|
||||
|
||||
## 7. Login logika (sdílená napříč Covance skripty)
|
||||
|
||||
```python
|
||||
page.goto(LOGIN_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
if not page.get_by_label("Email").is_visible():
|
||||
return # session aktivní → login přeskočit
|
||||
# jinak: Email → Next → Password → Verify → wait redirect (code= zmizí z URL)
|
||||
```
|
||||
|
||||
- **Proč `is_visible()` a ne kontrola URL:** po `goto(LOGIN_URL)` zůstane URL
|
||||
`xsp.covance.com` i po redirectu na dashboard, takže URL test je nespolehlivý.
|
||||
Spolehlivé je, zda **existuje pole Email** (login formulář) nebo ne.
|
||||
- `is_visible()` neháže výjimku — když pole není, vrátí `False`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Chrome flagy proti „Restore pages" / broken session
|
||||
|
||||
V `args` launchu:
|
||||
```
|
||||
--disable-restore-session-state # neobnovovat předchozí session
|
||||
--disable-session-crashed-bubble # potlačit "Chromium didn't shut down correctly"
|
||||
```
|
||||
Důvod: při natvrdo ukončeném skriptu (kill) Chromium jinak při dalším startu
|
||||
nabídne „Restore pages?" dialog, který rozbil interakci.
|
||||
|
||||
---
|
||||
|
||||
## 9. Robustnost smyčky
|
||||
|
||||
- Každé centrum je v `try/except` → **chyba u jednoho nezastaví zbytek**.
|
||||
- Na konci souhrn: `hotovo X/12` + seznam `SELHALA centra: …`.
|
||||
- Selhané centrum v logu = snadno se dohledá a vyřadí/opraví ID.
|
||||
|
||||
---
|
||||
|
||||
## 10. Možná budoucí rozšíření
|
||||
|
||||
- **Microbiology záložka**: existuje i `…/test-results/{SITE}/microbiology…`
|
||||
(analogická stránka, pravděpodobně stejná AG Grid logika).
|
||||
- **Druhá studie** (MDD3003, study 35472): přidat další `STUDY` + `SITE_IDS`
|
||||
a obalit do vnější smyčky (vzor viz `download_samples_report` se seznamem STUDIES).
|
||||
- **Ověření počtu**: počet `.ag-row` se loguje — dá se porovnat s počtem řádků
|
||||
ve staženém CSV jako sanity check.
|
||||
|
||||
---
|
||||
|
||||
## 11. Příbuzné skripty (stejná složka / portál)
|
||||
|
||||
| Skript | Co stahuje |
|
||||
|---|---|
|
||||
| `download_samples_report_v1.1.py` | All Samples (sampletracking, čeká na „Fetching Data") |
|
||||
| `download_kit_inventory_v2.1.py` | Kit inventory (on-hand expiration) |
|
||||
| `download_equeries_report_v1.1.py` | eQuery reporty (zdroj SITE_IDS) |
|
||||
| `download_test_results_v1.0.py` | **tento** — Standard Test Results |
|
||||
|
||||
Všechny sdílejí: persistent profile, login logiku s `is_visible()` checkem,
|
||||
`ag-export` + „Export to CSV" pattern (equeries/test-results).
|
||||
@@ -0,0 +1,173 @@
|
||||
# =============================================================================
|
||||
# Název: download_test_results_v1.0.py
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-05-29
|
||||
# Popis: Stahuje Standard Test Results ze xsp.labcorp.com pro studii 36940.
|
||||
# Čeká na načtení AG Grid řádků (.ag-row) před exportem.
|
||||
# Výstup: timestampované CSV do adresáře Source/.
|
||||
# =============================================================================
|
||||
from playwright.sync_api import sync_playwright
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
EMAIL = "vbuzalka@its.jnj.com"
|
||||
PASSWORD = "%zT3Wqfc9)cWua5"
|
||||
LOGIN_URL = "https://xsp.covance.com/"
|
||||
OUT_DIR = r"U:\PythonProject\Janssen\Covance_UCO3001\Source"
|
||||
PROFILE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "browser_profile")
|
||||
|
||||
# Studie + jejich interni cisla center.
|
||||
# 36940 = 77242113UCO3001 (zdroj center: download_equeries_report SITES)
|
||||
# 35472 = druha studie
|
||||
STUDIES = [
|
||||
{
|
||||
"study": "36940",
|
||||
"sites": [
|
||||
"930551", "930556", "930525", "930549", "930543", "930547",
|
||||
"930555", "930557", "930539", "930536", "930553", "930531",
|
||||
],
|
||||
},
|
||||
{
|
||||
"study": "35472",
|
||||
"sites": [
|
||||
"898745", "898739", "898733", "898744", "898727",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Typy reportu: zalozka v URL + suffix v nazvu souboru.
|
||||
REPORT_TYPES = [
|
||||
{"slug": "standard-test-results", "suffix": "standard"},
|
||||
{"slug": "microbiology", "suffix": "microbiology"},
|
||||
]
|
||||
|
||||
REPORTS = [
|
||||
{
|
||||
"site": sid,
|
||||
"study": st["study"],
|
||||
"type": rt["suffix"],
|
||||
"url": f"https://xsp.labcorp.com/sponsor/study/{st['study']}/test-results/{sid}/{rt['slug']}",
|
||||
"filename": f"sponsor-study-{st['study']}-test-results-{sid}-{rt['suffix']}.csv",
|
||||
}
|
||||
for st in STUDIES
|
||||
for sid in st["sites"]
|
||||
for rt in REPORT_TYPES
|
||||
]
|
||||
|
||||
|
||||
def login(page):
|
||||
log("LOGIN: otviram login stranku...")
|
||||
page.goto(LOGIN_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
if not page.get_by_label("Email").is_visible():
|
||||
log(f"LOGIN: session uz aktivni, prihlaseni preskoceno ({page.url})")
|
||||
return
|
||||
log("LOGIN: zadavam email...")
|
||||
page.get_by_label("Email").fill(EMAIL)
|
||||
page.get_by_role("button", name="Next").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
log("LOGIN: zadavam heslo...")
|
||||
page.get_by_label("Password").fill(PASSWORD)
|
||||
page.get_by_role("button", name="Verify").click()
|
||||
log("LOGIN: cekam na presmerovani po prihlaseni...")
|
||||
page.wait_for_url(lambda url: "code=" not in url, timeout=60000)
|
||||
page.wait_for_load_state("networkidle", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
log(f"LOGIN: prihlaseni OK ({page.url})")
|
||||
|
||||
|
||||
def download_report(page, report):
|
||||
log(f"=== Centrum {report['site']} / {report['type']} (studie {report['study']}) ===")
|
||||
|
||||
log(f"KROK 1/5: navigace na report URL...")
|
||||
page.goto(report["url"])
|
||||
log(f"KROK 1/5: stranka nactena ({page.url})")
|
||||
|
||||
# Grid je AG Grid uvnitř <covance-ag-grid>. Data jsou nactena, jakmile
|
||||
# se v gridu objevi radky (.ag-row jde z 0 -> N). Pockej na prvni radek
|
||||
# a pak na stabilizaci poctu (proti castecnemu renderu).
|
||||
log("KROK 2/5: cekam na radky gridu (.ag-row) nebo prazdny grid ('No Data')...")
|
||||
# AG Grid radky jsou position-absolute (virtualni render), takze nejsou
|
||||
# "visible" dle Playwrightu -> cekej na pritomnost v DOM, ne na viditelnost.
|
||||
# Prazdne centrum: AG Grid vykresli no-rows overlay s textem "No Data" ve
|
||||
# wrapperu .ag-overlay-no-rows-wrapper. POZOR: trida NENI -no-rows-center;
|
||||
# navic jsou na strance 2 overlaye (jeden skryty) -> kontroluj viditelny
|
||||
# (offsetParent != null). Detekuj, aby to u centra bez dat necekalo 120 s.
|
||||
EMPTY_GRID_JS = """() => {
|
||||
if (document.querySelectorAll('div.ag-row').length > 0) return false;
|
||||
return [...document.querySelectorAll('.ag-overlay-no-rows-wrapper')]
|
||||
.some(e => e.offsetParent !== null);
|
||||
}"""
|
||||
page.wait_for_function(
|
||||
f"""() => document.querySelectorAll('div.ag-row').length > 0
|
||||
|| ({EMPTY_GRID_JS})()""",
|
||||
timeout=120000,
|
||||
)
|
||||
if page.evaluate(EMPTY_GRID_JS):
|
||||
log("KROK 2/5: centrum bez dat ('No Data' overlay) — preskakuji export.")
|
||||
return
|
||||
log("KROK 2/5: radky se objevily, cekam na stabilizaci poctu...")
|
||||
prev = -1
|
||||
for i in range(20): # max ~40 s stabilizace
|
||||
cnt = page.locator("div.ag-row").count()
|
||||
log(f" ...kontrola #{i+1}: {cnt} radku")
|
||||
if cnt == prev and cnt > 0:
|
||||
break
|
||||
prev = cnt
|
||||
page.wait_for_timeout(2000)
|
||||
page.wait_for_timeout(2000) # buffer
|
||||
log(f"KROK 2/5: data stabilni ({prev} radku v gridu).")
|
||||
|
||||
# Tri tecky: na strance jsou 2x <ag-export> (jeden skryty), klikni na
|
||||
# VIDITELNY more_horiz button.
|
||||
log("KROK 3/5: klikam na viditelne tri tecky (more_horiz)...")
|
||||
page.locator("ag-export button:visible", has_text="more_horiz").first.click()
|
||||
log("KROK 3/5: menu otevreno.")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
dest = os.path.join(OUT_DIR, f"{timestamp} {report['filename']}")
|
||||
log("KROK 4/5: klikam na 'Export to CSV' a cekam na stahovani...")
|
||||
with page.expect_download(timeout=60000) as dl:
|
||||
# 2x "Export to CSV" v DOM (jeden skryty) -> klikni na VIDITELNY
|
||||
page.locator("mdl-menu-item:visible", has_text="Export to CSV").first.click()
|
||||
log("KROK 4/5: stahovani zachyceno, ukladam soubor...")
|
||||
dl.value.save_as(dest)
|
||||
log(f"KROK 5/5: HOTOVO -> {dest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with sync_playwright() as p:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=PROFILE_DIR,
|
||||
headless=False,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--disable-restore-session-state",
|
||||
"--disable-session-crashed-bubble",
|
||||
],
|
||||
no_viewport=True,
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
accept_downloads=True,
|
||||
)
|
||||
context.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
page = context.new_page()
|
||||
log("START: prohlizec spusten.")
|
||||
login(page)
|
||||
ok, failed = 0, []
|
||||
for idx, report in enumerate(REPORTS, 1):
|
||||
log(f">>> Report {idx}/{len(REPORTS)}")
|
||||
try:
|
||||
download_report(page, report)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed.append(f"{report['site']}/{report['type']}")
|
||||
log(f"CHYBA u centra {report['site']}/{report['type']}: {e!r} — pokracuji dalsim.")
|
||||
log(f"KONEC: hotovo {ok}/{len(REPORTS)} reportu.")
|
||||
if failed:
|
||||
log(f"KONEC: SELHALA centra: {', '.join(failed)}")
|
||||
context.close()
|
||||
@@ -0,0 +1,231 @@
|
||||
# download_test_results_v1.1.py — dokumentace
|
||||
|
||||
**Verze:** 1.1 · **Datum:** 2026-05-29
|
||||
**Umístění:** `U:\PythonProject\Janssen\Covance_UCO3001\download_test_results_v1.1.py`
|
||||
|
||||
> **Změny v1.1 oproti 1.0:** přidána **druhá studie 35472 (MDD)** a druhý typ
|
||||
> reportu **Microbiology**. Dříve jen 36940 + Standard.
|
||||
|
||||
---
|
||||
|
||||
## 1. Účel
|
||||
|
||||
Automatické stažení reportů **Test Results** z portálu Labcorp Sponsor Portal
|
||||
(`xsp.labcorp.com`). Skript projde **2 studie × jejich centra × 2 typy reportu**,
|
||||
u každého vyexportuje grid do CSV a uloží ho timestampovaný do `Source/`.
|
||||
|
||||
| Studie | Interní ID | Význam | Počet center |
|
||||
|---|---|---|---|
|
||||
| UC | `36940` | 77242113UCO3001 | 12 |
|
||||
| MDD | `35472` | druhá studie | 5 |
|
||||
|
||||
Typy reportu (záložka v URL): **Standard** (`/standard-test-results`) a
|
||||
**Microbiology** (`/microbiology`).
|
||||
|
||||
**Celkem: (12 + 5) × 2 = 34 reportů** (prázdná centra se přeskakují, viz 6.5).
|
||||
|
||||
---
|
||||
|
||||
## 2. Spuštění
|
||||
|
||||
```bat
|
||||
U:\PythonProject\Janssen\.venv\Scripts\python.exe ^
|
||||
U:\PythonProject\Janssen\Covance_UCO3001\download_test_results_v1.1.py
|
||||
```
|
||||
|
||||
- Prohlížeč běží **viditelně** (`headless=False`), maximalizovaný.
|
||||
- **Persistent profile** (`browser_profile/` vedle skriptu) — session přežívá.
|
||||
|
||||
### Dočasný test (1 centrum / studie)
|
||||
|
||||
Pro rychlé ověření stačí dočasně zúžit `STUDIES`, např.:
|
||||
|
||||
```python
|
||||
STUDIES = [
|
||||
{"study": "36940", "sites": ["930557"]}, # UC — centrum s daty
|
||||
{"study": "35472", "sites": ["898745"]}, # MDD — první centrum
|
||||
]
|
||||
```
|
||||
|
||||
→ proběhnou 4 reporty (2 centra × 2 typy). Po testu vrátit plný seznam.
|
||||
|
||||
---
|
||||
|
||||
## 3. Konfigurace (konstanty v hlavičce skriptu)
|
||||
|
||||
| Konstanta | Hodnota / význam |
|
||||
|---|---|
|
||||
| `EMAIL` | `vbuzalka@its.jnj.com` (login přes iMedidata/OKTA na xsp.covance.com) |
|
||||
| `PASSWORD` | heslo k účtu (uložené přímo v kódu) |
|
||||
| `LOGIN_URL` | `https://xsp.covance.com/` — po přihlášení redirect na xsp.labcorp.com |
|
||||
| `OUT_DIR` | `U:\PythonProject\Janssen\Covance_UCO3001\Source` |
|
||||
| `PROFILE_DIR` | `browser_profile/` vedle skriptu (persistent Chromium profil) |
|
||||
| `STUDIES` | seznam studií, každá má `study` (interní ID) + `sites` (interní ID center) |
|
||||
| `REPORT_TYPES` | `standard-test-results`/`standard` a `microbiology`/`microbiology` |
|
||||
|
||||
### Interní čísla center
|
||||
|
||||
```
|
||||
36940 (UC): 930551, 930556, 930525, 930549, 930543, 930547,
|
||||
930555, 930557, 930539, 930536, 930553, 930531
|
||||
35472 (MDD): 898745, 898739, 898733, 898744, 898727
|
||||
```
|
||||
|
||||
> **Pozor:** jsou to **interní ID** Labcorpu, **nikoli** čísla center typu
|
||||
> CZ10001. V URL test-results se používá právě toto interní ID:
|
||||
> `…/test-results/{SITE_ID}/{standard-test-results|microbiology}`.
|
||||
> (Pozn.: 36940 zdroj = `download_equeries_report_v1.1.py SITES`;
|
||||
> 35472 zdroj = ručně dodaná čísla center.)
|
||||
|
||||
### Generování REPORTS (DRY)
|
||||
|
||||
`REPORTS` se sestaví automaticky jako **kartézský součin**
|
||||
`STUDIES × sites × REPORT_TYPES`. Přidání/odebrání centra, studie nebo typu
|
||||
reportu = úprava příslušného seznamu, vzor URL i názvu se píše jen jednou.
|
||||
|
||||
---
|
||||
|
||||
## 4. Výstupní soubory
|
||||
|
||||
Formát názvu (timestamp + popisný název):
|
||||
|
||||
```
|
||||
{YYYY-MM-DD_HHMMSS} sponsor-study-{STUDY}-test-results-{SITE_ID}-{TYP}.csv
|
||||
```
|
||||
|
||||
`{TYP}` = `standard` nebo `microbiology`. Příklady:
|
||||
```
|
||||
2026-05-29_131121 sponsor-study-36940-test-results-930557-standard.csv
|
||||
2026-05-29_131500 sponsor-study-36940-test-results-930557-microbiology.csv
|
||||
2026-05-29_131800 sponsor-study-35472-test-results-898745-standard.csv
|
||||
```
|
||||
|
||||
- Timestamp se generuje pro **každý** report zvlášť (v okamžiku exportu).
|
||||
- Staré soubory se **nikdy nemažou** (verzování přes timestamp).
|
||||
- `expect_download` zachytí download event, `save_as()` uloží pod naším názvem.
|
||||
|
||||
---
|
||||
|
||||
## 5. Průběh skriptu (kroky + logging)
|
||||
|
||||
Každý krok se loguje s časem (`[HH:MM:SS]`, `flush=True`).
|
||||
|
||||
| Fáze | Co dělá |
|
||||
|---|---|
|
||||
| START | spustí prohlížeč |
|
||||
| LOGIN | otevře login; **pokud je session aktivní, přihlášení přeskočí** |
|
||||
| Pro každý report (studie × centrum × typ): | |
|
||||
| KROK 1/5 | navigace na report URL |
|
||||
| KROK 2/5 | čeká na řádky gridu `.ag-row` **nebo** prázdný grid („No Data"); pak stabilizace počtu |
|
||||
| KROK 3/5 | klik na viditelné tři tečky (`more_horiz`) → otevře menu |
|
||||
| KROK 4/5 | klik na viditelné „Export to CSV" → zachytí download |
|
||||
| KROK 5/5 | uloží soubor do `OUT_DIR` |
|
||||
| KONEC | souhrn `hotovo X/34` + seznam selhaných (`site/typ`) |
|
||||
|
||||
Log u každého reportu ukazuje i typ: `=== Centrum 930557 / microbiology (studie 36940) ===`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Klíčové technické poznatky (PROČ to tak je) — ověřeno přes Chrome MCP
|
||||
|
||||
Stránka test-results je **Angular SPA** s knihovnou **MDL** a tabulkou **AG Grid**.
|
||||
Microbiology záložka má **stejnou** strukturu jako Standard → vše níže platí pro oba.
|
||||
|
||||
### 6.1 Čekání na data = řádky AG Gridu
|
||||
- Data jsou načtena, jakmile se objeví řádky **`div.ag-row`** (count 0 → N).
|
||||
- **Řádky jsou `position-absolute`** (virtuální render) → Playwright je
|
||||
**nepovažuje za „visible"**. Proto:
|
||||
- ❌ `wait_for_selector("div.ag-row")` (default visible) **timeoutuje**.
|
||||
- ✅ `wait_for_function("() => document.querySelectorAll('div.ag-row').length > 0")`.
|
||||
- Stabilizace: počet řádků se čte co 2 s, dokud se 2× po sobě neshodne.
|
||||
- **POZN.: počet `.ag-row` se „zastropuje"** (typicky ~153) kvůli virtuálnímu
|
||||
renderu — NENÍ to skutečný počet záznamů. Slouží jen jako signál „data jsou".
|
||||
**Export do CSV ale vyexportuje VŠECHNY řádky** (ověřeno: různé velikosti CSV).
|
||||
|
||||
### 6.2 „Fetching Data" / spinner — NEPLATÍ pro tuto stránku
|
||||
- Na test-results **NENÍ** text „Fetching Data" (ten je na *samples* reportu).
|
||||
- `<loading-bar>` jen problikne při route-loadu → nespoléhat. Signál = `.ag-row`.
|
||||
|
||||
### 6.3 Tři tečky (export) — na stránce jsou DVA `<ag-export>`
|
||||
- 2× `<ag-export>` (1 skrytý), 3× ikona `more_horiz`. Proto filtr na viditelnost:
|
||||
✅ `page.locator("ag-export button:visible", has_text="more_horiz").first.click()`
|
||||
|
||||
### 6.4 „Export to CSV" — v DOM jsou DVĚ položky
|
||||
- 2× `mdl-menu-item` „Export to CSV" (1 skrytá). Proto:
|
||||
✅ `page.locator("mdl-menu-item:visible", has_text="Export to CSV").first.click()`
|
||||
- ❌ `get_by_text("Export to CSV")` → strict mode violation (2 elementy).
|
||||
|
||||
### 6.5 Prázdné centrum (ověřeno přes Chrome MCP na centru 930551)
|
||||
- AG Grid při 0 záznamech zobrazí no-rows overlay s textem **„No Data"**.
|
||||
- Struktura: `.ag-overlay` → `.ag-overlay-panel` →
|
||||
**`.ag-overlay-no-rows-wrapper`** → `<span>No Data</span>`.
|
||||
- ⚠️ Třída **NENÍ** `.ag-overlay-no-rows-center` (chybný předpoklad, nikdy
|
||||
nezabral) — správně je **`.ag-overlay-no-rows-wrapper`**.
|
||||
- ⚠️ Text „No Data" **NENÍ** v `.ag-body-viewport` (ten je prázdný, `height: 1px`).
|
||||
- ⚠️ Na stránce jsou **2 overlaye** (1 skrytý, 1 viditelný) → kontrola
|
||||
viditelnosti `offsetParent !== null`.
|
||||
- Detekce (KROK 2):
|
||||
```js
|
||||
() => {
|
||||
if (document.querySelectorAll('div.ag-row').length > 0) return false;
|
||||
return [...document.querySelectorAll('.ag-overlay-no-rows-wrapper')]
|
||||
.some(e => e.offsetParent !== null);
|
||||
}
|
||||
```
|
||||
- KROK 2 čeká na `.ag-row` **NEBO** tuto detekci → prázdné centrum necheká
|
||||
zbytečně 120 s a export se přeskočí.
|
||||
|
||||
---
|
||||
|
||||
## 7. Login logika (sdílená napříč Covance skripty)
|
||||
|
||||
```python
|
||||
page.goto(LOGIN_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
if not page.get_by_label("Email").is_visible():
|
||||
return # session aktivní → login přeskočit
|
||||
# jinak: Email → Next → Password → Verify → wait redirect (code= zmizí z URL)
|
||||
```
|
||||
|
||||
- **Proč `is_visible()` a ne kontrola URL:** po `goto(LOGIN_URL)` zůstane URL
|
||||
`xsp.covance.com` i po redirectu, takže URL test je nespolehlivý.
|
||||
- `is_visible()` neháže výjimku — když pole není, vrátí `False`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Chrome flagy proti „Restore pages" / broken session
|
||||
|
||||
```
|
||||
--disable-restore-session-state # neobnovovat předchozí session
|
||||
--disable-session-crashed-bubble # potlačit "Chromium didn't shut down correctly"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Robustnost smyčky
|
||||
|
||||
- Každý report je v `try/except` → **chyba u jednoho nezastaví zbytek**.
|
||||
- Na konci souhrn: `hotovo X/34` + seznam `SELHALA centra: site/typ, …`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Možná budoucí rozšíření
|
||||
|
||||
- **Další studie / centra**: přidat položku do `STUDIES`.
|
||||
- **Další typ reportu**: přidat položku do `REPORT_TYPES` (slug + suffix).
|
||||
- **Ověření počtu**: počet `.ag-row` se loguje — POZOR, je zastropený virtuálním
|
||||
renderem, takže ho nelze porovnávat s počtem řádků v CSV (CSV má všechny).
|
||||
|
||||
---
|
||||
|
||||
## 11. Příbuzné skripty (stejná složka / portál)
|
||||
|
||||
| Skript | Co stahuje |
|
||||
|---|---|
|
||||
| `download_samples_report_v1.1.py` | All Samples (sampletracking, čeká na „Fetching Data") |
|
||||
| `download_kit_inventory_v2.1.py` | Kit inventory (on-hand expiration) |
|
||||
| `download_equeries_report_v1.1.py` | eQuery reporty (zdroj SITE_IDS pro 36940) |
|
||||
| `download_test_results_v1.1.py` | **tento** — Test Results (Standard + Microbiology, 2 studie) |
|
||||
|
||||
Všechny sdílejí: persistent profile, login s `is_visible()` checkem,
|
||||
`ag-export` + „Export to CSV" pattern.
|
||||
@@ -0,0 +1,175 @@
|
||||
# =============================================================================
|
||||
# Název: download_test_results_v1.1.py
|
||||
# Verze: 1.1
|
||||
# Datum: 2026-05-29
|
||||
# Popis: Stahuje Test Results ze xsp.labcorp.com pro 2 studie (36940, 35472),
|
||||
# oba typy reportu (Standard + Microbiology), pres vsechna centra.
|
||||
# Ceka na nacteni AG Grid radku (.ag-row); prazdne centrum ('No Data')
|
||||
# preskoci. Vystup: timestampovane CSV do adresare Source/.
|
||||
# Zmeny v1.1: + studie 35472, + report typ microbiology (driv jen 36940/standard).
|
||||
# =============================================================================
|
||||
from playwright.sync_api import sync_playwright
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
EMAIL = "vbuzalka@its.jnj.com"
|
||||
PASSWORD = "%zT3Wqfc9)cWua5"
|
||||
LOGIN_URL = "https://xsp.covance.com/"
|
||||
OUT_DIR = r"U:\PythonProject\Janssen\Covance_UCO3001\Source"
|
||||
PROFILE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "browser_profile")
|
||||
|
||||
# Studie + jejich interni cisla center.
|
||||
# 36940 = 77242113UCO3001 (UC) — zdroj center: download_equeries_report SITES
|
||||
# 35472 = druha studie (MDD)
|
||||
STUDIES = [
|
||||
{
|
||||
"study": "36940",
|
||||
"sites": [
|
||||
"930551", "930556", "930525", "930549", "930543", "930547",
|
||||
"930555", "930557", "930539", "930536", "930553", "930531",
|
||||
],
|
||||
},
|
||||
{
|
||||
"study": "35472",
|
||||
"sites": [
|
||||
"898745", "898739", "898733", "898744", "898727",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Typy reportu: zalozka v URL + suffix v nazvu souboru.
|
||||
REPORT_TYPES = [
|
||||
{"slug": "standard-test-results", "suffix": "standard"},
|
||||
{"slug": "microbiology", "suffix": "microbiology"},
|
||||
]
|
||||
|
||||
REPORTS = [
|
||||
{
|
||||
"site": sid,
|
||||
"study": st["study"],
|
||||
"type": rt["suffix"],
|
||||
"url": f"https://xsp.labcorp.com/sponsor/study/{st['study']}/test-results/{sid}/{rt['slug']}",
|
||||
"filename": f"sponsor-study-{st['study']}-test-results-{sid}-{rt['suffix']}.csv",
|
||||
}
|
||||
for st in STUDIES
|
||||
for sid in st["sites"]
|
||||
for rt in REPORT_TYPES
|
||||
]
|
||||
|
||||
|
||||
def login(page):
|
||||
log("LOGIN: otviram login stranku...")
|
||||
page.goto(LOGIN_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
if not page.get_by_label("Email").is_visible():
|
||||
log(f"LOGIN: session uz aktivni, prihlaseni preskoceno ({page.url})")
|
||||
return
|
||||
log("LOGIN: zadavam email...")
|
||||
page.get_by_label("Email").fill(EMAIL)
|
||||
page.get_by_role("button", name="Next").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
log("LOGIN: zadavam heslo...")
|
||||
page.get_by_label("Password").fill(PASSWORD)
|
||||
page.get_by_role("button", name="Verify").click()
|
||||
log("LOGIN: cekam na presmerovani po prihlaseni...")
|
||||
page.wait_for_url(lambda url: "code=" not in url, timeout=60000)
|
||||
page.wait_for_load_state("networkidle", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
log(f"LOGIN: prihlaseni OK ({page.url})")
|
||||
|
||||
|
||||
def download_report(page, report):
|
||||
log(f"=== Centrum {report['site']} / {report['type']} (studie {report['study']}) ===")
|
||||
|
||||
log(f"KROK 1/5: navigace na report URL...")
|
||||
page.goto(report["url"])
|
||||
log(f"KROK 1/5: stranka nactena ({page.url})")
|
||||
|
||||
# Grid je AG Grid uvnitř <covance-ag-grid>. Data jsou nactena, jakmile
|
||||
# se v gridu objevi radky (.ag-row jde z 0 -> N). Pockej na prvni radek
|
||||
# a pak na stabilizaci poctu (proti castecnemu renderu).
|
||||
log("KROK 2/5: cekam na radky gridu (.ag-row) nebo prazdny grid ('No Data')...")
|
||||
# AG Grid radky jsou position-absolute (virtualni render), takze nejsou
|
||||
# "visible" dle Playwrightu -> cekej na pritomnost v DOM, ne na viditelnost.
|
||||
# Prazdne centrum: AG Grid vykresli no-rows overlay s textem "No Data" ve
|
||||
# wrapperu .ag-overlay-no-rows-wrapper. POZOR: trida NENI -no-rows-center;
|
||||
# navic jsou na strance 2 overlaye (jeden skryty) -> kontroluj viditelny
|
||||
# (offsetParent != null). Detekuj, aby to u centra bez dat necekalo 120 s.
|
||||
EMPTY_GRID_JS = """() => {
|
||||
if (document.querySelectorAll('div.ag-row').length > 0) return false;
|
||||
return [...document.querySelectorAll('.ag-overlay-no-rows-wrapper')]
|
||||
.some(e => e.offsetParent !== null);
|
||||
}"""
|
||||
page.wait_for_function(
|
||||
f"""() => document.querySelectorAll('div.ag-row').length > 0
|
||||
|| ({EMPTY_GRID_JS})()""",
|
||||
timeout=120000,
|
||||
)
|
||||
if page.evaluate(EMPTY_GRID_JS):
|
||||
log("KROK 2/5: centrum bez dat ('No Data' overlay) — preskakuji export.")
|
||||
return
|
||||
log("KROK 2/5: radky se objevily, cekam na stabilizaci poctu...")
|
||||
prev = -1
|
||||
for i in range(20): # max ~40 s stabilizace
|
||||
cnt = page.locator("div.ag-row").count()
|
||||
log(f" ...kontrola #{i+1}: {cnt} radku")
|
||||
if cnt == prev and cnt > 0:
|
||||
break
|
||||
prev = cnt
|
||||
page.wait_for_timeout(2000)
|
||||
page.wait_for_timeout(2000) # buffer
|
||||
log(f"KROK 2/5: data stabilni ({prev} radku v gridu).")
|
||||
|
||||
# Tri tecky: na strance jsou 2x <ag-export> (jeden skryty), klikni na
|
||||
# VIDITELNY more_horiz button.
|
||||
log("KROK 3/5: klikam na viditelne tri tecky (more_horiz)...")
|
||||
page.locator("ag-export button:visible", has_text="more_horiz").first.click()
|
||||
log("KROK 3/5: menu otevreno.")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
dest = os.path.join(OUT_DIR, f"{timestamp} {report['filename']}")
|
||||
log("KROK 4/5: klikam na 'Export to CSV' a cekam na stahovani...")
|
||||
with page.expect_download(timeout=60000) as dl:
|
||||
# 2x "Export to CSV" v DOM (jeden skryty) -> klikni na VIDITELNY
|
||||
page.locator("mdl-menu-item:visible", has_text="Export to CSV").first.click()
|
||||
log("KROK 4/5: stahovani zachyceno, ukladam soubor...")
|
||||
dl.value.save_as(dest)
|
||||
log(f"KROK 5/5: HOTOVO -> {dest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with sync_playwright() as p:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=PROFILE_DIR,
|
||||
headless=False,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--disable-restore-session-state",
|
||||
"--disable-session-crashed-bubble",
|
||||
],
|
||||
no_viewport=True,
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
accept_downloads=True,
|
||||
)
|
||||
context.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
page = context.new_page()
|
||||
log("START: prohlizec spusten.")
|
||||
login(page)
|
||||
ok, failed = 0, []
|
||||
for idx, report in enumerate(REPORTS, 1):
|
||||
log(f">>> Report {idx}/{len(REPORTS)}")
|
||||
try:
|
||||
download_report(page, report)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed.append(f"{report['site']}/{report['type']}")
|
||||
log(f"CHYBA u centra {report['site']}/{report['type']}: {e!r} — pokracuji dalsim.")
|
||||
log(f"KONEC: hotovo {ok}/{len(REPORTS)} reportu.")
|
||||
if failed:
|
||||
log(f"KONEC: SELHALA centra: {', '.join(failed)}")
|
||||
context.close()
|
||||
@@ -0,0 +1,233 @@
|
||||
# download_test_results_v1.2.py — dokumentace
|
||||
|
||||
**Verze:** 1.2 · **Datum:** 2026-05-29
|
||||
**Umístění:** `U:\PythonProject\Janssen\Covance_UCO3001\download_test_results_v1.2.py`
|
||||
|
||||
> **Změny v1.2 oproti 1.1:** přidán **paralelní běh přes sharding**
|
||||
> (`--shard N --of M`). Skript lze spustit ve více procesech současně, každý
|
||||
> zpracuje svůj podíl reportů a používá vlastní profil. Spouští se přes launcher
|
||||
> `run_test_results_parallel_v1.0.py`.
|
||||
> **Změny v1.1 oproti 1.0:** druhá studie 35472 (MDD) + report Microbiology.
|
||||
|
||||
---
|
||||
|
||||
## 1. Účel
|
||||
|
||||
Automatické stažení reportů **Test Results** z portálu Labcorp Sponsor Portal
|
||||
(`xsp.labcorp.com`). Skript projde **2 studie × jejich centra × 2 typy reportu**,
|
||||
u každého vyexportuje grid do CSV a uloží ho timestampovaný do `Source/`.
|
||||
|
||||
| Studie | Interní ID | Význam | Počet center |
|
||||
|---|---|---|---|
|
||||
| UC | `36940` | 77242113UCO3001 | 12 |
|
||||
| MDD | `35472` | druhá studie | 5 |
|
||||
|
||||
Typy reportu: **Standard** (`/standard-test-results`) a **Microbiology** (`/microbiology`).
|
||||
|
||||
**Celkem: (12 + 5) × 2 = 34 reportů** (prázdná centra se přeskakují, viz 6.5).
|
||||
|
||||
---
|
||||
|
||||
## 2. Spuštění
|
||||
|
||||
### 2a) Paralelně (doporučeno — rychlejší)
|
||||
|
||||
Přes launcher, který rozjede 4 procesy (každý ve vlastním okně):
|
||||
|
||||
```bat
|
||||
U:\PythonProject\Janssen\.venv\Scripts\python.exe ^
|
||||
U:\PythonProject\Janssen\Covance_UCO3001\run_test_results_parallel_v1.0.py
|
||||
```
|
||||
|
||||
### 2b) Serialně (jeden proces, jako dřív)
|
||||
|
||||
```bat
|
||||
U:\PythonProject\Janssen\.venv\Scripts\python.exe ^
|
||||
U:\PythonProject\Janssen\Covance_UCO3001\download_test_results_v1.2.py
|
||||
```
|
||||
|
||||
### 2c) Jeden konkrétní shard ručně
|
||||
|
||||
```bat
|
||||
…\python.exe download_test_results_v1.2.py --shard 2 --of 4
|
||||
```
|
||||
|
||||
- Prohlížeč běží **viditelně** (`headless=False`), maximalizovaný.
|
||||
- **Persistent profile** — session přežívá (viz sekce 4).
|
||||
|
||||
---
|
||||
|
||||
## 3. Paralelní běh (sharding) — jak funguje
|
||||
|
||||
### 3.1 Rozdělení práce
|
||||
Argumenty `--shard N --of M` rozkrojí seznam všech 34 reportů:
|
||||
|
||||
```python
|
||||
REPORTS = ALL_REPORTS[SHARD - 1::OF]
|
||||
```
|
||||
|
||||
Tj. shard 1 z 4 vezme reporty na indexech 0, 4, 8, … ; shard 2 indexy 1, 5, 9, …
|
||||
Rovnoměrné rozdělení, **žádný report neudělají dva shardy**. Bez argumentů
|
||||
(`--of 1`) běží serialně přes všech 34.
|
||||
|
||||
### 3.2 Proč nestačily 4 taby v jednom procesu
|
||||
Playwright **sync API je blokující** — `wait_for_*` drží jediné vlákno celou
|
||||
dobu čekání, takže 4 taby v jednom sync skriptu by se stejně střídaly sériově
|
||||
(čekání by se sčítala, ne překrývala). Skutečná paralelnost vyžaduje buď async
|
||||
API (přepis logiky), nebo — jak je zvoleno zde — **4 nezávislé procesy**, kde
|
||||
souběh zajišťuje OS.
|
||||
|
||||
### 3.3 Profil per shard (povinné)
|
||||
Chrome zamyká adresář profilu → **dvě běžící instance nemohou sdílet jeden
|
||||
profil**. Proto:
|
||||
|
||||
| Běh | Profil |
|
||||
|---|---|
|
||||
| serialní (`--of 1`) | `browser_profile/` (původní) |
|
||||
| shard N (`--of M>1`) | `browser_profile_{N}/` |
|
||||
|
||||
### 3.4 Login (varianta B)
|
||||
Session se mezi profily **nesdílí**. Každý shard se proto při prvním spuštění
|
||||
**přihlásí sám** (login je jen heslem, žádné MFA). Po prvním běhu už session
|
||||
zůstane uložená v `browser_profile_{N}/`, takže další běhy login přeskočí
|
||||
(`is_visible()` check). Launcher startuje procesy s **rozestupem** (`STAGGER_S`),
|
||||
aby se OKTA login nezahltil 4 současnými požadavky.
|
||||
|
||||
### 3.5 Launcher `run_test_results_parallel_v1.0.py`
|
||||
- `N_SHARDS = 4` — počet souběžných procesů (oken).
|
||||
- `STAGGER_S = 4` — rozestup mezi starty (s).
|
||||
- Každý proces se spustí v **novém konzolovém okně** (`CREATE_NEW_CONSOLE`) →
|
||||
logy se neprolínají; navíc každý log má prefix `[S{shard}/{of}]`.
|
||||
- Launcher počká, až všechny dobíhnou, a vypíše souhrn (které shardy selhaly
|
||||
dle `returncode`).
|
||||
|
||||
### 3.6 Reálné zrychlení
|
||||
Ne přesně 4× — export běží na serveru a velká centra generují hodně řádků,
|
||||
takže reálně spíš 2–3×. Server může souběžné exporty throttlovat.
|
||||
|
||||
---
|
||||
|
||||
## 4. Výstupní soubory
|
||||
|
||||
Formát názvu (timestamp + popisný název):
|
||||
|
||||
```
|
||||
{YYYY-MM-DD_HHMMSS} sponsor-study-{STUDY}-test-results-{SITE_ID}-{TYP}.csv
|
||||
```
|
||||
|
||||
`{TYP}` = `standard` nebo `microbiology`. Všechny shardy ukládají do **stejného**
|
||||
adresáře `Source/`; timestamp + unikátní názvy (site/typ) zaručují, že nedojde ke
|
||||
kolizi. Staré soubory se **nikdy nemažou** (verzování přes timestamp).
|
||||
|
||||
---
|
||||
|
||||
## 5. Průběh jednoho reportu (kroky + logging)
|
||||
|
||||
| Fáze | Co dělá |
|
||||
|---|---|
|
||||
| START | vypíše shard/of, profil, počet reportů; spustí prohlížeč |
|
||||
| LOGIN | otevře login; **pokud je session aktivní, přihlášení přeskočí** |
|
||||
| KROK 1/5 | navigace na report URL |
|
||||
| KROK 2/5 | čeká na `.ag-row` **nebo** prázdný grid („No Data"); pak stabilizace počtu |
|
||||
| KROK 3/5 | klik na viditelné tři tečky (`more_horiz`) → menu |
|
||||
| KROK 4/5 | klik na viditelné „Export to CSV" → zachytí download |
|
||||
| KROK 5/5 | uloží soubor do `OUT_DIR` |
|
||||
| KONEC | souhrn `hotovo X/Y (shard N/M)` + seznam selhaných |
|
||||
|
||||
Log u každého reportu: `=== Centrum 930557 / microbiology (studie 36940) ===`,
|
||||
prefixovaný `[S{shard}/{of}]` při paralelním běhu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Klíčové technické poznatky (PROČ to tak je) — ověřeno přes Chrome MCP
|
||||
|
||||
Stránka test-results je **Angular SPA** s **MDL** a tabulkou **AG Grid**.
|
||||
Microbiology záložka má **stejnou** strukturu jako Standard.
|
||||
|
||||
### 6.1 Čekání na data = řádky AG Gridu
|
||||
- Data načtena, jakmile se objeví `div.ag-row` (count 0 → N).
|
||||
- Řádky jsou `position-absolute` → Playwright je **nepovažuje za „visible"**:
|
||||
- ❌ `wait_for_selector("div.ag-row")` (default visible) **timeoutuje**.
|
||||
- ✅ `wait_for_function("() => document.querySelectorAll('div.ag-row').length > 0")`.
|
||||
- Stabilizace: počet se čte co 2 s, dokud se 2× neshodne.
|
||||
- **Počet `.ag-row` je zastropený** (~153) virtuálním renderem — NENÍ to skutečný
|
||||
počet záznamů. **Export do CSV ale vyexportuje VŠECHNY řádky** (ověřeno: různé
|
||||
velikosti CSV).
|
||||
|
||||
### 6.2 „Fetching Data" / spinner — NEPLATÍ pro tuto stránku
|
||||
Na test-results NENÍ text „Fetching Data" (je jen na *samples* reportu).
|
||||
`<loading-bar>` jen problikne → nespoléhat. Signál = `.ag-row`.
|
||||
|
||||
### 6.3 Tři tečky (export) — DVA `<ag-export>`
|
||||
2× `<ag-export>` (1 skrytý) → filtr na viditelnost:
|
||||
✅ `page.locator("ag-export button:visible", has_text="more_horiz").first.click()`
|
||||
|
||||
### 6.4 „Export to CSV" — DVĚ položky v DOM
|
||||
2× `mdl-menu-item` (1 skrytá) →
|
||||
✅ `page.locator("mdl-menu-item:visible", has_text="Export to CSV").first.click()`
|
||||
(❌ `get_by_text` → strict mode violation.)
|
||||
|
||||
### 6.5 Prázdné centrum (ověřeno přes Chrome MCP na centru 930551)
|
||||
- No-rows overlay s textem **„No Data"** ve `.ag-overlay-no-rows-wrapper`.
|
||||
- ⚠️ Třída **NENÍ** `.ag-overlay-no-rows-center`.
|
||||
- ⚠️ Text **NENÍ** v `.ag-body-viewport` (ten je prázdný, `height: 1px`).
|
||||
- ⚠️ Na stránce **2 overlaye** (1 skrytý) → kontrola `offsetParent !== null`.
|
||||
- Detekce (KROK 2):
|
||||
```js
|
||||
() => {
|
||||
if (document.querySelectorAll('div.ag-row').length > 0) return false;
|
||||
return [...document.querySelectorAll('.ag-overlay-no-rows-wrapper')]
|
||||
.some(e => e.offsetParent !== null);
|
||||
}
|
||||
```
|
||||
- KROK 2 čeká na `.ag-row` **NEBO** tuto detekci → prázdné centrum nečeká 120 s.
|
||||
|
||||
---
|
||||
|
||||
## 7. Login logika (sdílená napříč Covance skripty)
|
||||
|
||||
```python
|
||||
page.goto(LOGIN_URL); page.wait_for_load_state("networkidle")
|
||||
if not page.get_by_label("Email").is_visible():
|
||||
return # session aktivní → login přeskočit
|
||||
# jinak: Email → Next → Password → Verify → wait redirect (code= zmizí z URL)
|
||||
```
|
||||
|
||||
`is_visible()` (ne kontrola URL — po redirectu zůstává `xsp.covance.com`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Chrome flagy proti „Restore pages" / broken session
|
||||
|
||||
```
|
||||
--disable-restore-session-state
|
||||
--disable-session-crashed-bubble
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Robustnost
|
||||
|
||||
- Každý report v `try/except` → chyba u jednoho nezastaví zbytek shardu.
|
||||
- Souhrn na konci: `hotovo X/Y (shard N/M)` + `SELHALA centra: site/typ, …`.
|
||||
- Launcher hlídá `returncode` každého shardu.
|
||||
|
||||
---
|
||||
|
||||
## 10. Možná budoucí rozšíření
|
||||
|
||||
- **Více/méně procesů:** změnit `N_SHARDS` v launcheru (profily `_1.._N`).
|
||||
- **Další studie / centra / typ reportu:** přidat do `STUDIES` / `REPORT_TYPES`.
|
||||
- **Vyčistit profily:** smazat `browser_profile_*/` (vynutí nový login).
|
||||
|
||||
---
|
||||
|
||||
## 11. Příbuzné skripty (stejná složka / portál)
|
||||
|
||||
| Skript | Co stahuje |
|
||||
|---|---|
|
||||
| `download_samples_report_v1.1.py` | All Samples (sampletracking) |
|
||||
| `download_kit_inventory_v2.1.py` | Kit inventory |
|
||||
| `download_equeries_report_v1.1.py` | eQuery reporty (zdroj SITE_IDS pro 36940) |
|
||||
| `download_test_results_v1.2.py` | **tento** — Test Results (Standard + Microbiology, 2 studie, sharding) |
|
||||
| `run_test_results_parallel_v1.0.py` | launcher 4 paralelních shardů test-results |
|
||||
@@ -0,0 +1,199 @@
|
||||
# =============================================================================
|
||||
# Název: download_test_results_v1.2.py
|
||||
# Verze: 1.2
|
||||
# Datum: 2026-05-29
|
||||
# Popis: Stahuje Test Results ze xsp.labcorp.com pro 2 studie (36940, 35472),
|
||||
# oba typy reportu (Standard + Microbiology), pres vsechna centra.
|
||||
# Ceka na nacteni AG Grid radku (.ag-row); prazdne centrum ('No Data')
|
||||
# preskoci. Vystup: timestampovane CSV do adresare Source/.
|
||||
# Zmeny v1.2: + paralelni beh pres sharding (--shard N --of M). Kazdy shard
|
||||
# vezme svuj podil reportu (REPORTS[shard-1::of]) a pouziva vlastni
|
||||
# profil browser_profile_{shard}, takze 4 procesy mohou bezet
|
||||
# soucasne (kazdy se prihlasi do sveho profilu sam). Bez argumentu
|
||||
# = puvodni serialni beh nad profilem browser_profile.
|
||||
# Zmeny v1.1: + studie 35472, + report typ microbiology (driv jen 36940/standard).
|
||||
# =============================================================================
|
||||
from playwright.sync_api import sync_playwright
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import os
|
||||
|
||||
# --- argumenty: sharding pro paralelni beh ----------------------------------
|
||||
parser = argparse.ArgumentParser(description="Stahovani Test Results (XSP) s podporou shardingu.")
|
||||
parser.add_argument("--shard", type=int, default=1, help="poradi tohoto shardu (1..of)")
|
||||
parser.add_argument("--of", type=int, default=1, help="celkovy pocet shardu")
|
||||
ARGS = parser.parse_args()
|
||||
SHARD, OF = ARGS.shard, ARGS.of
|
||||
TAG = f"[S{SHARD}/{OF}]" if OF > 1 else ""
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {TAG} {msg}", flush=True)
|
||||
|
||||
EMAIL = "vbuzalka@its.jnj.com"
|
||||
PASSWORD = "%zT3Wqfc9)cWua5"
|
||||
LOGIN_URL = "https://xsp.covance.com/"
|
||||
OUT_DIR = r"U:\PythonProject\Janssen\Covance_UCO3001\Source"
|
||||
# Pri paralelnim behu MUSI mit kazdy shard vlastni profil (Chrome zamyka adresar
|
||||
# profilu -> dve bezici instance nemohou sdilet jeden). Serialni beh (of=1)
|
||||
# pouziva puvodni browser_profile.
|
||||
_BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROFILE_DIR = os.path.join(_BASE, "browser_profile" if OF == 1 else f"browser_profile_{SHARD}")
|
||||
|
||||
# Studie + jejich interni cisla center.
|
||||
# 36940 = 77242113UCO3001 (UC) — zdroj center: download_equeries_report SITES
|
||||
# 35472 = druha studie (MDD)
|
||||
STUDIES = [
|
||||
{
|
||||
"study": "36940",
|
||||
"sites": [
|
||||
"930551", "930556", "930525", "930549", "930543", "930547",
|
||||
"930555", "930557", "930539", "930536", "930553", "930531",
|
||||
],
|
||||
},
|
||||
{
|
||||
"study": "35472",
|
||||
"sites": [
|
||||
"898745", "898739", "898733", "898744", "898727",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Typy reportu: zalozka v URL + suffix v nazvu souboru.
|
||||
REPORT_TYPES = [
|
||||
{"slug": "standard-test-results", "suffix": "standard"},
|
||||
{"slug": "microbiology", "suffix": "microbiology"},
|
||||
]
|
||||
|
||||
ALL_REPORTS = [
|
||||
{
|
||||
"site": sid,
|
||||
"study": st["study"],
|
||||
"type": rt["suffix"],
|
||||
"url": f"https://xsp.labcorp.com/sponsor/study/{st['study']}/test-results/{sid}/{rt['slug']}",
|
||||
"filename": f"sponsor-study-{st['study']}-test-results-{sid}-{rt['suffix']}.csv",
|
||||
}
|
||||
for st in STUDIES
|
||||
for sid in st["sites"]
|
||||
for rt in REPORT_TYPES
|
||||
]
|
||||
|
||||
# Tento shard vezme kazdy of-ty report od indexu (shard-1). Rovnomerne rozdeleni
|
||||
# a zadny report neudela dva shardy zaroven.
|
||||
REPORTS = ALL_REPORTS[SHARD - 1::OF]
|
||||
|
||||
|
||||
def login(page):
|
||||
log("LOGIN: otviram login stranku...")
|
||||
page.goto(LOGIN_URL)
|
||||
page.wait_for_load_state("networkidle")
|
||||
if not page.get_by_label("Email").is_visible():
|
||||
log(f"LOGIN: session uz aktivni, prihlaseni preskoceno ({page.url})")
|
||||
return
|
||||
log("LOGIN: zadavam email...")
|
||||
page.get_by_label("Email").fill(EMAIL)
|
||||
page.get_by_role("button", name="Next").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
log("LOGIN: zadavam heslo...")
|
||||
page.get_by_label("Password").fill(PASSWORD)
|
||||
page.get_by_role("button", name="Verify").click()
|
||||
log("LOGIN: cekam na presmerovani po prihlaseni...")
|
||||
page.wait_for_url(lambda url: "code=" not in url, timeout=60000)
|
||||
page.wait_for_load_state("networkidle", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
log(f"LOGIN: prihlaseni OK ({page.url})")
|
||||
|
||||
|
||||
def download_report(page, report):
|
||||
log(f"=== Centrum {report['site']} / {report['type']} (studie {report['study']}) ===")
|
||||
|
||||
log(f"KROK 1/5: navigace na report URL...")
|
||||
page.goto(report["url"])
|
||||
log(f"KROK 1/5: stranka nactena ({page.url})")
|
||||
|
||||
# Grid je AG Grid uvnitř <covance-ag-grid>. Data jsou nactena, jakmile
|
||||
# se v gridu objevi radky (.ag-row jde z 0 -> N). Pockej na prvni radek
|
||||
# a pak na stabilizaci poctu (proti castecnemu renderu).
|
||||
log("KROK 2/5: cekam na radky gridu (.ag-row) nebo prazdny grid ('No Data')...")
|
||||
# AG Grid radky jsou position-absolute (virtualni render), takze nejsou
|
||||
# "visible" dle Playwrightu -> cekej na pritomnost v DOM, ne na viditelnost.
|
||||
# Prazdne centrum: AG Grid vykresli no-rows overlay s textem "No Data" ve
|
||||
# wrapperu .ag-overlay-no-rows-wrapper. POZOR: trida NENI -no-rows-center;
|
||||
# navic jsou na strance 2 overlaye (jeden skryty) -> kontroluj viditelny
|
||||
# (offsetParent != null). Detekuj, aby to u centra bez dat necekalo 120 s.
|
||||
EMPTY_GRID_JS = """() => {
|
||||
if (document.querySelectorAll('div.ag-row').length > 0) return false;
|
||||
return [...document.querySelectorAll('.ag-overlay-no-rows-wrapper')]
|
||||
.some(e => e.offsetParent !== null);
|
||||
}"""
|
||||
page.wait_for_function(
|
||||
f"""() => document.querySelectorAll('div.ag-row').length > 0
|
||||
|| ({EMPTY_GRID_JS})()""",
|
||||
timeout=120000,
|
||||
)
|
||||
if page.evaluate(EMPTY_GRID_JS):
|
||||
log("KROK 2/5: centrum bez dat ('No Data' overlay) — preskakuji export.")
|
||||
return
|
||||
log("KROK 2/5: radky se objevily, cekam na stabilizaci poctu...")
|
||||
prev = -1
|
||||
for i in range(20): # max ~40 s stabilizace
|
||||
cnt = page.locator("div.ag-row").count()
|
||||
log(f" ...kontrola #{i+1}: {cnt} radku")
|
||||
if cnt == prev and cnt > 0:
|
||||
break
|
||||
prev = cnt
|
||||
page.wait_for_timeout(2000)
|
||||
page.wait_for_timeout(2000) # buffer
|
||||
log(f"KROK 2/5: data stabilni ({prev} radku v gridu).")
|
||||
|
||||
# Tri tecky: na strance jsou 2x <ag-export> (jeden skryty), klikni na
|
||||
# VIDITELNY more_horiz button.
|
||||
log("KROK 3/5: klikam na viditelne tri tecky (more_horiz)...")
|
||||
page.locator("ag-export button:visible", has_text="more_horiz").first.click()
|
||||
log("KROK 3/5: menu otevreno.")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
dest = os.path.join(OUT_DIR, f"{timestamp} {report['filename']}")
|
||||
log("KROK 4/5: klikam na 'Export to CSV' a cekam na stahovani...")
|
||||
with page.expect_download(timeout=60000) as dl:
|
||||
# 2x "Export to CSV" v DOM (jeden skryty) -> klikni na VIDITELNY
|
||||
page.locator("mdl-menu-item:visible", has_text="Export to CSV").first.click()
|
||||
log("KROK 4/5: stahovani zachyceno, ukladam soubor...")
|
||||
dl.value.save_as(dest)
|
||||
log(f"KROK 5/5: HOTOVO -> {dest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log(f"START: shard {SHARD}/{OF}, profil '{os.path.basename(PROFILE_DIR)}', "
|
||||
f"{len(REPORTS)}/{len(ALL_REPORTS)} reportu k zpracovani.")
|
||||
with sync_playwright() as p:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=PROFILE_DIR,
|
||||
headless=False,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--disable-restore-session-state",
|
||||
"--disable-session-crashed-bubble",
|
||||
],
|
||||
no_viewport=True,
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
accept_downloads=True,
|
||||
)
|
||||
context.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
page = context.new_page()
|
||||
log("START: prohlizec spusten.")
|
||||
login(page)
|
||||
ok, failed = 0, []
|
||||
for idx, report in enumerate(REPORTS, 1):
|
||||
log(f">>> Report {idx}/{len(REPORTS)}")
|
||||
try:
|
||||
download_report(page, report)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed.append(f"{report['site']}/{report['type']}")
|
||||
log(f"CHYBA u centra {report['site']}/{report['type']}: {e!r} — pokracuji dalsim.")
|
||||
log(f"KONEC: hotovo {ok}/{len(REPORTS)} reportu (shard {SHARD}/{OF}).")
|
||||
if failed:
|
||||
log(f"KONEC: SELHALA centra: {', '.join(failed)}")
|
||||
context.close()
|
||||
@@ -0,0 +1,263 @@
|
||||
# download_test_results_v1.3.py — dokumentace
|
||||
|
||||
**Verze:** 1.3 · **Datum:** 2026-05-29
|
||||
**Umístění:** `U:\PythonProject\Janssen\Covance_UCO3001\download_test_results_v1.3.py`
|
||||
|
||||
> **Změny v1.3 oproti 1.2:** **robustní login + okno se při chybě nezavře.**
|
||||
> - `login()` už **nečeká na `networkidle`** (login SPA labcorp/OKTA jí nikdy
|
||||
> nedosáhne → dřív to vedlo k timeoutu a pádu procesu / „zmizelému" oknu).
|
||||
> Místo toho čeká přímo na pole **Email** a **Password** přes
|
||||
> `wait_for(state="visible")`.
|
||||
> - Celý běh je obalen `try/except` a na konci je `input()` → **konzolové okno
|
||||
> shardu se při chybě nezavře**, takže je vidět chybový log.
|
||||
> - Launcher povýšen na `run_test_results_parallel_v1.1.py` (cílí na v1.3,
|
||||
> `STAGGER_S = 8`).
|
||||
>
|
||||
> **Změny v1.2 oproti 1.1:** paralelní běh přes sharding (`--shard N --of M`).
|
||||
> **Změny v1.1 oproti 1.0:** druhá studie 35472 (MDD) + report Microbiology.
|
||||
|
||||
---
|
||||
|
||||
## 1. Účel
|
||||
|
||||
Automatické stažení reportů **Test Results** z portálu Labcorp Sponsor Portal
|
||||
(`xsp.labcorp.com`). Skript projde **2 studie × jejich centra × 2 typy reportu**,
|
||||
u každého vyexportuje grid do CSV a uloží ho timestampovaný do `Source/`.
|
||||
|
||||
| Studie | Interní ID | Význam | Počet center |
|
||||
|---|---|---|---|
|
||||
| UC | `36940` | 77242113UCO3001 | 12 |
|
||||
| MDD | `35472` | druhá studie | 5 |
|
||||
|
||||
Typy reportu: **Standard** (`/standard-test-results`) a **Microbiology** (`/microbiology`).
|
||||
|
||||
**Celkem: (12 + 5) × 2 = 34 reportů** (prázdná centra se přeskakují, viz 6.5).
|
||||
|
||||
---
|
||||
|
||||
## 2. Spuštění
|
||||
|
||||
### 2a) Paralelně (doporučeno — rychlejší)
|
||||
|
||||
Přes launcher, který rozjede 4 procesy (každý ve vlastním okně):
|
||||
|
||||
```bat
|
||||
U:\PythonProject\Janssen\.venv\Scripts\python.exe ^
|
||||
U:\PythonProject\Janssen\Covance_UCO3001\run_test_results_parallel_v1.1.py
|
||||
```
|
||||
|
||||
### 2b) Serialně (jeden proces, jako dřív)
|
||||
|
||||
```bat
|
||||
U:\PythonProject\Janssen\.venv\Scripts\python.exe ^
|
||||
U:\PythonProject\Janssen\Covance_UCO3001\download_test_results_v1.3.py
|
||||
```
|
||||
|
||||
### 2c) Jeden konkrétní shard ručně
|
||||
|
||||
```bat
|
||||
…\python.exe download_test_results_v1.3.py --shard 2 --of 4
|
||||
```
|
||||
|
||||
- Prohlížeč běží **viditelně** (`headless=False`), maximalizovaný.
|
||||
- **Persistent profile** — session přežívá (viz sekce 4).
|
||||
- Po dokončení (i po chybě) okno **čeká na Enter** — schválně, ať je vidět log.
|
||||
|
||||
---
|
||||
|
||||
## 3. Paralelní běh (sharding) — jak funguje
|
||||
|
||||
### 3.1 Rozdělení práce
|
||||
Argumenty `--shard N --of M` rozkrojí seznam všech 34 reportů:
|
||||
|
||||
```python
|
||||
REPORTS = ALL_REPORTS[SHARD - 1::OF]
|
||||
```
|
||||
|
||||
Tj. shard 1 z 4 vezme reporty na indexech 0, 4, 8, … ; shard 2 indexy 1, 5, 9, …
|
||||
Rovnoměrné rozdělení, **žádný report neudělají dva shardy**. Bez argumentů
|
||||
(`--of 1`) běží serialně přes všech 34.
|
||||
|
||||
### 3.2 Proč nestačily 4 taby v jednom procesu
|
||||
Playwright **sync API je blokující** — `wait_for_*` drží jediné vlákno celou
|
||||
dobu čekání, takže 4 taby v jednom sync skriptu by se stejně střídaly sériově.
|
||||
Skutečná paralelnost vyžaduje buď async API, nebo — jak je zvoleno zde —
|
||||
**4 nezávislé procesy**, kde souběh zajišťuje OS.
|
||||
|
||||
### 3.3 Profil per shard (povinné)
|
||||
Chrome zamyká adresář profilu → **dvě běžící instance nemohou sdílet jeden
|
||||
profil**. Proto:
|
||||
|
||||
| Běh | Profil |
|
||||
|---|---|
|
||||
| serialní (`--of 1`) | `browser_profile/` (původní) |
|
||||
| shard N (`--of M>1`) | `browser_profile_{N}/` |
|
||||
|
||||
### 3.4 Login (varianta B) — POZOR na fragilitu
|
||||
Session se mezi profily **nesdílí**. Každý shard se proto při prvním spuštění
|
||||
**přihlásí sám** (login je jen heslem, žádné MFA). Po prvním běhu už session
|
||||
zůstane uložená v `browser_profile_{N}/`, takže další běhy login přeskočí.
|
||||
|
||||
**Klíčová oprava ve v1.3:** login stránka labcorp/OKTA je SPA, která
|
||||
**nikdy nedosáhne stavu `networkidle`** (běží tam analytika/polling). Původní
|
||||
`page.wait_for_load_state("networkidle")` se proto zasekl až do timeoutu a
|
||||
shard spadl ještě před vyplněním přihlášení (okno „zmizelo"). Nové `login()`:
|
||||
|
||||
```python
|
||||
page.goto(LOGIN_URL)
|
||||
try:
|
||||
page.get_by_label("Email").wait_for(state="visible", timeout=12000)
|
||||
except Exception:
|
||||
return # Email se neobjevil -> session aktivni
|
||||
page.get_by_label("Email").fill(EMAIL)
|
||||
page.get_by_role("button", name="Next").click()
|
||||
page.get_by_label("Password").wait_for(state="visible", timeout=30000)
|
||||
page.get_by_label("Password").fill(PASSWORD)
|
||||
page.get_by_role("button", name="Verify").click()
|
||||
page.wait_for_url(lambda url: "code=" not in url or "xsp." in url, timeout=60000)
|
||||
```
|
||||
|
||||
Launcher startuje procesy s rozestupem (`STAGGER_S = 8 s`), aby se OKTA login
|
||||
nezahltil souběžnými požadavky.
|
||||
|
||||
### 3.5 Okno se při chybě nezavře
|
||||
`__main__` je obalen `try/except` + `finally: input(...)`. Když shard spadne,
|
||||
vypíše `FATAL: ...` + traceback a **čeká na Enter** místo okamžitého zavření
|
||||
okna. Tím je vždy vidět, co se pokazilo. (Důsledek: launcher reportuje
|
||||
„HOTOVO" až po zavření všech oken Enterem.)
|
||||
|
||||
### 3.6 Launcher `run_test_results_parallel_v1.1.py`
|
||||
- `N_SHARDS = 4` — počet souběžných procesů (oken).
|
||||
- `STAGGER_S = 8` — rozestup mezi starty (s).
|
||||
- Každý proces v **novém konzolovém okně** (`CREATE_NEW_CONSOLE`) → logy se
|
||||
neprolínají; navíc každý log má prefix `[S{shard}/{of}]`.
|
||||
- Launcher počká na všechny a vypíše souhrn (které shardy selhaly dle `returncode`).
|
||||
|
||||
### 3.7 Reálné zrychlení
|
||||
Ne přesně 4× — export běží na serveru, velká centra generují hodně řádků →
|
||||
reálně spíš 2–3×. Server může souběžné exporty throttlovat.
|
||||
|
||||
---
|
||||
|
||||
## 4. Výstupní soubory
|
||||
|
||||
```
|
||||
{YYYY-MM-DD_HHMMSS} sponsor-study-{STUDY}-test-results-{SITE_ID}-{TYP}.csv
|
||||
```
|
||||
|
||||
`{TYP}` = `standard` nebo `microbiology`. Všechny shardy ukládají do **stejného**
|
||||
adresáře `Source/`; timestamp + unikátní názvy (site/typ) zaručí, že nedojde ke
|
||||
kolizi. Staré soubory se **nikdy nemažou**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Průběh jednoho reportu (kroky + logging)
|
||||
|
||||
| Fáze | Co dělá |
|
||||
|---|---|
|
||||
| START | vypíše shard/of, profil, počet reportů; spustí prohlížeč |
|
||||
| LOGIN | čeká na pole Email; pokud není → session aktivní, login přeskočí |
|
||||
| KROK 1/5 | navigace na report URL |
|
||||
| KROK 2/5 | čeká na `.ag-row` **nebo** prázdný grid („No Data"); pak stabilizace počtu |
|
||||
| KROK 3/5 | klik na viditelné tři tečky (`more_horiz`) → menu |
|
||||
| KROK 4/5 | klik na viditelné „Export to CSV" → zachytí download |
|
||||
| KROK 5/5 | uloží soubor do `OUT_DIR` |
|
||||
| KONEC | souhrn `hotovo X/Y (shard N/M)` + seznam selhaných; pak čeká na Enter |
|
||||
|
||||
---
|
||||
|
||||
## 6. Klíčové technické poznatky (PROČ to tak je) — ověřeno přes Chrome MCP
|
||||
|
||||
Stránka test-results je **Angular SPA** s **MDL** a tabulkou **AG Grid**.
|
||||
Microbiology záložka má **stejnou** strukturu jako Standard.
|
||||
|
||||
### 6.1 Čekání na data = řádky AG Gridu
|
||||
- Data načtena, jakmile se objeví `div.ag-row` (count 0 → N).
|
||||
- Řádky jsou `position-absolute` → Playwright je **nepovažuje za „visible"**:
|
||||
- ❌ `wait_for_selector("div.ag-row")` (default visible) **timeoutuje**.
|
||||
- ✅ `wait_for_function("() => document.querySelectorAll('div.ag-row').length > 0")`.
|
||||
- Stabilizace: počet se čte co 2 s, dokud se 2× neshodne.
|
||||
- **Počet `.ag-row` je zastropený** (~153) virtuálním renderem — NENÍ to skutečný
|
||||
počet záznamů. **Export do CSV ale vyexportuje VŠECHNY řádky.**
|
||||
|
||||
### 6.2 „Fetching Data" / spinner — NEPLATÍ pro tuto stránku
|
||||
Na test-results NENÍ text „Fetching Data" (je jen na *samples* reportu).
|
||||
`<loading-bar>` jen problikne → nespoléhat. Signál = `.ag-row`.
|
||||
|
||||
### 6.3 Tři tečky (export) — DVA `<ag-export>`
|
||||
2× `<ag-export>` (1 skrytý) → filtr na viditelnost:
|
||||
✅ `page.locator("ag-export button:visible", has_text="more_horiz").first.click()`
|
||||
|
||||
### 6.4 „Export to CSV" — DVĚ položky v DOM
|
||||
2× `mdl-menu-item` (1 skrytá) →
|
||||
✅ `page.locator("mdl-menu-item:visible", has_text="Export to CSV").first.click()`
|
||||
|
||||
### 6.5 Prázdné centrum (ověřeno přes Chrome MCP na centru 930551)
|
||||
- No-rows overlay s textem **„No Data"** ve `.ag-overlay-no-rows-wrapper`.
|
||||
- ⚠️ Třída **NENÍ** `.ag-overlay-no-rows-center`.
|
||||
- ⚠️ Text **NENÍ** v `.ag-body-viewport` (ten je prázdný, `height: 1px`).
|
||||
- ⚠️ Na stránce **2 overlaye** (1 skrytý) → kontrola `offsetParent !== null`.
|
||||
- Detekce (KROK 2):
|
||||
```js
|
||||
() => {
|
||||
if (document.querySelectorAll('div.ag-row').length > 0) return false;
|
||||
return [...document.querySelectorAll('.ag-overlay-no-rows-wrapper')]
|
||||
.some(e => e.offsetParent !== null);
|
||||
}
|
||||
```
|
||||
- KROK 2 čeká na `.ag-row` **NEBO** tuto detekci → prázdné centrum nečeká 120 s.
|
||||
|
||||
---
|
||||
|
||||
## 7. Login logika (sdílená napříč Covance skripty) — v1.3
|
||||
|
||||
```python
|
||||
page.goto(LOGIN_URL)
|
||||
# NEcekat na networkidle (login SPA ji nikdy nedosahne)
|
||||
try:
|
||||
page.get_by_label("Email").wait_for(state="visible", timeout=12000)
|
||||
except Exception:
|
||||
return # session aktivni → login přeskočit
|
||||
# jinak: Email → Next → cekej na Password → Password → Verify → wait redirect
|
||||
```
|
||||
|
||||
`wait_for(state="visible")` na pole (NE `networkidle`, NE kontrola URL — po
|
||||
redirectu zůstává `xsp.covance.com`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Chrome flagy proti „Restore pages" / broken session
|
||||
|
||||
```
|
||||
--disable-restore-session-state
|
||||
--disable-session-crashed-bubble
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Robustnost
|
||||
|
||||
- Každý report v `try/except` → chyba u jednoho nezastaví zbytek shardu.
|
||||
- Celý běh shardu v `try/except` + `finally: input()` → okno se při pádu nezavře.
|
||||
- Souhrn na konci: `hotovo X/Y (shard N/M)` + `SELHALA centra: …`.
|
||||
- Launcher hlídá `returncode` každého shardu.
|
||||
|
||||
---
|
||||
|
||||
## 10. Možná budoucí rozšíření
|
||||
|
||||
- **Více/méně procesů:** změnit `N_SHARDS` v launcheru (profily `_1.._N`).
|
||||
- **Další studie / centra / typ reportu:** přidat do `STUDIES` / `REPORT_TYPES`.
|
||||
- **Vyčistit profily:** smazat `browser_profile_*/` (vynutí nový login).
|
||||
|
||||
---
|
||||
|
||||
## 11. Příbuzné skripty (stejná složka / portál)
|
||||
|
||||
| Skript | Co stahuje |
|
||||
|---|---|
|
||||
| `download_samples_report_v1.1.py` | All Samples (sampletracking) |
|
||||
| `download_kit_inventory_v2.1.py` | Kit inventory |
|
||||
| `download_equeries_report_v1.1.py` | eQuery reporty (zdroj SITE_IDS pro 36940) |
|
||||
| `download_test_results_v1.3.py` | **tento** — Test Results (Standard + Microbiology, 2 studie, sharding) |
|
||||
| `run_test_results_parallel_v1.1.py` | launcher 4 paralelních shardů test-results |
|
||||
@@ -0,0 +1,233 @@
|
||||
# =============================================================================
|
||||
# Název: download_test_results_v1.3.py
|
||||
# Verze: 1.3
|
||||
# Datum: 2026-05-29
|
||||
# Popis: Stahuje Test Results ze xsp.labcorp.com pro 2 studie (36940, 35472),
|
||||
# oba typy reportu (Standard + Microbiology), pres vsechna centra.
|
||||
# Ceka na nacteni AG Grid radku (.ag-row); prazdne centrum ('No Data')
|
||||
# preskoci. Vystup: timestampovane CSV do adresare Source/.
|
||||
# Zmeny v1.3: ROBUSTNI LOGIN — login() uz NEceka na 'networkidle' (login SPA
|
||||
# labcorp/OKTA ji nikdy nedosahne -> drive timeout/pad procesu).
|
||||
# Misto toho ceka primo na pole Email/Password pres wait_for(visible).
|
||||
# Cely beh obalen try/except + na konci input() -> okno se pri chybe
|
||||
# NEZAVRE, takze je videt chybovy log (driv 'zmizla' okna shardu).
|
||||
# Zmeny v1.2: + paralelni beh pres sharding (--shard N --of M). Kazdy shard
|
||||
# vezme svuj podil reportu (REPORTS[shard-1::of]) a pouziva vlastni
|
||||
# profil browser_profile_{shard}. Bez argumentu = serialni beh.
|
||||
# Zmeny v1.1: + studie 35472, + report typ microbiology (driv jen 36940/standard).
|
||||
# =============================================================================
|
||||
from playwright.sync_api import sync_playwright
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import traceback
|
||||
import os
|
||||
|
||||
# --- argumenty: sharding pro paralelni beh ----------------------------------
|
||||
parser = argparse.ArgumentParser(description="Stahovani Test Results (XSP) s podporou shardingu.")
|
||||
parser.add_argument("--shard", type=int, default=1, help="poradi tohoto shardu (1..of)")
|
||||
parser.add_argument("--of", type=int, default=1, help="celkovy pocet shardu")
|
||||
ARGS = parser.parse_args()
|
||||
SHARD, OF = ARGS.shard, ARGS.of
|
||||
TAG = f"[S{SHARD}/{OF}]" if OF > 1 else ""
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {TAG} {msg}", flush=True)
|
||||
|
||||
EMAIL = "vbuzalka@its.jnj.com"
|
||||
PASSWORD = "%zT3Wqfc9)cWua5"
|
||||
LOGIN_URL = "https://xsp.covance.com/"
|
||||
OUT_DIR = r"U:\PythonProject\Janssen\Covance_UCO3001\Source"
|
||||
# Pri paralelnim behu MUSI mit kazdy shard vlastni profil (Chrome zamyka adresar
|
||||
# profilu -> dve bezici instance nemohou sdilet jeden). Serialni beh (of=1)
|
||||
# pouziva puvodni browser_profile.
|
||||
_BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROFILE_DIR = os.path.join(_BASE, "browser_profile" if OF == 1 else f"browser_profile_{SHARD}")
|
||||
|
||||
# Studie + jejich interni cisla center.
|
||||
# 36940 = 77242113UCO3001 (UC) — zdroj center: download_equeries_report SITES
|
||||
# 35472 = druha studie (MDD)
|
||||
STUDIES = [
|
||||
{
|
||||
"study": "36940",
|
||||
"sites": [
|
||||
"930551", "930556", "930525", "930549", "930543", "930547",
|
||||
"930555", "930557", "930539", "930536", "930553", "930531",
|
||||
],
|
||||
},
|
||||
{
|
||||
"study": "35472",
|
||||
"sites": [
|
||||
"898745", "898739", "898733", "898744", "898727",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Typy reportu: zalozka v URL + suffix v nazvu souboru.
|
||||
REPORT_TYPES = [
|
||||
{"slug": "standard-test-results", "suffix": "standard"},
|
||||
{"slug": "microbiology", "suffix": "microbiology"},
|
||||
]
|
||||
|
||||
ALL_REPORTS = [
|
||||
{
|
||||
"site": sid,
|
||||
"study": st["study"],
|
||||
"type": rt["suffix"],
|
||||
"url": f"https://xsp.labcorp.com/sponsor/study/{st['study']}/test-results/{sid}/{rt['slug']}",
|
||||
"filename": f"sponsor-study-{st['study']}-test-results-{sid}-{rt['suffix']}.csv",
|
||||
}
|
||||
for st in STUDIES
|
||||
for sid in st["sites"]
|
||||
for rt in REPORT_TYPES
|
||||
]
|
||||
|
||||
# Tento shard vezme kazdy of-ty report od indexu (shard-1). Rovnomerne rozdeleni
|
||||
# a zadny report neudela dva shardy zaroven.
|
||||
REPORTS = ALL_REPORTS[SHARD - 1::OF]
|
||||
|
||||
|
||||
def login(page):
|
||||
log("LOGIN: otviram login stranku...")
|
||||
page.goto(LOGIN_URL)
|
||||
# NEcekat na 'networkidle' — login SPA (labcorp/OKTA) ji nikdy nedosahne
|
||||
# (analytika/polling bezi porad) -> drive to vedlo k timeoutu a padu procesu.
|
||||
# Misto toho cekame primo na pole Email. Pokud se do 12 s neobjevi,
|
||||
# povazujeme session za aktivni (uz prihlaseno).
|
||||
try:
|
||||
page.get_by_label("Email").wait_for(state="visible", timeout=12000)
|
||||
except Exception:
|
||||
log(f"LOGIN: Email pole se neobjevilo -> session aktivni, login preskocen ({page.url})")
|
||||
return
|
||||
|
||||
log("LOGIN: zadavam email...")
|
||||
page.get_by_label("Email").fill(EMAIL)
|
||||
page.get_by_role("button", name="Next").click()
|
||||
|
||||
log("LOGIN: cekam na pole pro heslo...")
|
||||
page.get_by_label("Password").wait_for(state="visible", timeout=30000)
|
||||
log("LOGIN: zadavam heslo...")
|
||||
page.get_by_label("Password").fill(PASSWORD)
|
||||
page.get_by_role("button", name="Verify").click()
|
||||
|
||||
log("LOGIN: cekam na presmerovani po prihlaseni...")
|
||||
try:
|
||||
# Po prihlaseni se v URL objevi 'code=' (OAuth redirect) a pak zmizi
|
||||
# az dorazime do aplikace. Bereme i pripad, ze uz jsme na xsp domene.
|
||||
page.wait_for_url(
|
||||
lambda url: "code=" not in url or "xsp." in url,
|
||||
timeout=60000,
|
||||
)
|
||||
except Exception:
|
||||
log("LOGIN: wait_for_url vyprsel, pokracuji (overim pristup pri 1. reportu).")
|
||||
page.wait_for_timeout(3000)
|
||||
log(f"LOGIN: prihlaseni hotovo ({page.url})")
|
||||
|
||||
|
||||
def download_report(page, report):
|
||||
log(f"=== Centrum {report['site']} / {report['type']} (studie {report['study']}) ===")
|
||||
|
||||
log(f"KROK 1/5: navigace na report URL...")
|
||||
page.goto(report["url"])
|
||||
log(f"KROK 1/5: stranka nactena ({page.url})")
|
||||
|
||||
# Grid je AG Grid uvnitř <covance-ag-grid>. Data jsou nactena, jakmile
|
||||
# se v gridu objevi radky (.ag-row jde z 0 -> N). Pockej na prvni radek
|
||||
# a pak na stabilizaci poctu (proti castecnemu renderu).
|
||||
log("KROK 2/5: cekam na radky gridu (.ag-row) nebo prazdny grid ('No Data')...")
|
||||
# AG Grid radky jsou position-absolute (virtualni render), takze nejsou
|
||||
# "visible" dle Playwrightu -> cekej na pritomnost v DOM, ne na viditelnost.
|
||||
# Prazdne centrum: AG Grid vykresli no-rows overlay s textem "No Data" ve
|
||||
# wrapperu .ag-overlay-no-rows-wrapper. POZOR: trida NENI -no-rows-center;
|
||||
# navic jsou na strance 2 overlaye (jeden skryty) -> kontroluj viditelny
|
||||
# (offsetParent != null). Detekuj, aby to u centra bez dat necekalo 120 s.
|
||||
EMPTY_GRID_JS = """() => {
|
||||
if (document.querySelectorAll('div.ag-row').length > 0) return false;
|
||||
return [...document.querySelectorAll('.ag-overlay-no-rows-wrapper')]
|
||||
.some(e => e.offsetParent !== null);
|
||||
}"""
|
||||
page.wait_for_function(
|
||||
f"""() => document.querySelectorAll('div.ag-row').length > 0
|
||||
|| ({EMPTY_GRID_JS})()""",
|
||||
timeout=120000,
|
||||
)
|
||||
if page.evaluate(EMPTY_GRID_JS):
|
||||
log("KROK 2/5: centrum bez dat ('No Data' overlay) — preskakuji export.")
|
||||
return
|
||||
log("KROK 2/5: radky se objevily, cekam na stabilizaci poctu...")
|
||||
prev = -1
|
||||
for i in range(20): # max ~40 s stabilizace
|
||||
cnt = page.locator("div.ag-row").count()
|
||||
log(f" ...kontrola #{i+1}: {cnt} radku")
|
||||
if cnt == prev and cnt > 0:
|
||||
break
|
||||
prev = cnt
|
||||
page.wait_for_timeout(2000)
|
||||
page.wait_for_timeout(2000) # buffer
|
||||
log(f"KROK 2/5: data stabilni ({prev} radku v gridu).")
|
||||
|
||||
# Tri tecky: na strance jsou 2x <ag-export> (jeden skryty), klikni na
|
||||
# VIDITELNY more_horiz button.
|
||||
log("KROK 3/5: klikam na viditelne tri tecky (more_horiz)...")
|
||||
page.locator("ag-export button:visible", has_text="more_horiz").first.click()
|
||||
log("KROK 3/5: menu otevreno.")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
dest = os.path.join(OUT_DIR, f"{timestamp} {report['filename']}")
|
||||
log("KROK 4/5: klikam na 'Export to CSV' a cekam na stahovani...")
|
||||
with page.expect_download(timeout=60000) as dl:
|
||||
# 2x "Export to CSV" v DOM (jeden skryty) -> klikni na VIDITELNY
|
||||
page.locator("mdl-menu-item:visible", has_text="Export to CSV").first.click()
|
||||
log("KROK 4/5: stahovani zachyceno, ukladam soubor...")
|
||||
dl.value.save_as(dest)
|
||||
log(f"KROK 5/5: HOTOVO -> {dest}")
|
||||
|
||||
|
||||
def main():
|
||||
log(f"START: shard {SHARD}/{OF}, profil '{os.path.basename(PROFILE_DIR)}', "
|
||||
f"{len(REPORTS)}/{len(ALL_REPORTS)} reportu k zpracovani.")
|
||||
with sync_playwright() as p:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=PROFILE_DIR,
|
||||
headless=False,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--disable-restore-session-state",
|
||||
"--disable-session-crashed-bubble",
|
||||
],
|
||||
no_viewport=True,
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
accept_downloads=True,
|
||||
)
|
||||
context.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
page = context.new_page()
|
||||
log("START: prohlizec spusten.")
|
||||
login(page)
|
||||
ok, failed = 0, []
|
||||
for idx, report in enumerate(REPORTS, 1):
|
||||
log(f">>> Report {idx}/{len(REPORTS)}")
|
||||
try:
|
||||
download_report(page, report)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed.append(f"{report['site']}/{report['type']}")
|
||||
log(f"CHYBA u centra {report['site']}/{report['type']}: {e!r} — pokracuji dalsim.")
|
||||
log(f"KONEC: hotovo {ok}/{len(REPORTS)} reportu (shard {SHARD}/{OF}).")
|
||||
if failed:
|
||||
log(f"KONEC: SELHALA centra: {', '.join(failed)}")
|
||||
context.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
log(f"FATAL: beh shardu spadl: {e!r}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
# Nech okno otevrene, at je videt log/chyba (driv okno pri padu zmizelo).
|
||||
try:
|
||||
input("\n[Enter] pro zavreni tohoto okna...")
|
||||
except EOFError:
|
||||
pass
|
||||
@@ -0,0 +1,181 @@
|
||||
# =============================================================================
|
||||
# Název: import_to_mongo_v1.2.py
|
||||
# Verze: 1.2
|
||||
# Datum: 2026-05-28
|
||||
# Popis: Import CSV reportů do MongoDB (db: covance).
|
||||
# Pipeline 1 — allSamples: kolekce allsamples, klíč Container Barcode No.
|
||||
# Zdroj: UCO3001/Source + MDD3003/Source
|
||||
# Pipeline 2 — kits: kolekce kits, klíč Accession
|
||||
# Zdroj: UCO3001/Source (oba study 36940 + 35472)
|
||||
# Upsert s historií změn, zpracovaný soubor přesunut do Zpracovano/.
|
||||
# =============================================================================
|
||||
|
||||
import csv
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
DB_NAME = "covance"
|
||||
|
||||
UCO3001_SOURCE = Path(__file__).parent / "Source"
|
||||
MDD3003_SOURCE = Path(__file__).parent.parent / "Covance_MDD3003" / "Source"
|
||||
|
||||
PIPELINES = [
|
||||
{
|
||||
"name": "allsamples",
|
||||
"collection": "allsamples",
|
||||
"upsert_key": "Container Barcode No.",
|
||||
"pattern": re.compile(r".*-allSamples\.csv$", re.IGNORECASE),
|
||||
"sources": [UCO3001_SOURCE, MDD3003_SOURCE],
|
||||
"indexes": [
|
||||
[("fields.Sample Status", ASCENDING)],
|
||||
[("fields.Specimen Type", ASCENDING)],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "kits",
|
||||
"collection": "kits",
|
||||
"upsert_key": "Accession",
|
||||
"pattern": re.compile(r".*-kit-inventory-on-hand-expiration\.csv$", re.IGNORECASE),
|
||||
"sources": [UCO3001_SOURCE],
|
||||
"indexes": [
|
||||
[("fields.Kit Type", ASCENDING)],
|
||||
[("fields.Site", ASCENDING)],
|
||||
[("fields.Expiration Date", ASCENDING)],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def extract_snapshot_date(filename: str) -> str:
|
||||
match = re.match(r"(\d{4}-\d{2}-\d{2})", Path(filename).name)
|
||||
return match.group(1) if match else datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def clean_value(val: str) -> str | None:
|
||||
val = val.strip()
|
||||
return val if val else None
|
||||
|
||||
|
||||
def import_file(csv_path: Path, collection, upsert_key: str) -> dict:
|
||||
snapshot_date = extract_snapshot_date(csv_path.name)
|
||||
inserted = changed = unchanged = skipped = 0
|
||||
|
||||
with open(csv_path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = list(reader)
|
||||
|
||||
for row in rows:
|
||||
fields = {k: clean_value(v) for k, v in row.items() if k}
|
||||
|
||||
key_val = fields.get(upsert_key)
|
||||
if not key_val:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
existing = collection.find_one({"record_id": key_val})
|
||||
|
||||
if existing is None:
|
||||
collection.insert_one({
|
||||
"record_id": key_val,
|
||||
"fields": fields,
|
||||
"sourceFile": csv_path.name,
|
||||
"firstSeen": snapshot_date,
|
||||
"lastSeen": snapshot_date,
|
||||
"history": [],
|
||||
})
|
||||
inserted += 1
|
||||
|
||||
elif existing["fields"] != fields:
|
||||
collection.update_one(
|
||||
{"_id": existing["_id"]},
|
||||
{
|
||||
"$push": {"history": {"date": existing["lastSeen"], "fields": existing["fields"]}},
|
||||
"$set": {"fields": fields, "sourceFile": csv_path.name, "lastSeen": snapshot_date},
|
||||
},
|
||||
)
|
||||
changed += 1
|
||||
|
||||
else:
|
||||
collection.update_one(
|
||||
{"_id": existing["_id"]},
|
||||
{"$set": {"lastSeen": snapshot_date, "sourceFile": csv_path.name}},
|
||||
)
|
||||
unchanged += 1
|
||||
|
||||
total_rows = len(rows)
|
||||
db_count = collection.count_documents({})
|
||||
print(f" [{snapshot_date}]: +{inserted} new, ~{changed} changed, ={unchanged} same, -{skipped} bez klice")
|
||||
print(f" Radku v CSV: {total_rows}, dokumentu v DB: {db_count}")
|
||||
|
||||
if inserted + changed + unchanged + skipped != total_rows:
|
||||
print(f" !!! VAROVANI: soucet ({inserted+changed+unchanged+skipped}) != radku v CSV ({total_rows})")
|
||||
|
||||
return {"inserted": inserted, "changed": changed, "unchanged": unchanged}
|
||||
|
||||
|
||||
def collect_files(pipeline: dict, cli_args: list[str]) -> list[Path]:
|
||||
if cli_args:
|
||||
paths = []
|
||||
for arg in cli_args:
|
||||
p = Path(arg)
|
||||
if p.is_file() and pipeline["pattern"].match(p.name):
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
paths = []
|
||||
for src_dir in pipeline["sources"]:
|
||||
if src_dir.exists():
|
||||
paths.extend(sorted(p for p in src_dir.glob("*.csv") if pipeline["pattern"].match(p.name)))
|
||||
return paths
|
||||
|
||||
|
||||
def run_pipeline(pipeline: dict, client, cli_args: list[str]):
|
||||
paths = collect_files(pipeline, cli_args)
|
||||
if not paths:
|
||||
print(f"[{pipeline['name']}] Zadne soubory k importu.")
|
||||
return
|
||||
|
||||
print(f"\n=== Pipeline: {pipeline['name']} ({len(paths)} souboru) ===")
|
||||
|
||||
col = client[DB_NAME][pipeline["collection"]]
|
||||
col.create_index([("record_id", ASCENDING)], unique=True)
|
||||
for idx in pipeline["indexes"]:
|
||||
col.create_index(idx)
|
||||
|
||||
for src_dir in pipeline["sources"]:
|
||||
(src_dir / "Zpracovano").mkdir(exist_ok=True)
|
||||
|
||||
total = {"inserted": 0, "changed": 0, "unchanged": 0}
|
||||
|
||||
for csv_path in paths:
|
||||
print(f"Import: {csv_path.name} [{csv_path.parent.parent.name}]")
|
||||
stats = import_file(csv_path, col, pipeline["upsert_key"])
|
||||
for k in total:
|
||||
total[k] += stats[k]
|
||||
dest = csv_path.parent / "Zpracovano" / csv_path.name
|
||||
shutil.move(str(csv_path), str(dest))
|
||||
print(f" -> presunut do Zpracovano/\n")
|
||||
|
||||
print(f"[{pipeline['name']}] Celkem: +{total['inserted']} new, ~{total['changed']} changed, ={total['unchanged']} same")
|
||||
|
||||
|
||||
def main():
|
||||
cli_args = sys.argv[1:]
|
||||
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
|
||||
for pipeline in PIPELINES:
|
||||
run_pipeline(pipeline, client, cli_args)
|
||||
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,297 @@
|
||||
# =============================================================================
|
||||
# Název: import_to_mongo_v1.3.py
|
||||
# Verze: 1.3
|
||||
# Datum: 2026-06-09
|
||||
# Popis: Import CSV reportů do MongoDB (db: covance).
|
||||
# Pipeline 1 — allSamples: kolekce allsamples, klíč Container Barcode No.
|
||||
# Zdroj: UCO3001/Source + MDD3003/Source
|
||||
# Pipeline 2 — kits: kolekce kits, klíč Accession
|
||||
# Zdroj: UCO3001/Source (study 36940 + 35472)
|
||||
# Pipeline 3 — results: kolekce results, laboratorní výsledky per centrum.
|
||||
# Zdroj: UCO3001/Source, soubory test-results-{SITE}-{typ}.csv
|
||||
# (1. řádek = disclaimer, hlavička je 2. řádek!)
|
||||
# Dva typy (standard / microbiology) s odlišným schématem
|
||||
# v jedné kolekci, rozlišené polem resultType.
|
||||
# Základní jednotka = subject (pacient), jeden řádek = 1 test.
|
||||
# record_id:
|
||||
# standard: STD|{Accession}|{Test Group}|{Test}|{occ}
|
||||
# microbiology: MIC|{Accession}|{Test Group}|{Specimen}|
|
||||
# {Test Description}|{Drug Name/Agent}|{occ}
|
||||
# occ = pořadí výskytu téhož klíče v souboru (1-based);
|
||||
# >1 jen u multi-value testů (panel s víc nálezy). Value
|
||||
# záměrně NENÍ v klíči → revize výsledku jde do history[].
|
||||
# Upsert s historií změn, zpracovaný soubor přesunut do Zpracovano/.
|
||||
# Přepínač --dry-run: nic nezapisuje do DB ani nepřesouvá soubory.
|
||||
# =============================================================================
|
||||
|
||||
import csv
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
DB_NAME = "covance"
|
||||
|
||||
UCO3001_SOURCE = Path(__file__).parent / "Source"
|
||||
MDD3003_SOURCE = Path(__file__).parent.parent / "Covance_MDD3003" / "Source"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builders record_id + metadata pro jednotlivé pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
def make_keyed_record(upsert_key: str):
|
||||
"""Jednoduchý klíč = hodnota jednoho sloupce (allsamples, kits)."""
|
||||
def builder(fields: dict, fmeta: dict | None, occ: dict):
|
||||
key_val = fields.get(upsert_key)
|
||||
if not key_val:
|
||||
return None, {}
|
||||
return key_val, {}
|
||||
return builder
|
||||
|
||||
|
||||
def _norm_subject(raw: str | None) -> str:
|
||||
"""'CZ100062001 - null' -> 'CZ100062001'."""
|
||||
s = (raw or "").strip()
|
||||
return s.split(" - null")[0].strip()
|
||||
|
||||
|
||||
def make_results_record(fields: dict, fmeta: dict, occ: dict):
|
||||
rtype = fmeta["resultType"]
|
||||
accession = fields.get("Accession")
|
||||
if not accession:
|
||||
return None, {}
|
||||
|
||||
if rtype == "standard":
|
||||
parts = (accession, fields.get("Test Group", ""), fields.get("Test", ""))
|
||||
prefix = "STD"
|
||||
else: # microbiology
|
||||
parts = (
|
||||
accession,
|
||||
fields.get("Test Group", ""),
|
||||
fields.get("Specimen", ""),
|
||||
fields.get("Test Description", ""),
|
||||
fields.get("Drug Name/Agent", ""),
|
||||
)
|
||||
prefix = "MIC"
|
||||
|
||||
occ[parts] = occ.get(parts, 0) + 1
|
||||
record_id = f"{prefix}|" + "|".join(str(p or "") for p in parts) + f"|{occ[parts]}"
|
||||
|
||||
extra = {
|
||||
"study": fmeta["study"],
|
||||
"site": fmeta["site"],
|
||||
"subject": _norm_subject(fields.get("Subject")),
|
||||
"resultType": rtype,
|
||||
}
|
||||
return record_id, extra
|
||||
|
||||
|
||||
def results_file_meta(filename: str) -> dict | None:
|
||||
m = re.search(r"study-(\d+)-test-results-(\d+)-(standard|microbiology)", filename, re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
return {"study": m.group(1), "site": m.group(2), "resultType": m.group(3).lower()}
|
||||
|
||||
|
||||
PIPELINES = [
|
||||
{
|
||||
"name": "allsamples",
|
||||
"collection": "allsamples",
|
||||
"pattern": re.compile(r".*-allSamples\.csv$", re.IGNORECASE),
|
||||
"sources": [UCO3001_SOURCE, MDD3003_SOURCE],
|
||||
"header_skip": 0,
|
||||
"make_record": make_keyed_record("Container Barcode No."),
|
||||
"file_meta": None,
|
||||
"indexes": [
|
||||
[("fields.Sample Status", ASCENDING)],
|
||||
[("fields.Specimen Type", ASCENDING)],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "kits",
|
||||
"collection": "kits",
|
||||
"pattern": re.compile(r".*-kit-inventory-on-hand-expiration\.csv$", re.IGNORECASE),
|
||||
"sources": [UCO3001_SOURCE],
|
||||
"header_skip": 0,
|
||||
"make_record": make_keyed_record("Accession"),
|
||||
"file_meta": None,
|
||||
"indexes": [
|
||||
[("fields.Kit Type", ASCENDING)],
|
||||
[("fields.Site", ASCENDING)],
|
||||
[("fields.Expiration Date", ASCENDING)],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "results",
|
||||
"collection": "results",
|
||||
"pattern": re.compile(r".*test-results-\d+-(standard|microbiology)\.csv$", re.IGNORECASE),
|
||||
"sources": [UCO3001_SOURCE],
|
||||
"header_skip": 1, # 1. řádek je disclaimer, hlavička je 2. řádek
|
||||
"make_record": make_results_record,
|
||||
"file_meta": results_file_meta,
|
||||
"indexes": [
|
||||
[("subject", ASCENDING)],
|
||||
[("study", ASCENDING)],
|
||||
[("site", ASCENDING)],
|
||||
[("resultType", ASCENDING)],
|
||||
[("fields.Accession", ASCENDING)],
|
||||
[("fields.Test Group", ASCENDING)],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def extract_snapshot_date(filename: str) -> str:
|
||||
match = re.match(r"(\d{4}-\d{2}-\d{2})", Path(filename).name)
|
||||
return match.group(1) if match else datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def clean_value(val: str) -> str | None:
|
||||
val = val.strip()
|
||||
return val if val else None
|
||||
|
||||
|
||||
def import_file(csv_path: Path, collection, pipeline: dict, dry_run: bool) -> dict:
|
||||
snapshot_date = extract_snapshot_date(csv_path.name)
|
||||
inserted = changed = unchanged = skipped = 0
|
||||
|
||||
fmeta = pipeline["file_meta"](csv_path.name) if pipeline["file_meta"] else None
|
||||
if pipeline["file_meta"] and fmeta is None:
|
||||
print(f" !!! VAROVANI: nelze rozpoznat study/site/typ z nazvu, preskakuji")
|
||||
return {"inserted": 0, "changed": 0, "unchanged": 0}
|
||||
|
||||
with open(csv_path, newline="", encoding="utf-8-sig") as f:
|
||||
lines = f.readlines()
|
||||
reader = csv.DictReader(lines[pipeline["header_skip"]:])
|
||||
rows = list(reader)
|
||||
|
||||
occ: dict = {} # stav pořadí výskytů (per soubor)
|
||||
|
||||
for row in rows:
|
||||
fields = {k: clean_value(v) for k, v in row.items() if k}
|
||||
|
||||
record_id, extra = pipeline["make_record"](fields, fmeta, occ)
|
||||
if not record_id:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
existing = None if dry_run else collection.find_one({"record_id": record_id})
|
||||
|
||||
if existing is None and dry_run:
|
||||
inserted += 1 # v dry-run nevime jistě, počítáme jako kandidáty na insert
|
||||
continue
|
||||
|
||||
if existing is None:
|
||||
collection.insert_one({
|
||||
"record_id": record_id,
|
||||
"fields": fields,
|
||||
**extra,
|
||||
"sourceFile": csv_path.name,
|
||||
"firstSeen": snapshot_date,
|
||||
"lastSeen": snapshot_date,
|
||||
"history": [],
|
||||
})
|
||||
inserted += 1
|
||||
|
||||
elif existing["fields"] != fields:
|
||||
collection.update_one(
|
||||
{"_id": existing["_id"]},
|
||||
{
|
||||
"$push": {"history": {"date": existing["lastSeen"], "fields": existing["fields"]}},
|
||||
"$set": {"fields": fields, **extra, "sourceFile": csv_path.name, "lastSeen": snapshot_date},
|
||||
},
|
||||
)
|
||||
changed += 1
|
||||
|
||||
else:
|
||||
collection.update_one(
|
||||
{"_id": existing["_id"]},
|
||||
{"$set": {"lastSeen": snapshot_date, "sourceFile": csv_path.name}},
|
||||
)
|
||||
unchanged += 1
|
||||
|
||||
total_rows = len(rows)
|
||||
db_count = "-" if dry_run else collection.count_documents({})
|
||||
tag = "[DRY] " if dry_run else ""
|
||||
print(f" {tag}[{snapshot_date}]: +{inserted} new, ~{changed} changed, ={unchanged} same, -{skipped} bez klice")
|
||||
print(f" Radku v CSV: {total_rows}, dokumentu v DB: {db_count}")
|
||||
|
||||
if inserted + changed + unchanged + skipped != total_rows:
|
||||
print(f" !!! VAROVANI: soucet ({inserted+changed+unchanged+skipped}) != radku v CSV ({total_rows})")
|
||||
|
||||
return {"inserted": inserted, "changed": changed, "unchanged": unchanged}
|
||||
|
||||
|
||||
def collect_files(pipeline: dict, cli_args: list[str]) -> list[Path]:
|
||||
if cli_args:
|
||||
paths = []
|
||||
for arg in cli_args:
|
||||
p = Path(arg)
|
||||
if p.is_file() and pipeline["pattern"].match(p.name):
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
paths = []
|
||||
for src_dir in pipeline["sources"]:
|
||||
if src_dir.exists():
|
||||
paths.extend(sorted(p for p in src_dir.glob("*.csv") if pipeline["pattern"].match(p.name)))
|
||||
return paths
|
||||
|
||||
|
||||
def run_pipeline(pipeline: dict, client, cli_args: list[str], dry_run: bool):
|
||||
paths = collect_files(pipeline, cli_args)
|
||||
if not paths:
|
||||
print(f"[{pipeline['name']}] Zadne soubory k importu.")
|
||||
return
|
||||
|
||||
print(f"\n=== Pipeline: {pipeline['name']} ({len(paths)} souboru){' [DRY-RUN]' if dry_run else ''} ===")
|
||||
|
||||
col = None
|
||||
if not dry_run:
|
||||
col = client[DB_NAME][pipeline["collection"]]
|
||||
col.create_index([("record_id", ASCENDING)], unique=True)
|
||||
for idx in pipeline["indexes"]:
|
||||
col.create_index(idx)
|
||||
for src_dir in pipeline["sources"]:
|
||||
(src_dir / "Zpracovano").mkdir(exist_ok=True)
|
||||
|
||||
total = {"inserted": 0, "changed": 0, "unchanged": 0}
|
||||
|
||||
for csv_path in paths:
|
||||
print(f"Import: {csv_path.name} [{csv_path.parent.parent.name}]")
|
||||
stats = import_file(csv_path, col, pipeline, dry_run)
|
||||
for k in total:
|
||||
total[k] += stats[k]
|
||||
if not dry_run:
|
||||
dest = csv_path.parent / "Zpracovano" / csv_path.name
|
||||
shutil.move(str(csv_path), str(dest))
|
||||
print(f" -> presunut do Zpracovano/\n")
|
||||
else:
|
||||
print()
|
||||
|
||||
print(f"[{pipeline['name']}] Celkem: +{total['inserted']} new, ~{total['changed']} changed, ={total['unchanged']} same")
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
dry_run = "--dry-run" in args
|
||||
cli_args = [a for a in args if a != "--dry-run"]
|
||||
|
||||
client = None
|
||||
if not dry_run:
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
|
||||
for pipeline in PIPELINES:
|
||||
run_pipeline(pipeline, client, cli_args, dry_run)
|
||||
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,54 @@
|
||||
# =============================================================================
|
||||
# Název: run_test_results_parallel_v1.0.py
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-05-29
|
||||
# Popis: Launcher pro paralelni stahovani Test Results. Spusti N procesu
|
||||
# download_test_results_v1.2.py, kazdy s --shard i --of N, kazdy
|
||||
# ve vlastnim konzolovem okne (CREATE_NEW_CONSOLE) a s vlastnim
|
||||
# profilem browser_profile_{i}. Pocka, az vsechny dobehnou.
|
||||
# Pri variante B se kazdy proces pri prvnim startu prihlasi sam
|
||||
# (login jen heslem) a session si ulozi do sveho profilu.
|
||||
# =============================================================================
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
N_SHARDS = 4 # pocet soubeznych procesu (oken)
|
||||
STAGGER_S = 4 # rozestup mezi starty (s) — at se OKTA login
|
||||
# nezahlti 4 soucasnymi pozadavky najednou
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRIPT = os.path.join(BASE, "download_test_results_v1.2.py")
|
||||
PYEXE = sys.executable # stejny interpreter (.venv), kterym byl spusten launcher
|
||||
|
||||
# Vlastni konzolove okno pro kazdy proces (jen Windows) -> logy se neprolinaji.
|
||||
CREATE_NEW_CONSOLE = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Launcher: spoustim {N_SHARDS} shardu skriptu {os.path.basename(SCRIPT)}")
|
||||
procs = []
|
||||
for shard in range(1, N_SHARDS + 1):
|
||||
cmd = [PYEXE, SCRIPT, "--shard", str(shard), "--of", str(N_SHARDS)]
|
||||
print(f" -> shard {shard}/{N_SHARDS}: {' '.join(cmd)}")
|
||||
p = subprocess.Popen(cmd, cwd=BASE, creationflags=CREATE_NEW_CONSOLE)
|
||||
procs.append((shard, p))
|
||||
if shard < N_SHARDS and STAGGER_S:
|
||||
time.sleep(STAGGER_S) # rozestup startu (login)
|
||||
|
||||
print("Launcher: vsechny shardy spusteny, cekam na dokonceni...")
|
||||
rc = {}
|
||||
for shard, p in procs:
|
||||
p.wait()
|
||||
rc[shard] = p.returncode
|
||||
print(f"Launcher: shard {shard} skoncil (returncode={p.returncode}).")
|
||||
|
||||
failed = [s for s, code in rc.items() if code != 0]
|
||||
if failed:
|
||||
print(f"Launcher: HOTOVO, ale shardy {failed} skoncily s chybou (returncode != 0).")
|
||||
else:
|
||||
print("Launcher: HOTOVO — vsechny shardy uspesne dokonceny.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
# =============================================================================
|
||||
# Název: run_test_results_parallel_v1.1.py
|
||||
# Verze: 1.1
|
||||
# Datum: 2026-05-29
|
||||
# Popis: Launcher pro paralelni stahovani Test Results. Spusti N procesu
|
||||
# download_test_results_v1.3.py, kazdy s --shard i --of N, kazdy
|
||||
# ve vlastnim konzolovem okne (CREATE_NEW_CONSOLE) a s vlastnim
|
||||
# profilem browser_profile_{i}. Pocka, az vsechny dobehnou.
|
||||
# Pri variante B se kazdy proces pri prvnim startu prihlasi sam
|
||||
# (login jen heslem) a session si ulozi do sveho profilu.
|
||||
# Zmeny v1.1: cili na download_test_results_v1.3.py (robustni login + okno se
|
||||
# pri chybe nezavre). STAGGER_S zvysen na 8 s, aby se 4 soubezne
|
||||
# OKTA loginy nezahltily.
|
||||
# =============================================================================
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
N_SHARDS = 4 # pocet soubeznych procesu (oken)
|
||||
STAGGER_S = 8 # rozestup mezi starty (s) — at se OKTA login
|
||||
# nezahlti soucasnymi pozadavky najednou
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRIPT = os.path.join(BASE, "download_test_results_v1.3.py")
|
||||
PYEXE = sys.executable # stejny interpreter (.venv), kterym byl spusten launcher
|
||||
|
||||
# Vlastni konzolove okno pro kazdy proces (jen Windows) -> logy se neprolinaji.
|
||||
CREATE_NEW_CONSOLE = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Launcher: spoustim {N_SHARDS} shardu skriptu {os.path.basename(SCRIPT)}")
|
||||
procs = []
|
||||
for shard in range(1, N_SHARDS + 1):
|
||||
cmd = [PYEXE, SCRIPT, "--shard", str(shard), "--of", str(N_SHARDS)]
|
||||
print(f" -> shard {shard}/{N_SHARDS}: {' '.join(cmd)}")
|
||||
p = subprocess.Popen(cmd, cwd=BASE, creationflags=CREATE_NEW_CONSOLE)
|
||||
procs.append((shard, p))
|
||||
if shard < N_SHARDS and STAGGER_S:
|
||||
time.sleep(STAGGER_S) # rozestup startu (login)
|
||||
|
||||
print("Launcher: vsechny shardy spusteny, cekam na dokonceni...")
|
||||
rc = {}
|
||||
for shard, p in procs:
|
||||
p.wait()
|
||||
rc[shard] = p.returncode
|
||||
print(f"Launcher: shard {shard} skoncil (returncode={p.returncode}).")
|
||||
|
||||
failed = [s for s, code in rc.items() if code != 0]
|
||||
if failed:
|
||||
print(f"Launcher: HOTOVO, ale shardy {failed} skoncily s chybou (returncode != 0).")
|
||||
else:
|
||||
print("Launcher: HOTOVO — vsechny shardy uspesne dokonceny.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user