This commit is contained in:
2026-06-09 08:22:49 +02:00
parent cf5e681a42
commit 915357cca9
2110 changed files with 54778 additions and 1 deletions
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()
+238
View File
@@ -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).
+173
View File
@@ -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()
+231
View File
@@ -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.
+175
View File
@@ -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()
+233
View File
@@ -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íš 23×. 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 |
+199
View File
@@ -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()
+263
View File
@@ -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íš 23×. 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 |
+233
View File
@@ -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
+181
View File
@@ -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()
+297
View File
@@ -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()