From 902ff936cba360502bb7057c157b94c300178fe0 Mon Sep 17 00:00:00 2001 From: Vasanth Date: Wed, 21 Oct 2026 17:12:27 +0530 Subject: [PATCH] Initial commit: Charge Billed Automation bot Waystar Charge Billed Report bot. Reads CMS-1500 claim PDFs from the text layer (src/cms1500_text.py) instead of Google Document AI. Dependencies managed with uv (Python 3.12). Secrets, PHI and build output are git-ignored. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 18 + .python-version | 1 + .vscode/launch.json | 16 + pyproject.toml | 22 + src/ChargeBilledAutomation.py | 844 +++++++++++++++++ src/ChargeBilledAutomation.spec | 38 + src/cms1500_text.py | 261 ++++++ src/command to run exe.txt | 22 + src/draft.py | 1548 +++++++++++++++++++++++++++++++ src/main.py | 1351 +++++++++++++++++++++++++++ src/main.spec | 38 + src/main_v1.spec | 38 + src/pdf extraction.py | 491 ++++++++++ src/rough draft.py | 1441 ++++++++++++++++++++++++++++ src/test_docai.py | 31 + src/test_pdf.py | 60 ++ tests/conftest.py | 5 + tests/test_cms1500_text.py | 197 ++++ tests/test_selftest.py | 18 + uv.lock | 1039 +++++++++++++++++++++ 20 files changed, 7479 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .vscode/launch.json create mode 100644 pyproject.toml create mode 100644 src/ChargeBilledAutomation.py create mode 100644 src/ChargeBilledAutomation.spec create mode 100644 src/cms1500_text.py create mode 100644 src/command to run exe.txt create mode 100644 src/draft.py create mode 100644 src/main.py create mode 100644 src/main.spec create mode 100644 src/main_v1.spec create mode 100644 src/pdf extraction.py create mode 100644 src/rough draft.py create mode 100644 src/test_docai.py create mode 100644 src/test_pdf.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cms1500_text.py create mode 100644 tests/test_selftest.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c288485 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Secrets: Google key, Waystar login and security answers +.env/ +Input/ + +# PHI: claim PDFs, reports, logs, crash screenshots, payment export +Output/ +Screenshots/ +output.csv + +# Python / tools +.venv*/ +__pycache__/ +.pytest_cache/ + +# PyInstaller build output +src/build/ +src/dist/ +build_log.txt diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f4daedb --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b016aca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "charge-billed-automation" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "openpyxl>=3.1.5", + "pandas>=3.0.6", + "pdfplumber>=0.11.10", + "pywinauto>=0.6.9", # optional; imported inside try/except + "requests>=2.34.2", + "selenium>=4.51.0", +] + +[dependency-groups] +dev = [ + "pyinstaller==6.22.3", + "pytest>=9.1.1", +] + +[tool.pytest.ini_options] +# src/test_docai.py and src/test_pdf.py are old Document AI scripts, not tests +testpaths = ["tests"] diff --git a/src/ChargeBilledAutomation.py b/src/ChargeBilledAutomation.py new file mode 100644 index 0000000..3e8452b --- /dev/null +++ b/src/ChargeBilledAutomation.py @@ -0,0 +1,844 @@ +import subprocess +import time +import os +import sys +import socket +import re +import pandas as pd +import requests +from pathlib import Path +from datetime import datetime +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.chrome.service import Service +from selenium.common.exceptions import WebDriverException +from selenium.common.exceptions import NoSuchElementException +from selenium.common.exceptions import StaleElementReferenceException +from selenium.webdriver.support.ui import Select +from selenium.webdriver.common.keys import Keys + +from cms1500_text import extract_claim, to_report_row + +try: + from pywinauto import Application +except Exception: + Application = None + +# ================= PROJECT ROOT ================= +if getattr(sys, "frozen", False): + PROJECT_ROOT = Path(sys.executable).parent +else: + PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# ================= GLOBAL CONFIG ================= +DEBUG_PORT = 9222 +USER_DATA_DIR = os.path.join( + os.environ["USERPROFILE"], "Automation", "ChromeProfile_CF" +) +today_date = datetime.now().strftime("%m-%d-%Y") +DOWNLOADS_DIR = Path.home() / "Downloads" + + +# ================= CHROME PATH ================= +def find_chrome_path(): + possible_paths = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + os.path.join( + os.environ.get("LOCALAPPDATA", ""), + r"Google\Chrome\Application\chrome.exe" + ), + ] + for path in possible_paths: + if os.path.exists(path): + return path + return None + +# ================= PROXY CONFIG ================= +def read_input_config(): + config_path = PROJECT_ROOT / "Input" / "input.xlsx" + if not config_path.exists(): + print("[ERROR] input.xlsx not found.") + sys.exit(1) + df = pd.read_excel(config_path, sheet_name="Sheet1", header=None) + config = {} + for _, row in df.iterrows(): + key = str(row[0]).strip() + value = str(row[1]).strip() + config[key] = value + proxy_ip = config.get("Proxy IP", "") + proxy_port = config.get("Proxy Port", "") + waystar_username = config.get("Waystar Username", "") + waystar_password = config.get("Waystar Password", "") + portal_url = config.get("Waystar URL", "") + first_car_answer = config.get("Waystar SQ First Car", "") + first_job_answer = config.get("Waystar SQ First Job", "") + print(f"[INFO] Proxy → {proxy_ip}:{proxy_port}") + print(f"[INFO] Username → {waystar_username}") + print(f"[INFO] Portal URL → {portal_url}") + return ( + proxy_ip, proxy_port, waystar_username, waystar_password, + portal_url, first_car_answer, first_job_answer, + ) + + +# ================= DEBUG PORT CHECK ================= +def wait_for_debug_port(port=9222, timeout=40): + start = time.time() + while time.time() - start < timeout: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(("127.0.0.1", port)) == 0: + return True + time.sleep(1) + return False + + +# ================= KILL CHROME ================= +def kill_all_chrome(): + try: + os.system("taskkill /F /IM chrome.exe /T >nul 2>&1") + time.sleep(3) + print("[INFO] All Chrome instances closed") + except Exception as e: + print(f"[WARN] Unable to kill Chrome: {e}") + + +# ================= START CHROME DEBUG ================= +def start_chrome_debug(proxy_ip=None, proxy_port=None): + chrome_path = find_chrome_path() + if not chrome_path: + raise Exception("Google Chrome not found.") + print(f"[INFO] Using Chrome path: {chrome_path}") + os.makedirs(USER_DATA_DIR, exist_ok=True) + cmd = [ + chrome_path, + f"--remote-debugging-port={DEBUG_PORT}", + f"--user-data-dir={USER_DATA_DIR}", + "--disable-blink-features=AutomationControlled", + "--ignore-certificate-errors", + "about:blank", + ] + if proxy_ip and proxy_port: + proxy = f"{proxy_ip}:{proxy_port}" + cmd.append(f"--proxy-server=http://{proxy}") + print(f"[INFO] Chrome started with proxy → {proxy}") + else: + print("[INFO] Chrome started WITHOUT proxy") + print("[INFO] Launching Chrome...") + subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if not wait_for_debug_port(DEBUG_PORT, timeout=40): + print("[ERROR] Chrome debug port did not open") + return False + print("[INFO] Chrome debug port is ready") + return True + + +# ================= SCREENSHOT ================= +def capture_screenshot(driver, client_name, step_name): + if not driver: + return None + safe_client = "".join(c for c in client_name if c.isalnum() or c in ("_", "-")) + safe_step = "".join(c for c in step_name if c.isalnum() or c in ("_", "-")) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + screenshots_dir = PROJECT_ROOT / "Screenshots" / today_date / safe_client + screenshots_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshots_dir / f"{safe_step}_{timestamp}.png" + try: + driver.save_screenshot(str(screenshot_path)) + print(f"[INFO] Screenshot saved: {screenshot_path}") + return screenshot_path + except Exception as e: + print(f"[ERROR] Screenshot failed: {e}") + return None + + +# ================= ATTACH BROWSER ================= +def attach_browser(download_path, retries=5): + for attempt in range(1, retries + 1): + try: + print(f"[INFO] Attaching browser (attempt {attempt})") + chrome_options = Options() + chrome_options.add_experimental_option( + "debuggerAddress", f"127.0.0.1:{DEBUG_PORT}" + ) + chrome_options.set_capability("webSocketUrl", False) + prefs = { + "download.default_directory": str(download_path), + "download.prompt_for_download": False, + "download.directory_upgrade": True, + "plugins.always_open_pdf_externally": True, + "safebrowsing.enabled": True, + } + chrome_options.add_experimental_option("prefs", prefs) + service = Service() + driver = webdriver.Chrome(service=service, options=chrome_options) + _ = driver.current_url + driver.maximize_window() + print("[INFO] Browser attached successfully") + try: + driver.execute_cdp_cmd( + "Page.setDownloadBehavior", + {"behavior": "allow", "downloadPath": str(download_path)}, + ) + except Exception as e: + print(f"[WARN] Unable to set download behavior: {e}") + return driver + except WebDriverException as e: + print(f"[WARN] Attach attempt {attempt} failed: {e}") + time.sleep(3) + print("[ERROR] Failed to attach Chrome after retries") + return None + + +# ================= LOGIN ================= +def login_to_portal(driver, waystar_username, waystar_password, + first_car_answer, first_job_answer): + wait = WebDriverWait(driver, 30) + try: + time.sleep(5) + print("[INFO] Starting login process") + username_field = wait.until(EC.presence_of_element_located((By.ID, "loginName"))) + username_field.clear() + username_field.send_keys(waystar_username) + time.sleep(2) + password_field = wait.until(EC.presence_of_element_located((By.ID, "password"))) + password_field.clear() + password_field.send_keys(waystar_password) + time.sleep(2) + driver.find_element(By.ID, "loginButton").click() + time.sleep(5) + print("[INFO] Login clicked") + try: + alert_page = driver.find_elements( + By.XPATH, "//span[contains(text(),'Proactive Alert')]" + ) + if alert_page: + wait.until( + EC.element_to_be_clickable((By.XPATH, "//button[@id='continueButton']")) + ).click() + time.sleep(3) + except Exception: + pass + try: + question_exists = driver.find_elements( + By.XPATH, "//*[@id='authDiv']//strong" + ) + if question_exists: + question = wait.until( + EC.presence_of_element_located((By.XPATH, "//*[@id='authDiv']//strong")) + ).text.lower() + print(f"[INFO] Security Question → {question}") + if "first car" in question: + answer = first_car_answer + elif "first job" in question: + answer = first_job_answer + else: + raise Exception(f"Unknown security question: {question}") + answer_box = wait.until( + EC.presence_of_element_located((By.ID, "verifyAnswer")) + ) + answer_box.clear() + answer_box.send_keys(answer) + driver.find_element(By.ID, "VerifyButton").click() + print("[INFO] Security answer submitted") + time.sleep(3) + except Exception: + pass + try: + security_qpage = driver.find_elements( + By.XPATH, "//h2[normalize-space()='Security Information']" + ) + if security_qpage: + print("[INFO] Security page appeared, skipping") + wait.until(EC.element_to_be_clickable((By.ID, "NextButton"))).click() + time.sleep(2) + wait.until( + EC.element_to_be_clickable( + (By.XPATH, "//a[normalize-space()='Skip This Step']") + ) + ).click() + time.sleep(5) + except Exception: + pass + print("[INFO] Login completed successfully") + except Exception as e: + print(f"[ERROR] Login failed: {e}") + raise + + +# ================= CALCULATE DATE RANGE ================= +def get_date_range(): + now = datetime.now() + first_of_current = datetime(now.year, now.month, 1) + last_of_prev = first_of_current - pd.Timedelta(days=1) + first_of_prev = datetime(last_of_prev.year, last_of_prev.month, 1) + return ( + first_of_prev.strftime("%m/%d/%Y"), + last_of_prev.strftime("%m/%d/%Y"), + ) + + +# ================= APPLY FILTERS ================= +def apply_filters(driver): + wait = WebDriverWait(driver, 40) + print("[INFO] Applying filters") + wait.until(EC.presence_of_element_located((By.ID, "ClaimListingSearchButtonBottom"))) + wait.until(EC.presence_of_element_located((By.ID, "SearchCriteria_Status"))) + status_dropdown = Select( + wait.until(EC.element_to_be_clickable((By.ID, "SearchCriteria_Status"))) + ) + status_dropdown.select_by_visible_text("All") + date_dropdown = Select( + wait.until(EC.element_to_be_clickable((By.ID, "SearchCriteria_TransDate"))) + ) + date_dropdown.select_by_visible_text("Custom") + time.sleep(2) + start_date, end_date = get_date_range() + print(f"[INFO] Setting date range: {start_date} to {end_date}") + try: + start_date_field = wait.until( + EC.presence_of_element_located((By.ID, "transactionDateFromDateBox")) + ) + start_date_field.clear() + start_date_field.send_keys(start_date) + time.sleep(1) + except Exception as e: + print(f"[WARN] Could not set start date: {e}") + try: + end_date_field = wait.until( + EC.presence_of_element_located((By.ID, "transactionDateToDateBox")) + ) + end_date_field.clear() + end_date_field.send_keys(end_date) + time.sleep(1) + except Exception as e: + print(f"[WARN] Could not set end date: {e}") + search_btn = wait.until( + EC.element_to_be_clickable((By.ID, "ClaimListingSearchButtonBottom")) + ) + driver.execute_script("arguments[0].click();", search_btn) + wait.until( + EC.presence_of_element_located( + (By.XPATH, "//table[@id='claimsGrid']//tbody//tr") + ) + ) + time.sleep(3) + print("[INFO] Filters applied successfully") + + +# ================= CLICK NEXT PAGE ================= +def click_next_page(driver): + try: + next_btn = driver.find_element(By.XPATH, "//button[@id='innerPagerRight']") + if "disabled" in next_btn.get_attribute("class"): + return False + driver.execute_script("arguments[0].click();", next_btn) + WebDriverWait(driver, 10).until(EC.staleness_of(next_btn)) + time.sleep(2) + return True + except Exception: + return False + + +# ================= DIRECT DOWNLOAD ================= +def download_pdf_direct(driver, pdf_url, save_path): + try: + cookies = {c["name"]: c["value"] for c in driver.get_cookies()} + headers = { + "User-Agent": driver.execute_script("return navigator.userAgent;"), + "Referer": driver.current_url, + "Accept": "application/pdf,*/*", + } + response = requests.get( + pdf_url, headers=headers, cookies=cookies, stream=True, timeout=60 + ) + if response.status_code != 200: + print(f"[ERROR] HTTP {response.status_code} → {pdf_url}") + return False + content_type = response.headers.get("Content-Type", "") + if "pdf" not in content_type.lower() and "octet-stream" not in content_type.lower(): + print(f"[WARN] Unexpected content type → {content_type}") + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + with open(save_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + print(f"[SUCCESS] Downloaded → {save_path.name}") + return True + except Exception as e: + print(f"[ERROR] Direct download failed → {e}") + return False + + +# ================= PDF EXTRACTION ================= +# Waystar claim PDFs are digital, so fields are read from the PDF text layer +# at their CMS-1500 positions (see cms1500_text.py) — exact, offline, no OCR. +def extract_pdf_data_fast( + pdf_path, + claim_id="", + patient_account="", + insurance="", + patient_name="", + total_charges="", + signature_physician="", +): + try: + claim = extract_claim(pdf_path) + except Exception as e: + print(f"[ERROR] PDF extraction failed → {pdf_path}: {e}") + return None + if claim.issues: + print(f"[WARN] Claim {claim_id} needs review → {'; '.join(claim.issues)}") + return to_report_row( + claim, + claim_id=claim_id, + insurance=insurance, + patient_name=patient_name, + signature_physician=signature_physician, + fallback_account=patient_account, + fallback_charges=total_charges, + ) + + +# ================= UPDATE LOG ================= +def update_log(log_path, claim_id, status, remarks=""): + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + new_row = pd.DataFrame([{ + "Claim ID": claim_id, + "Status": status, + "Remarks": remarks, + "Timestamp": now, + }]) + try: + existing = pd.read_excel(log_path) + final = pd.concat([existing, new_row], ignore_index=True) + except Exception: + final = new_row + final.to_excel(log_path, index=False) + + +# ================= INCREMENTAL EXCEL SAVE ================= +def _save_batch_to_excel(excel_path, batch_data: list): + if not batch_data: + return + df_new = pd.DataFrame(batch_data) + try: + existing = pd.read_excel(excel_path) + existing = existing.dropna(how="all") + final = pd.concat([existing, df_new], ignore_index=True) + except Exception: + final = df_new + final.to_excel(excel_path, index=False) + print(f"[INFO] Saved {len(batch_data)} records → {excel_path.name} " + f"(total rows: {len(final)})") + + +def _get_processed_claim_ids(log_path) -> set: + try: + df = pd.read_excel(log_path) + done = df[df["Status"] == "SUCCESS"]["Claim ID"].astype(str).tolist() + return set(done) + except Exception: + return set() + + +# ================= RUN CLAIM REPORT ================= +def run_claim_report(driver, account_name): + SAVE_EVERY = 10 + wait = WebDriverWait(driver, 30) + actions = ActionChains(driver) + print(f"\n[INFO] ====== Running report for {account_name} ======") + + today = datetime.now().strftime("%m-%d-%Y") + safe_name = "".join( + c for c in account_name if c.isalnum() or c in (" ", "_", "(", ")") + ) + base = PROJECT_ROOT / "Output" / today + pdf_folder = base / "PDF Folder" / safe_name + report_dir = base / "reports" + logs_dir = base / "Logs" + + pdf_folder.mkdir(parents=True, exist_ok=True) + report_dir.mkdir(parents=True, exist_ok=True) + logs_dir.mkdir(parents=True, exist_ok=True) + + excel_path = report_dir / f"{safe_name}.xlsx" + log_path = logs_dir / f"{safe_name}_log.xlsx" + + if not excel_path.exists(): + pd.DataFrame(columns=[ + "Claim ID", "Rendering Provider #", "Signature of Physician", + "Patient's Account No", "Date of Birth", "Insurance Name", + "Insured ID Number", "Patient's Name", "Date of Service", + "Diagnosis Codes", "CPT", "POS", "Modifier", "Units", "Charges", + "Line Charges", "Review Notes", + ]).to_excel(excel_path, index=False) + + if not log_path.exists(): + pd.DataFrame(columns=[ + "Claim ID", "Status", "Remarks", "Timestamp", + ]).to_excel(log_path, index=False) + + already_done = _get_processed_claim_ids(log_path) + print(f"[INFO] {len(already_done)} claims already processed for {account_name}") + + # Navigate to Claims + try: + claims_processing = wait.until( + EC.presence_of_element_located( + (By.XPATH, "//a[contains(.,'Claims Processing')]") + ) + ) + actions.move_to_element(claims_processing).perform() + time.sleep(1) + claims_option = wait.until( + EC.presence_of_element_located( + (By.XPATH, "//a[normalize-space()='Claims']") + ) + ) + driver.execute_script("arguments[0].click();", claims_option) + time.sleep(3) + except Exception as e: + print(f"[ERROR] Navigation to Claims failed: {e}") + return + + try: + apply_filters(driver) + except Exception as e: + print(f"[ERROR] Filter application failed: {e}") + return + + page = 1 + batch = [] + total_saved = 0 + consecutive_errors = 0 + + while True: + print(f"\n[INFO] Processing page {page}") + try: + WebDriverWait(driver, 20).until( + EC.presence_of_element_located( + (By.XPATH, "//table[@id='claimsGrid']//tbody//tr[@data-claimid]") + ) + ) + rows = driver.find_elements( + By.XPATH, "//table[@id='claimsGrid']//tbody//tr[@data-claimid]" + ) + print(f"[INFO] Rows on page {page} → {len(rows)}") + except Exception as e: + print(f"[ERROR] Could not load rows on page {page}: {e}") + consecutive_errors += 1 + if consecutive_errors >= 3: + print("[ERROR] 3 consecutive page-load failures — stopping account") + break + time.sleep(5) + continue + + consecutive_errors = 0 + + for i in range(len(rows)): + claim_id = f"row_{page}_{i + 1}" + try: + rows = driver.find_elements( + By.XPATH, + "//table[@id='claimsGrid']//tbody//tr[@data-claimid]", + ) + if i >= len(rows): + print(f"[WARN] Row {i + 1} no longer exists — skipping") + continue + + row = rows[i] + claim_id = row.get_attribute("data-claimid") or "" + instance_id = row.get_attribute("data-instanceid") or "" + + if not claim_id or not instance_id: + print(f"[WARN] Missing IDs row {i + 1} — skipping") + continue + if claim_id in already_done: + print(f"[INFO] [{i + 1}/{len(rows)}] Skipping (already done): {claim_id}") + continue + + try: + claim_number = row.find_element(By.XPATH, ".//td[2]").text.strip() + except Exception: + claim_number = "" + + try: + sig_raw = row.find_element(By.XPATH, ".//td[10]").text.strip() + if "," in sig_raw: + last, first = sig_raw.split(",", 1) + signature_physician = ( + f"{first.strip().title()} {last.strip().title()}" + ) + else: + signature_physician = sig_raw.strip().title() + except Exception: + signature_physician = "" + + insurance = re.sub( + r"\s*\(.*?\)\s*$", "", + row.get_attribute("data-payername") or "", + ).strip() + patient_name = row.get_attribute("data-patientname") or "" + charges = row.get_attribute("data-totalcharges") or "" + + print(f"[INFO] [{i + 1}/{len(rows)}] Claim: {claim_id} | {patient_name}") + + pdf_url = ( + f"https://claims.zirmed.com/Claims/History/ViewClaimPDF" + f"?appId=1&claimId={claim_id}&instanceId={instance_id}" + f"&form=CMS1500_0212" + ) + save_path = pdf_folder / f"{claim_id}.pdf" + if save_path.exists(): + save_path = pdf_folder / f"{claim_id}_{instance_id}.pdf" + + success = download_pdf_direct(driver, pdf_url, save_path) + if not success: + time.sleep(3) + success = download_pdf_direct(driver, pdf_url, save_path) + if not success: + update_log(log_path, claim_id, "FAILED", "Download failed after retry") + continue + + pdf_data = extract_pdf_data_fast( + save_path, + claim_id=claim_id, + patient_account=claim_number, + insurance=insurance, + patient_name=patient_name, + total_charges=charges, + signature_physician=signature_physician, + ) + if not pdf_data: + update_log(log_path, claim_id, "FAILED", "PDF extraction failed") + continue + + batch.append(pdf_data) + already_done.add(claim_id) + update_log(log_path, claim_id, "SUCCESS", pdf_data["Review Notes"]) + + if len(batch) >= SAVE_EVERY: + _save_batch_to_excel(excel_path, batch) + total_saved += len(batch) + batch = [] + + except Exception as e: + print(f"[ERROR] Row {i + 1} (claim {claim_id}) failed → {e}") + update_log(log_path, claim_id, "FAILED", str(e)[:200]) + + if batch: + _save_batch_to_excel(excel_path, batch) + total_saved += len(batch) + batch = [] + + if not click_next_page(driver): + print(f"[INFO] No more pages for {account_name}") + break + page += 1 + + print(f"\n[SUCCESS] {account_name} complete — {total_saved} records saved") + print(f"[INFO] ====== Account {account_name} finished ======\n") + + +# ================= PROCESS ALL ACCOUNTS ================= +def process_all_accounts(driver): + wait = WebDriverWait(driver, 30) + print("[INFO] Starting account processing...") + all_accounts = [] + + try: + dropdown = wait.until( + EC.element_to_be_clickable( + (By.XPATH, "//input[contains(@data-search-url,'AccountSearch')]") + ) + ) + dropdown.click() + time.sleep(2) + account_elements = driver.find_elements( + By.XPATH, "//ul[contains(@class,'ui-autocomplete')]//li" + ) + all_accounts = [ + acc.text.strip() for acc in account_elements if acc.text.strip() + ] + print(f"[INFO] Total accounts found → {len(all_accounts)}") + driver.find_element(By.TAG_NAME, "body").click() + time.sleep(1) + except Exception as e: + print(f"[ERROR] Failed to get account list: {e}") + return + + if not all_accounts: + print("[WARN] No accounts found — exiting") + return + + # Deduplicate while preserving order + seen_names = set() + unique_accounts = [] + for name in all_accounts: + if name not in seen_names: + seen_names.add(name) + unique_accounts.append(name) + + if len(unique_accounts) < len(all_accounts): + print(f"[INFO] Removed {len(all_accounts) - len(unique_accounts)} " + f"duplicate account names") + + all_accounts = unique_accounts + print(f"[INFO] Unique accounts to process → {len(all_accounts)}") + + portal_home_url = driver.current_url + processed_accounts = set() + + for idx, account_name in enumerate(all_accounts, 1): + if account_name in processed_accounts: + print(f"[WARN] '{account_name}' already processed — skipping") + continue + + print(f"\n[INFO] ===== Account {idx}/{len(all_accounts)}: {account_name} =====") + + try: + driver.get(portal_home_url) + time.sleep(3) + + dropdown = wait.until( + EC.element_to_be_clickable( + (By.XPATH, "//input[contains(@data-search-url,'AccountSearch')]") + ) + ) + dropdown.clear() + dropdown.click() + time.sleep(2) + + account_elements = driver.find_elements( + By.XPATH, "//ul[contains(@class,'ui-autocomplete')]//li" + ) + clicked = False + for acc in account_elements: + if acc.text.strip() == account_name: + acc.click() + clicked = True + break + + if not clicked: + print(f"[WARN] '{account_name}' not found in dropdown — skipping") + continue + + try: + wait.until( + EC.invisibility_of_element_located( + (By.XPATH, "//ul[contains(@class,'ui-autocomplete')]") + ) + ) + except Exception: + pass + + time.sleep(3) + processed_accounts.add(account_name) + run_claim_report(driver, account_name) + + except Exception as e: + print(f"[ERROR] Account '{account_name}' failed: {e}") + try: + driver.get(portal_home_url) + time.sleep(3) + except Exception: + pass + continue + + print(f"\n[INFO] ===== All {len(processed_accounts)} accounts processed =====") + print(f"[INFO] Accounts completed: {sorted(processed_accounts)}") + + +# ================= SELF-TEST ================= +def self_test(pdf_paths): + """Check a build without Chrome or Waystar: imports load, PDFs extract. + Prints counts only (no patient data). Returns a process exit code.""" + failed = 0 + for path in pdf_paths: + try: + claim = extract_claim(path) + print(f"[SELFTEST] {Path(path).name}: lines={len(claim.lines)} " + f"dx={len(claim.dx)} review_notes={len(claim.issues)}") + except Exception as e: + failed += 1 + print(f"[SELFTEST] {Path(path).name}: ERROR {type(e).__name__}: {e}") + print("[SELFTEST] SELFTEST FAILED" if failed else "[SELFTEST] SELFTEST OK") + return 1 if failed else 0 + + +# ================= MAIN ================= +if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(self_test(sys.argv[sys.argv.index("--selftest") + 1:])) + print("=" * 80) + print("🚀 STARTING CHARGE BILLED AUTOMATION - PDF text layer v8.0") + print("=" * 80) + driver = None + try: + print("\n[STEP 1] Closing existing Chrome instances...") + kill_all_chrome() + + print("\n[STEP 2] Reading configuration...") + ( + proxy_ip, proxy_port, username, waystar_password, + portal_url, first_car_answer, first_job_answer, + ) = read_input_config() + + print("\n[STEP 3] Launching Chrome in debug mode...") + if not start_chrome_debug(proxy_ip, proxy_port): + raise Exception("Chrome debug failed to start") + time.sleep(5) + + print("\n[STEP 4] Attaching Selenium to Chrome...") + driver = attach_browser(DOWNLOADS_DIR) + if not driver: + raise Exception("Driver attach failed") + + print("\n[STEP 5] Opening Waystar portal...") + driver.execute_script("window.open('');") + driver.switch_to.window(driver.window_handles[-1]) + driver.get(portal_url) + WebDriverWait(driver, 30).until( + EC.presence_of_element_located((By.ID, "loginName")) + ) + print("[INFO] Portal loaded successfully") + + print("\n[STEP 6] Performing login...") + login_to_portal( + driver, username, waystar_password, + first_car_answer, first_job_answer, + ) + + print("\n[STEP 7] Starting account processing...") + process_all_accounts(driver) + + print("\n" + "=" * 80) + print("✅ AUTOMATION COMPLETED SUCCESSFULLY") + print("=" * 80) + + except Exception as e: + print("\n" + "=" * 80) + print(f"❌ FATAL ERROR: {str(e)}") + print("=" * 80) + if driver: + try: + capture_screenshot(driver, "MAIN", "Fatal_Error") + except Exception: + pass + + finally: + print("\n[CLEANUP] Closing resources...") + if driver: + try: + driver.quit() + print("[INFO] Browser closed successfully") + except Exception as e: + print(f"[WARN] Error closing browser: {e}") + print("\n🛑 Execution finished\n") \ No newline at end of file diff --git a/src/ChargeBilledAutomation.spec b/src/ChargeBilledAutomation.spec new file mode 100644 index 0000000..3b0c64c --- /dev/null +++ b/src/ChargeBilledAutomation.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['ChargeBilledAutomation.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=['openpyxl', 'pywinauto'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['torch', 'onnx', 'paddle', 'tensorflow', 'scipy', 'cv2'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='ChargeBilledAutomation', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/src/cms1500_text.py b/src/cms1500_text.py new file mode 100644 index 0000000..7600f11 --- /dev/null +++ b/src/cms1500_text.py @@ -0,0 +1,261 @@ +""" +CMS-1500 (02/12) reader that uses the PDF's own text layer. + +Waystar's ViewClaimPDF returns digital PDFs: every value is real text at a +fixed spot on the form. Reading those words by position is exact and runs +offline, so no OCR and no PHI leaves the machine. + +All coordinates are normalized to 0-1 (x from the left, y from the top) and +taken from Waystar's CMS1500_0212 rendering. +""" +import re +from decimal import Decimal +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Word: + text: str + x: float # horizontal mid-point, 0-1 + y: float # vertical mid-point, 0-1 + + +# ================= BOX 21 — DIAGNOSIS CODES ================= +_DX_SLOT_LABEL = re.compile(r"^([A-L])\.$") +_ICD10 = re.compile(r"^[A-Z]\d[0-9A-Z]{1,5}$") +_DX_Y_MIN, _DX_Y_MAX = 0.590, 0.640 +_DX_X_MAX = 0.615 # box 22/23 starts to the right +_DX_ROW_TOL = 0.010 # codes print a little above their slot label + + +def parse_diagnosis_codes(words): + """Return [(slot_letter, code), ...] ordered A..L.""" + in_box = [w for w in words if _DX_Y_MIN <= w.y <= _DX_Y_MAX and w.x <= _DX_X_MAX] + labels = [(m.group(1), w) for w in in_box if (m := _DX_SLOT_LABEL.match(w.text))] + found = {} + for w in in_box: + if not _ICD10.match(w.text): + continue + same_row = [(s, lw) for s, lw in labels + if abs(lw.y - w.y) <= _DX_ROW_TOL and lw.x <= w.x] + if not same_row: + continue + slot, _ = max(same_row, key=lambda sl: sl[1].x) # nearest label to the left + found.setdefault(slot, w.text) + return sorted(found.items()) + + +# ================= BOX 24 — SERVICE LINES ================= +@dataclass(frozen=True) +class ServiceLine: + line_no: int + dos_from: str + dos_to: str + pos: str + cpt: str + modifiers: tuple + dx_pointer: str + charge: object # Decimal, or None when not printed + units: str + npi: str + + +_CPT = re.compile(r"^(\d{5}|[A-V]\d{4}|\d{4}[TF])$") +_LINES_Y_MIN, _LINES_Y_MAX = 0.665, 0.848 # below the box-24 header, above box 25 +_LINE_ROW_TOL = 0.006 # shaded supplemental row sits ~0.009 above + +# Column x-ranges (on the word's mid-point), left to right. +_COLS = { + "from_mm": (0.035, 0.066), "from_dd": (0.066, 0.101), "from_yy": (0.101, 0.137), + "to_mm": (0.137, 0.171), "to_dd": (0.171, 0.205), "to_yy": (0.205, 0.240), + "pos": (0.240, 0.280), + "cpt": (0.315, 0.405), + "mods": (0.405, 0.545), + "pointer": (0.545, 0.615), + "charge": (0.615, 0.712), + "units": (0.712, 0.760), + "npi": (0.825, 0.960), +} + + +def _in_col(row, col): + lo, hi = _COLS[col] + return [w.text for w in sorted(row, key=lambda w: w.x) if lo <= w.x < hi] + + +def _date(mm, dd, yy): + if not (mm and dd and yy): + return "" + year = yy if len(yy) == 4 else f"20{yy}" + return f"{mm.zfill(2)}/{dd.zfill(2)}/{year}" + + +def parse_charge(text): + """'9000' -> 90.00 (Waystar prints dollars and cents without a point).""" + text = text.replace("$", "").replace(",", "").strip() + if not re.fullmatch(r"\d+(\.\d{1,2})?", text): + return None + return Decimal(text).quantize(Decimal("0.01")) if "." in text else Decimal(text) / 100 + + +def parse_service_lines(words): + """Return ServiceLine for each box-24 row that has a CPT/HCPCS code, top to bottom.""" + section = [w for w in words if _LINES_Y_MIN <= w.y <= _LINES_Y_MAX] + lo, hi = _COLS["cpt"] + anchors = sorted((w for w in section if lo <= w.x < hi and _CPT.match(w.text)), + key=lambda w: w.y) + lines = [] + for anchor in anchors: + row = [w for w in section if abs(w.y - anchor.y) <= _LINE_ROW_TOL] + first = lambda col: (_in_col(row, col) or [""])[0] + charge_text = "".join(_in_col(row, "charge")) + lines.append(ServiceLine( + line_no=len(lines) + 1, + dos_from=_date(first("from_mm"), first("from_dd"), first("from_yy")), + dos_to=_date(first("to_mm"), first("to_dd"), first("to_yy")), + pos=first("pos"), + cpt=anchor.text, + modifiers=tuple(_in_col(row, "mods")), + dx_pointer="".join(_in_col(row, "pointer")), + charge=parse_charge(charge_text) if charge_text else None, + units="".join(_in_col(row, "units")), + npi=first("npi"), + )) + return lines + + +# ================= HEADER BOXES (1a, 3, 26, 28) ================= +_BOX = { # (x_min, x_max, y_min, y_max) + "insured_id": (0.600, 0.990, 0.135, 0.155), + "dob": (0.380, 0.500, 0.166, 0.182), + "patient_account": (0.300, 0.470, 0.862, 0.876), + "total_charge": (0.615, 0.745, 0.862, 0.876), +} + + +def _box_words(words, box): + x0, x1, y0, y1 = _BOX[box] + return [w.text for w in sorted(words, key=lambda w: (round(w.y, 3), w.x)) + if x0 <= w.x <= x1 and y0 <= w.y <= y1] + + +def parse_header(words): + dob = [t for t in _box_words(words, "dob") if t.isdigit()] + total = "".join(t for t in _box_words(words, "total_charge") if t != "$") + return { + "insured_id": "".join(_box_words(words, "insured_id")).upper(), + "dob": _date(*dob) if len(dob) == 3 else "", + "patient_account": " ".join(_box_words(words, "patient_account")), + "total_charge": parse_charge(total) if total else None, + } + + +# ================= WHOLE CLAIM ================= +@dataclass +class Claim: + insured_id: str + dob: str + patient_account: str + total_charge: object # Decimal or None + dx: list # [(slot_letter, code), ...] + lines: list # [ServiceLine, ...] + issues: list # human-readable review notes; empty = clean + + +def build_claim(pages): + """Build one Claim from the word lists of every page of a claim PDF.""" + if not any(pages): + return Claim("", "", "", None, [], [], ["No text layer on PDF (scanned?) - check by hand"]) + + header = parse_header(pages[0]) + lines, totals = [], [] + for words in pages: + for ln in parse_service_lines(words): + lines.append(ServiceLine(**{**ln.__dict__, "line_no": len(lines) + 1})) + totals.append(parse_header(words)["total_charge"]) + total = None if None in totals else sum(totals, Decimal("0.00")) + claim = Claim(header["insured_id"], header["dob"], header["patient_account"], + total, parse_diagnosis_codes(pages[0]), lines, []) + claim.issues = _review(claim) + return claim + + +def _review(claim): + issues = [] + if not claim.insured_id: + issues.append("No Insured ID (box 1a)") + if not claim.dob: + issues.append("No Date of Birth (box 3)") + if not claim.dx: + issues.append("No diagnosis codes (box 21)") + if not claim.lines: + issues.append("No service lines found") + slots = {s for s, _ in claim.dx} + for ln in claim.lines: + for label, value in (("Date of Service", ln.dos_from), ("POS", ln.pos), + ("Units", ln.units), ("Charge", ln.charge), + ("Rendering NPI", ln.npi)): + if not value: + issues.append(f"Line {ln.line_no}: no {label}") + for letter in ln.dx_pointer: + if letter not in slots: + issues.append(f"Line {ln.line_no}: Dx pointer {letter} has no diagnosis code") + line_sum = sum((ln.charge or Decimal("0.00") for ln in claim.lines), Decimal("0.00")) + if claim.total_charge is None: + issues.append("No total charge (box 28)") + elif line_sum != claim.total_charge: + issues.append(f"Line charges {line_sum:.2f} != box 28 total {claim.total_charge:.2f}") + return issues + + +# ================= REPORT ROW ================= +def _per_line(values, label): + if len(values) == 1: + return values[0] + return "; ".join(f"{label} {i}: {v}" for i, v in enumerate(values, 1)) + + +def to_report_row(claim, claim_id="", insurance="", patient_name="", + signature_physician="", fallback_account="", fallback_charges=""): + """One report row per claim: the bot's original 15 columns + Line Charges + Review Notes.""" + lines = claim.lines + money = lambda d: f"{d:.2f}" if d is not None else "" + dx_codes = [code for _, code in claim.dx] + return { + "Claim ID": claim_id, + "Rendering Provider #": _per_line([l.npi for l in lines], "RP #"), + "Signature of Physician": signature_physician, + "Patient's Account No": claim.patient_account or fallback_account, + "Date of Birth": claim.dob, + "Insurance Name": insurance, + "Insured ID Number": claim.insured_id, + "Patient's Name": patient_name, + "Date of Service": _per_line([l.dos_from for l in lines], "DOS"), + "Diagnosis Codes": f"{', '.join(dx_codes)} (Total: {len(dx_codes)})" if dx_codes else "", + "CPT": _per_line([l.cpt for l in lines], "CPT"), + "POS": _per_line([l.pos for l in lines], "POS"), + "Modifier": _per_line([" ".join(l.modifiers) for l in lines], "Modifier"), + "Units": _per_line([l.units for l in lines], "Units"), + "Charges": money(claim.total_charge) or fallback_charges, + "Line Charges": _per_line([money(l.charge) for l in lines], "Charge"), + "Review Notes": "; ".join(claim.issues), + } + + +# ================= PDF ENTRY POINT ================= +def read_pdf_pages(pdf_path): + """Word lists (normalized positions) for every page of the PDF.""" + import pdfplumber + pages = [] + with pdfplumber.open(str(pdf_path)) as pdf: + for page in pdf.pages: + width, height = float(page.width), float(page.height) + pages.append([ + Word(w["text"], (w["x0"] + w["x1"]) / 2 / width, (w["top"] + w["bottom"]) / 2 / height) + for w in page.extract_words() + ]) + return pages + + +def extract_claim(pdf_path): + return build_claim(read_pdf_pages(pdf_path)) diff --git a/src/command to run exe.txt b/src/command to run exe.txt new file mode 100644 index 0000000..d8068d1 --- /dev/null +++ b/src/command to run exe.txt @@ -0,0 +1,22 @@ +python -m PyInstaller ^ + --onefile ^ + --console ^ + --name ClaimStatusBot ^ + --add-data "..\Config;Config" ^ + main.py + +python -m PyInstaller --onefile ^ +--add-data "..\Input;Input" ^ +--add-data "..\Screenshots;Screenshots" ^ +--hidden-import=pyotp ^ +--hidden-import=pandas ^ +--hidden-import=openpyxl ^ +main.py + + +python -m PyInstaller --onefile ^ +--add-data "..\Input;Input" ^ +--add-data "..\Screenshots;Screenshots" ^ +--hidden-import=pandas ^ +--hidden-import=openpyxl ^ +AR_Workable \ No newline at end of file diff --git a/src/draft.py b/src/draft.py new file mode 100644 index 0000000..d7cc61b --- /dev/null +++ b/src/draft.py @@ -0,0 +1,1548 @@ +import subprocess +import time +import os +import sys +import socket +import re +import pandas as pd +from google.cloud import documentai_v1 as documentai +from google.oauth2 import service_account +import requests +from pathlib import Path +from datetime import datetime +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.chrome.service import Service +from selenium.common.exceptions import WebDriverException +from selenium.common.exceptions import NoSuchElementException +from selenium.common.exceptions import StaleElementReferenceException +from selenium.webdriver.support.ui import Select +from selenium.webdriver.common.keys import Keys + +try: + from pywinauto import Application +except Exception: + Application = None + +# ================= DOCUMENT AI CONFIG ================= +DOCAI_CREDENTIALS_PATH = r"C:\Potential Project G2\.env\pdf-extraction-492807-41531f078f92.json" +DOCAI_PROJECT_ID = "1065395041789" +DOCAI_LOCATION = "us" +DOCAI_PROCESSOR_ID = "2d4c7121ce241b13" + +# ================= PROJECT ROOT ================= +if getattr(sys, "frozen", False): + PROJECT_ROOT = Path(sys.executable).parent +else: + PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# ================= GLOBAL CONFIG ================= +DEBUG_PORT = 9222 +USER_DATA_DIR = os.path.join( + os.environ["USERPROFILE"], "Automation", "ChromeProfile_CF" +) +today_date = datetime.now().strftime("%m-%d-%Y") +DOWNLOADS_DIR = Path.home() / "Downloads" + + +# ================= CHROME PATH ================= +def find_chrome_path(): + possible_paths = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + os.path.join( + os.environ.get("LOCALAPPDATA", ""), + r"Google\Chrome\Application\chrome.exe" + ), + ] + for path in possible_paths: + if os.path.exists(path): + return path + return None + + +# ================= PROXY CONFIG ================= +def read_input_config(): + config_path = PROJECT_ROOT / "Input" / "input.xlsx" + if not config_path.exists(): + print("[ERROR] input.xlsx not found.") + sys.exit(1) + df = pd.read_excel(config_path, sheet_name="Sheet1", header=None) + config = {} + for _, row in df.iterrows(): + key = str(row[0]).strip() + value = str(row[1]).strip() + config[key] = value + proxy_ip = config.get("Proxy IP", "") + proxy_port = config.get("Proxy Port", "") + waystar_username = config.get("Waystar Username", "") + waystar_password = config.get("Waystar Password", "") + portal_url = config.get("Waystar URL", "") + first_car_answer = config.get("Waystar SQ First Car", "") + first_job_answer = config.get("Waystar SQ First Job", "") + print(f"[INFO] Proxy → {proxy_ip}:{proxy_port}") + print(f"[INFO] Username → {waystar_username}") + print(f"[INFO] Portal URL → {portal_url}") + return ( + proxy_ip, proxy_port, waystar_username, waystar_password, + portal_url, first_car_answer, first_job_answer, + ) + + +# ================= DEBUG PORT CHECK ================= +def wait_for_debug_port(port=9222, timeout=40): + start = time.time() + while time.time() - start < timeout: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(("127.0.0.1", port)) == 0: + return True + time.sleep(1) + return False + + +# ================= KILL CHROME ================= +def kill_all_chrome(): + try: + os.system("taskkill /F /IM chrome.exe /T >nul 2>&1") + time.sleep(3) + print("[INFO] All Chrome instances closed") + except Exception as e: + print(f"[WARN] Unable to kill Chrome: {e}") + + +# ================= START CHROME DEBUG ================= +def start_chrome_debug(proxy_ip=None, proxy_port=None): + chrome_path = find_chrome_path() + if not chrome_path: + raise Exception("Google Chrome not found.") + print(f"[INFO] Using Chrome path: {chrome_path}") + os.makedirs(USER_DATA_DIR, exist_ok=True) + cmd = [ + chrome_path, + f"--remote-debugging-port={DEBUG_PORT}", + f"--user-data-dir={USER_DATA_DIR}", + "--disable-blink-features=AutomationControlled", + "--ignore-certificate-errors", + "about:blank", + ] + if proxy_ip and proxy_port: + proxy = f"{proxy_ip}:{proxy_port}" + cmd.append(f"--proxy-server=http://{proxy}") + print(f"[INFO] Chrome started with proxy → {proxy}") + else: + print("[INFO] Chrome started WITHOUT proxy") + print("[INFO] Launching Chrome...") + subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if not wait_for_debug_port(DEBUG_PORT, timeout=40): + print("[ERROR] Chrome debug port did not open") + return False + print("[INFO] Chrome debug port is ready") + return True + + +# ================= SCREENSHOT ================= +def capture_screenshot(driver, client_name, step_name): + if not driver: + return None + safe_client = "".join(c for c in client_name if c.isalnum() or c in ("_", "-")) + safe_step = "".join(c for c in step_name if c.isalnum() or c in ("_", "-")) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + screenshots_dir = PROJECT_ROOT / "Screenshots" / today_date / safe_client + screenshots_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshots_dir / f"{safe_step}_{timestamp}.png" + try: + driver.save_screenshot(str(screenshot_path)) + print(f"[INFO] Screenshot saved: {screenshot_path}") + return screenshot_path + except Exception as e: + print(f"[ERROR] Screenshot failed: {e}") + return None + + +# ================= ATTACH BROWSER ================= +def attach_browser(download_path, retries=5): + for attempt in range(1, retries + 1): + try: + print(f"[INFO] Attaching browser (attempt {attempt})") + chrome_options = Options() + chrome_options.add_experimental_option( + "debuggerAddress", f"127.0.0.1:{DEBUG_PORT}" + ) + chrome_options.set_capability("webSocketUrl", False) + prefs = { + "download.default_directory": str(download_path), + "download.prompt_for_download": False, + "download.directory_upgrade": True, + "plugins.always_open_pdf_externally": True, + "safebrowsing.enabled": True, + } + chrome_options.add_experimental_option("prefs", prefs) + service = Service() + driver = webdriver.Chrome(service=service, options=chrome_options) + _ = driver.current_url + driver.maximize_window() + print("[INFO] Browser attached successfully") + try: + driver.execute_cdp_cmd( + "Page.setDownloadBehavior", + {"behavior": "allow", "downloadPath": str(download_path)}, + ) + except Exception as e: + print(f"[WARN] Unable to set download behavior: {e}") + return driver + except WebDriverException as e: + print(f"[WARN] Attach attempt {attempt} failed: {e}") + time.sleep(3) + print("[ERROR] Failed to attach Chrome after retries") + return None + + +# ================= LOGIN ================= +def login_to_portal(driver, waystar_username, waystar_password, + first_car_answer, first_job_answer): + wait = WebDriverWait(driver, 30) + try: + time.sleep(5) + print("[INFO] Starting login process") + username_field = wait.until(EC.presence_of_element_located((By.ID, "loginName"))) + username_field.clear() + username_field.send_keys(waystar_username) + time.sleep(2) + password_field = wait.until(EC.presence_of_element_located((By.ID, "password"))) + password_field.clear() + password_field.send_keys(waystar_password) + time.sleep(2) + driver.find_element(By.ID, "loginButton").click() + time.sleep(5) + print("[INFO] Login clicked") + try: + alert_page = driver.find_elements( + By.XPATH, "//span[contains(text(),'Proactive Alert')]" + ) + if alert_page: + wait.until( + EC.element_to_be_clickable((By.XPATH, "//button[@id='continueButton']")) + ).click() + time.sleep(3) + except Exception: + pass + try: + question_exists = driver.find_elements( + By.XPATH, "//*[@id='authDiv']//strong" + ) + if question_exists: + question = wait.until( + EC.presence_of_element_located((By.XPATH, "//*[@id='authDiv']//strong")) + ).text.lower() + print(f"[INFO] Security Question → {question}") + if "first car" in question: + answer = first_car_answer + elif "first job" in question: + answer = first_job_answer + else: + raise Exception(f"Unknown security question: {question}") + answer_box = wait.until( + EC.presence_of_element_located((By.ID, "verifyAnswer")) + ) + answer_box.clear() + answer_box.send_keys(answer) + driver.find_element(By.ID, "VerifyButton").click() + print("[INFO] Security answer submitted") + time.sleep(3) + except Exception: + pass + try: + security_qpage = driver.find_elements( + By.XPATH, "//h2[normalize-space()='Security Information']" + ) + if security_qpage: + print("[INFO] Security page appeared, skipping") + wait.until(EC.element_to_be_clickable((By.ID, "NextButton"))).click() + time.sleep(2) + wait.until( + EC.element_to_be_clickable( + (By.XPATH, "//a[normalize-space()='Skip This Step']") + ) + ).click() + time.sleep(5) + except Exception: + pass + print("[INFO] Login completed successfully") + except Exception as e: + print(f"[ERROR] Login failed: {e}") + raise + + +# ================= CALCULATE DATE RANGE ================= +def get_date_range(): + now = datetime.now() + first_of_current = datetime(now.year, now.month, 1) + last_of_prev = first_of_current - pd.Timedelta(days=1) + first_of_prev = datetime(last_of_prev.year, last_of_prev.month, 1) + return ( + first_of_prev.strftime("%m/%d/%Y"), + last_of_prev.strftime("%m/%d/%Y"), + ) + + +# ================= APPLY FILTERS ================= +def apply_filters(driver): + wait = WebDriverWait(driver, 40) + print("[INFO] Applying filters") + wait.until(EC.presence_of_element_located((By.ID, "ClaimListingSearchButtonBottom"))) + wait.until(EC.presence_of_element_located((By.ID, "SearchCriteria_Status"))) + status_dropdown = Select( + wait.until(EC.element_to_be_clickable((By.ID, "SearchCriteria_Status"))) + ) + status_dropdown.select_by_visible_text("All") + date_dropdown = Select( + wait.until(EC.element_to_be_clickable((By.ID, "SearchCriteria_TransDate"))) + ) + date_dropdown.select_by_visible_text("Custom") + time.sleep(2) + start_date, end_date = get_date_range() + print(f"[INFO] Setting date range: {start_date} to {end_date}") + try: + start_date_field = wait.until( + EC.presence_of_element_located((By.ID, "transactionDateFromDateBox")) + ) + start_date_field.clear() + start_date_field.send_keys(start_date) + time.sleep(1) + except Exception as e: + print(f"[WARN] Could not set start date: {e}") + try: + end_date_field = wait.until( + EC.presence_of_element_located((By.ID, "transactionDateToDateBox")) + ) + end_date_field.clear() + end_date_field.send_keys(end_date) + time.sleep(1) + except Exception as e: + print(f"[WARN] Could not set end date: {e}") + search_btn = wait.until( + EC.element_to_be_clickable((By.ID, "ClaimListingSearchButtonBottom")) + ) + driver.execute_script("arguments[0].click();", search_btn) + wait.until( + EC.presence_of_element_located( + (By.XPATH, "//table[@id='claimsGrid']//tbody//tr") + ) + ) + time.sleep(3) + print("[INFO] Filters applied successfully") + + +# ================= CLICK NEXT PAGE ================= +def click_next_page(driver): + try: + next_btn = driver.find_element(By.XPATH, "//button[@id='innerPagerRight']") + if "disabled" in next_btn.get_attribute("class"): + return False + driver.execute_script("arguments[0].click();", next_btn) + WebDriverWait(driver, 10).until(EC.staleness_of(next_btn)) + time.sleep(2) + return True + except Exception: + return False + + +# ================= DIRECT DOWNLOAD ================= +def download_pdf_direct(driver, pdf_url, save_path): + try: + cookies = {c["name"]: c["value"] for c in driver.get_cookies()} + headers = { + "User-Agent": driver.execute_script("return navigator.userAgent;"), + "Referer": driver.current_url, + "Accept": "application/pdf,*/*", + } + response = requests.get( + pdf_url, headers=headers, cookies=cookies, stream=True, timeout=60 + ) + if response.status_code != 200: + print(f"[ERROR] HTTP {response.status_code} → {pdf_url}") + return False + content_type = response.headers.get("Content-Type", "") + if "pdf" not in content_type.lower() and "octet-stream" not in content_type.lower(): + print(f"[WARN] Unexpected content type → {content_type}") + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + with open(save_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + print(f"[SUCCESS] Downloaded → {save_path.name}") + return True + except Exception as e: + print(f"[ERROR] Direct download failed → {e}") + return False + + +# ============================================================ +# CMS-1500 DOCUMENT AI EXTRACTOR — v7.0 (PRODUCTION) +# +# Key guarantees: +# • DOS : Serial-number gutter (x < 0.045) excluded — row +# numbers like "01","02" never picked up as MM. +# • DX : Single, authoritative _is_icd10() covering all +# ICD-10-CM forms up to 7 chars (S81812A, G20C, +# R2681, Z7409, dotted A01.0 etc.). +# • CPT : Accepts standard 5-digit AND HCPCS alpha codes +# (Q4250, G0283, L8699). +# • Units: Primary lookup confined to tight x-range; fallback +# also constrained to same x-range — charges column +# never bleeds in. Diagnosis-pointer letters (A-L) +# are explicitly rejected. +# ============================================================ + +# ── Diagnosis pointer letters (column E of section 24) ────── +_DIAG_POINTER_LETTERS = set("ABCDEFGHIJKL") + +# ── Normalized field regions ───────────────────────────────── +FIELD_1A = { + "x_min": 0.55, "x_max": 0.95, + "y_min": 0.095, "y_max": 0.185, +} + +FIELD_21 = { + "x_min": 0.02, "x_max": 0.68, + "y_min": 0.510, "y_max": 0.590, +} + +SECTION_24_Y_MIN = 0.615 +SECTION_24_Y_MAX = 0.880 + +# ── Column x-ranges (normalized 0–1) ───────────────────────── +# DOS_FROM_MM x_min = 0.045 ← excludes left-gutter row serial +# numbers printed at x ≈ 0.01–0.035. +COL = { + "DOS_FROM_MM": (0.045, 0.085), + "DOS_FROM_DD": (0.078, 0.120), + "DOS_FROM_YY": (0.112, 0.165), + "POS": (0.215, 0.272), + "CPT": (0.285, 0.430), + "MODIFIER": (0.410, 0.540), + "UNITS": (0.648, 0.695), + "RENDERING": (0.810, 0.998), +} + +_VALID_MODIFIERS = { + "GP","GN","GO","GQ","GX","GY","GZ", + "LT","RT", + "E1","E2","E3","E4", + "FA","F1","F2","F3","F4","F5","F6","F7","F8","F9", + "TA","T1","T2","T3","T4","T5","T6","T7","T8","T9", + "TC","26","59","25","51","76","77","78","79","91", + "24","57","58","80","81","82", + "AA","AD","AE","AF","AG","AH","AI","AJ","AK","AM","AS","AT", + "CQ","CR","CS","GA","GC","GJ","GK","GL","GT", + "JA","JB","JW","JZ","KX","KU","KV","NU","RR","UE", + "Q0","Q1","Q2","Q3","Q4","Q5","Q6", + "SA","SB","SC","SD","SF","SG","XE","XP","XS","XU", + "22","23","32","33","47","50","52","53", + "54","55","56","62","63","66", + "73","74","90","92","93","95","96","97","99", + "AB", +} + +_NOT_MODIFIERS = { + "MM","DD","YY","TO","OF","OR","IN","IS","NO","BY", + "AT","BE","DO","HE","HI","IF","ME","MY","OK","ON", + "SO","UP","US","WE","NPI","ICD","CPT","POS","DOS", + "ID","AN","AC","EMG","FROM","THE","AND", +} + +_VALID_POS = { + "11","12","13","14","15","16","17","18","19","20", + "21","22","23","24","25","31","32","33","34", + "41","42","49","50","51","52","53","54","55","56","57", + "60","61","62","65","71","72","81","99", +} + +_SKIP_ID_LABELS = { + "MEDICARE","MEDICAID","CHAMPVA","TRICARE","CHAMPUS", + "NUMBER","INSURED","MEMBER","HEALTH","PLAN","PICA", + "PROGRAM","ITEM","FOR","OTHER","GROUP","FECA","CARRIER", + "1A","FOR","IN","ITEM","NPI","SSN","EIN","QUAL", + "ID","INSURED'S","I.D.","OTHER","(FOR","(",")", + "1)","IN","ITEM","1", +} + +# ── DX slot labels printed on the form (A. B. … L.) ───────── +_DX_SLOT_LABEL_PAT = re.compile(r"^[A-L]\.?$", re.IGNORECASE) + +# ── Form section / header tokens to skip in field 21 ──────── +_DX_SKIP_TOKENS = { + "21", "22", "23", "24", "0", "O", + "ICD", "IND", "ICO", "ICD-10-CM", + "DIAGNOSIS", "NATURE", "ILLNESS", "INJURY", +} + + +# ============================================================ +# DOCUMENT AI CLIENT +# ============================================================ + +def _get_docai_client(): + credentials = service_account.Credentials.from_service_account_file( + DOCAI_CREDENTIALS_PATH, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + return documentai.DocumentProcessorServiceClient( + credentials=credentials, + client_options={"api_endpoint": "us-documentai.googleapis.com"}, + ) + + +def _process_pdf_with_docai(pdf_path: str): + client = _get_docai_client() + with open(pdf_path, "rb") as f: + pdf_bytes = f.read() + processor_name = ( + f"projects/{DOCAI_PROJECT_ID}/locations/{DOCAI_LOCATION}" + f"/processors/{DOCAI_PROCESSOR_ID}" + ) + result = client.process_document( + request=documentai.ProcessRequest( + name=processor_name, + raw_document=documentai.RawDocument( + content=pdf_bytes, mime_type="application/pdf" + ), + ) + ) + return result.document + + +# ============================================================ +# TOKEN / WORD HELPERS +# ============================================================ + +def _token_text(document, token) -> str: + seg = token.layout.text_anchor.text_segments + if not seg: + return "" + start = int(seg[0].start_index) if seg[0].start_index else 0 + end = int(seg[0].end_index) + return document.text[start:end].strip() + + +def _token_norm_bounds(token): + verts = token.layout.bounding_poly.normalized_vertices + if len(verts) < 4: + return None + xs = [v.x for v in verts] + ys = [v.y for v in verts] + return min(xs), min(ys), max(xs), max(ys) + + +def _build_words(document, page) -> list: + words = [] + for token in page.tokens: + text = _token_text(document, token).strip() + if not text: + continue + bounds = _token_norm_bounds(token) + if bounds is None: + continue + x0, y_top, x1, y_bot = bounds + words.append({ + "text": text, + "x0": x0, + "x1": x1, + "y_top": y_top, + "y_bot": y_bot, + "x_mid": (x0 + x1) / 2, + "y_mid": (y_top + y_bot) / 2, + }) + return words + + +def _words_in_region(words: list, region: dict) -> list: + return [ + w for w in words + if region["x_min"] <= w["x_mid"] <= region["x_max"] + and region["y_min"] <= w["y_mid"] <= region["y_max"] + ] + + +def _words_in_col(words: list, row_y_mid: float, + col_name: str, y_tol: float = 0.013) -> list: + x_min, x_max = COL[col_name] + return [ + w for w in words + if x_min <= w["x_mid"] <= x_max + and abs(w["y_mid"] - row_y_mid) <= y_tol + ] + + +# ============================================================ +# VALIDATORS +# ============================================================ + +def _is_icd10(t: str) -> bool: + """ + Validates ICD-10-CM codes — single authoritative definition. + + Accepts ALL of: + • Dotted : A01.0 M54.51 S72.001A (letter + 2-3 digits + dot + 1-4 chars + optional suffix) + • Undotted: F329 Z7409 R2681 G20C S81812A M80011A + (letter + digit + 1-5 alphanumeric, total 3-7 chars) + + Rule: first char = letter, second char = digit (ICD-10-CM spec). + Maximum valid length = 7 characters. + """ + t = t.upper().strip() + if len(t) < 3 or len(t) > 7: + return False + if not t[0].isalpha() or not t[1].isdigit(): + return False + # Dotted form: e.g. A01.0, M54.51, S72.001A + if re.fullmatch(r"[A-Z]\d{2,3}\.\d{1,4}[A-Z]?", t): + return True + # Undotted form: letter + digit + 1-5 alphanumeric chars + if re.fullmatch(r"[A-Z]\d[0-9A-Z]{1,5}", t): + return True + return False + + +def _is_cpt(t: str) -> bool: + """ + Validates CPT / HCPCS codes. + + Accepts: + • Standard CPT (5 digits) : 97110, 99348, 15271 + • HCPCS Level II (letter + 4 digits) : Q4250, G0283, L8699, A6530 + • Category III CPT (4 digits + T/F) : 0596T, 0163F + """ + t = t.strip().upper() + # Standard 5-digit numeric CPT + if re.fullmatch(r"\d{5}", t): + return 10000 <= int(t) <= 99999 + # HCPCS Level II: A–V letter + 4 digits + if re.fullmatch(r"[A-V]\d{4}", t): + return True + # Category III CPT: 4 digits + T or F suffix + if re.fullmatch(r"\d{4}[TF]", t): + return True + return False + + +def _is_valid_insured_id(t: str) -> bool: + t_clean = re.sub(r"[-_.\s()]", "", t.upper()) + if not re.match(r"^[A-Z0-9]+$", t_clean): + return False + length = len(t_clean) + if length < 5 or length > 20: + return False + base = t.upper().rstrip(".,)(") + if base in _SKIP_ID_LABELS: + return False + if any(label in t.upper() + for label in { + "INSURED", "NUMBER", "PROGRAM", "MEDICARE", "MEDICAID", + "PICA", "CARRIER", "HEALTH", "PLAN", "CHAMPVA", "TRICARE", + }): + return False + if re.fullmatch(r"\d{10}", t_clean): # NPI + return False + if _is_cpt(t_clean): # CPT / HCPCS + return False + if re.fullmatch(r"[A-Z]{1,5}", t_clean): # abbreviations + return False + if re.fullmatch(r"\d+", t_clean): + return 7 <= length <= 12 + if re.search(r"[A-Z]", t_clean) and re.search(r"\d", t_clean): + return True + return False + + +def _is_valid_dos(mm: str, dd: str, yy: str) -> bool: + try: + m, d, y = int(mm), int(dd), int(yy) + return (1 <= m <= 12) and (1 <= d <= 31) and (2000 <= y <= 2099) + except Exception: + return False + + +# ============================================================ +# FIELD EXTRACTORS +# ============================================================ + +def _extract_dob(text: str) -> str: + m = re.search(r"\b(\d{2})\s+(\d{2})\s+(\d{4})\b", text) + if m: + return f"{m.group(1)}/{m.group(2)}/{m.group(3)}" + m = re.search(r"\b(\d{2})[\/\-](\d{2})[\/\-](\d{4})\b", text) + if m: + return f"{m.group(1)}/{m.group(2)}/{m.group(3)}" + return "" + + +def _extract_insured_id(words: list, text: str) -> str: + wide_region = {"x_min": 0.50, "x_max": 0.95, + "y_min": 0.08, "y_max": 0.20} + wide_tokens = _words_in_region(words, wide_region) + print(f"[DEBUG] Field 1a wide region tokens: " + f"{[(w['text'], round(w['y_mid'], 3)) for w in wide_tokens]}") + + # Primary: region scan + candidates = _words_in_region(words, FIELD_1A) + for w in sorted(candidates, key=lambda w: (w["y_top"], w["x0"])): + t = w["text"].strip() + if _is_valid_insured_id(t): + print(f"[DEBUG] Insured ID via region: {t}") + return t.upper() + + # Fallback 1: look for label "1a" then scan right + for i, w in enumerate(words): + if re.fullmatch(r"1[aA]\.?", w["text"].strip()): + for nxt in words[i + 1: i + 15]: + if nxt["x_mid"] > 0.50 and nxt["y_mid"] < 0.22: + if _is_valid_insured_id(nxt["text"].strip()): + print(f"[DEBUG] Insured ID via 1a label: {nxt['text']}") + return nxt["text"].strip().upper() + + # Fallback 2: top-right area scan + top_right = [w for w in words if w["x_mid"] > 0.52 and w["y_mid"] < 0.22] + for w in sorted(top_right, key=lambda w: (w["y_top"], w["x0"])): + t = w["text"].strip() + if _is_valid_insured_id(t): + print(f"[DEBUG] Insured ID via top-right scan: {t}") + return t.upper() + + print("[DEBUG] Insured ID: not found") + return "" + + +def _extract_diagnosis_codes(words: list, text: str) -> list: + """ + Extract ICD-10-CM diagnosis codes from Field 21 of the CMS-1500. + Captures everything: dotted, undotted, suffix letters, 3-7 chars. + """ + region_words = _words_in_region(words, FIELD_21) + print(f"[DEBUG] Field 21 region token count: {len(region_words)}") + print(f"[DEBUG] Field 21 tokens: {[w['text'] for w in region_words]}") + + codes = [] + seen = set() + + for w in sorted(region_words, key=lambda w: (w["y_top"], w["x0"])): + t = w["text"].strip().upper() + + # Skip slot label letters printed on the form (A. B. … L.) + if _DX_SLOT_LABEL_PAT.match(t): + continue + + # Skip form header / section labels + if t in _DX_SKIP_TOKENS: + continue + + # Skip pure digits — no ICD-10 code is all-numeric + if t.isdigit(): + continue + + # Accept valid ICD-10-CM codes + if _is_icd10(t) and t not in seen: + codes.append(t) + seen.add(t) + print(f"[DEBUG] DX captured: {t}") + + # ── Text-regex fallback when region yields nothing ──────── + if not codes: + print("[DEBUG] Region empty — trying text regex fallback") + seg = re.search( + r"(?:DIAGNOSIS|21\.?)([\s\S]{0,600})" + r"(?:24\.|DATE.S.\s*OF\s*SERVICE)", + text, re.IGNORECASE, + ) + if seg: + # Broadened fallback: letter + digit + 1-5 alphanum, + # with optional dot + 1-4 chars + for c in re.findall( + r"\b([A-Z]\d[0-9A-Z]{1,5}(?:\.[0-9A-Z]{1,4})?)\b", + seg.group(1), + ): + cu = c.upper() + if cu not in seen and _is_icd10(cu): + seen.add(cu) + codes.append(cu) + print(f"[DEBUG] DX fallback: {cu}") + + print(f"[DEBUG] Diagnosis codes final ({len(codes)}): {codes}") + return codes + + +# ============================================================ +# SERVICE LINE HELPERS +# ============================================================ + +def _is_real_cpt_location(w: dict, words: list) -> bool: + """ + Confirm a CPT token truly belongs to section 24 by requiring + a DOS digit OR a 10-digit NPI on the same row. + """ + row_y = w["y_mid"] + y_tol = 0.018 + + # DOS columns — use updated x_min values (gutter excluded) + for col in ("DOS_FROM_MM", "DOS_FROM_DD", "DOS_FROM_YY"): + x_min, x_max = COL[col] + dos_hit = [ + d for d in words + if x_min <= d["x_mid"] <= x_max + and abs(d["y_mid"] - row_y) <= y_tol + and re.fullmatch(r"\d{1,4}", d["text"].strip()) + ] + if dos_hit: + return True + + # Rendering NPI column + x_min_npi, x_max_npi = COL["RENDERING"] + npi_hit = [ + d for d in words + if x_min_npi <= d["x_mid"] <= x_max_npi + and abs(d["y_mid"] - row_y) <= y_tol + and re.fullmatch(r"\d{10}", d["text"].strip()) + ] + return bool(npi_hit) + + +def _extract_dos_for_row(words: list, row_y: float) -> str: + """ + Extract Date of Service for a service line row. + Gutter guard: skip tokens at x_mid < 0.025 only + (row serial numbers are printed at x < 0.02). + """ + def _first_digit(col_name): + ws = _words_in_col(words, row_y, col_name, y_tol=0.018) + for w in sorted(ws, key=lambda w: w["x0"]): + # Only skip extreme left gutter (row numbers like "1","2","3") + if w["x_mid"] < 0.025: + continue + if re.fullmatch(r"\d{1,4}", w["text"].strip()): + return w["text"].strip() + return "" + + mm = _first_digit("DOS_FROM_MM").zfill(2) + dd = _first_digit("DOS_FROM_DD").zfill(2) + yy = _first_digit("DOS_FROM_YY") + + if re.fullmatch(r"\d{2}", yy): + yy = ("20" + yy) if int(yy) < 50 else ("19" + yy) + + if _is_valid_dos(mm, dd, yy): + return f"{mm}/{dd}/{yy}" + + # Fallback: collect all digit tokens across DOS columns sorted left→right + all_w = [] + for col in ("DOS_FROM_MM", "DOS_FROM_DD", "DOS_FROM_YY"): + all_w += [ + w for w in _words_in_col(words, row_y, col, y_tol=0.018) + if w["x_mid"] >= 0.025 # only skip extreme gutter + ] + + parts = [ + w["text"].strip() + for w in sorted(all_w, key=lambda w: w["x0"]) + if re.fullmatch(r"\d+", w["text"].strip()) + ] + + print(f"[DEBUG DOS] row_y={row_y:.4f} | mm={mm} dd={dd} yy={yy} | " + f"fallback parts={parts}") + + if len(parts) >= 3: + mm2 = parts[0].zfill(2) + dd2 = parts[1].zfill(2) + yy2 = parts[2] + if re.fullmatch(r"\d{2}", yy2): + yy2 = ("20" + yy2) if int(yy2) < 50 else ("19" + yy2) + if _is_valid_dos(mm2, dd2, yy2): + return f"{mm2}/{dd2}/{yy2}" + + return "" + +def _extract_pos_for_row(words: list, row_y: float) -> str: + for w in _words_in_col(words, row_y, "POS"): + if w["text"].strip() in _VALID_POS: + return w["text"].strip() + return "" + + +def _extract_modifier_for_row(words: list, row_y: float) -> str: + mod_words = _words_in_col(words, row_y, "MODIFIER", y_tol=0.016) + found = [] + for w in sorted(mod_words, key=lambda w: w["x0"]): + t = w["text"].strip().upper() + if not t: + continue + # Skip diagnosis pointer letters + if re.fullmatch(r"[A-L]", t): + continue + if t in _NOT_MODIFIERS: + continue + if t in _VALID_MODIFIERS and t not in found: + found.append(t) + if len(found) == 4: + break + return " ".join(found) + + +def _extract_units_for_row(words: list, row_y: float, + cpt_val: str = "", mod_val: str = "", + pos_val: str = "", npi_val: str = "") -> str: + """ + Extract units (G column) for a service line row. + + Strategy: elimination-based. + On the CMS-1500, section 24 columns left→right are: + DOS | POS | EMG | CPT | MODIFIER | DIAG-POINTER | CHARGES | UNITS | RENDERING + Units is a small integer (1-99) that sits between the + diagnosis-pointer column and the rendering NPI column. + We collect ALL tokens on the row, ban every token already + claimed by a named column, then pick the leftmost remaining + 1-2 digit integer that falls in the right half of the form + (x_mid > 0.54) but left of the rendering column (x_mid < 0.81). + """ + y_tol = 0.022 + + # ── Debug: print all tokens on this row so we can calibrate ── + all_row = [ + w for w in words + if abs(w["y_mid"] - row_y) <= y_tol + and SECTION_24_Y_MIN <= w["y_mid"] <= SECTION_24_Y_MAX + ] + print(f"[DEBUG UNITS] All tokens on row y≈{row_y:.4f}: " + f"{[(w['text'], round(w['x_mid'], 4)) for w in sorted(all_row, key=lambda w: w['x_mid'])]}") + + # ── Build ban-set from all other named columns ──────────── + ban = set() + # Values already extracted for this row + for v in (cpt_val, pos_val, npi_val): + if v: + ban.add(v.upper()) + if mod_val: + for m in mod_val.split(): + ban.add(m.upper()) + + # Tokens from every other named column + for col in ("DOS_FROM_MM", "DOS_FROM_DD", "DOS_FROM_YY", + "POS", "CPT", "MODIFIER", "RENDERING"): + for w in _words_in_col(words, row_y, col, y_tol=y_tol + 0.005): + ban.add(w["text"].strip().upper()) + + print(f"[DEBUG UNITS] Ban set: {ban}") + + # ── Find the actual NPI left-edge to use as hard right wall ─ + # This adapts to whatever x the NPI lands on for this form. + npi_x_boundary = 0.998 # fallback if no NPI found on this row + for w in words: + if (abs(w["y_mid"] - row_y) <= y_tol + and re.fullmatch(r"\d{10}", w["text"].strip())): + npi_x_boundary = min(npi_x_boundary, w["x0"]) + + # ── Candidate zone: between modifier area and NPI left edge ─ + UNIT_X_MIN = 0.540 # right of modifier / diag-pointer + UNIT_X_MAX = npi_x_boundary # left wall of NPI token + + print(f"[DEBUG UNITS] Search zone x={UNIT_X_MIN:.3f}→{UNIT_X_MAX:.3f}") + + candidates = [ + w for w in words + if UNIT_X_MIN <= w["x_mid"] <= UNIT_X_MAX + and abs(w["y_mid"] - row_y) <= y_tol + and SECTION_24_Y_MIN <= w["y_mid"] <= SECTION_24_Y_MAX + and w["text"].strip().upper() not in ban + and w["text"].strip().upper() not in _DIAG_POINTER_LETTERS + and re.fullmatch(r"[1-9]\d?", w["text"].strip()) # 1-99, never 4-digit charges + ] + + print(f"[DEBUG UNITS] Candidates after ban: " + f"{[(w['text'], round(w['x_mid'], 4)) for w in sorted(candidates, key=lambda w: w['x_mid'])]}") + + if candidates: + # Rightmost qualifying token = closest to NPI = units column + best = sorted(candidates, key=lambda w: w["x_mid"], reverse=True)[0] + print(f"[DEBUG UNITS] → Selected: {best['text']} at x={best['x_mid']:.4f}") + return best["text"].strip() + + return "" + + +def _extract_npi_for_row(words: list, row_y: float) -> str: + for w in _words_in_col(words, row_y, "RENDERING", y_tol=0.016): + if re.fullmatch(r"\d{10}", w["text"].strip()): + return w["text"].strip() + return "" + + +# ============================================================ +# SERVICE LINE EXTRACTION +# ============================================================ + +def _extract_service_lines(words: list): + cpts, pos_list, dos_list, mod_list, units_list, rp_list = [], [], [], [], [], [] + + x_min_cpt, x_max_cpt = COL["CPT"] + + # Find CPT / HCPCS candidates in section 24 + cpt_candidates = [ + w for w in words + if _is_cpt(w["text"].strip()) + and x_min_cpt <= w["x_mid"] <= x_max_cpt + and SECTION_24_Y_MIN <= w["y_mid"] <= SECTION_24_Y_MAX + ] + print(f"[DEBUG] CPT candidates before validation: " + f"{[(w['text'], round(w['y_mid'], 4)) for w in cpt_candidates]}") + + cpt_words = [w for w in cpt_candidates if _is_real_cpt_location(w, words)] + print(f"[DEBUG] CPT tokens after validation: " + f"{[(w['text'], round(w['y_mid'], 4)) for w in cpt_words]}") + + if not cpt_words: + print("[DEBUG] No valid CPTs after validation — trying wider scan") + wider_candidates = [ + w for w in words + if _is_cpt(w["text"].strip()) + and 0.55 <= w["y_mid"] <= 0.92 + and 0.20 <= w["x_mid"] <= 0.50 + and _is_real_cpt_location(w, words) + ] + cpt_words = wider_candidates + print(f"[DEBUG] Wider scan CPTs: {[w['text'] for w in cpt_words]}") + + seen_rows = [] + for cpt_word in sorted(cpt_words, key=lambda w: w["y_mid"]): + row_y = cpt_word["y_mid"] + if any(abs(row_y - sr) <= 0.008 for sr in seen_rows): + continue + seen_rows.append(row_y) + + cpt_val = cpt_word["text"].strip().upper() + dos = _extract_dos_for_row(words, row_y) + pos = _extract_pos_for_row(words, row_y) + mod = _extract_modifier_for_row(words, row_y) + npi = _extract_npi_for_row(words, row_y) + units = _extract_units_for_row(words, row_y, cpt_val, mod, pos, npi) + + cpts.append(cpt_val) + dos_list.append(dos) + pos_list.append(pos) + mod_list.append(mod) + units_list.append(units) + rp_list.append(npi) + + print(f"[DEBUG] CPT={cpt_val} | DOS={dos} | POS={pos} | " + f"MOD={mod} | UNITS={units} | NPI={npi}") + + return cpts, pos_list, dos_list, mod_list, units_list, rp_list + + +def _format_list(values: list, label: str) -> str: + clean = [v for v in values if v] + if not clean: + return "" + if len(clean) == 1: + return clean[0] + return "; ".join(f"{label} {i + 1}: {v}" for i, v in enumerate(clean)) + + +# ============================================================ +# MAIN EXTRACTION ENTRY POINT +# ============================================================ + +def extract_pdf_data_fast( + pdf_path, + claim_id="", + patient_account="", + insurance="", + patient_name="", + total_charges="", + signature_physician="", +): + data = { + "Claim ID": claim_id, + "Rendering Provider #": "", + "Signature of Physician": signature_physician, + "Patient's Account No": patient_account, + "Date of Birth": "", + "Insurance Name": insurance, + "Insured ID Number": "", + "Patient's Name": patient_name, + "Date of Service": "", + "Diagnosis Codes": "", + "CPT": "", + "POS": "", + "Modifier": "", + "Units": "", + "Charges": total_charges, + } + + try: + print(f"[INFO] Processing with Document AI: {pdf_path}") + document = _process_pdf_with_docai(str(pdf_path)) + + if not document.pages: + print("[ERROR] No pages returned by Document AI") + return None + + page = document.pages[0] + text = document.text + + print(f"[DEBUG] Document AI extracted {len(text)} characters") + print(f"[DEBUG] Token count: {len(page.tokens)}") + print(f"[DEBUG] Page dimensions: " + f"{page.dimension.width:.1f} x {page.dimension.height:.1f} pt") + + words = _build_words(document, page) + print(f"[DEBUG] Words built: {len(words)}") + + data["Date of Birth"] = _extract_dob(text) + data["Insured ID Number"] = _extract_insured_id(words, text) + + dx_codes = _extract_diagnosis_codes(words, text) + if dx_codes: + data["Diagnosis Codes"] = ( + ", ".join(dx_codes) + f" (Total: {len(dx_codes)})" + ) + + cpts, pos_list, dos_list, mod_list, units_list, rp_list = \ + _extract_service_lines(words) + + data["CPT"] = _format_list(cpts, "CPT") + data["POS"] = _format_list(pos_list, "POS") + data["Date of Service"] = _format_list(dos_list, "DOS") + data["Modifier"] = _format_list(mod_list, "Modifier") + data["Units"] = _format_list(units_list, "Units") + data["Rendering Provider #"] = _format_list(rp_list, "RP #") + + return data + + except Exception as e: + import traceback + print(f"[ERROR] Document AI extraction failed → {pdf_path}") + print(f"[ERROR] {e}") + traceback.print_exc() + return None + + +# ================= UPDATE LOG ================= +def update_log(log_path, claim_id, status, remarks=""): + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + new_row = pd.DataFrame([{ + "Claim ID": claim_id, + "Status": status, + "Remarks": remarks, + "Timestamp": now, + }]) + try: + existing = pd.read_excel(log_path) + final = pd.concat([existing, new_row], ignore_index=True) + except Exception: + final = new_row + final.to_excel(log_path, index=False) + + +# ================= INCREMENTAL EXCEL SAVE ================= +def _save_batch_to_excel(excel_path, batch_data: list): + if not batch_data: + return + df_new = pd.DataFrame(batch_data) + try: + existing = pd.read_excel(excel_path) + existing = existing.dropna(how="all") + final = pd.concat([existing, df_new], ignore_index=True) + except Exception: + final = df_new + final.to_excel(excel_path, index=False) + print(f"[INFO] Saved {len(batch_data)} records → {excel_path.name} " + f"(total rows: {len(final)})") + + +def _get_processed_claim_ids(log_path) -> set: + try: + df = pd.read_excel(log_path) + done = df[df["Status"] == "SUCCESS"]["Claim ID"].astype(str).tolist() + return set(done) + except Exception: + return set() + + +# ================= RUN CLAIM REPORT ================= +def run_claim_report(driver, account_name): + SAVE_EVERY = 10 + wait = WebDriverWait(driver, 30) + actions = ActionChains(driver) + print(f"\n[INFO] ====== Running report for {account_name} ======") + + today = datetime.now().strftime("%m-%d-%Y") + safe_name = "".join( + c for c in account_name if c.isalnum() or c in (" ", "_", "(", ")") + ) + base = PROJECT_ROOT / "Output" / today + pdf_folder = base / "PDF Folder" / safe_name + report_dir = base / "reports" + logs_dir = base / "Logs" + + pdf_folder.mkdir(parents=True, exist_ok=True) + report_dir.mkdir(parents=True, exist_ok=True) + logs_dir.mkdir(parents=True, exist_ok=True) + + excel_path = report_dir / f"{safe_name}.xlsx" + log_path = logs_dir / f"{safe_name}_log.xlsx" + + if not excel_path.exists(): + pd.DataFrame(columns=[ + "Claim ID", "Rendering Provider #", "Signature of Physician", + "Patient's Account No", "Date of Birth", "Insurance Name", + "Insured ID Number", "Patient's Name", "Date of Service", + "Diagnosis Codes", "CPT", "POS", "Modifier", "Units", "Charges", + ]).to_excel(excel_path, index=False) + + if not log_path.exists(): + pd.DataFrame(columns=[ + "Claim ID", "Status", "Remarks", "Timestamp", + ]).to_excel(log_path, index=False) + + already_done = _get_processed_claim_ids(log_path) + print(f"[INFO] {len(already_done)} claims already processed for {account_name}") + + # Navigate to Claims + try: + claims_processing = wait.until( + EC.presence_of_element_located( + (By.XPATH, "//a[contains(.,'Claims Processing')]") + ) + ) + actions.move_to_element(claims_processing).perform() + time.sleep(1) + claims_option = wait.until( + EC.presence_of_element_located( + (By.XPATH, "//a[normalize-space()='Claims']") + ) + ) + driver.execute_script("arguments[0].click();", claims_option) + time.sleep(3) + except Exception as e: + print(f"[ERROR] Navigation to Claims failed: {e}") + return + + try: + apply_filters(driver) + except Exception as e: + print(f"[ERROR] Filter application failed: {e}") + return + + page = 1 + batch = [] + total_saved = 0 + consecutive_errors = 0 + + while True: + print(f"\n[INFO] Processing page {page}") + try: + WebDriverWait(driver, 20).until( + EC.presence_of_element_located( + (By.XPATH, "//table[@id='claimsGrid']//tbody//tr[@data-claimid]") + ) + ) + rows = driver.find_elements( + By.XPATH, "//table[@id='claimsGrid']//tbody//tr[@data-claimid]" + ) + print(f"[INFO] Rows on page {page} → {len(rows)}") + except Exception as e: + print(f"[ERROR] Could not load rows on page {page}: {e}") + consecutive_errors += 1 + if consecutive_errors >= 3: + print("[ERROR] 3 consecutive page-load failures — stopping account") + break + time.sleep(5) + continue + + consecutive_errors = 0 + + for i in range(len(rows)): + claim_id = f"row_{page}_{i + 1}" + try: + rows = driver.find_elements( + By.XPATH, + "//table[@id='claimsGrid']//tbody//tr[@data-claimid]", + ) + if i >= len(rows): + print(f"[WARN] Row {i + 1} no longer exists — skipping") + continue + + row = rows[i] + claim_id = row.get_attribute("data-claimid") or "" + instance_id = row.get_attribute("data-instanceid") or "" + + if not claim_id or not instance_id: + print(f"[WARN] Missing IDs row {i + 1} — skipping") + continue + if claim_id in already_done: + print(f"[INFO] [{i + 1}/{len(rows)}] Skipping (already done): {claim_id}") + continue + + try: + claim_number = row.find_element(By.XPATH, ".//td[2]").text.strip() + except Exception: + claim_number = "" + + try: + sig_raw = row.find_element(By.XPATH, ".//td[10]").text.strip() + if "," in sig_raw: + last, first = sig_raw.split(",", 1) + signature_physician = ( + f"{first.strip().title()} {last.strip().title()}" + ) + else: + signature_physician = sig_raw.strip().title() + except Exception: + signature_physician = "" + + insurance = re.sub( + r"\s*\(.*?\)\s*$", "", + row.get_attribute("data-payername") or "", + ).strip() + patient_name = row.get_attribute("data-patientname") or "" + charges = row.get_attribute("data-totalcharges") or "" + + print(f"[INFO] [{i + 1}/{len(rows)}] Claim: {claim_id} | {patient_name}") + + pdf_url = ( + f"https://claims.zirmed.com/Claims/History/ViewClaimPDF" + f"?appId=1&claimId={claim_id}&instanceId={instance_id}" + f"&form=CMS1500_0212" + ) + save_path = pdf_folder / f"{claim_id}.pdf" + if save_path.exists(): + save_path = pdf_folder / f"{claim_id}_{instance_id}.pdf" + + success = download_pdf_direct(driver, pdf_url, save_path) + if not success: + time.sleep(3) + success = download_pdf_direct(driver, pdf_url, save_path) + if not success: + update_log(log_path, claim_id, "FAILED", "Download failed after retry") + continue + + pdf_data = extract_pdf_data_fast( + save_path, + claim_id=claim_id, + patient_account=claim_number, + insurance=insurance, + patient_name=patient_name, + total_charges=charges, + signature_physician=signature_physician, + ) + if not pdf_data: + update_log(log_path, claim_id, "FAILED", "PDF extraction failed") + continue + + batch.append(pdf_data) + already_done.add(claim_id) + update_log(log_path, claim_id, "SUCCESS", "") + + if len(batch) >= SAVE_EVERY: + _save_batch_to_excel(excel_path, batch) + total_saved += len(batch) + batch = [] + + except Exception as e: + print(f"[ERROR] Row {i + 1} (claim {claim_id}) failed → {e}") + update_log(log_path, claim_id, "FAILED", str(e)[:200]) + + if batch: + _save_batch_to_excel(excel_path, batch) + total_saved += len(batch) + batch = [] + + if not click_next_page(driver): + print(f"[INFO] No more pages for {account_name}") + break + page += 1 + + print(f"\n[SUCCESS] {account_name} complete — {total_saved} records saved") + print(f"[INFO] ====== Account {account_name} finished ======\n") + + +# ================= PROCESS ALL ACCOUNTS ================= +def process_all_accounts(driver): + wait = WebDriverWait(driver, 30) + print("[INFO] Starting account processing...") + all_accounts = [] + + try: + dropdown = wait.until( + EC.element_to_be_clickable( + (By.XPATH, "//input[contains(@data-search-url,'AccountSearch')]") + ) + ) + dropdown.click() + time.sleep(2) + account_elements = driver.find_elements( + By.XPATH, "//ul[contains(@class,'ui-autocomplete')]//li" + ) + all_accounts = [ + acc.text.strip() for acc in account_elements if acc.text.strip() + ] + print(f"[INFO] Total accounts found → {len(all_accounts)}") + driver.find_element(By.TAG_NAME, "body").click() + time.sleep(1) + except Exception as e: + print(f"[ERROR] Failed to get account list: {e}") + return + + if not all_accounts: + print("[WARN] No accounts found — exiting") + return + + # Deduplicate while preserving order + seen_names = set() + unique_accounts = [] + for name in all_accounts: + if name not in seen_names: + seen_names.add(name) + unique_accounts.append(name) + + if len(unique_accounts) < len(all_accounts): + print(f"[INFO] Removed {len(all_accounts) - len(unique_accounts)} " + f"duplicate account names") + + all_accounts = unique_accounts + print(f"[INFO] Unique accounts to process → {len(all_accounts)}") + + portal_home_url = driver.current_url + processed_accounts = set() + + for idx, account_name in enumerate(all_accounts, 1): + if account_name in processed_accounts: + print(f"[WARN] '{account_name}' already processed — skipping") + continue + + print(f"\n[INFO] ===== Account {idx}/{len(all_accounts)}: {account_name} =====") + + try: + driver.get(portal_home_url) + time.sleep(3) + + dropdown = wait.until( + EC.element_to_be_clickable( + (By.XPATH, "//input[contains(@data-search-url,'AccountSearch')]") + ) + ) + dropdown.clear() + dropdown.click() + time.sleep(2) + + account_elements = driver.find_elements( + By.XPATH, "//ul[contains(@class,'ui-autocomplete')]//li" + ) + clicked = False + for acc in account_elements: + if acc.text.strip() == account_name: + acc.click() + clicked = True + break + + if not clicked: + print(f"[WARN] '{account_name}' not found in dropdown — skipping") + continue + + try: + wait.until( + EC.invisibility_of_element_located( + (By.XPATH, "//ul[contains(@class,'ui-autocomplete')]") + ) + ) + except Exception: + pass + + time.sleep(3) + processed_accounts.add(account_name) + run_claim_report(driver, account_name) + + except Exception as e: + print(f"[ERROR] Account '{account_name}' failed: {e}") + try: + driver.get(portal_home_url) + time.sleep(3) + except Exception: + pass + continue + + print(f"\n[INFO] ===== All {len(processed_accounts)} accounts processed =====") + print(f"[INFO] Accounts completed: {sorted(processed_accounts)}") + + +# ================= MAIN ================= +if __name__ == "__main__": + print("=" * 80) + print("🚀 STARTING CHARGE BILLED AUTOMATION - Document AI v7.0") + print("=" * 80) + driver = None + try: + print("\n[STEP 1] Closing existing Chrome instances...") + kill_all_chrome() + + print("\n[STEP 2] Reading configuration...") + ( + proxy_ip, proxy_port, username, waystar_password, + portal_url, first_car_answer, first_job_answer, + ) = read_input_config() + + print("\n[STEP 3] Launching Chrome in debug mode...") + if not start_chrome_debug(proxy_ip, proxy_port): + raise Exception("Chrome debug failed to start") + time.sleep(5) + + print("\n[STEP 4] Attaching Selenium to Chrome...") + driver = attach_browser(DOWNLOADS_DIR) + if not driver: + raise Exception("Driver attach failed") + + print("\n[STEP 5] Opening Waystar portal...") + driver.execute_script("window.open('');") + driver.switch_to.window(driver.window_handles[-1]) + driver.get(portal_url) + WebDriverWait(driver, 30).until( + EC.presence_of_element_located((By.ID, "loginName")) + ) + print("[INFO] Portal loaded successfully") + + print("\n[STEP 6] Performing login...") + login_to_portal( + driver, username, waystar_password, + first_car_answer, first_job_answer, + ) + + print("\n[STEP 7] Starting account processing...") + process_all_accounts(driver) + + print("\n" + "=" * 80) + print("✅ AUTOMATION COMPLETED SUCCESSFULLY") + print("=" * 80) + + except Exception as e: + print("\n" + "=" * 80) + print(f"❌ FATAL ERROR: {str(e)}") + print("=" * 80) + if driver: + try: + capture_screenshot(driver, "MAIN", "Fatal_Error") + except Exception: + pass + + finally: + print("\n[CLEANUP] Closing resources...") + if driver: + try: + driver.quit() + print("[INFO] Browser closed successfully") + except Exception as e: + print(f"[WARN] Error closing browser: {e}") + print("\n🛑 Execution finished\n") \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..385ae55 --- /dev/null +++ b/src/main.py @@ -0,0 +1,1351 @@ +import subprocess +import time +import os +import sys +import socket +import re +import pandas as pd +import pdfplumber +import requests +from pathlib import Path +from datetime import datetime +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.chrome.service import Service +from selenium.common.exceptions import WebDriverException +from selenium.common.exceptions import NoSuchElementException +from selenium.common.exceptions import StaleElementReferenceException +from selenium.webdriver.support.ui import Select +from selenium.webdriver.common.keys import Keys + +try: + from pywinauto import Application +except Exception: + Application = None + +# ================= PROJECT ROOT ================= +if getattr(sys, "frozen", False): + PROJECT_ROOT = Path(sys.executable).parent +else: + PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# ================= GLOBAL CONFIG ================= +DEBUG_PORT = 9222 +USER_DATA_DIR = os.path.join( + os.environ["USERPROFILE"], "Automation", "ChromeProfile_CF" +) + +today_date = datetime.now().strftime("%m-%d-%Y") +DOWNLOADS_DIR = Path.home() / "Downloads" + +# ================= CHROME PATH ================= +def find_chrome_path(): + """Find Chrome installation path""" + possible_paths = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + os.path.join( + os.environ.get("LOCALAPPDATA", ""), + r"Google\Chrome\Application\chrome.exe" + ) + ] + + for path in possible_paths: + if os.path.exists(path): + return path + + return None + +# ================= PROXY CONFIG ================= +def read_input_config(): + """Read configuration from input.xlsx""" + config_path = PROJECT_ROOT / "Input" / "input.xlsx" + + if not config_path.exists(): + print("[ERROR] input.xlsx not found.") + sys.exit(1) + + df = pd.read_excel(config_path, sheet_name="Sheet1", header=None) + + config = {} + for _, row in df.iterrows(): + key = str(row[0]).strip() + value = str(row[1]).strip() + config[key] = value + + proxy_ip = config.get("Proxy IP", "") + proxy_port = config.get("Proxy Port", "") + waystar_username = config.get("Waystar Username", "") + waystar_password = config.get("Waystar Password", "") + portal_url = config.get("Waystar URL", "") + first_car_answer = config.get("Waystar SQ First Car", "") + first_job_answer = config.get("Waystar SQ First Job", "") + + print(f"[INFO] Proxy → {proxy_ip}:{proxy_port}") + print(f"[INFO] Username → {waystar_username}") + print(f"[INFO] Portal URL → {portal_url}") + + return ( + proxy_ip, + proxy_port, + waystar_username, + waystar_password, + portal_url, + first_car_answer, + first_job_answer + ) + +# ================= DEBUG PORT CHECK ================= +def wait_for_debug_port(port=9222, timeout=40): + """Wait for Chrome debug port to be ready""" + start = time.time() + + while time.time() - start < timeout: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(("127.0.0.1", port)) == 0: + return True + time.sleep(1) + + return False + +# ================= KILL CHROME ================= +def kill_all_chrome(): + """Kill all Chrome processes""" + try: + os.system("taskkill /F /IM chrome.exe /T >nul 2>&1") + time.sleep(3) + print("[INFO] All Chrome instances closed") + except Exception as e: + print(f"[WARN] Unable to kill Chrome: {e}") + +# ================= START CHROME DEBUG ================= +def start_chrome_debug(proxy_ip=None, proxy_port=None): + """Start Chrome in debug mode""" + chrome_path = find_chrome_path() + + if not chrome_path: + raise Exception( + "Google Chrome not found. Please install Google Chrome and try again." + ) + + print(f"[INFO] Using Chrome path: {chrome_path}") + + os.makedirs(USER_DATA_DIR, exist_ok=True) + + cmd = [ + chrome_path, + f"--remote-debugging-port={DEBUG_PORT}", + f"--user-data-dir={USER_DATA_DIR}", + "--disable-blink-features=AutomationControlled", + "--ignore-certificate-errors", + "about:blank" + ] + + if proxy_ip and proxy_port: + proxy = f"{proxy_ip}:{proxy_port}" + cmd.append(f"--proxy-server=http://{proxy}") + print(f"[INFO] Chrome started with proxy → {proxy}") + else: + print("[INFO] Chrome started WITHOUT proxy") + + print("[INFO] Launching Chrome...") + subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + if not wait_for_debug_port(DEBUG_PORT, timeout=40): + print("[ERROR] Chrome debug port did not open") + return False + + print("[INFO] Chrome debug port is ready") + return True + +# ================= SCREENSHOT ================= +def capture_screenshot(driver, client_name, step_name): + """Capture screenshot for debugging""" + if not driver: + return None + + safe_client = "".join(c for c in client_name if c.isalnum() or c in ("_", "-")) + safe_step = "".join(c for c in step_name if c.isalnum() or c in ("_", "-")) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + screenshots_dir = PROJECT_ROOT / "Screenshots" / today_date / safe_client + screenshots_dir.mkdir(parents=True, exist_ok=True) + + screenshot_path = screenshots_dir / f"{safe_step}_{timestamp}.png" + + try: + driver.save_screenshot(str(screenshot_path)) + print(f"[INFO] Screenshot saved: {screenshot_path}") + return screenshot_path + except Exception as e: + print(f"[ERROR] Screenshot failed: {e}") + return None + +# ================= ATTACH BROWSER ================= +def attach_browser(download_path, retries=5): + """Attach to Chrome via remote debugging""" + for attempt in range(1, retries + 1): + try: + print(f"[INFO] Attaching browser (attempt {attempt})") + + chrome_options = Options() + chrome_options.add_experimental_option( + "debuggerAddress", f"127.0.0.1:{DEBUG_PORT}" + ) + chrome_options.set_capability("webSocketUrl", False) + + prefs = { + "download.default_directory": str(download_path), + "download.prompt_for_download": False, + "download.directory_upgrade": True, + "plugins.always_open_pdf_externally": True, + "safebrowsing.enabled": True + } + chrome_options.add_experimental_option("prefs", prefs) + + service = Service() + driver = webdriver.Chrome(service=service, options=chrome_options) + _ = driver.current_url + driver.maximize_window() + + print("[INFO] Browser attached successfully") + + try: + driver.execute_cdp_cmd( + "Page.setDownloadBehavior", + {"behavior": "allow", "downloadPath": str(download_path)} + ) + except Exception as e: + print(f"[WARN] Unable to set download behavior: {e}") + + return driver + + except WebDriverException as e: + print(f"[WARN] Attach attempt {attempt} failed: {e}") + time.sleep(3) + + print("[ERROR] Failed to attach Chrome after retries") + return None + +# ================= LOGIN ================= +def login_to_portal(driver, waystar_username, waystar_password, first_car_answer, first_job_answer): + """Login to Waystar portal""" + wait = WebDriverWait(driver, 30) + + try: + time.sleep(5) + print("[INFO] Starting login process") + + # Enter username + username_field = wait.until( + EC.presence_of_element_located((By.ID, "loginName")) + ) + username_field.clear() + username_field.send_keys(waystar_username) + time.sleep(2) + + # Enter password + password_field = wait.until( + EC.presence_of_element_located((By.ID, "password")) + ) + password_field.clear() + password_field.send_keys(waystar_password) + time.sleep(2) + + # Click login + driver.find_element(By.ID, "loginButton").click() + time.sleep(5) + print("[INFO] Login clicked") + + # Handle proactive alert if present + try: + alert_page = driver.find_elements(By.XPATH, "//span[contains(text(),'Proactive Alert')]") + if alert_page: + wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@id='continueButton']"))).click() + time.sleep(3) + except: + pass + + # Handle security question if present + try: + question_exists = driver.find_elements(By.XPATH, "//*[@id='authDiv']//strong") + if question_exists: + question = wait.until( + EC.presence_of_element_located((By.XPATH, "//*[@id='authDiv']//strong")) + ).text.lower() + + print(f"[INFO] Security Question → {question}") + + if "first car" in question: + answer = first_car_answer + elif "first job" in question: + answer = first_job_answer + else: + raise Exception(f"Unknown security question: {question}") + + print(f"[INFO] Submitting answer") + + answer_box = wait.until( + EC.presence_of_element_located((By.ID, "verifyAnswer")) + ) + answer_box.clear() + answer_box.send_keys(answer) + driver.find_element(By.ID, "VerifyButton").click() + + print("[INFO] Security answer submitted") + time.sleep(3) + except: + pass + + # Handle security information page if present + try: + security_qpage = driver.find_elements(By.XPATH, "//h2[normalize-space()='Security Information']") + if security_qpage: + print("[INFO] Security page appeared, skipping") + wait.until(EC.element_to_be_clickable((By.ID, "NextButton"))).click() + time.sleep(2) + wait.until( + EC.element_to_be_clickable((By.XPATH, "//a[normalize-space()='Skip This Step']")) + ).click() + time.sleep(5) + except: + pass + + print("[INFO] Login completed successfully") + + except Exception as e: + print(f"[ERROR] Login failed: {e}") + raise + +# ================= CALCULATE DATE RANGE ================= +def get_date_range(): + """ + Returns the first and last day of the PREVIOUS month. + e.g. if today is April 2026 → returns 03/01/2026 and 03/31/2026 + """ + now = datetime.now() + + # First day of current month + first_of_current = datetime(now.year, now.month, 1) + + # Last day of previous month = one day before first of current month + last_of_prev = first_of_current - pd.Timedelta(days=1) + + # First day of previous month + first_of_prev = datetime(last_of_prev.year, last_of_prev.month, 1) + + return ( + first_of_prev.strftime("%m/%d/%Y"), # e.g. 03/01/2026 + last_of_prev.strftime("%m/%d/%Y"), # e.g. 03/31/2026 + ) + +# ================= APPLY FILTERS ================= +def apply_filters(driver): + """Apply filters to claims grid with custom date range""" + wait = WebDriverWait(driver, 40) + print("[INFO] Applying filters") + + # Wait for filter panel to load + wait.until(EC.presence_of_element_located((By.ID, "ClaimListingSearchButtonBottom"))) + wait.until(EC.presence_of_element_located((By.ID, "SearchCriteria_Status"))) + + # Set status to "All" + status_dropdown = Select(wait.until(EC.element_to_be_clickable((By.ID, "SearchCriteria_Status")))) + status_dropdown.select_by_visible_text("All") + + # Set date to "Custom" + date_dropdown = Select(wait.until(EC.element_to_be_clickable((By.ID, "SearchCriteria_TransDate")))) + date_dropdown.select_by_visible_text("Custom") + + time.sleep(2) # Wait for custom date fields to appear + + # Get date range + start_date, end_date = get_date_range() + + print(f"[INFO] Setting date range: {start_date} to {end_date}") + + # Enter start date + try: + start_date_field = wait.until(EC.presence_of_element_located((By.ID, "transactionDateFromDateBox"))) + start_date_field.clear() + start_date_field.send_keys(start_date) + time.sleep(1) + except Exception as e: + print(f"[WARN] Could not set start date: {e}") + + # Enter end date + try: + end_date_field = wait.until(EC.presence_of_element_located((By.ID, "transactionDateToDateBox"))) + end_date_field.clear() + end_date_field.send_keys(end_date) + time.sleep(1) + except Exception as e: + print(f"[WARN] Could not set end date: {e}") + + # Click search + search_btn = wait.until(EC.element_to_be_clickable((By.ID, "ClaimListingSearchButtonBottom"))) + driver.execute_script("arguments[0].click();", search_btn) + + # Wait for results + wait.until(EC.presence_of_element_located((By.XPATH, "//table[@id='claimsGrid']//tbody//tr"))) + time.sleep(3) + + print("[INFO] Filters applied successfully") + +# ================= CLICK NEXT PAGE ================= +def click_next_page(driver): + """Click next page button""" + try: + next_btn = driver.find_element(By.XPATH, "//button[@id='innerPagerRight']") + + if "disabled" in next_btn.get_attribute("class"): + return False + + driver.execute_script("arguments[0].click();", next_btn) + + # Wait for page to refresh + WebDriverWait(driver, 10).until(EC.staleness_of(next_btn)) + time.sleep(2) + + return True + except: + return False + +# ================= DIRECT DOWNLOAD ================= +def download_pdf_direct(driver, pdf_url, save_path): + """Download PDF directly using session cookies""" + try: + # Get cookies from browser + cookies = {c["name"]: c["value"] for c in driver.get_cookies()} + + # Set headers + headers = { + "User-Agent": driver.execute_script("return navigator.userAgent;"), + "Referer": driver.current_url, + "Accept": "application/pdf,*/*", + } + + # Download PDF + response = requests.get( + pdf_url, + headers=headers, + cookies=cookies, + stream=True, + timeout=60 + ) + + if response.status_code != 200: + print(f"[ERROR] HTTP {response.status_code} → {pdf_url}") + return False + + # Verify content type + content_type = response.headers.get("Content-Type", "") + if "pdf" not in content_type.lower() and "octet-stream" not in content_type.lower(): + print(f"[WARN] Unexpected content type → {content_type}") + + # Write to disk + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + print(f"[SUCCESS] Downloaded → {save_path.name}") + return True + + except Exception as e: + print(f"[ERROR] Direct download failed → {e}") + return False + +# ============================================================ +# CMS-1500 PDF EXTRACTOR — Production v4.3 +# Fix: Numeric-only modifiers (59, 25, etc.) DO appear alone +# in the modifier column. The correct solution is to use +# PDF COORDINATES to identify which column each token +# belongs to, rather than text-based heuristics. +# ============================================================ + +# ---------- modifier constants ---------- +_VALID_MODIFIERS = { + # Physical therapy / rehab discipline + "GP", "GN", "GO", "GQ", "GX", "GY", "GZ", + # Laterality + "LT", "RT", + # Eye / digit + "E1", "E2", "E3", "E4", + "FA", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", + "TA", "T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", + # Common billing + "TC", "26", "59", "25", "51", "76", "77", "78", "79", "91", + "24", "57", "58", "80", "81", "82", + # Assistant / supervision + "AA", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AM", "AS", "AT", + # CQ / CR / CS + "CQ", "CR", "CS", + # GA / GC etc + "GA", "GC", "GJ", "GK", "GL", "GT", + # JA / JB / JW / JZ + "JA", "JB", "JW", "JZ", + # KX / KU / KV + "KX", "KU", "KV", + # NU / RR / UE + "NU", "RR", "UE", + # Q modifiers + "Q0", "Q1", "Q2", "Q3", "Q4", "Q5", "Q6", + # SA / SB etc + "SA", "SB", "SC", "SD", "SF", "SG", + # XE / XP / XS / XU + "XE", "XP", "XS", "XU", + # 2-digit numeric modifiers + "22", "23", "24", "25", "26", + "32", "33", + "47", "50", "51", "52", "53", + "54", "55", "56", "57", "58", "59", + "62", "63", "66", + "73", "74", + "76", "77", "78", "79", + "80", "81", "82", + "90", "91", "92", "93", "95", "96", "97", "99", +} + +_NOT_MODIFIERS = { + "MM","DD","YY","TO","OF","OR","IN","IS","NO","BY", + "AT","BE","DO","HE","HI","IF","ME","MY","OK","ON", + "SO","UP","US","WE","NPI","ABC","ICD","CPT","POS", + "DOS","EMG","REF","ID","AN","AC", +} + +# Valid POS codes +_VALID_POS = { + "11","12","13","14","15","16","17","18","19","20", + "21","22","23","24","25","31","32","33","34", + "41","42","49","50","51","52","53","54","55","56","57", + "60","61","62","65","71","72","81","99", +} + +# ============================================================ +# CMS-1500 SECTION 24 COLUMN X-RANGES (standard letter PDF) +# These are approximate x-coordinates for each column. +# Calibrated from standard CMS-1500 02/12 form at 8.5x11". +# Adjust COL_SCALE if your PDFs are a different DPI/size. +# ============================================================ +# Column definitions (x0_min, x0_max): +_COL_RANGES = { + "DOS_FROM": ( 30, 130), # A: Date of Service From + "DOS_TO": ( 130, 200), # A: Date of Service To + "POS": ( 200, 240), # B: Place of Service + "EMG": ( 240, 260), # C: EMG + "CPT": ( 260, 370), # D: CPT/HCPCS + "MODIFIER": ( 370, 480), # D: Modifier (same col D, after CPT) + "DIAG_PTR": ( 480, 530), # E: Diagnosis Pointer + "CHARGES": ( 530, 640), # F: $ Charges + "UNITS": ( 640, 690), # G: Days or Units + "EPSDT": ( 690, 720), # H: EPSDT + "ID_QUAL": ( 720, 750), # I: ID Qual + "RENDERING": ( 750, 870), # J: Rendering Provider ID +} + + +# ---------- small helpers ---------- +def _is_cpt(token: str) -> bool: + """5-digit number strictly in CPT range 10000-99999.""" + if not re.fullmatch(r"\d{5}", token): + return False + return 10000 <= int(token) <= 99999 + + +def _is_icd10(token: str) -> bool: + return bool(re.fullmatch( + r"[A-Z]\d{2,3}(?:\.\d{1,4})?", token.upper() + ) or re.fullmatch( + r"[A-Z]\d{3,7}", token.upper() + )) + + +def _cell(c) -> str: + return str(c).strip() if c else "" + + +def _format_list(values: list, label: str) -> str: + clean = [v for v in values if v] + if not clean: + return "" + if len(clean) == 1: + return clean[0] + return "; ".join(f"{label} {i+1}: {v}" for i, v in enumerate(clean)) + + +# ============================================================ +# COORDINATE-BASED COLUMN EXTRACTION +# ============================================================ + +def _get_words_in_col(page_words: list, row_y: float, row_height: float, + col_name: str, y_tolerance: float = 6.0) -> list: + """ + Return all words that fall within: + - the given row's y-range (row_y ± y_tolerance) + - the given column's x-range + """ + x_min, x_max = _COL_RANGES[col_name] + return [ + w for w in page_words + if abs(w["top"] - row_y) <= y_tolerance + and x_min <= w["x0"] <= x_max + ] + + +def _detect_col_scale(page_words: list) -> float: + """ + Auto-detect scaling factor by finding the 'NPI' label in column J + and comparing its x-position to our expected range. + Returns a scale multiplier (1.0 = no scaling needed). + """ + npi_words = [w for w in page_words if w["text"].strip().upper() == "NPI"] + if not npi_words: + return 1.0 + + # NPI label should be around x=720-750 in our reference + npi_x = npi_words[0]["x0"] + expected_npi_x = 735 # midpoint of ID_QUAL range + if expected_npi_x > 0 and npi_x > 50: + return npi_x / expected_npi_x + return 1.0 + + +def _scale_col_ranges(scale: float) -> dict: + """Apply scale factor to all column x-ranges.""" + return { + col: (int(x0 * scale), int(x1 * scale)) + for col, (x0, x1) in _COL_RANGES.items() + } + + +def _extract_modifiers_coordinate(page_words: list, cpt_word: dict, + col_ranges: dict) -> str: + """ + v4.3: Extract modifiers using COORDINATES. + + The modifier column (D, after CPT) on CMS-1500 spans roughly + x=370 to x=480. We look for valid modifier tokens in that x-range + on the same row as the CPT code. + + This correctly handles: + - Alpha-only: GP, GO, CQ, KX + - Numeric-only: 59, 25 + - Mixed: GP CQ, GO CO, GP 59 + """ + row_y = cpt_word["top"] + x_min, x_max = col_ranges["MODIFIER"] + + mod_words = [ + w for w in page_words + if abs(w["top"] - row_y) <= 8 + and x_min <= w["x0"] <= x_max + ] + + found = [] + for w in sorted(mod_words, key=lambda x: x["x0"]): + t = w["text"].strip().upper() + if not t: + continue + if t in _NOT_MODIFIERS: + continue + if t in _VALID_MODIFIERS and t not in found: + found.append(t) + if len(found) == 4: + break + + return " ".join(found) + + +def _extract_units_coordinate(page_words: list, cpt_word: dict, + col_ranges: dict) -> str: + """ + v4.3: Extract units using COORDINATES. + + Column G (Days/Units) on CMS-1500 spans roughly x=640 to x=690. + Units is a small integer (1-99). + """ + row_y = cpt_word["top"] + x_min, x_max = col_ranges["UNITS"] + + unit_words = [ + w for w in page_words + if abs(w["top"] - row_y) <= 8 + and x_min <= w["x0"] <= x_max + and re.fullmatch(r"[1-9]\d?", w["text"].strip()) + ] + + if unit_words: + # Take the leftmost value in the units column + best = min(unit_words, key=lambda w: w["x0"]) + return best["text"].strip() + + return "" + + +# ---------- DIAGNOSIS CODES (field 21, slots A-L) ---------- +def _extract_diagnosis_codes(text: str, words: list) -> list: + codes: list[str] = [] + + anchor_y = None + anchor_x = None + for w in words: + if w["text"].strip().rstrip(".") == "21": + if w["x0"] < 120: + anchor_y = w["top"] + anchor_x = w["x0"] + break + + if anchor_y is not None: + region = [ + w for w in words + if (anchor_y - 5) <= w["top"] <= (anchor_y + 120) + and w["x0"] >= anchor_x - 5 + ] + for w in region: + t = w["text"].strip().upper() + if re.fullmatch(r"[A-L]\.?", t): + continue + if t in ("O", "ICD", "IND", "21"): + continue + if _is_icd10(t): + codes.append(t) + + if not codes: + m = re.search( + r"(?:21|DIAGNOSIS)[^\n]*\n([\s\S]{0,600})(?:22\b|23\b|24\b|ICD\b)", + text, re.IGNORECASE + ) + if m: + raw = re.findall(r"\b([A-Z]\d{2,7}(?:\.\d{1,4})?)\b", m.group(1)) + codes.extend(raw) + + if not codes: + raw = re.findall(r"\b([A-Z]\d{2,7}(?:\.\d{1,4})?)\b", text) + codes = [c for c in raw if not c.startswith("NP")] + + seen, unique = set(), [] + for c in codes: + cu = c.upper() + if cu not in seen: + seen.add(cu) + unique.append(cu) + return unique + + +# ============================================================ +# MAIN FUNCTION +# ============================================================ +def extract_pdf_data_fast( + pdf_path, + claim_id="", + patient_account="", + insurance="", + patient_name="", + total_charges="", + signature_physician="", +): + """ + Extract all CMS-1500 fields from a PDF. + v4.3 fixes: + - Modifier extraction: now COORDINATE-BASED using column D + x-range. Correctly captures numeric-only modifiers (59, 25) + as well as alpha (GP, GO, CQ) and mixed (GP CQ, GO 59). + Charge column (F) and units column (G) are in different + x-ranges so they can NEVER bleed into modifier results. + - Units extraction: also COORDINATE-BASED using column G + x-range. Charge column is physically separate so no bleed. + - Auto-detects PDF scale factor via NPI label position. + """ + + data = { + "Claim ID": claim_id, + "Rendering Provider #": "", + "Signature of Physician": signature_physician, + "Patient's Account No": patient_account, + "Date of Birth": "", + "Insurance Name": insurance, + "Insured ID Number": "", + "Patient's Name": patient_name, + "Date of Service": "", + "Diagnosis Codes": "", + "CPT": "", + "POS": "", + "Modifier": "", + "Units": "", + "Charges": total_charges, + } + + try: + with pdfplumber.open(pdf_path) as pdf: + page = pdf.pages[0] + text = page.extract_text(x_tolerance=2, y_tolerance=2) or "" + words = page.extract_words() + tables = page.extract_tables() + + print(f"[DEBUG] Opened: {pdf_path} | Tables: {len(tables)}") + + # Auto-detect scale and apply to column ranges + scale = _detect_col_scale(words) + col_ranges = _scale_col_ranges(scale) if abs(scale - 1.0) > 0.05 else _COL_RANGES + print(f"[DEBUG] PDF scale factor: {scale:.3f}") + + # ============ DATE OF BIRTH ============ + dob = re.search(r"\b(\d{2})\s+(\d{2})\s+(\d{4})\b", text) + if dob: + data["Date of Birth"] = f"{dob.group(1)}/{dob.group(2)}/{dob.group(3)}" + + # ============ INSURED ID (field 1a) ============ + _SKIP = {"MEDICARE", "MEDICAID", "CHAMPVA", "TRICARE", "CHAMPUS"} + + def _valid_insured_id(t: str) -> bool: + t_up = t.upper() + return ( + re.match(r"^[A-Z0-9]{6,20}$", t_up) + and bool(re.search(r"[A-Z]", t_up)) + and bool(re.search(r"\d", t_up)) + and t_up not in _SKIP + ) + + insured_id = "" + + for i, w in enumerate(words): + if w["text"].lower().rstrip(".") == "1a": + for nxt in words[i + 1: i + 8]: + if _valid_insured_id(nxt["text"]): + insured_id = nxt["text"].upper() + break + if insured_id: + break + + if not insured_id: + for i, w in enumerate(words): + if "INSURED" in w["text"].upper(): + nearby = words[i: min(i + 8, len(words))] + if any("NUMBER" in n["text"].upper() for n in nearby): + base_y = w["top"] + candidates = [ + n for n in words + if abs(n["top"] - base_y) < 20 and n["x0"] > w["x0"] + ] + for c in reversed(candidates): + if _valid_insured_id(c["text"]): + insured_id = c["text"].upper() + break + if insured_id: + break + + if not insured_id: + top_right = sorted( + [w for w in words if w["x0"] > 380 and w["top"] < 220], + key=lambda w: (w["top"], w["x0"]) + ) + for w in top_right: + if _valid_insured_id(w["text"]): + insured_id = w["text"].upper() + break + + if not insured_id: + m = re.search(r"1a[.\s]+([A-Z0-9]{6,20})", text, re.IGNORECASE) + if m and _valid_insured_id(m.group(1)): + insured_id = m.group(1).upper() + + data["Insured ID Number"] = insured_id + print(f"[DEBUG] Insured ID: {insured_id}") + + # ============ DIAGNOSIS CODES (field 21, slots A-L) ============ + dx_codes = _extract_diagnosis_codes(text, words) + if dx_codes: + codes_str = ", ".join(dx_codes) + data["Diagnosis Codes"] = f"{codes_str} (Total: {len(dx_codes)})" + print(f"[DEBUG] Diagnosis codes ({len(dx_codes)}): {dx_codes}") + + # ============ SERVICE LINES (section 24, columns A-J) ============ + cpts, pos_list, dos_list, mod_list, units_list, rp_list = [], [], [], [], [], [] + + for table_idx, table in enumerate(tables): + print(f"[DEBUG] Table {table_idx+1}: {len(table)} rows") + + for row_idx, row in enumerate(table): + if not row: + continue + + cells = [_cell(c) for c in row] + row_text = " ".join(cells) + + candidate_cpts = re.findall(r"\b(\d{5})\b", row_text) + valid_cpts = [c for c in candidate_cpts if _is_cpt(c)] + if not valid_cpts: + continue + + if len(re.findall(r"\b[A-Z]{3,}\b", row_text)) > 4: + continue + + print(f"[DEBUG] Service row {row_idx}: {row_text[:130]}") + + cpt_val = valid_cpts[0] + cpts.append(cpt_val) + + # ---- Date of Service ---- + dm = re.search( + r"\b(\d{2})[\/\s\-]+(\d{2})[\/\s\-]+(\d{2,4})\b", row_text + ) + if dm: + mm, dd, yy = dm.group(1), dm.group(2), dm.group(3) + if len(yy) == 2: + yy = ("20" + yy) if int(yy) < 50 else ("19" + yy) + dos_list.append(f"{mm}/{dd}/{yy}") + else: + dos_list.append("") + + # ---- POS: last valid 2-digit code BEFORE the CPT ---- + before_cpt = row_text[: row_text.index(cpt_val)] + pos_candidates = re.findall(r"\b(\d{2})\b", before_cpt) + pos_val = "" + for p in reversed(pos_candidates): + if p in _VALID_POS: + pos_val = p + break + pos_list.append(pos_val) + + # ---- Find CPT word on the page for coordinate lookups ---- + cpt_word = None + for w in words: + if w["text"].strip() == cpt_val: + cpt_word = w + break + + # ---- Modifiers: coordinate-based ---- + if cpt_word: + mod_val = _extract_modifiers_coordinate(words, cpt_word, col_ranges) + else: + # Fallback: text-based (strip all digits, alpha modifiers only) + after_cpt = row_text[row_text.index(cpt_val) + len(cpt_val):] + after_cpt = re.sub(r"\b\d+\b", " ", after_cpt) + after_cpt = re.sub(r"\bNPI\b", " ", after_cpt, flags=re.IGNORECASE) + after_cpt = re.sub(r"\b[A-La-l]\b", " ", after_cpt) + mod_tokens = [ + t.upper() for t in after_cpt.split() + if t.upper() in _VALID_MODIFIERS + and t.upper() not in _NOT_MODIFIERS + and t.upper().isalpha() # fallback: alpha only + ] + mod_val = " ".join(dict.fromkeys(mod_tokens))[:4] + + mod_list.append(mod_val) + + # ---- Units: coordinate-based ---- + if cpt_word: + units_val = _extract_units_coordinate(words, cpt_word, col_ranges) + else: + # Fallback: strict text-based + seg = row_text[row_text.index(cpt_val) + len(cpt_val):] + seg = re.sub(r"\d+\.\d+", " ", seg) + seg = re.sub(r"\b\d{3,}\b", " ", seg) + seg = re.sub(r"\bNPI\b", " ", seg, flags=re.IGNORECASE) + for mod in _VALID_MODIFIERS: + seg = re.sub(rf"\b{re.escape(mod)}\b", " ", seg, flags=re.IGNORECASE) + seg = re.sub(r"\b[A-La-l]\b", " ", seg) + um = re.search(r"\b([1-9]\d?)\b", seg) + units_val = um.group(1) if um else "" + + units_list.append(units_val) + + # ---- Rendering Provider / NPI (10-digit) ---- + npi_candidates = re.findall(r"\b(\d{10})\b", row_text) + rp_list.append(npi_candidates[-1] if npi_candidates else "") + + print(f"[DEBUG] CPT={cpt_val} | POS={pos_val} | " + f"MOD={mod_val} | UNITS={units_val} | NPI={rp_list[-1]}") + + print(f"[DEBUG] Total service lines: {len(cpts)}") + + # ---- Pad all lists to match CPT count ---- + n = len(cpts) + def _pad(lst): + return lst + [""] * max(0, n - len(lst)) + + pos_list = _pad(pos_list) + dos_list = _pad(dos_list) + mod_list = _pad(mod_list) + units_list = _pad(units_list) + rp_list = _pad(rp_list) + + # ---- Format output ---- + data["CPT"] = _format_list(cpts, "CPT") + data["POS"] = _format_list(pos_list, "POS") + data["Date of Service"] = _format_list(dos_list, "DOS") + data["Modifier"] = _format_list(mod_list, "Modifier") + data["Units"] = _format_list(units_list, "Units") + data["Rendering Provider #"] = _format_list(rp_list, "RP #") + + return data + + except Exception as e: + import traceback + print(f"[ERROR] PDF parsing failed → {pdf_path} → {e}") + traceback.print_exc() + return None + +# ================= UPDATE LOG ================= +def update_log(log_path, claim_id, status, remarks=""): + """Update processing log""" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + new_row = pd.DataFrame([{ + "Claim ID": claim_id, + "Status": status, + "Remarks": remarks, + "Timestamp": now + }]) + + try: + existing = pd.read_excel(log_path) + final = pd.concat([existing, new_row], ignore_index=True) + except: + final = new_row + + final.to_excel(log_path, index=False) + +# ================= RUN CLAIM REPORT ================= +def run_claim_report(driver, account_name): + """Process claims for an account""" + wait = WebDriverWait(driver, 30) + actions = ActionChains(driver) + + print(f"\n[INFO] ====== Running report for {account_name} ======") + + # Setup directories + today = datetime.now().strftime("%m-%d-%Y") + safe_name = "".join(c for c in account_name if c.isalnum() or c in (" ", "_", "(", ")")) + + base = PROJECT_ROOT / "Output" / today + pdf_folder = base / "PDF Folder" / safe_name + report_dir = base / "reports" + + pdf_folder.mkdir(parents=True, exist_ok=True) + report_dir.mkdir(parents=True, exist_ok=True) + + excel_path = report_dir / f"{safe_name}.xlsx" + log_path = report_dir / "log.xlsx" + + # Create Excel file if not exists + if not excel_path.exists(): + columns = [ + "Claim ID", "Rendering Provider #", "Signature of Physician", + "Patient's Account No", "Date of Birth", "Insurance Name", + "Insured ID Number", "Patient's Name", "Date of Service", + "Diagnosis Codes", + "CPT", "POS", "Modifier", "Units", "Charges" + ] + pd.DataFrame(columns=columns).to_excel(excel_path, index=False) + + # Create log file if not exists + if not log_path.exists(): + pd.DataFrame(columns=["Claim ID", "Status", "Remarks", "Timestamp"]).to_excel(log_path, index=False) + + # Navigate to claims + try: + claims_processing = wait.until( + EC.presence_of_element_located((By.XPATH, "//a[contains(.,'Claims Processing')]")) + ) + actions.move_to_element(claims_processing).perform() + time.sleep(1) + + claims_option = wait.until( + EC.presence_of_element_located((By.XPATH, "//a[normalize-space()='Claims']")) + ) + driver.execute_script("arguments[0].click();", claims_option) + time.sleep(3) + except Exception as e: + print(f"[ERROR] Navigation failed: {e}") + return + + # Apply filters + apply_filters(driver) + + # Process claims + page = 1 + all_extracted_data = [] + + while True: + print(f"\n[INFO] Processing page {page}") + + try: + rows = driver.find_elements( + By.XPATH, "//table[@id='claimsGrid']//tbody//tr[@data-claimid]" + ) + print(f"[INFO] Total rows on page → {len(rows)}") + except Exception as e: + print(f"[ERROR] Failed to get rows: {e}") + break + + for i in range(len(rows)): + try: + # Re-fetch rows to avoid stale elements + rows = driver.find_elements( + By.XPATH, "//table[@id='claimsGrid']//tbody//tr[@data-claimid]" + ) + row = rows[i] + + # Extract data from table + claim_id = row.get_attribute("data-claimid") or "" + instance_id = row.get_attribute("data-instanceid") or "" + + # Claim Number (Patient Account Number) + try: + claim_number = row.find_element(By.XPATH, ".//td[2]").text.strip() + except: + claim_number = "" + + # Signature physician (provider name from table) + try: + signature_physician = row.find_element(By.XPATH, ".//td[10]").text.strip() + if "," in signature_physician: + parts = signature_physician.split(",", 1) + last = parts[0].strip().title() + first = parts[1].strip().title() + signature_physician = f"{first} {last}" + else: + signature_physician = signature_physician.strip().title() + except: + signature_physician = "" + + insurance = row.get_attribute("data-payername") or "" + insurance = re.sub(r"\s*\(.*?\)\s*$", "", insurance).strip() + patient_name = row.get_attribute("data-patientname") or "" + charges = row.get_attribute("data-totalcharges") or "" + + print(f"[INFO] [{i+1}/{len(rows)}] Processing Claim: {claim_id} | Patient: {patient_name}") + + if not claim_id or not instance_id: + print(f"[WARN] Missing IDs - skipping row {i+1}") + continue + + # Build PDF URL + pdf_url = ( + f"https://claims.zirmed.com/Claims/History/ViewClaimPDF" + f"?appId=1&claimId={claim_id}&instanceId={instance_id}&form=CMS1500_0212" + ) + + # Save path + save_path = pdf_folder / f"{claim_id}.pdf" + if save_path.exists(): + save_path = pdf_folder / f"{claim_id}_{instance_id}.pdf" + + # Download PDF + success = download_pdf_direct(driver, pdf_url, save_path) + if not success: + update_log(log_path, claim_id, "FAILED", "Download failed") + continue + + # Extract PDF data + pdf_data = extract_pdf_data_fast( + save_path, + claim_id=claim_id, + patient_account=claim_number, + insurance=insurance, + patient_name=patient_name, + total_charges=charges, + signature_physician=signature_physician + ) + + if not pdf_data: + update_log(log_path, claim_id, "FAILED", "PDF extraction failed") + continue + + all_extracted_data.append(pdf_data) + update_log(log_path, claim_id, "SUCCESS", "") + + except Exception as e: + print(f"[ERROR] Row {i+1} failed → {e}") + try: + update_log(log_path, claim_id if claim_id else f"row_{i+1}", "FAILED", str(e)) + except: + pass + + # Next page + if not click_next_page(driver): + print("[INFO] No more pages") + break + + page += 1 + + # Save to Excel + if all_extracted_data: + df = pd.DataFrame(all_extracted_data) + + try: + existing_df = pd.read_excel(excel_path) + final_df = pd.concat([existing_df, df], ignore_index=True) + except: + final_df = df + + final_df.to_excel(excel_path, index=False) + print(f"\n[SUCCESS] Saved {len(all_extracted_data)} records to {excel_path}") + else: + print("[WARN] No data extracted") + + print(f"[INFO] ====== Account {account_name} completed ======\n") + +# ================= PROCESS ALL ACCOUNTS ================= +def process_all_accounts(driver): + """Process all accounts in the dropdown""" + wait = WebDriverWait(driver, 30) + processed_accounts = set() + + print("[INFO] Starting account processing...") + + try: + # Click dropdown + dropdown = wait.until( + EC.element_to_be_clickable((By.XPATH, "//input[contains(@data-search-url,'AccountSearch')]")) + ) + dropdown.click() + time.sleep(2) + + # Get all accounts + account_elements = driver.find_elements( + By.XPATH, "//ul[contains(@class,'ui-autocomplete')]//li" + ) + all_accounts = [acc.text.strip() for acc in account_elements if acc.text.strip()] + + print(f"[INFO] Total accounts found → {len(all_accounts)}") + + # Close dropdown + driver.find_element(By.TAG_NAME, "body").click() + time.sleep(1) + + except Exception as e: + print(f"[ERROR] Failed to get accounts: {e}") + return + + # Process each account + for idx, account_name in enumerate(all_accounts, 1): + if account_name in processed_accounts: + continue + + print(f"\n[INFO] ===== Processing account {idx}/{len(all_accounts)}: {account_name} =====") + + try: + # Re-open dropdown + dropdown = wait.until( + EC.element_to_be_clickable((By.XPATH, "//input[contains(@data-search-url,'AccountSearch')]")) + ) + dropdown.click() + time.sleep(2) + + # Re-fetch list + account_elements = driver.find_elements( + By.XPATH, "//ul[contains(@class,'ui-autocomplete')]//li" + ) + + # Find and click matching account + for acc in account_elements: + if acc.text.strip() == account_name: + acc.click() + break + + # Wait for dropdown to close + wait.until( + EC.invisibility_of_element_located( + (By.XPATH, "//ul[contains(@class,'ui-autocomplete')]") + ) + ) + + processed_accounts.add(account_name) + time.sleep(3) + + # Run claim report + run_claim_report(driver, account_name) + + except Exception as e: + print(f"[ERROR] Failed to process account {account_name}: {e}") + continue + + print("\n[INFO] ===== All accounts processed successfully =====") + +# ================= MAIN EXECUTION ================= +if __name__ == "__main__": + print("=" * 80) + print("🚀 STARTING CHARGE BILLED AUTOMATION") + print("=" * 80) + + driver = None + + try: + # Kill existing Chrome + print("\n[STEP 1] Closing existing Chrome instances...") + kill_all_chrome() + + # Read config + print("\n[STEP 2] Reading configuration...") + ( + proxy_ip, proxy_port, username, waystar_password, + portal_url, first_car_answer, first_job_answer + ) = read_input_config() + + # Start Chrome + print("\n[STEP 3] Launching Chrome in debug mode...") + if not start_chrome_debug(proxy_ip, proxy_port): + raise Exception("Chrome debug failed to start") + time.sleep(5) + + # Attach driver + print("\n[STEP 4] Attaching Selenium to Chrome...") + driver = attach_browser(DOWNLOADS_DIR) + if not driver: + raise Exception("Driver attach failed") + + # Open portal + print("\n[STEP 5] Opening Waystar portal...") + driver.execute_script("window.open('');") + driver.switch_to.window(driver.window_handles[-1]) + driver.get(portal_url) + + WebDriverWait(driver, 30).until( + EC.presence_of_element_located((By.ID, "loginName")) + ) + print("[INFO] Portal loaded successfully") + + # Login + print("\n[STEP 6] Performing login...") + login_to_portal(driver, username, waystar_password, first_car_answer, first_job_answer) + + # Process accounts + print("\n[STEP 7] Starting account processing...") + process_all_accounts(driver) + + print("\n" + "=" * 80) + print("✅ AUTOMATION COMPLETED SUCCESSFULLY") + print("=" * 80) + + except Exception as e: + print("\n" + "=" * 80) + print(f"❌ FATAL ERROR: {str(e)}") + print("=" * 80) + + if driver: + try: + capture_screenshot(driver, "MAIN", "Fatal_Error") + except: + pass + + finally: + print("\n[CLEANUP] Closing resources...") + if driver: + try: + driver.quit() + print("[INFO] Browser closed successfully") + except Exception as e: + print(f"[WARN] Error closing browser: {e}") + + print("\n🛑 Execution finished\n") \ No newline at end of file diff --git a/src/main.spec b/src/main.spec new file mode 100644 index 0000000..d4db9bc --- /dev/null +++ b/src/main.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[('..\\Input', 'Input'), ('..\\Screenshots', 'Screenshots')], + hiddenimports=['pyotp', 'pandas', 'openpyxl'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='main', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/src/main_v1.spec b/src/main_v1.spec new file mode 100644 index 0000000..b8ae2e4 --- /dev/null +++ b/src/main_v1.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main_v1.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='main_v1', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/src/pdf extraction.py b/src/pdf extraction.py new file mode 100644 index 0000000..cacb62e --- /dev/null +++ b/src/pdf extraction.py @@ -0,0 +1,491 @@ +import re +import pdfplumber + +# =========================================================================== +# CMS-1500 PDF EXTRACTOR — Production-Ready v3.0 +# =========================================================================== +# Fixes vs prior versions: +# • CPT : strict 5-digit match guarded by known CPT range (10000-99999), +# zip-codes / account-numbers / tax-ids filtered out +# • POS : extracted per service-line row; count always matches CPT count +# • Modifier: column-position aware extraction (not regex on full row text) +# • Diagnosis codes: dual-pass — word-level near field-21 + full-text regex +# • Date of Service: handles split cells (MM | DD | YY) and slash formats +# • Units: column-position aware (column G on CMS-1500) +# • Rendering Provider #: 10-digit NPI per row, column J +# • All multi-value fields formatted as "Field 1: val; Field 2: val …" +# =========================================================================== + + +# --------------------------------------------------------------------------- +# Helper: is this token a valid CPT code? +# --------------------------------------------------------------------------- +def _is_cpt(token: str) -> bool: + """Return True only for 5-digit numbers in the plausible CPT range.""" + if not re.fullmatch(r"\d{5}", token): + return False + val = int(token) + return 10000 <= val <= 99999 + + +# --------------------------------------------------------------------------- +# Helper: is this token a valid ICD-10 diagnosis code? +# --------------------------------------------------------------------------- +def _is_icd10(token: str) -> bool: + return bool(re.fullmatch(r"[A-Z]\d{2,3}(?:\.\d{1,4})?", token)) + + +# --------------------------------------------------------------------------- +# Helper: clean / normalise a cell value +# --------------------------------------------------------------------------- +def _cell(c) -> str: + return str(c).strip() if c else "" + + +# --------------------------------------------------------------------------- +# Helper: join multi-value list into labelled string +# --------------------------------------------------------------------------- +def _format_list(values: list, label: str) -> str: + clean = [v for v in values if v] + if not clean: + return "" + if len(clean) == 1: + return clean[0] + return "; ".join(f"{label} {i+1}: {v}" for i, v in enumerate(clean)) + + +# --------------------------------------------------------------------------- +# Known modifier codes (GP, GN, GO …) + numeric modifiers (25, 26, 51, 59 …) +# CMS-1500 column E holds up to 4 modifier slots. +# --------------------------------------------------------------------------- +_ALPHA_MODIFIERS = { + "GP", "GN", "GO", "GQ", "GX", "GY", "GZ", + "CQ", "TC", "26", "LT", "RT", "NU", "RR", "UE", + "KX", "KU", "KV", "KW", "KX", + "JW", "JZ", + "AA", "AB", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", + "AM", "AO", "AP", "AQ", "AR", "AS", "AT", + "CA", "CB", "CC", "CD", "CE", "CF", "CH", "CI", "CJ", "CK", "CL", + "CM", "CN", "CO", "CP", "CR", "CS", + "DA", + "E1","E2","E3","E4", + "FA","F1","F2","F3","F4","F5","F6","F7","F8","F9", + "FX", + "GA","GB","GC","GD","GE","GF","GH","GJ","GK","GL","GM", + "HA","HB","HC","HD","HE","HF","HG","HH","HI","HJ","HK", + "HL","HM","HN","HO","HP","HQ","HR","HS","HT","HU","HV","HW", + "HX","HY","HZ", + "JA","JB","JC","JD","JE", + "LA","LC","LD","LE","LF","LG","LH","LI","LJ","LK","LL","LM", + "LN","LO","LP","LQ","LR","LS","LT","LU","LV","LW","LX","LY","LZ", + "P1","P2","P3","P4","P5","P6", + "Q0","Q1","Q2","Q3","Q4","Q5","Q6","Q7","Q8","Q9", + "QA","QB","QC","QD","QE","QF","QG","QH","QJ","QK","QL","QM","QN", + "QP","QQ","QR","QS","QT","QU","QV","QW","QX","QY","QZ", + "RA","RB","RC","RD","RE","RF","RG","RH","RI","RJ","RK","RL","RM", + "RN","RO","RP","RQ","RR","RS","RT","RU","RV","RW","RX","RY","RZ", + "SA","SB","SC","SD","SE","SF","SG","SH","SI","SJ","SK","SL","SM", + "SN","SO","SP","SQ","SR","SS","ST","SU","SV","SW","SX","SY", + "TA","TB","TC","TD","TE","TF","TG","TH","TI","TJ","TK","TL","TM", + "TN","TP","TQ","TR","TS","TT","TU","TV","TW","TX","TY","TZ", + "U1","U2","U3","U4","U5","U6","U7","U8","U9", + "UA","UB","UC","UD","UE","UF","UG","UH","UJ","UK","UL","UM","UN", + "UP","UQ","UR","US", + "V5","V6","V7","V8","V9", + "XE","XP","XS","XU", +} + +_NUMERIC_MODIFIERS = {str(n) for n in range(22, 100)} # 22-99 + +_ALL_MODIFIERS = _ALPHA_MODIFIERS | _NUMERIC_MODIFIERS + +# Tokens that look like 2-letter codes but are NOT modifiers +_NOT_MODIFIERS = { + "MM","DD","YY","TO","OF","OR","IN","IS","NO","BY","AT","BE", + "DO","GO","HE","HI","IF","ME","MY","OK","ON","SO","UP","US","WE", + "NPI","ABC","ICD","CPT","POS","DOS","EMG","REF","QTY", + "MM","DD","YY", +} + + +def _extract_modifiers_from_tokens(tokens: list[str]) -> str: + """ + Given the token list of a service-line row, return only legitimate + modifier codes. Tokens are scanned left-to-right; we stop after 4 + modifiers (CMS-1500 allows max 4 per line). + """ + found = [] + for t in tokens: + t_up = t.upper() + if t_up in _NOT_MODIFIERS: + continue + if t_up in _ALL_MODIFIERS and t_up not in found: + found.append(t_up) + if len(found) == 4: + break + return " ".join(found) + + +# --------------------------------------------------------------------------- +# CMS-1500 service-line table parser +# --------------------------------------------------------------------------- +def _parse_service_lines(tables: list) -> dict: + """ + Walk every table extracted by pdfplumber and collect per-row data for + columns A-J of CMS-1500 section 24: + A Date(s) of Service (From / To) + B Place of Service + C EMG + D CPT / HCPCS + Modifier(s) + E Diagnosis Pointer + F $ Charges + G Days or Units + H EPSDT + I ID Qual + J Rendering Provider ID + """ + cpts, pos_list, dos_list, mod_list, units_list, rp_list = [], [], [], [], [], [] + + for table in tables: + for row in table: + if not row: + continue + + cells = [_cell(c) for c in row] + row_text = " ".join(cells) + + # ---- Quick pre-filter: row must contain a valid CPT code -------- + candidate_cpts = re.findall(r"\b(\d{5})\b", row_text) + valid_cpts_in_row = [c for c in candidate_cpts if _is_cpt(c)] + if not valid_cpts_in_row: + continue + + # ---- Guard: skip header / label rows ---------------------------- + # If the row text contains many uppercase words it's likely a label + upper_words = re.findall(r"\b[A-Z]{3,}\b", row_text) + if len(upper_words) > 4: + continue + + # ---- CPT: first valid CPT per row (CMS-1500 = 1 CPT per line) --- + cpt_val = valid_cpts_in_row[0] + cpts.append(cpt_val) + + # ---- Date of Service ------------------------------------------- + # Format variants: "03 26 26", "03/26/26", "03-26-26" + date_match = re.search( + r"\b(\d{2})[\/\s\-]+(\d{2})[\/\s\-]+(\d{2,4})\b", row_text + ) + if date_match: + mm, dd, yy = date_match.group(1), date_match.group(2), date_match.group(3) + yy = yy if len(yy) == 4 else ("20" + yy if int(yy) < 50 else "19" + yy) + dos_list.append(f"{mm}/{dd}/{yy}") + else: + dos_list.append("") + + # ---- POS (Place of Service) — 2-digit code, column B ----------- + # Strategy: find the 2-digit token that appears BEFORE the CPT code + # in the row text. Common POS values: 11,12,21,22,23,24,31,32,33, + # 41,42,49,50,51,52,53,54,55,56,57,61,62,65,71,72,81,99 + pos_val = "" + row_before_cpt = row_text[: row_text.index(cpt_val)] + pos_candidates = re.findall(r"\b(\d{2})\b", row_before_cpt) + # Take the LAST 2-digit number before CPT (closest to CPT = POS) + # but exclude date fragments (must not be 01-31 preceded by another) + valid_pos_codes = { + "11","12","13","14","15","16","17","18","19","20", + "21","22","23","24","25","31","32","33","34","41", + "42","49","50","51","52","53","54","55","56","57", + "60","61","62","65","71","72","81","99", + } + for p in reversed(pos_candidates): + if p in valid_pos_codes: + pos_val = p + break + pos_list.append(pos_val) + + # ---- Modifiers — tokens AFTER CPT code, before $-amount -------- + # Isolate the segment after CPT to reduce noise + cpt_idx = row_text.index(cpt_val) + segment_after_cpt = row_text[cpt_idx + len(cpt_val):] + tokens_after = segment_after_cpt.split() + mod_val = _extract_modifiers_from_tokens(tokens_after) + mod_list.append(mod_val) + + # ---- Units (Days/Units) — column G on CMS-1500 ----------------- + # Units is almost always a small integer (1-99). + # We look for a standalone 1-2 digit number that appears AFTER + # any dollar amount (charges column F). + units_val = "" + # Remove currency-looking tokens to isolate units + seg_no_dollars = re.sub(r"\d{1,3}(?:,\d{3})*(?:\.\d{2})?", " MONEY ", segment_after_cpt) + unit_candidates = re.findall(r"\b([1-9]\d?)\b", seg_no_dollars) + # The first small integer after modifiers is usually units + for uc in unit_candidates: + if 1 <= int(uc) <= 99: + units_val = uc + break + units_list.append(units_val) + + # ---- Rendering Provider / NPI — 10-digit number, column J ------ + npi_candidates = re.findall(r"\b(\d{10})\b", row_text) + rp_list.append(npi_candidates[-1] if npi_candidates else "") + + return { + "cpts": cpts, + "pos_list": pos_list, + "dos_list": dos_list, + "mod_list": mod_list, + "units_list": units_list, + "rp_list": rp_list, + } + + +# --------------------------------------------------------------------------- +# Diagnosis code extraction (field 21 of CMS-1500) +# --------------------------------------------------------------------------- +def _extract_diagnosis_codes(text: str, words: list) -> list: + """Return a deduplicated, ordered list of ICD-10 codes from field 21.""" + codes = [] + + # --- Pass 1: word-coordinate scan near field "21" label ----------------- + for i, w in enumerate(words): + if w["text"] in ("21", "21.", "21:"): + base_y = w["top"] + # Field 21 spans ~80px vertically and the full page width after label + region = [ + nxt for nxt in words + if (base_y - 5) <= nxt["top"] <= (base_y + 90) + and nxt["x0"] >= w["x0"] - 5 + ] + for word in region: + if _is_icd10(word["text"]): + codes.append(word["text"]) + if codes: + break + + # --- Pass 2: regex scan on full extracted text -------------------------- + if not codes: + # Try to isolate the diagnosis section (between field 21 and 22/23/24) + diag_section_match = re.search( + r"(?:21|DIAGNOSIS)[^\n]*\n([\s\S]{0,400})(?:22\.|23\.|24\.|ICD)", + text, re.IGNORECASE + ) + target = diag_section_match.group(1) if diag_section_match else text + raw = re.findall(r"\b([A-Z]\d{2,3}(?:\.\d{1,4})?)\b", target) + codes.extend(raw) + + # --- Pass 3: broad scan if still empty ---------------------------------- + if not codes: + raw = re.findall(r"\b([A-Z]\d{2,3}(?:\.\d{1,4})?)\b", text) + # Filter obvious non-ICD tokens + codes = [c for c in raw if not c.startswith("NP")] + + # Deduplicate preserving order + seen = set() + unique = [] + for c in codes: + if c not in seen: + seen.add(c) + unique.append(c) + return unique + + +# =========================================================================== +# MAIN FUNCTION +# =========================================================================== +def extract_pdf_data_fast( + pdf_path: str, + claim_id: str = "", + patient_account: str = "", + insurance: str = "", + patient_name: str = "", + total_charges: str = "", + signature_physician: str = "", +) -> dict | None: + """ + Extract all relevant fields from a CMS-1500 PDF. + + Parameters + ---------- + pdf_path : Path to the PDF file. + claim_id : Pre-populated from upstream data (e.g. spreadsheet). + patient_account : Pre-populated patient account number. + insurance : Pre-populated insurance name. + patient_name : Pre-populated patient name. + total_charges : Pre-populated total charges. + signature_physician : Pre-populated physician signature. + + Returns + ------- + dict with all extracted fields, or None on critical failure. + """ + + data = { + "Claim ID": claim_id, + "Rendering Provider #": "", + "Signature of Physician": signature_physician, + "Patient's Account No": patient_account, + "Date of Birth": "", + "Insurance Name": insurance, + "Insured ID Number": "", + "Patient's Name": patient_name, + "Date of Service": "", + "Diagnosis Codes": "", + "CPT": "", + "POS": "", + "Modifier": "", + "Units": "", + "Charges": total_charges, + } + + try: + # ------------------------------------------------------------------ # + # Open once, grab text + words + tables # + # ------------------------------------------------------------------ # + with pdfplumber.open(pdf_path) as pdf: + page = pdf.pages[0] + text = page.extract_text(x_tolerance=2, y_tolerance=2) or "" + words = page.extract_words() + tables = page.extract_tables() + + print(f"[DEBUG] Opened PDF: {pdf_path}") + print(f"[DEBUG] Tables found: {len(tables)}") + + # ================================================================== # + # 1. DATE OF BIRTH (field 3 — format MM DD YYYY) # + # ================================================================== # + dob_match = re.search(r"\b(\d{2})\s+(\d{2})\s+(\d{4})\b", text) + if dob_match: + data["Date of Birth"] = ( + f"{dob_match.group(1)}/{dob_match.group(2)}/{dob_match.group(3)}" + ) + + # ================================================================== # + # 2. INSURED'S I.D. NUMBER (field 1a) # + # ================================================================== # + insured_id = "" + + # Method A: word following "1a." label + for i, w in enumerate(words): + if w["text"].lower().rstrip(".") in ("1a",): + for nxt in words[i + 1 : i + 6]: + t = nxt["text"] + # Must be alphanumeric, ≥8 chars, contain both letters & digits + if ( + re.match(r"^[A-Z0-9]{8,20}$", t) + and re.search(r"[A-Z]", t) + and re.search(r"\d", t) + ): + insured_id = t + break + if insured_id: + break + + # Method B: proximity to "INSURED'S I.D. NUMBER" label + if not insured_id: + for i, w in enumerate(words): + if "INSURED" in w["text"].upper(): + nearby = words[i : min(i + 6, len(words))] + if any("NUMBER" in nxt["text"].upper() for nxt in nearby): + base_y = w["top"] + candidates = [ + nxt for nxt in words + if abs(nxt["top"] - base_y) < 15 and nxt["x0"] > w["x0"] + ] + for c in reversed(candidates): + t = c["text"] + if ( + re.match(r"^[A-Z0-9]{8,20}$", t) + and re.search(r"[A-Z]", t) + and re.search(r"\d", t) + ): + insured_id = t + break + if insured_id: + break + + # Method C: top-right quadrant scan + if not insured_id: + _skip = {"MEDICARE", "MEDICAID", "CHAMPVA", "TRICARE", "CHAMPUS"} + for w in words: + if w["x0"] > 400 and w["top"] < 200: + t = w["text"] + if ( + re.match(r"^[A-Z0-9]{8,20}$", t) + and re.search(r"[A-Z]", t) + and re.search(r"\d", t) + and t not in _skip + ): + insured_id = t + break + + data["Insured ID Number"] = insured_id + print(f"[DEBUG] Insured ID: {insured_id}") + + # ================================================================== # + # 3. DIAGNOSIS CODES (field 21) # + # ================================================================== # + dx_codes = _extract_diagnosis_codes(text, words) + if dx_codes: + codes_str = ", ".join(dx_codes) + data["Diagnosis Codes"] = f"{codes_str} (Total: {len(dx_codes)})" + print(f"[DEBUG] Diagnosis codes ({len(dx_codes)}): {dx_codes}") + + # ================================================================== # + # 4. SERVICE LINES (section 24 — columns A-J) # + # ================================================================== # + svc = _parse_service_lines(tables) + cpts = svc["cpts"] + pos_list = svc["pos_list"] + dos_list = svc["dos_list"] + mod_list = svc["mod_list"] + units_list= svc["units_list"] + rp_list = svc["rp_list"] + + print(f"[DEBUG] Service lines found: {len(cpts)}") + print(f"[DEBUG] CPTs : {cpts}") + print(f"[DEBUG] POS : {pos_list}") + print(f"[DEBUG] DOS : {dos_list}") + print(f"[DEBUG] Modifiers : {mod_list}") + print(f"[DEBUG] Units : {units_list}") + print(f"[DEBUG] Providers : {rp_list}") + + # Counts must match CPT count — pad with "" if somehow shorter + n = len(cpts) + def _pad(lst): return lst + [""] * max(0, n - len(lst)) + + pos_list = _pad(pos_list) + dos_list = _pad(dos_list) + mod_list = _pad(mod_list) + units_list = _pad(units_list) + rp_list = _pad(rp_list) + + data["CPT"] = _format_list(cpts, "CPT") + data["POS"] = _format_list(pos_list, "POS") + data["Date of Service"] = _format_list(dos_list, "DOS") + data["Modifier"] = _format_list(mod_list, "Modifier") + data["Units"] = _format_list(units_list, "Units") + data["Rendering Provider #"] = _format_list(rp_list, "RP #") + + return data + + except Exception as exc: + import traceback + print(f"[ERROR] PDF parsing failed → {pdf_path} → {exc}") + traceback.print_exc() + return None + + +# =========================================================================== +# Quick smoke-test (remove / guard with __main__ in production) +# =========================================================================== +if __name__ == "__main__": + import sys, json + + if len(sys.argv) < 2: + print("Usage: python extract_pdf_data_v3.py ") + sys.exit(1) + + result = extract_pdf_data_fast(sys.argv[1]) + print("\n===== EXTRACTED DATA =====") + print(json.dumps(result, indent=2)) \ No newline at end of file diff --git a/src/rough draft.py b/src/rough draft.py new file mode 100644 index 0000000..a21bd2b --- /dev/null +++ b/src/rough draft.py @@ -0,0 +1,1441 @@ +import subprocess +import time +import os +import sys +import socket +import re +import pandas as pd +from google.cloud import documentai_v1 as documentai +from google.oauth2 import service_account +import requests +from pathlib import Path +from datetime import datetime +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.chrome.service import Service +from selenium.common.exceptions import WebDriverException +from selenium.common.exceptions import NoSuchElementException +from selenium.common.exceptions import StaleElementReferenceException +from selenium.webdriver.support.ui import Select +from selenium.webdriver.common.keys import Keys + +# ================= DOCUMENT AI CONFIG ================= +DOCAI_CREDENTIALS_PATH = r"C:\Potential Project G2\.env\pdf-extraction-492807-41531f078f92.json" +DOCAI_PROJECT_ID = "1065395041789" +DOCAI_LOCATION = "us" +DOCAI_PROCESSOR_ID = "2d4c7121ce241b13" + +try: + from pywinauto import Application +except Exception: + Application = None + +# ================= PROJECT ROOT ================= +if getattr(sys, "frozen", False): + PROJECT_ROOT = Path(sys.executable).parent +else: + PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# ================= GLOBAL CONFIG ================= +DEBUG_PORT = 9222 +USER_DATA_DIR = os.path.join( + os.environ["USERPROFILE"], "Automation", "ChromeProfile_CF" +) + +today_date = datetime.now().strftime("%m-%d-%Y") +DOWNLOADS_DIR = Path.home() / "Downloads" + +# ================= CHROME PATH ================= +def find_chrome_path(): + possible_paths = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + os.path.join( + os.environ.get("LOCALAPPDATA", ""), + r"Google\Chrome\Application\chrome.exe" + ) + ] + for path in possible_paths: + if os.path.exists(path): + return path + return None + +# ================= PROXY CONFIG ================= +def read_input_config(): + config_path = PROJECT_ROOT / "Input" / "input.xlsx" + if not config_path.exists(): + print("[ERROR] input.xlsx not found.") + sys.exit(1) + df = pd.read_excel(config_path, sheet_name="Sheet1", header=None) + config = {} + for _, row in df.iterrows(): + key = str(row[0]).strip() + value = str(row[1]).strip() + config[key] = value + proxy_ip = config.get("Proxy IP", "") + proxy_port = config.get("Proxy Port", "") + waystar_username = config.get("Waystar Username", "") + waystar_password = config.get("Waystar Password", "") + portal_url = config.get("Waystar URL", "") + first_car_answer = config.get("Waystar SQ First Car", "") + first_job_answer = config.get("Waystar SQ First Job", "") + print(f"[INFO] Proxy → {proxy_ip}:{proxy_port}") + print(f"[INFO] Username → {waystar_username}") + print(f"[INFO] Portal URL → {portal_url}") + return ( + proxy_ip, proxy_port, waystar_username, waystar_password, + portal_url, first_car_answer, first_job_answer + ) + +# ================= DEBUG PORT CHECK ================= +def wait_for_debug_port(port=9222, timeout=40): + start = time.time() + while time.time() - start < timeout: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(("127.0.0.1", port)) == 0: + return True + time.sleep(1) + return False + +# ================= KILL CHROME ================= +def kill_all_chrome(): + try: + os.system("taskkill /F /IM chrome.exe /T >nul 2>&1") + time.sleep(3) + print("[INFO] All Chrome instances closed") + except Exception as e: + print(f"[WARN] Unable to kill Chrome: {e}") + +# ================= START CHROME DEBUG ================= +def start_chrome_debug(proxy_ip=None, proxy_port=None): + chrome_path = find_chrome_path() + if not chrome_path: + raise Exception("Google Chrome not found.") + print(f"[INFO] Using Chrome path: {chrome_path}") + os.makedirs(USER_DATA_DIR, exist_ok=True) + cmd = [ + chrome_path, + f"--remote-debugging-port={DEBUG_PORT}", + f"--user-data-dir={USER_DATA_DIR}", + "--disable-blink-features=AutomationControlled", + "--ignore-certificate-errors", + "about:blank" + ] + if proxy_ip and proxy_port: + proxy = f"{proxy_ip}:{proxy_port}" + cmd.append(f"--proxy-server=http://{proxy}") + print(f"[INFO] Chrome started with proxy → {proxy}") + else: + print("[INFO] Chrome started WITHOUT proxy") + print("[INFO] Launching Chrome...") + subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if not wait_for_debug_port(DEBUG_PORT, timeout=40): + print("[ERROR] Chrome debug port did not open") + return False + print("[INFO] Chrome debug port is ready") + return True + +# ================= SCREENSHOT ================= +def capture_screenshot(driver, client_name, step_name): + if not driver: + return None + safe_client = "".join(c for c in client_name if c.isalnum() or c in ("_", "-")) + safe_step = "".join(c for c in step_name if c.isalnum() or c in ("_", "-")) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + screenshots_dir = PROJECT_ROOT / "Screenshots" / today_date / safe_client + screenshots_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshots_dir / f"{safe_step}_{timestamp}.png" + try: + driver.save_screenshot(str(screenshot_path)) + print(f"[INFO] Screenshot saved: {screenshot_path}") + return screenshot_path + except Exception as e: + print(f"[ERROR] Screenshot failed: {e}") + return None + +# ================= ATTACH BROWSER ================= +def attach_browser(download_path, retries=5): + for attempt in range(1, retries + 1): + try: + print(f"[INFO] Attaching browser (attempt {attempt})") + chrome_options = Options() + chrome_options.add_experimental_option( + "debuggerAddress", f"127.0.0.1:{DEBUG_PORT}" + ) + chrome_options.set_capability("webSocketUrl", False) + prefs = { + "download.default_directory": str(download_path), + "download.prompt_for_download": False, + "download.directory_upgrade": True, + "plugins.always_open_pdf_externally": True, + "safebrowsing.enabled": True + } + chrome_options.add_experimental_option("prefs", prefs) + service = Service() + driver = webdriver.Chrome(service=service, options=chrome_options) + _ = driver.current_url + driver.maximize_window() + print("[INFO] Browser attached successfully") + try: + driver.execute_cdp_cmd( + "Page.setDownloadBehavior", + {"behavior": "allow", "downloadPath": str(download_path)} + ) + except Exception as e: + print(f"[WARN] Unable to set download behavior: {e}") + return driver + except WebDriverException as e: + print(f"[WARN] Attach attempt {attempt} failed: {e}") + time.sleep(3) + print("[ERROR] Failed to attach Chrome after retries") + return None + +# ================= LOGIN ================= +def login_to_portal(driver, waystar_username, waystar_password, first_car_answer, first_job_answer): + wait = WebDriverWait(driver, 30) + try: + time.sleep(5) + print("[INFO] Starting login process") + username_field = wait.until(EC.presence_of_element_located((By.ID, "loginName"))) + username_field.clear() + username_field.send_keys(waystar_username) + time.sleep(2) + password_field = wait.until(EC.presence_of_element_located((By.ID, "password"))) + password_field.clear() + password_field.send_keys(waystar_password) + time.sleep(2) + driver.find_element(By.ID, "loginButton").click() + time.sleep(5) + print("[INFO] Login clicked") + try: + alert_page = driver.find_elements(By.XPATH, "//span[contains(text(),'Proactive Alert')]") + if alert_page: + wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@id='continueButton']"))).click() + time.sleep(3) + except: + pass + try: + question_exists = driver.find_elements(By.XPATH, "//*[@id='authDiv']//strong") + if question_exists: + question = wait.until( + EC.presence_of_element_located((By.XPATH, "//*[@id='authDiv']//strong")) + ).text.lower() + print(f"[INFO] Security Question → {question}") + if "first car" in question: + answer = first_car_answer + elif "first job" in question: + answer = first_job_answer + else: + raise Exception(f"Unknown security question: {question}") + answer_box = wait.until(EC.presence_of_element_located((By.ID, "verifyAnswer"))) + answer_box.clear() + answer_box.send_keys(answer) + driver.find_element(By.ID, "VerifyButton").click() + print("[INFO] Security answer submitted") + time.sleep(3) + except: + pass + try: + security_qpage = driver.find_elements(By.XPATH, "//h2[normalize-space()='Security Information']") + if security_qpage: + print("[INFO] Security page appeared, skipping") + wait.until(EC.element_to_be_clickable((By.ID, "NextButton"))).click() + time.sleep(2) + wait.until( + EC.element_to_be_clickable((By.XPATH, "//a[normalize-space()='Skip This Step']")) + ).click() + time.sleep(5) + except: + pass + print("[INFO] Login completed successfully") + except Exception as e: + print(f"[ERROR] Login failed: {e}") + raise + +# ================= CALCULATE DATE RANGE ================= +def get_date_range(): + now = datetime.now() + first_of_current = datetime(now.year, now.month, 1) + last_of_prev = first_of_current - pd.Timedelta(days=1) + first_of_prev = datetime(last_of_prev.year, last_of_prev.month, 1) + return ( + first_of_prev.strftime("%m/%d/%Y"), + last_of_prev.strftime("%m/%d/%Y"), + ) + +# ================= APPLY FILTERS ================= +def apply_filters(driver): + wait = WebDriverWait(driver, 40) + print("[INFO] Applying filters") + wait.until(EC.presence_of_element_located((By.ID, "ClaimListingSearchButtonBottom"))) + wait.until(EC.presence_of_element_located((By.ID, "SearchCriteria_Status"))) + status_dropdown = Select(wait.until(EC.element_to_be_clickable((By.ID, "SearchCriteria_Status")))) + status_dropdown.select_by_visible_text("All") + date_dropdown = Select(wait.until(EC.element_to_be_clickable((By.ID, "SearchCriteria_TransDate")))) + date_dropdown.select_by_visible_text("Custom") + time.sleep(2) + start_date, end_date = get_date_range() + print(f"[INFO] Setting date range: {start_date} to {end_date}") + try: + start_date_field = wait.until(EC.presence_of_element_located((By.ID, "transactionDateFromDateBox"))) + start_date_field.clear() + start_date_field.send_keys(start_date) + time.sleep(1) + except Exception as e: + print(f"[WARN] Could not set start date: {e}") + try: + end_date_field = wait.until(EC.presence_of_element_located((By.ID, "transactionDateToDateBox"))) + end_date_field.clear() + end_date_field.send_keys(end_date) + time.sleep(1) + except Exception as e: + print(f"[WARN] Could not set end date: {e}") + search_btn = wait.until(EC.element_to_be_clickable((By.ID, "ClaimListingSearchButtonBottom"))) + driver.execute_script("arguments[0].click();", search_btn) + wait.until(EC.presence_of_element_located((By.XPATH, "//table[@id='claimsGrid']//tbody//tr"))) + time.sleep(3) + print("[INFO] Filters applied successfully") + +# ================= CLICK NEXT PAGE ================= +def click_next_page(driver): + try: + next_btn = driver.find_element(By.XPATH, "//button[@id='innerPagerRight']") + if "disabled" in next_btn.get_attribute("class"): + return False + driver.execute_script("arguments[0].click();", next_btn) + WebDriverWait(driver, 10).until(EC.staleness_of(next_btn)) + time.sleep(2) + return True + except: + return False + +# ================= DIRECT DOWNLOAD ================= +def download_pdf_direct(driver, pdf_url, save_path): + try: + cookies = {c["name"]: c["value"] for c in driver.get_cookies()} + headers = { + "User-Agent": driver.execute_script("return navigator.userAgent;"), + "Referer": driver.current_url, + "Accept": "application/pdf,*/*", + } + response = requests.get( + pdf_url, headers=headers, cookies=cookies, + stream=True, timeout=60 + ) + if response.status_code != 200: + print(f"[ERROR] HTTP {response.status_code} → {pdf_url}") + return False + content_type = response.headers.get("Content-Type", "") + if "pdf" not in content_type.lower() and "octet-stream" not in content_type.lower(): + print(f"[WARN] Unexpected content type → {content_type}") + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + with open(save_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + print(f"[SUCCESS] Downloaded → {save_path.name}") + return True + except Exception as e: + print(f"[ERROR] Direct download failed → {e}") + return False + + +# ============================================================ +# CMS-1500 DOCUMENT AI EXTRACTOR — v6.0 +# +# FIXES vs v5.x: +# 1. ICD-10 / Diagnosis codes: broadened regex to catch +# alphanumeric codes with suffix letters (S81812A, Z7409, +# L89892, Q4250, etc.) +# 2. CPT: now accepts alphanumeric HCPCS codes (Q4250, +# G0283, 11042, etc.) — 5-char codes starting with +# digit OR letter followed by 4 digits. +# 3. Date of Service: serial-number column (x < 0.04) is +# now excluded from DOS token scanning so row numbers +# like "01","02" are never picked up as MM. +# 4. Units: tightened column bounds and added a guard that +# prevents the Diagnosis Pointer column (single letter +# A-L) from being mistaken for a unit value. +# ============================================================ + +# ================= DOCUMENT AI CONFIG (duplicate import guard) ================= +# Already imported at top of file — reuse same constants. + +# ============================================================ +# NORMALIZED FIELD REGIONS — CALIBRATED v6.0 +# ============================================================ +FIELD_1A = { + "x_min": 0.55, "x_max": 0.95, + "y_min": 0.095, "y_max": 0.185, +} + +FIELD_21 = { + "x_min": 0.02, "x_max": 0.68, + "y_min": 0.510, "y_max": 0.590, +} + +SECTION_24_Y_MIN = 0.615 +SECTION_24_Y_MAX = 0.880 + +# ── Column x-ranges (normalized, 0–1) ────────────────────── +# FIX #3: DOS_FROM_MM x_min raised to 0.045 so the row-serial +# number printed in the left gutter (x ≈ 0.01–0.035) is +# excluded from date scanning. +COL = { + "DOS_FROM_MM": (0.045, 0.085), # was (0.025, 0.075) + "DOS_FROM_DD": (0.078, 0.120), # was (0.070, 0.112) + "DOS_FROM_YY": (0.112, 0.165), # was (0.105, 0.160) + "POS": (0.215, 0.272), + "CPT": (0.285, 0.430), # slightly wider for alpha-CPTs + "MODIFIER": (0.410, 0.540), + # FIX #4: Units column tightened; avoids Diagnosis-Pointer + # column which sits around x=0.62–0.64 + "UNITS": (0.648, 0.695), # was (0.648, 0.720) + "RENDERING": (0.810, 0.998), +} + +_VALID_MODIFIERS = { + "GP","GN","GO","GQ","GX","GY","GZ", + "LT","RT", + "E1","E2","E3","E4", + "FA","F1","F2","F3","F4","F5","F6","F7","F8","F9", + "TA","T1","T2","T3","T4","T5","T6","T7","T8","T9", + "TC","26","59","25","51","76","77","78","79","91", + "24","57","58","80","81","82", + "AA","AD","AE","AF","AG","AH","AI","AJ","AK","AM","AS","AT", + "CQ","CR","CS","GA","GC","GJ","GK","GL","GT", + "JA","JB","JW","JZ","KX","KU","KV","NU","RR","UE", + "Q0","Q1","Q2","Q3","Q4","Q5","Q6", + "SA","SB","SC","SD","SF","SG","XE","XP","XS","XU", + "22","23","32","33","47","50","52","53", + "54","55","56","62","63","66", + "73","74","90","92","93","95","96","97","99", + "AB", +} + +_NOT_MODIFIERS = { + "MM","DD","YY","TO","OF","OR","IN","IS","NO","BY", + "AT","BE","DO","HE","HI","IF","ME","MY","OK","ON", + "SO","UP","US","WE","NPI","ICD","CPT","POS","DOS", + "ID","AN","AC","EMG","FROM","THE","AND", +} + +_VALID_POS = { + "11","12","13","14","15","16","17","18","19","20", + "21","22","23","24","25","31","32","33","34", + "41","42","49","50","51","52","53","54","55","56","57", + "60","61","62","65","71","72","81","99", +} + +_SKIP_ID_LABELS = { + "MEDICARE","MEDICAID","CHAMPVA","TRICARE","CHAMPUS", + "NUMBER","INSURED","MEMBER","HEALTH","PLAN","PICA", + "PROGRAM","ITEM","FOR","OTHER","GROUP","FECA","CARRIER", + "1A","FOR","IN","ITEM","NPI","SSN","EIN","QUAL", + "ID","INSURED'S","I.D.","OTHER","(FOR","(",")", + "1)","IN","ITEM","1", +} + +# ── Diagnosis Pointer letters — single letters that appear +# in the E column of section 24 and must NOT be units ── +_DIAG_POINTER_LETTERS = set("ABCDEFGHIJKL") + +# ============================================================ +# DOCUMENT AI CLIENT +# ============================================================ + +def _get_docai_client(): + credentials = service_account.Credentials.from_service_account_file( + DOCAI_CREDENTIALS_PATH, + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + return documentai.DocumentProcessorServiceClient( + credentials=credentials, + client_options={"api_endpoint": "us-documentai.googleapis.com"} + ) + + +def _process_pdf_with_docai(pdf_path: str): + client = _get_docai_client() + with open(pdf_path, "rb") as f: + pdf_bytes = f.read() + processor_name = ( + f"projects/{DOCAI_PROJECT_ID}/locations/{DOCAI_LOCATION}" + f"/processors/{DOCAI_PROCESSOR_ID}" + ) + result = client.process_document(request=documentai.ProcessRequest( + name=processor_name, + raw_document=documentai.RawDocument( + content=pdf_bytes, mime_type="application/pdf" + ) + )) + return result.document + + +def _token_text(document, token) -> str: + seg = token.layout.text_anchor.text_segments + if not seg: + return "" + start = int(seg[0].start_index) if seg[0].start_index else 0 + end = int(seg[0].end_index) + return document.text[start:end].strip() + + +def _token_norm_bounds(token): + verts = token.layout.bounding_poly.normalized_vertices + if len(verts) < 4: + return None + xs = [v.x for v in verts] + ys = [v.y for v in verts] + return min(xs), min(ys), max(xs), max(ys) + + +def _build_words(document, page) -> list: + words = [] + for token in page.tokens: + text = _token_text(document, token).strip() + if not text: + continue + bounds = _token_norm_bounds(token) + if bounds is None: + continue + x0, y_top, x1, y_bot = bounds + words.append({ + "text": text, + "x0": x0, + "x1": x1, + "y_top": y_top, + "y_bot": y_bot, + "x_mid": (x0 + x1) / 2, + "y_mid": (y_top + y_bot) / 2, + }) + return words + + +def _words_in_region(words: list, region: dict) -> list: + return [ + w for w in words + if region["x_min"] <= w["x_mid"] <= region["x_max"] + and region["y_min"] <= w["y_mid"] <= region["y_max"] + ] + + +def _words_in_col(words: list, row_y_mid: float, + col_name: str, y_tol: float = 0.013) -> list: + x_min, x_max = COL[col_name] + return [ + w for w in words + if x_min <= w["x_mid"] <= x_max + and abs(w["y_mid"] - row_y_mid) <= y_tol + ] + + +# ============================================================ +# FIX #1 — ICD-10 / Diagnosis code validator +# Now accepts: +# • Standard: A00–Z99, A00.0–Z99.9, up to 7 chars after dot +# • Suffix letters (7th character extension): S81812A, M80011A +# • Short alphanumeric codes without dot: Z7409, L89892, Q4250 +# • Must start with a letter; second char must be a digit +# ============================================================ +def _is_icd10(t: str) -> bool: + t = t.upper().strip() + # Pattern 1: Dotted form — e.g. A01.0, M54.51, S72.001A + if re.fullmatch(r"[A-Z]\d{2,3}\.\d{1,4}[A-Z]?", t): + return True + # Pattern 2: Non-dotted — letter + digit + 1-5 alphanumeric chars + # Covers: R2681, G20C, S81812A, Z7409, L89892, Q4250, F329, Z23 + # Requires: starts with letter, second char is digit, total 3-7 chars + if re.fullmatch(r"[A-Z]\d[0-9A-Z]{1,5}", t): + return True + return False + + +# ============================================================ +# FIX #2 — CPT / HCPCS code validator +# Now accepts: +# • Standard CPT : 5 digits (97110, 99348, 15271) +# • HCPCS Level II: letter + 4 digits (Q4250, G0283, L8699) +# • Category III : 4 digits + letter (0596T) — rare but valid +# ============================================================ +def _is_cpt(t: str) -> bool: + t = t.strip().upper() + # Standard 5-digit numeric CPT + if re.fullmatch(r"\d{5}", t): + return 10000 <= int(t) <= 99999 + # HCPCS Level II: A–V letter + 4 digits (excludes W–Z which are + # not standard HCPCS prefixes used on CMS-1500) + if re.fullmatch(r"[A-V]\d{4}", t): + return True + # Category III CPT: 4 digits + T/F/U/M suffix + if re.fullmatch(r"\d{4}[TFUM]", t): + return True + return False + + +def _is_icd10(t: str) -> bool: + """ + FIX #1 — broadened ICD-10-CM validator. + Accepts codes like: S81812A, Z7409, L89892, Q4250, + M80011A, F329, Z23, as well as dotted forms A01.0, M54.5 + """ + t = t.upper().strip() + # Must start with a letter and have at least 3 chars total + if len(t) < 3 or not t[0].isalpha(): + return False + # Second character must be a digit (ICD-10-CM rule) + if len(t) < 2 or not t[1].isdigit(): + return False + # Dotted form: e.g. A01.0, M54.51, S72.001A + if re.fullmatch(r"[A-Z]\d{2,3}\.\d{1,4}[A-Z]?", t): + return True + # Non-dotted form: letter + 2–6 alphanumeric chars + # second char already confirmed digit above + if re.fullmatch(r"[A-Z][0-9][0-9A-Z]{1,5}", t): + return True + return False + + +def _is_valid_insured_id(t: str) -> bool: + t_clean = re.sub(r"[-_.\s()]", "", t.upper()) + if not re.match(r"^[A-Z0-9]+$", t_clean): + return False + length = len(t_clean) + if length < 5 or length > 20: + return False + base = t.upper().rstrip(".,)(") + if base in _SKIP_ID_LABELS: + return False + if any(label in t.upper() + for label in {"INSURED","NUMBER","PROGRAM","MEDICARE","MEDICAID", + "PICA","CARRIER","HEALTH","PLAN","CHAMPVA","TRICARE"}): + return False + if re.fullmatch(r"\d{10}", t_clean): + return False + if _is_cpt(t_clean): + return False + if re.fullmatch(r"[A-Z]{1,5}", t_clean): + return False + if re.fullmatch(r"\d+", t_clean): + if 7 <= length <= 12: + return True + return False + if re.search(r"[A-Z]", t_clean) and re.search(r"\d", t_clean): + return True + return False + + +def _is_valid_dos(mm: str, dd: str, yy: str) -> bool: + try: + m, d, y = int(mm), int(dd), int(yy) + return (1 <= m <= 12) and (1 <= d <= 31) and (2000 <= y <= 2099) + except Exception: + return False + + +def _extract_dob(text: str) -> str: + m = re.search(r"\b(\d{2})\s+(\d{2})\s+(\d{4})\b", text) + if m: + return f"{m.group(1)}/{m.group(2)}/{m.group(3)}" + m = re.search(r"\b(\d{2})[\/\-](\d{2})[\/\-](\d{4})\b", text) + if m: + return f"{m.group(1)}/{m.group(2)}/{m.group(3)}" + return "" + + +def _extract_insured_id(words: list, text: str) -> str: + wide_region = {"x_min": 0.50, "x_max": 0.95, + "y_min": 0.08, "y_max": 0.20} + wide_tokens = _words_in_region(words, wide_region) + print(f"[DEBUG] Field 1a wide region tokens: " + f"{[(w['text'], round(w['y_mid'],3)) for w in wide_tokens]}") + + candidates = _words_in_region(words, FIELD_1A) + for w in sorted(candidates, key=lambda w: (w["y_top"], w["x0"])): + t = w["text"].strip() + if _is_valid_insured_id(t): + print(f"[DEBUG] Insured ID via region: {t}") + return t.upper() + + for i, w in enumerate(words): + if re.fullmatch(r"1[aA]\.?", w["text"].strip()): + for nxt in words[i+1: i+15]: + if nxt["x_mid"] > 0.50 and nxt["y_mid"] < 0.22: + if _is_valid_insured_id(nxt["text"].strip()): + print(f"[DEBUG] Insured ID via 1a label: {nxt['text']}") + return nxt["text"].strip().upper() + + top_right = [w for w in words + if w["x_mid"] > 0.52 and w["y_mid"] < 0.22] + for w in sorted(top_right, key=lambda w: (w["y_top"], w["x0"])): + t = w["text"].strip() + if _is_valid_insured_id(t): + print(f"[DEBUG] Insured ID via top-right scan: {t}") + return t.upper() + + print(f"[DEBUG] Insured ID: not found") + return "" + + +# ============================================================ +# FIX #1 (cont.) — Diagnosis code extraction +# Uses the new broadened _is_icd10() validator. +# Also skips single-letter slot labels AND slot numbers 0-9. +# ============================================================ +def _extract_diagnosis_codes(words: list, text: str) -> list: + region_words = _words_in_region(words, FIELD_21) + print(f"[DEBUG] Field 21 region token count: {len(region_words)}") + print(f"[DEBUG] Field 21 tokens: {[w['text'] for w in region_words]}") + codes = [] + seen = set() + + # Slot label pattern — "A." "B." ... "L." or bare "A"–"L" + slot_label_pat = re.compile(r"^[A-L]\.?$", re.IGNORECASE) + + for w in sorted(region_words, key=lambda w: (w["y_top"], w["x0"])): + t = w["text"].strip().upper() + + # Skip slot labels + if slot_label_pat.match(t): + continue + + # Skip pure form/section labels + if t in { + "21", "22", "23", "24", + "0", "O", + "ICD", "IND", "ICO", "ICD-10-CM", + "DIAGNOSIS", "NATURE", "ILLNESS", "INJURY", + }: + continue + + # Skip pure digits + if t.isdigit(): + continue + + # Accept ICD-10 codes (now using broadened validator) + if _is_icd10(t) and t not in seen: + codes.append(t) + seen.add(t) + print(f"[DEBUG] DX captured: {t}") + + # Fallback via raw text regex if region found nothing + if not codes: + print("[DEBUG] Region empty — trying text regex fallback") + seg = re.search( + r"(?:DIAGNOSIS|21\.?)([\s\S]{0,600})(?:24\.|DATE.S.\s*OF\s*SERVICE)", + text, re.IGNORECASE + ) + if seg: + # Broadened fallback pattern: letter + digit + 1-5 more alphanum + for c in re.findall( + r"\b([A-Z][0-9][0-9A-Z]{1,5}(?:\.[0-9A-Z]{1,4})?)\b", + seg.group(1) + ): + cu = c.upper() + if cu not in seen and _is_icd10(cu): + seen.add(cu) + codes.append(cu) + print(f"[DEBUG] DX fallback: {cu}") + + print(f"[DEBUG] Diagnosis codes final ({len(codes)}): {codes}") + return codes + + +# ============================================================ +# SERVICE LINE FIELD EXTRACTORS +# ============================================================ + +def _is_real_cpt_location(w: dict, words: list) -> bool: + """ + Verify a CPT candidate is truly in section 24 by checking + that a valid DOS (date) or NPI exists on the same row. + """ + row_y = w["y_mid"] + y_tol = 0.018 + + # FIX #3: use updated COL ranges — serial-number gutter excluded + dos_cols = ["DOS_FROM_MM", "DOS_FROM_DD", "DOS_FROM_YY"] + for col in dos_cols: + x_min, x_max = COL[col] + dos_words = [ + d for d in words + if x_min <= d["x_mid"] <= x_max + and abs(d["y_mid"] - row_y) <= y_tol + and re.fullmatch(r"\d{1,4}", d["text"].strip()) + ] + if dos_words: + return True + + x_min_npi, x_max_npi = COL["RENDERING"] + npi_words = [ + d for d in words + if x_min_npi <= d["x_mid"] <= x_max_npi + and abs(d["y_mid"] - row_y) <= y_tol + and re.fullmatch(r"\d{10}", d["text"].strip()) + ] + if npi_words: + return True + + return False + + +# ============================================================ +# FIX #3 — DOS extraction +# Serial-number gutter (x < 0.04) excluded via updated COL +# x_min values. Additionally a guard ensures we never treat +# a 1- or 2-digit number whose x_mid is < 0.04 as a month. +# ============================================================ +def _extract_dos_for_row(words: list, row_y: float) -> str: + def _first_digit(col_name): + ws = _words_in_col(words, row_y, col_name) + for w in sorted(ws, key=lambda w: w["x0"]): + t = w["text"].strip() + # Extra guard: ignore anything in the left gutter + if w["x_mid"] < 0.04: + continue + if re.fullmatch(r"\d{1,4}", t): + return t + return "" + + mm = _first_digit("DOS_FROM_MM").zfill(2) + dd = _first_digit("DOS_FROM_DD").zfill(2) + yy = _first_digit("DOS_FROM_YY") + if re.fullmatch(r"\d{2}", yy): + yy = ("20" + yy) if int(yy) < 50 else ("19" + yy) + if _is_valid_dos(mm, dd, yy): + return f"{mm}/{dd}/{yy}" + + # Fallback: all digit tokens in DOS cols sorted left→right, + # still excluding x < 0.04 + all_w = [] + for col in ("DOS_FROM_MM", "DOS_FROM_DD", "DOS_FROM_YY"): + all_w += [ + w for w in _words_in_col(words, row_y, col) + if w["x_mid"] >= 0.04 + ] + parts = [ + w["text"].strip() + for w in sorted(all_w, key=lambda w: w["x0"]) + if re.fullmatch(r"\d+", w["text"].strip()) + ] + if len(parts) >= 3: + mm2, dd2, yy2 = parts[0].zfill(2), parts[1].zfill(2), parts[2] + if re.fullmatch(r"\d{2}", yy2): + yy2 = ("20" + yy2) if int(yy2) < 50 else ("19" + yy2) + if _is_valid_dos(mm2, dd2, yy2): + return f"{mm2}/{dd2}/{yy2}" + return "" + + +def _extract_pos_for_row(words: list, row_y: float) -> str: + for w in _words_in_col(words, row_y, "POS"): + if w["text"].strip() in _VALID_POS: + return w["text"].strip() + return "" + + +def _extract_modifier_for_row(words: list, row_y: float) -> str: + mod_words = _words_in_col(words, row_y, "MODIFIER", y_tol=0.016) + found = [] + for w in sorted(mod_words, key=lambda w: w["x0"]): + t = w["text"].strip().upper() + if not t: + continue + if re.fullmatch(r"[A-L]", t): + continue + if t in _NOT_MODIFIERS: + continue + if t in _VALID_MODIFIERS and t not in found: + found.append(t) + if len(found) == 4: + break + return " ".join(found) + + +# ============================================================ +# FIX #4 — Units extraction +# • Tighter x-range in COL["UNITS"] avoids Diagnosis Pointer col +# • Explicitly rejects single letters A-L (diagnosis pointers) +# • Rejects zero — units on CMS-1500 are always ≥ 1 +# • Fallback exclusion set improved +# ============================================================ +def _extract_units_for_row(words: list, row_y: float, + cpt_val: str = "", mod_val: str = "", + pos_val: str = "", npi_val: str = "") -> str: + unit_x_min, unit_x_max = COL["UNITS"] + y_tol = 0.022 + + # Primary: strict column, reject diagnosis pointers and zero + unit_col_words = [ + w for w in words + if unit_x_min <= w["x_mid"] <= unit_x_max + and abs(w["y_mid"] - row_y) <= y_tol + ] + for w in sorted(unit_col_words, key=lambda w: w["x0"]): + t = w["text"].strip().upper() + # FIX #4a: skip single diagnosis-pointer letters + if t in _DIAG_POINTER_LETTERS: + continue + # FIX #4b: must be 1–2 digit number, value 1–99 + if re.fullmatch(r"[1-9]\d?", t): + return t + + # Fallback: eliminate all known-column tokens and pick leftover digit + ban = {cpt_val.upper(), pos_val.upper(), npi_val.upper()} + if mod_val: + for m in mod_val.split(): + ban.add(m.upper()) + + for col in ("DOS_FROM_MM", "DOS_FROM_DD", "DOS_FROM_YY"): + for w in _words_in_col(words, row_y, col, y_tol=0.025): + ban.add(w["text"].strip().upper()) + + for w in _words_in_col(words, row_y, "RENDERING", y_tol=0.025): + ban.add(w["text"].strip().upper()) + for w in _words_in_col(words, row_y, "CPT", y_tol=0.025): + ban.add(w["text"].strip().upper()) + for w in _words_in_col(words, row_y, "POS", y_tol=0.025): + ban.add(w["text"].strip().upper()) + + # In the fallback section, add x_mid column guard: + row_tokens = [ + w for w in words + if SECTION_24_Y_MIN <= w["y_mid"] <= SECTION_24_Y_MAX + and abs(w["y_mid"] - row_y) <= 0.025 + and unit_x_min <= w["x_mid"] <= unit_x_max # ← ADD THIS GUARD + and w["text"].strip().upper() not in ban + and w["text"].strip().upper() not in _DIAG_POINTER_LETTERS + and re.fullmatch(r"\d{1,3}", w["text"].strip()) + and 1 <= int(w["text"].strip()) <= 99 + ] + + for tok in sorted(row_tokens, key=lambda w: w["x_mid"]): + t = tok["text"].strip() + if re.fullmatch(r"[1-9]\d?", t): + return t + + return "" + + +def _extract_npi_for_row(words: list, row_y: float) -> str: + for w in _words_in_col(words, row_y, "RENDERING", y_tol=0.016): + if re.fullmatch(r"\d{10}", w["text"].strip()): + return w["text"].strip() + return "" + + +# ============================================================ +# FIX #2 (cont.) — Service line extraction uses new _is_cpt() +# ============================================================ +def _extract_service_lines(words: list): + cpts, pos_list, dos_list, mod_list, units_list, rp_list = [], [], [], [], [], [] + + x_min_cpt, x_max_cpt = COL["CPT"] + + # FIX #2: use updated _is_cpt() that accepts HCPCS alpha codes + cpt_candidates = [ + w for w in words + if _is_cpt(w["text"].strip()) + and x_min_cpt <= w["x_mid"] <= x_max_cpt + and SECTION_24_Y_MIN <= w["y_mid"] <= SECTION_24_Y_MAX + ] + + print(f"[DEBUG] CPT candidates before validation: " + f"{[(w['text'], round(w['y_mid'],4)) for w in cpt_candidates]}") + + cpt_words = [ + w for w in cpt_candidates + if _is_real_cpt_location(w, words) + ] + + print(f"[DEBUG] CPT tokens after validation: " + f"{[(w['text'], round(w['y_mid'],4)) for w in cpt_words]}") + + if not cpt_words: + print("[DEBUG] No valid CPTs after validation — trying wider scan") + wider_candidates = [ + w for w in words + if _is_cpt(w["text"].strip()) + and 0.55 <= w["y_mid"] <= 0.92 + and 0.20 <= w["x_mid"] <= 0.50 + and _is_real_cpt_location(w, words) + ] + cpt_words = wider_candidates + print(f"[DEBUG] Wider scan CPTs: {[w['text'] for w in cpt_words]}") + + seen_rows = [] + for cpt_word in sorted(cpt_words, key=lambda w: w["y_mid"]): + row_y = cpt_word["y_mid"] + + if any(abs(row_y - sr) <= 0.008 for sr in seen_rows): + continue + seen_rows.append(row_y) + + cpt_val = cpt_word["text"].strip().upper() + dos = _extract_dos_for_row(words, row_y) + pos = _extract_pos_for_row(words, row_y) + mod = _extract_modifier_for_row(words, row_y) + npi = _extract_npi_for_row(words, row_y) + units = _extract_units_for_row(words, row_y, cpt_val, mod, pos, npi) + + cpts.append(cpt_val) + dos_list.append(dos) + pos_list.append(pos) + mod_list.append(mod) + units_list.append(units) + rp_list.append(npi) + + print(f"[DEBUG] CPT={cpt_val} | DOS={dos} | POS={pos} | " + f"MOD={mod} | UNITS={units} | NPI={npi}") + + return cpts, pos_list, dos_list, mod_list, units_list, rp_list + + +def _format_list(values: list, label: str) -> str: + clean = [v for v in values if v] + if not clean: + return "" + if len(clean) == 1: + return clean[0] + return "; ".join(f"{label} {i+1}: {v}" for i, v in enumerate(clean)) + + +# ============================================================ +# MAIN ENTRY POINT +# ============================================================ + +def extract_pdf_data_fast( + pdf_path, + claim_id="", + patient_account="", + insurance="", + patient_name="", + total_charges="", + signature_physician="", +): + data = { + "Claim ID": claim_id, + "Rendering Provider #": "", + "Signature of Physician": signature_physician, + "Patient's Account No": patient_account, + "Date of Birth": "", + "Insurance Name": insurance, + "Insured ID Number": "", + "Patient's Name": patient_name, + "Date of Service": "", + "Diagnosis Codes": "", + "CPT": "", + "POS": "", + "Modifier": "", + "Units": "", + "Charges": total_charges, + } + + try: + print(f"[INFO] Processing with Document AI: {pdf_path}") + document = _process_pdf_with_docai(str(pdf_path)) + + if not document.pages: + print("[ERROR] No pages returned by Document AI") + return None + + page = document.pages[0] + text = document.text + + print(f"[DEBUG] Document AI extracted {len(text)} characters") + print(f"[DEBUG] Token count: {len(page.tokens)}") + print(f"[DEBUG] Page dimensions: {page.dimension.width:.1f} x " + f"{page.dimension.height:.1f} pt") + + words = _build_words(document, page) + print(f"[DEBUG] Words built: {len(words)}") + + data["Date of Birth"] = _extract_dob(text) + data["Insured ID Number"] = _extract_insured_id(words, text) + + dx_codes = _extract_diagnosis_codes(words, text) + if dx_codes: + data["Diagnosis Codes"] = ( + ", ".join(dx_codes) + f" (Total: {len(dx_codes)})" + ) + + cpts, pos_list, dos_list, mod_list, units_list, rp_list = \ + _extract_service_lines(words) + + data["CPT"] = _format_list(cpts, "CPT") + data["POS"] = _format_list(pos_list, "POS") + data["Date of Service"] = _format_list(dos_list, "DOS") + data["Modifier"] = _format_list(mod_list, "Modifier") + data["Units"] = _format_list(units_list, "Units") + data["Rendering Provider #"] = _format_list(rp_list, "RP #") + + return data + + except Exception as e: + import traceback + print(f"[ERROR] Document AI extraction failed → {pdf_path}") + print(f"[ERROR] {e}") + traceback.print_exc() + return None + + +# ================= UPDATE LOG ================= +def update_log(log_path, claim_id, status, remarks=""): + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + new_row = pd.DataFrame([{ + "Claim ID": claim_id, + "Status": status, + "Remarks": remarks, + "Timestamp": now + }]) + try: + existing = pd.read_excel(log_path) + final = pd.concat([existing, new_row], ignore_index=True) + except: + final = new_row + final.to_excel(log_path, index=False) + + +# ================= INCREMENTAL EXCEL SAVE ================= +def _save_batch_to_excel(excel_path, batch_data: list): + if not batch_data: + return + df_new = pd.DataFrame(batch_data) + try: + existing = pd.read_excel(excel_path) + existing = existing.dropna(how="all") + final = pd.concat([existing, df_new], ignore_index=True) + except Exception: + final = df_new + final.to_excel(excel_path, index=False) + print(f"[INFO] Saved {len(batch_data)} records → {excel_path.name} " + f"(total rows: {len(final)})") + + +def _get_processed_claim_ids(log_path) -> set: + try: + df = pd.read_excel(log_path) + done = df[df["Status"] == "SUCCESS"]["Claim ID"].astype(str).tolist() + return set(done) + except Exception: + return set() + + +def run_claim_report(driver, account_name): + SAVE_EVERY = 10 + wait = WebDriverWait(driver, 30) + actions = ActionChains(driver) + print(f"\n[INFO] ====== Running report for {account_name} ======") + today = datetime.now().strftime("%m-%d-%Y") + safe_name = "".join( + c for c in account_name if c.isalnum() or c in (" ", "_", "(", ")") + ) + base = PROJECT_ROOT / "Output" / today + pdf_folder = base / "PDF Folder" / safe_name + report_dir = base / "reports" + pdf_folder.mkdir(parents=True, exist_ok=True) + report_dir.mkdir(parents=True, exist_ok=True) + excel_path = report_dir / f"{safe_name}.xlsx" + logs_dir = base / "Logs" + logs_dir.mkdir(parents=True, exist_ok=True) + log_path = logs_dir / f"{safe_name}_log.xlsx" + if not excel_path.exists(): + pd.DataFrame(columns=[ + "Claim ID", "Rendering Provider #", "Signature of Physician", + "Patient's Account No", "Date of Birth", "Insurance Name", + "Insured ID Number", "Patient's Name", "Date of Service", + "Diagnosis Codes", "CPT", "POS", "Modifier", "Units", "Charges" + ]).to_excel(excel_path, index=False) + if not log_path.exists(): + pd.DataFrame(columns=[ + "Claim ID", "Status", "Remarks", "Timestamp" + ]).to_excel(log_path, index=False) + already_done = _get_processed_claim_ids(log_path) + print(f"[INFO] {len(already_done)} claims already processed for {account_name}") + try: + claims_processing = wait.until( + EC.presence_of_element_located( + (By.XPATH, "//a[contains(.,'Claims Processing')]") + ) + ) + actions.move_to_element(claims_processing).perform() + time.sleep(1) + claims_option = wait.until( + EC.presence_of_element_located( + (By.XPATH, "//a[normalize-space()='Claims']") + ) + ) + driver.execute_script("arguments[0].click();", claims_option) + time.sleep(3) + except Exception as e: + print(f"[ERROR] Navigation to Claims failed: {e}") + return + try: + apply_filters(driver) + except Exception as e: + print(f"[ERROR] Filter application failed: {e}") + return + page = 1 + batch = [] + total_saved = 0 + consecutive_errors = 0 + while True: + print(f"\n[INFO] Processing page {page}") + try: + WebDriverWait(driver, 20).until( + EC.presence_of_element_located( + (By.XPATH, "//table[@id='claimsGrid']//tbody//tr[@data-claimid]") + ) + ) + rows = driver.find_elements( + By.XPATH, "//table[@id='claimsGrid']//tbody//tr[@data-claimid]" + ) + print(f"[INFO] Rows on page {page} → {len(rows)}") + except Exception as e: + print(f"[ERROR] Could not load rows on page {page}: {e}") + consecutive_errors += 1 + if consecutive_errors >= 3: + print("[ERROR] 3 consecutive page-load failures — stopping account") + break + time.sleep(5) + continue + consecutive_errors = 0 + for i in range(len(rows)): + claim_id = f"row_{page}_{i+1}" + try: + rows = driver.find_elements( + By.XPATH, + "//table[@id='claimsGrid']//tbody//tr[@data-claimid]" + ) + if i >= len(rows): + print(f"[WARN] Row {i+1} no longer exists — skipping") + continue + row = rows[i] + claim_id = row.get_attribute("data-claimid") or "" + instance_id = row.get_attribute("data-instanceid") or "" + if not claim_id or not instance_id: + print(f"[WARN] Missing IDs row {i+1} — skipping") + continue + if claim_id in already_done: + print(f"[INFO] [{i+1}/{len(rows)}] Skipping (already done): {claim_id}") + continue + try: + claim_number = row.find_element(By.XPATH, ".//td[2]").text.strip() + except Exception: + claim_number = "" + try: + sig_raw = row.find_element(By.XPATH, ".//td[10]").text.strip() + if "," in sig_raw: + last, first = sig_raw.split(",", 1) + signature_physician = f"{first.strip().title()} {last.strip().title()}" + else: + signature_physician = sig_raw.strip().title() + except Exception: + signature_physician = "" + insurance = re.sub( + r"\s*\(.*?\)\s*$", "", + row.get_attribute("data-payername") or "" + ).strip() + patient_name = row.get_attribute("data-patientname") or "" + charges = row.get_attribute("data-totalcharges") or "" + print(f"[INFO] [{i+1}/{len(rows)}] Claim: {claim_id} | {patient_name}") + pdf_url = ( + f"https://claims.zirmed.com/Claims/History/ViewClaimPDF" + f"?appId=1&claimId={claim_id}&instanceId={instance_id}" + f"&form=CMS1500_0212" + ) + save_path = pdf_folder / f"{claim_id}.pdf" + if save_path.exists(): + save_path = pdf_folder / f"{claim_id}_{instance_id}.pdf" + success = download_pdf_direct(driver, pdf_url, save_path) + if not success: + time.sleep(3) + success = download_pdf_direct(driver, pdf_url, save_path) + if not success: + update_log(log_path, claim_id, "FAILED", "Download failed after retry") + continue + pdf_data = extract_pdf_data_fast( + save_path, + claim_id=claim_id, + patient_account=claim_number, + insurance=insurance, + patient_name=patient_name, + total_charges=charges, + signature_physician=signature_physician, + ) + if not pdf_data: + update_log(log_path, claim_id, "FAILED", "PDF extraction failed") + continue + batch.append(pdf_data) + already_done.add(claim_id) + update_log(log_path, claim_id, "SUCCESS", "") + if len(batch) >= SAVE_EVERY: + _save_batch_to_excel(excel_path, batch) + total_saved += len(batch) + batch = [] + except Exception as e: + print(f"[ERROR] Row {i+1} (claim {claim_id}) failed → {e}") + update_log(log_path, claim_id, "FAILED", str(e)[:200]) + if batch: + _save_batch_to_excel(excel_path, batch) + total_saved += len(batch) + batch = [] + if not click_next_page(driver): + print(f"[INFO] No more pages for {account_name}") + break + page += 1 + print(f"\n[SUCCESS] {account_name} complete — {total_saved} records saved") + print(f"[INFO] ====== Account {account_name} finished ======\n") + + +def process_all_accounts(driver): + wait = WebDriverWait(driver, 30) + print("[INFO] Starting account processing...") + all_accounts = [] + try: + dropdown = wait.until( + EC.element_to_be_clickable( + (By.XPATH, "//input[contains(@data-search-url,'AccountSearch')]") + ) + ) + dropdown.click() + time.sleep(2) + account_elements = driver.find_elements( + By.XPATH, "//ul[contains(@class,'ui-autocomplete')]//li" + ) + all_accounts = [ + acc.text.strip() for acc in account_elements if acc.text.strip() + ] + print(f"[INFO] Total accounts found → {len(all_accounts)}") + driver.find_element(By.TAG_NAME, "body").click() + time.sleep(1) + except Exception as e: + print(f"[ERROR] Failed to get account list: {e}") + return + if not all_accounts: + print("[WARN] No accounts found — exiting") + return + seen_names = set() + unique_accounts = [] + for name in all_accounts: + if name not in seen_names: + seen_names.add(name) + unique_accounts.append(name) + if len(unique_accounts) < len(all_accounts): + print(f"[INFO] Removed {len(all_accounts) - len(unique_accounts)} " + f"duplicate account names") + all_accounts = unique_accounts + print(f"[INFO] Unique accounts to process → {len(all_accounts)}") + portal_home_url = driver.current_url + processed_accounts = set() + for idx, account_name in enumerate(all_accounts, 1): + if account_name in processed_accounts: + print(f"[WARN] '{account_name}' already processed — skipping") + continue + print(f"\n[INFO] ===== Account {idx}/{len(all_accounts)}: {account_name} =====") + try: + driver.get(portal_home_url) + time.sleep(3) + dropdown = wait.until( + EC.element_to_be_clickable( + (By.XPATH, "//input[contains(@data-search-url,'AccountSearch')]") + ) + ) + dropdown.clear() + dropdown.click() + time.sleep(2) + account_elements = driver.find_elements( + By.XPATH, "//ul[contains(@class,'ui-autocomplete')]//li" + ) + clicked = False + for acc in account_elements: + if acc.text.strip() == account_name: + acc.click() + clicked = True + break + if not clicked: + print(f"[WARN] '{account_name}' not found in dropdown — skipping") + continue + try: + wait.until( + EC.invisibility_of_element_located( + (By.XPATH, "//ul[contains(@class,'ui-autocomplete')]") + ) + ) + except Exception: + pass + time.sleep(3) + processed_accounts.add(account_name) + run_claim_report(driver, account_name) + except Exception as e: + print(f"[ERROR] Account '{account_name}' failed: {e}") + try: + driver.get(portal_home_url) + time.sleep(3) + except Exception: + pass + continue + print(f"\n[INFO] ===== All {len(processed_accounts)} accounts processed =====") + print(f"[INFO] Accounts completed: {sorted(processed_accounts)}") + + +if __name__ == "__main__": + print("=" * 80) + print("🚀 STARTING CHARGE BILLED AUTOMATION - Document AI v6.0") + print("=" * 80) + driver = None + try: + print("\n[STEP 1] Closing existing Chrome instances...") + kill_all_chrome() + print("\n[STEP 2] Reading configuration...") + ( + proxy_ip, proxy_port, username, waystar_password, + portal_url, first_car_answer, first_job_answer + ) = read_input_config() + print("\n[STEP 3] Launching Chrome in debug mode...") + if not start_chrome_debug(proxy_ip, proxy_port): + raise Exception("Chrome debug failed to start") + time.sleep(5) + print("\n[STEP 4] Attaching Selenium to Chrome...") + driver = attach_browser(DOWNLOADS_DIR) + if not driver: + raise Exception("Driver attach failed") + print("\n[STEP 5] Opening Waystar portal...") + driver.execute_script("window.open('');") + driver.switch_to.window(driver.window_handles[-1]) + driver.get(portal_url) + WebDriverWait(driver, 30).until( + EC.presence_of_element_located((By.ID, "loginName")) + ) + print("[INFO] Portal loaded successfully") + print("\n[STEP 6] Performing login...") + login_to_portal(driver, username, waystar_password, first_car_answer, first_job_answer) + print("\n[STEP 7] Starting account processing...") + process_all_accounts(driver) + print("\n" + "=" * 80) + print("✅ AUTOMATION COMPLETED SUCCESSFULLY") + print("=" * 80) + except Exception as e: + print("\n" + "=" * 80) + print(f"❌ FATAL ERROR: {str(e)}") + print("=" * 80) + if driver: + try: + capture_screenshot(driver, "MAIN", "Fatal_Error") + except: + pass + finally: + print("\n[CLEANUP] Closing resources...") + if driver: + try: + driver.quit() + print("[INFO] Browser closed successfully") + except Exception as e: + print(f"[WARN] Error closing browser: {e}") + print("\n🛑 Execution finished\n") \ No newline at end of file diff --git a/src/test_docai.py b/src/test_docai.py new file mode 100644 index 0000000..01df89a --- /dev/null +++ b/src/test_docai.py @@ -0,0 +1,31 @@ +from google.cloud import documentai_v1 as documentai +from google.oauth2 import service_account + +DOCAI_CREDENTIALS_PATH = r"C:\Potential Project G2\.env\pdf-extraction-492807-41531f078f92.json" +DOCAI_PROJECT_ID = "580776411227" +DOCAI_LOCATION = "us" +DOCAI_PROCESSOR_ID = "894f921d904bc9bd" + +def test_connection(): + try: + credentials = service_account.Credentials.from_service_account_file( + DOCAI_CREDENTIALS_PATH, + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + client_options = {"api_endpoint": "us-documentai.googleapis.com"} + client = documentai.DocumentProcessorServiceClient( + credentials=credentials, + client_options=client_options + ) + processor_name = ( + f"projects/{DOCAI_PROJECT_ID}/locations/{DOCAI_LOCATION}" + f"/processors/{DOCAI_PROCESSOR_ID}" + ) + processor = client.get_processor(name=processor_name) + print(f"✅ Connection successful!") + print(f" Processor: {processor.display_name}") + print(f" State: {processor.state.name}") + except Exception as e: + print(f"❌ Connection failed: {e}") + +test_connection() \ No newline at end of file diff --git a/src/test_pdf.py b/src/test_pdf.py new file mode 100644 index 0000000..b34b47c --- /dev/null +++ b/src/test_pdf.py @@ -0,0 +1,60 @@ +from google.cloud import documentai_v1 as documentai +from google.oauth2 import service_account + +DOCAI_CREDENTIALS_PATH = r"C:\Potential Project G2\.env\pdf-extraction-492807-41531f078f92.json" +DOCAI_PROJECT_ID = "1065395041789" +DOCAI_LOCATION = "us" +DOCAI_PROCESSOR_ID = "2d4c7121ce241b13" + +# ← Put path to any CMS-1500 PDF you have +TEST_PDF_PATH = r"C:\Potential Project G2\Output\04-07-2026\PDF Folder\Dynamic Mobile Rehab (238268)\10882239127.pdf" + +def test_pdf(): + credentials = service_account.Credentials.from_service_account_file( + DOCAI_CREDENTIALS_PATH, + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + client_options = {"api_endpoint": "us-documentai.googleapis.com"} + client = documentai.DocumentProcessorServiceClient( + credentials=credentials, + client_options=client_options + ) + + with open(TEST_PDF_PATH, "rb") as f: + pdf_bytes = f.read() + + processor_name = ( + f"projects/{DOCAI_PROJECT_ID}/locations/{DOCAI_LOCATION}" + f"/processors/{DOCAI_PROCESSOR_ID}" + ) + + request = documentai.ProcessRequest( + name=processor_name, + raw_document=documentai.RawDocument( + content=pdf_bytes, + mime_type="application/pdf" + ) + ) + + result = client.process_document(request=request) + document = result.document + + print(f"\n✅ PDF processed successfully!") + print(f" Pages: {len(document.pages)}") + print(f"\n--- RAW TEXT (first 500 chars) ---") + print(document.text[:500]) + print(f"\n--- FORM FIELDS DETECTED ---") + for page in document.pages: + for field in page.form_fields: + key = "".join( + document.text[s.start_index:s.end_index] + for s in field.field_name.text_anchor.text_segments + ).strip() + val = "".join( + document.text[s.start_index:s.end_index] + for s in field.field_value.text_anchor.text_segments + ).strip() + if key and val: + print(f" {key:30s} → {val}") + +test_pdf() \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5509b56 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import sys +from pathlib import Path + +# The bot's code lives in src/ (not a package); make it importable. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) diff --git a/tests/test_cms1500_text.py b/tests/test_cms1500_text.py new file mode 100644 index 0000000..f9c6fc5 --- /dev/null +++ b/tests/test_cms1500_text.py @@ -0,0 +1,197 @@ +"""Unit tests for the CMS-1500 text-layer reader. + +Words are synthetic (made-up codes and numbers placed at real CMS-1500 +positions), so no patient data lives in this file. +""" +from decimal import Decimal + +from cms1500_text import Word, parse_diagnosis_codes, parse_service_lines + + +def W(text, x, y): + return Word(text=text, x=x, y=y) + + +# Slot labels as Waystar prints them in box 21 (x, y are normalized 0-1). +DX_LABELS = [ + W("A.", 0.047, 0.602), W("B.", 0.199, 0.603), W("C.", 0.352, 0.602), W("D.", 0.505, 0.604), + W("E.", 0.047, 0.617), W("F.", 0.200, 0.618), W("G.", 0.352, 0.618), W("H.", 0.505, 0.619), + W("I.", 0.045, 0.632), W("J.", 0.199, 0.632), W("K.", 0.352, 0.631), W("L.", 0.504, 0.632), + W("21.", 0.046, 0.581), W("DIAGNOSIS", 0.082, 0.581), +] + + +def test_dx_codes_are_keyed_by_slot_letter_in_slot_order(): + words = DX_LABELS + [ + # Printed slightly above their labels, with jittery y — the old OCR + # sort by (y, x) scrambled exactly this kind of row. + W("Z9999", 0.248, 0.5975), # B + W("M1111", 0.095, 0.5985), # A + W("R2222", 0.400, 0.5980), # C + W("S81812A", 0.095, 0.6130), # E + ] + assert parse_diagnosis_codes(words) == [ + ("A", "M1111"), ("B", "Z9999"), ("C", "R2222"), ("E", "S81812A"), + ] + + +def line_words(y, mm="01", dd="05", yy="26", pos="12", cpt="97110", mods=("GP",), + ptr="AB", charge="9000", units="2", npi="1234567890", row_no=None): + """One box-24 service line at Waystar's x positions. None = not printed.""" + ws = [W("NPI", 0.801, y)] + if row_no: + ws.append(W(row_no, 0.026, y - 0.009)) # gutter number sits in the shaded row above + for text, x in ((mm, 0.048), (dd, 0.083), (yy, 0.118), (pos, 0.259), (cpt, 0.348), + (ptr, 0.565), (charge, 0.683), (units, 0.724), (npi, 0.883)): + if text is not None: + ws.append(W(text, x, y)) + for i, m in enumerate(mods): + ws.append(W(m, 0.424 + 0.035 * i, y)) + return ws + + +BOX_24_HEADER = [W("24.", 0.046, 0.642), W("UNITS", 0.736, 0.661)] +BOX_25_ROW = [W("25.", 0.046, 0.854), W("44680", 0.324, 0.869), W("14439", 0.395, 0.869), + W("63000", 0.701, 0.869)] + + +def test_service_line_fields_are_read_from_their_columns(): + words = BOX_24_HEADER + BOX_25_ROW + line_words(0.689, mods=("GP", "CQ", "59")) + [line] = parse_service_lines(words) + assert line.line_no == 1 + assert line.dos_from == "01/05/2026" + assert line.dos_to == "" + assert line.pos == "12" + assert line.cpt == "97110" + assert line.modifiers == ("GP", "CQ", "59") + assert line.dx_pointer == "AB" + assert line.charge == Decimal("90.00") + assert line.units == "2" + assert line.npi == "1234567890" + + +def test_lines_are_numbered_top_to_bottom_and_ignore_gutter_and_box_26(): + words = (BOX_24_HEADER + BOX_25_ROW + + line_words(0.719, cpt="97140", row_no="2") + + line_words(0.689, cpt="97110", row_no="1")) + lines = parse_service_lines(words) + assert [(l.line_no, l.cpt) for l in lines] == [(1, "97110"), (2, "97140")] + assert all(l.dos_from == "01/05/2026" for l in lines) # gutter "1"/"2" never read as MM + + +def test_a_missing_value_stays_blank_on_its_own_line(): + words = (BOX_24_HEADER + + line_words(0.689, units="2") + + line_words(0.719, units=None) + + line_words(0.750, units="1")) + assert [l.units for l in parse_service_lines(words)] == ["2", "", "1"] + + +def test_charge_without_decimal_point_has_implied_cents(): + words = (BOX_24_HEADER + + line_words(0.689, charge="12000") + + line_words(0.719, charge="45.50")) + assert [l.charge for l in parse_service_lines(words)] == [Decimal("120.00"), Decimal("45.50")] + + +def test_hcpcs_line_with_dos_to_and_large_units(): + words = BOX_24_HEADER + line_words(0.689, cpt="Q4250", mods=(), ptr="A", units="120") + words += [W("01", 0.155, 0.689), W("07", 0.186, 0.689), W("26", 0.224, 0.689)] + [line] = parse_service_lines(words) + assert (line.cpt, line.units, line.modifiers) == ("Q4250", "120", ()) + assert line.dos_to == "01/07/2026" + + +from cms1500_text import Claim, build_claim, parse_header, to_report_row # noqa: E402 + +HEADER_WORDS = [ + W("1a.", 0.619, 0.128), W("NUMBER", 0.723, 0.128), + W("1AB2CD3EF45", 0.677, 0.145), + W("3.", 0.381, 0.158), W("MM", 0.402, 0.165), W("DD", 0.439, 0.165), W("YY", 0.482, 0.165), + W("07", 0.401, 0.174), W("04", 0.436, 0.174), W("1950", 0.483, 0.174), W("M", 0.524, 0.174), +] + + +def test_header_fields_come_from_their_boxes(): + h = parse_header(HEADER_WORDS + BOX_25_ROW) + assert h == { + "insured_id": "1AB2CD3EF45", + "dob": "07/04/1950", + "patient_account": "44680 14439", + "total_charge": Decimal("630.00"), + } + + +def page(*line_specs, total="18000"): + """A full synthetic page: header + dx + given lines + box 25/28 row.""" + ws = HEADER_WORDS + DX_LABELS + [W("M1111", 0.095, 0.598), W("Z9999", 0.248, 0.598)] + ws += BOX_24_HEADER + [W("25.", 0.046, 0.854), W("44680", 0.324, 0.869), + W("14439", 0.395, 0.869), W(total, 0.701, 0.869)] + for i, kw in enumerate(line_specs): + ws += line_words(0.689 + 0.030 * i, **kw) + return ws + + +def test_clean_claim_has_no_review_notes(): + claim = build_claim([page({}, {"cpt": "97140"})]) + assert isinstance(claim, Claim) + assert [l.cpt for l in claim.lines] == ["97110", "97140"] + assert claim.issues == [] + + +def test_line_charges_must_add_up_to_box_28(): + claim = build_claim([page({}, {}, total="20000")]) + assert claim.issues == ["Line charges 180.00 != box 28 total 200.00"] + + +def test_missing_line_values_and_bad_pointer_are_flagged(): + claim = build_claim([page({"units": None, "ptr": "AC"}, {"npi": None})]) + assert claim.issues == [ + "Line 1: no Units", + "Line 1: Dx pointer C has no diagnosis code", + "Line 2: no Rendering NPI", + ] + + +def test_claim_with_no_service_lines_or_no_text_is_flagged(): + assert build_claim([page()]).issues == ["No service lines found", "Line charges 0.00 != box 28 total 180.00"] + assert build_claim([[]]).issues == ["No text layer on PDF (scanned?) - check by hand"] + + +def test_lines_continue_across_pages_and_totals_add_up(): + claim = build_claim([page({}, {}, {}, {}, {}, {}, total="54000"), page({}, total="9000")]) + assert [l.line_no for l in claim.lines] == [1, 2, 3, 4, 5, 6, 7] + assert claim.total_charge == Decimal("630.00") + assert claim.issues == [] + + +def test_report_row_keeps_legacy_columns_and_alignment(): + claim = build_claim([page({"units": "2"}, {"cpt": "97140", "units": None, "mods": ("GP", "59")})]) + row = to_report_row(claim, claim_id="999", insurance="PAYER", patient_name="DOE, J", + signature_physician="Jo Doe", fallback_account="") + assert list(row)[:15] == [ + "Claim ID", "Rendering Provider #", "Signature of Physician", "Patient's Account No", + "Date of Birth", "Insurance Name", "Insured ID Number", "Patient's Name", + "Date of Service", "Diagnosis Codes", "CPT", "POS", "Modifier", "Units", "Charges", + ] + assert row["CPT"] == "CPT 1: 97110; CPT 2: 97140" + assert row["Units"] == "Units 1: 2; Units 2: " # blank stays on line 2 + assert row["Modifier"] == "Modifier 1: GP; Modifier 2: GP 59" + assert row["Diagnosis Codes"] == "M1111, Z9999 (Total: 2)" + assert row["Patient's Account No"] == "44680 14439" + assert row["Charges"] == "180.00" + assert row["Line Charges"] == "Charge 1: 90.00; Charge 2: 90.00" + assert row["Review Notes"] == "Line 2: no Units" + + +def test_report_row_for_single_line_claim_uses_plain_values(): + row = to_report_row(build_claim([page({}, total="9000")]), claim_id="1") + assert (row["CPT"], row["Units"], row["Rendering Provider #"]) == ("97110", "2", "1234567890") + assert row["Review Notes"] == "" + + +def test_report_row_falls_back_to_grid_charges_when_box_28_is_blank(): + claim = build_claim([page({})]) + claim.total_charge = None + row = to_report_row(claim, claim_id="1", fallback_charges="90") + assert row["Charges"] == "90" diff --git a/tests/test_selftest.py b/tests/test_selftest.py new file mode 100644 index 0000000..2e0d0ad --- /dev/null +++ b/tests/test_selftest.py @@ -0,0 +1,18 @@ +"""The --selftest switch must check the build without touching Chrome or Waystar.""" +import ChargeBilledAutomation as bot + + +def test_selftest_without_pdfs_passes_and_says_so(capsys): + assert bot.self_test([]) == 0 + assert "SELFTEST OK" in capsys.readouterr().out + + +def test_selftest_fails_for_unreadable_pdf(capsys, tmp_path): + assert bot.self_test([str(tmp_path / "missing.pdf")]) == 1 + assert "SELFTEST FAILED" in capsys.readouterr().out + + +def test_selftest_never_starts_chrome(monkeypatch): + monkeypatch.setattr(bot, "kill_all_chrome", lambda: (_ for _ in ()).throw(AssertionError("Chrome touched"))) + monkeypatch.setattr(bot, "start_chrome_debug", lambda *a: (_ for _ in ()).throw(AssertionError("Chrome touched"))) + assert bot.self_test([]) == 0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e2fbfcb --- /dev/null +++ b/uv.lock @@ -0,0 +1,1039 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charge-billed-automation" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "openpyxl" }, + { name = "pandas" }, + { name = "pdfplumber" }, + { name = "pywinauto" }, + { name = "requests" }, + { name = "selenium" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyinstaller" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "pandas", specifier = ">=3.0.6" }, + { name = "pdfplumber", specifier = ">=0.11.10" }, + { name = "pywinauto", specifier = ">=0.6.9" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "selenium", specifier = ">=4.51.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyinstaller", specifier = "==6.22.3" }, + { name = "pytest", specifier = ">=9.1.1" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comtypes" +version = "1.4.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/ff/c7836bd0d78fc615281016f154f514e9d523d7cb9ab9e8b4d344adc5f5e7/comtypes-1.4.17.tar.gz", hash = "sha256:3d9c1e92ad8daf7600d371e76ee16161a627a5fb70c3144f6e52e78af6034363", size = 262743, upload-time = "2026-09-21T08:08:07.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/9c/d0bc1fb69ad22a04bdc01dd9b5613a2ebc4d02ba1ec24b02127dc93791ba/comtypes-1.4.17-py3-none-any.whl", hash = "sha256:4e0a221dde2c589b82977bed802efd71cd5eb67a380347b732e02735e597f586", size = 298743, upload-time = "2026-09-21T08:08:06.461Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, + { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, + { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, + { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, + { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/4c/597d588c055d4373cff19cbbc32d4dd046c7be8fadee957585b5ba9e5b24/pandas-3.0.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dac2d65e9087e8e7b5a45fe15c4920911a221df061ab629943ce016489145c7", size = 10397411, upload-time = "2026-09-17T23:20:49.465Z" }, + { url = "https://files.pythonhosted.org/packages/18/8f/48907c7c707b61a8e5018c32e1a3f70623209bfb59020a2f6196159d3aa7/pandas-3.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9dab635a549e58a053c7b0fa054dc0bd7be22f0ed9a720f4a85d5fb993276172", size = 10051279, upload-time = "2026-09-17T23:20:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/67/fa/613d867c3d9554a61bafdec6f79565c8a3e73235feb52cc4a72ad2e0fa6a/pandas-3.0.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3dccb584123b399c07562ac4d62543e90ede49ddf8ce3c13ffc64cbe828c281", size = 10303429, upload-time = "2026-09-17T23:20:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/67/0c0f18e38d7f2d2af8c24b3315bc4046e73bbdd4a5540405506671ad0c0d/pandas-3.0.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0704044b676496b8350e023b09f174a26772456c974a2b11c36bebb558c9490d", size = 10788193, upload-time = "2026-09-17T23:20:58.617Z" }, + { url = "https://files.pythonhosted.org/packages/39/53/1b57f3162501fe36687e4870e1b918a6458ca7386b173af75663ace95857/pandas-3.0.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e7c1905ef02c3d6d43d9dbd5b6ccb4da4870a0b0c821bbc103fbdb6f3ad2707b", size = 11323501, upload-time = "2026-09-17T23:21:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d2/b1182e8d39100369d7f13f4a125a3fb6b096fef112c46d0566c25781ff68/pandas-3.0.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:569e114072b24fc4970c12e2b4bab252671668a40b324318903380cab0254c0c", size = 11838173, upload-time = "2026-09-17T23:21:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5b717af24d2f27995e51e0875a269dddd373045216e34cf62e3aa764eaa1/pandas-3.0.6-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:2a8fc94be2ee5f1d86f97aacd8cc566f81680b6498e76f3007421bb5d98151bf", size = 7118499, upload-time = "2026-09-17T23:21:07.661Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/14b3b17cd75cef4a1ee1af4234eb41cc1afe4103b98c2d80e8916abfd42b/pandas-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:3ef908d28590b3f42d7070e7ad8f9b34b442b260b7f3c1afb57e0040c58cdb1b", size = 9661439, upload-time = "2026-09-17T23:21:10.959Z" }, + { url = "https://files.pythonhosted.org/packages/3b/11/3d580a604a1e35d69f6676847bd12db7d14344bc677b717f4413e79c5d0d/pandas-3.0.6-cp312-cp312-win_arm64.whl", hash = "sha256:f4e7c52eb108d752e7592268108fd3e98efd76d83a3125cdd06c621c2e44359b", size = 8959806, upload-time = "2026-09-17T23:21:13.851Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/143605a1f6443ad50ebda78a31e5a3a10147fec2590e931584aaa5ff0a09/pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ae8073aed8e21d1a7fe263dcdc6840743549722a6738198a0a46000fa9476f2", size = 10418900, upload-time = "2026-09-17T23:21:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:60d81f9e1799b36f3739e7fff44d1fbb2e8fd5a271b3863e03de9715fccda0fa", size = 10064785, upload-time = "2026-09-17T23:21:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/d951442e5607c6e3b2462eff8f420797d428aa74b87c6ecfe4f48553626e/pandas-3.0.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097090508a1dd335013d39106fc10b20f4fd4a171638e47b77d55798ed9dab6c", size = 10245290, upload-time = "2026-09-17T23:21:22.797Z" }, + { url = "https://files.pythonhosted.org/packages/50/fa/96d50e1e6cd0b08b5e2b7c838f65ae644940f75a124063380b5ef73b6866/pandas-3.0.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e92d9fa834c7d877130027cddc0cad8dcff97c1f6cca26bd6310f847228b658", size = 10757657, upload-time = "2026-09-17T23:21:25.673Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/f82d13a2cb703e1a8acee7e01fdc2b898d9cd0c00f07d1dfce63af43e350/pandas-3.0.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b27c8d890e4aa2171437ae2a39de1d215e674158e4865c4023a8b31c932513b2", size = 11249114, upload-time = "2026-09-17T23:21:28.898Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ce/8aef2e561a2f2c8b38c913c67373c65ba6748174e763d27c80271b24bd17/pandas-3.0.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8029ec0f1f89e4f985929ce1f6626dabf3140d61a4e9c1215afdab34eaf9a5d", size = 11820511, upload-time = "2026-09-17T23:21:32.11Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bd/63cb67e6903ef6d9c2871916dbcbc09d254da0fe8b870cf62e16b21945f2/pandas-3.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:f3ce8a6968045481e91a3990e797e348ce13db45ee164a7095bbc824e26c09dd", size = 9638092, upload-time = "2026-09-17T23:21:34.883Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/e7b35b712edb068d382ddc8b2bea8a04974100515ba2daa22b478b265842/pandas-3.0.6-cp313-cp313-win_arm64.whl", hash = "sha256:cc39303913e2ea129915670de5d1c9fbd647f543bb72e5543bac8baa94e9e42f", size = 8952032, upload-time = "2026-09-17T23:21:37.729Z" }, + { url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" }, + { url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" }, + { url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" }, + { url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" }, + { url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/47c5fb0be8bdd00116814c2c40d9ec42dbeb943865ef95130fe58a898226/pandas-3.0.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:50c44cbf5820b6b91a5f74aae04972472aefadd3cd9fbd1010409d85528bd570", size = 10483960, upload-time = "2026-09-17T23:22:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/09/08/a310cb2fefe6d2b4623ab2150da818d93d4e73e33b58e4d163bb74243c5a/pandas-3.0.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:eb6900de08ac85f93ac4948aa6b80842eba555875337b8359035ac9c43e92d34", size = 10149432, upload-time = "2026-09-17T23:22:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/54/36/6af478ec3a26d7754555cd62c3101c589c0931b1d85398e4fa5403910a1c/pandas-3.0.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e25e2e1adee99ddfada6f7206a79ae8e9c8a8861b0e3eaaba165006d3eef18e", size = 10360981, upload-time = "2026-09-17T23:22:35.621Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/5ece0e9cb79a6473216620db6a90546f578001c2bd857b77c444b27acad1/pandas-3.0.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ff44b2cb51cbd691c91f92c4ea6c71e34003f239ebd67c2e857dc898466b49c", size = 10776585, upload-time = "2026-09-17T23:22:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b6/cd3038f31ade5e8d2b4e1c9549d4b31e4d598469562f47142b2ad9171c0a/pandas-3.0.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5edd0a7abb0986ecce1ac81f56d99b6763f86aa6946dceb6c661224f90af5a19", size = 11385249, upload-time = "2026-09-17T23:22:41.18Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/05bb3003737f80375d7311774916a24d161e2e591abe8c672aed7813defc/pandas-3.0.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1bcb3e9ed29e74a7439cedff9e2aefd3ea65de84d7de9ccb6c194192541bd60e", size = 11848595, upload-time = "2026-09-17T23:22:44.207Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/1c0d46acf236d19ef975e9cdd5d1e0dd54924b03f0ed8287096ad4a18152/pandas-3.0.6-cp315-cp315-win_amd64.whl", hash = "sha256:253e12cb9081b0afbac607920f6142975966bc315135e09de275fdbaa415d2de", size = 9783810, upload-time = "2026-09-17T23:22:47.097Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/df3304a9c2833c4810e7f1c887b04105b24731f9deef8b5d5d04522a375b/pandas-3.0.6-cp315-cp315-win_arm64.whl", hash = "sha256:97274c9adf6255bb48c620cd6959805efa7f09ea2167f0e0ae006a448cd2fca7", size = 9116820, upload-time = "2026-09-17T23:22:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/47/25/d5f6cce5efa17c38e4f752a875b4a26d66cfadd529cb8c81672f0376d6a6/pandas-3.0.6-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:265f562fdd1079f69f3de96dd425c3405224038c0af4f920c54bd240ee2c4640", size = 10894614, upload-time = "2026-09-17T23:22:53.223Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8b/e1876bfdc1df06bafc022d33202b5663bd86c81df2a6140aacafd0344669/pandas-3.0.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c6e4aae3e9bea26c6c9a20d88d96c86ec4a99b4db5fd516bcb4e829ab2c0ee36", size = 10546973, upload-time = "2026-09-17T23:22:56.155Z" }, + { url = "https://files.pythonhosted.org/packages/e9/27/e0a27a5a5c27f7db66657b44def9e93121fd0ad4f0808355b9898fcbf204/pandas-3.0.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a77a1a44e4d88f1c6a2a64d3eb12efec8420875722e14279800b173a7c7c2804", size = 10239717, upload-time = "2026-09-17T23:22:59.603Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/4eadb7d86a921c1e8bc70916cfe601ed667c4169118d17d69958611c21f8/pandas-3.0.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fa853a12e0b70927e2b1ee00d56d2224ec9cbb4b9d58348b5ad52d2f21150e", size = 10641275, upload-time = "2026-09-17T23:23:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d1/eba72e9d905e84e79aefeefdcc6bc1abe15d9077973566643f4211636966/pandas-3.0.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c826e9babb7790142c399f58599d8de679bea059d7b39c5b6efa2096fac37266", size = 11262919, upload-time = "2026-09-17T23:23:06.038Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/1c5bd2642b450e374b6191189f47c49538fe41f82348a66de6f647e6ab59/pandas-3.0.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8fe77b408d82e2615674dfed62533b95e18a03610573877422aada4f625d4947", size = 11709148, upload-time = "2026-09-17T23:23:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/73/3d/1b142bd0d0f1326a5d98c91c955b923cd1e06b5eecb428cd9d8817fa01c0/pandas-3.0.6-cp315-cp315t-win_amd64.whl", hash = "sha256:83e91d15738d7783c050197cef2f2cf82fc6353dae9865aa87ed1fa16aa4d55a", size = 10161518, upload-time = "2026-09-17T23:23:12.365Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/6419c14da2adc1f09a6a183b8f91d7494d325b287f4ca984ac04f663638a/pandas-3.0.6-cp315-cp315t-win_arm64.whl", hash = "sha256:963ca21199097a84c7827c4678b04e30833084fbf8ef44fde3fa7180a29f8fa0", size = 9364529, upload-time = "2026-09-17T23:23:15.274Z" }, +] + +[[package]] +name = "pdfminer-six" +version = "20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/a4/5cec1112009f0439a5ca6afa8ace321f0ab2f48da3255b7a1c8953014670/pdfminer_six-20260107.tar.gz", hash = "sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602", size = 8512094, upload-time = "2026-01-07T13:29:12.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/8b/28c4eaec9d6b036a52cb44720408f26b1a143ca9bce76cc19e8f5de00ab4/pdfminer_six-20260107-py3-none-any.whl", hash = "sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9", size = 6592252, upload-time = "2026-01-07T13:29:10.742Z" }, +] + +[[package]] +name = "pdfplumber" +version = "0.11.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pdfminer-six" }, + { name = "pillow" }, + { name = "pypdfium2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/56/6f450312ba05a27d7713b73857c1a25100dbda04fbc1331b13fb227a607d/pdfplumber-0.11.10.tar.gz", hash = "sha256:b95b2d28c66efb0a794a83b88c6c6aea5987532a445d20a1cbcfa657022e6e57", size = 102892, upload-time = "2026-06-15T03:31:31.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/9a/07d658e1e7fad860f1c541ab941348125dbdab773be3a0afaf32361866c7/pdfplumber-0.11.10-py3-none-any.whl", hash = "sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580", size = 60047, upload-time = "2026-06-15T03:31:29.702Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "pillow" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" }, + { url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" }, + { url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" }, + { url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" }, + { url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" }, + { url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" }, + { url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" }, + { url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" }, + { url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" }, + { url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" }, + { url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" }, + { url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" }, + { url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" }, + { url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.22.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/41/f90302845945abd4ed647933ff5ee7c6ac93983187be67f897b6cb613331/pyinstaller-6.22.3.tar.gz", hash = "sha256:05eb2f5615503e72939a7224d68b4aff572c6b0438ee4a17d0a4b481f399362d", size = 4437180, upload-time = "2026-09-12T21:54:09.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/af/898f70947e27f8e65d55183c780e77112e023c0b4a6fba6255d4d563a27d/pyinstaller-6.22.3-py3-none-macosx_10_13_universal2.whl", hash = "sha256:052f4a1cd4f81092ccb7a18fe8ebc9ecf832a5917317e39fb3775af4e959b253", size = 1071847, upload-time = "2026-09-12T21:53:01.711Z" }, + { url = "https://files.pythonhosted.org/packages/c4/96/99f5184985141c2b024bfa61335e9142ae444555224f303186e59b7bb438/pyinstaller-6.22.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:312da84b4b31ab9750066a823734c11b7c68757fdd67c9f038bf19c330a954e4", size = 758172, upload-time = "2026-09-12T21:53:06.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/9ccb12cc1e6617042cb028fe79f0a756f5f03305bd14b584a4e043fe3844/pyinstaller-6.22.3-py3-none-manylinux2014_i686.whl", hash = "sha256:020d4fe0627f5c73dfbcfad1a6578ef354d5fe4bdccd9d8e5a615528f5a45d2b", size = 772135, upload-time = "2026-09-12T21:53:10.291Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/4b1d5193a88628c052db89f064433681076959a9f41d0d9f660001b49079/pyinstaller-6.22.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:9e4f7bd64b2103f14b4a6240fb9155e9fe7bc61391b4043bbddb7d3ebc8ab0fa", size = 770461, upload-time = "2026-09-12T21:53:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/f173f8757c618d83e116fa2b8e57c5cd796e5ad09792e664eabd6ac5edae/pyinstaller-6.22.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9", size = 764997, upload-time = "2026-09-12T21:53:19.079Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b6/dd21d61fc5e25377be24eabf13e99e4e46548a5abe2bf7b15d8b3d9cc74a/pyinstaller-6.22.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:451a4ae14b719365bf1a2f0a99dae7b3463060061c3a394c70d5264cfb439528", size = 764974, upload-time = "2026-09-12T21:53:23.176Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/d9590942972b1fc205519f7ccffb0d5cd8eb1d6fa61edad319c71d25baed/pyinstaller-6.22.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0", size = 764450, upload-time = "2026-09-12T21:53:27.419Z" }, + { url = "https://files.pythonhosted.org/packages/72/d6/f22c55d1768433b65c460b19a3136dd9769b6c74b786149b829344654c7d/pyinstaller-6.22.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282", size = 763863, upload-time = "2026-09-12T21:53:31.609Z" }, + { url = "https://files.pythonhosted.org/packages/c5/87/cf28cfd42ceca889171e6dd5e7f1553d4372db83d047049ffb7cf95fcbd4/pyinstaller-6.22.3-py3-none-win32.whl", hash = "sha256:46a3e30a118341a1ec20667a5a95aa7e0aa8c4fa129893c0151b3eb3f801a722", size = 1390905, upload-time = "2026-09-12T21:53:38.133Z" }, + { url = "https://files.pythonhosted.org/packages/24/83/c5422204772a27aef643280fdc887b7ef55679989096b21dceb1b9625fc4/pyinstaller-6.22.3-py3-none-win_amd64.whl", hash = "sha256:500bd58c7bf7e584a8435adccbd763a0b918d5c12b08d74ff50fd79b2915458b", size = 1454907, upload-time = "2026-09-12T21:53:44.812Z" }, + { url = "https://files.pythonhosted.org/packages/67/2e/ebc93968bf63f81b122e205da116b16036b653b7ce2cc8ad86e3ddd19586/pyinstaller-6.22.3-py3-none-win_arm64.whl", hash = "sha256:31df3cc4261e804a53d358b7897c293ec8b88b167a7cca06c78018085c855f97", size = 1402956, upload-time = "2026-09-12T21:53:51.369Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/25/e47c6188228281372be7e9911a93931e01ec8798c02c93abd680b9b9521f/pyinstaller_hooks_contrib-2026.9.tar.gz", hash = "sha256:2748767901acb5cecd1304652b6c7add5bffcff1d71d01207fe24a5d42942a56", size = 180783, upload-time = "2026-10-20T15:43:54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fe/ded0e20de0580257d97dbabb4db7ab4d96ad8ade7d41149e66308057cf52/pyinstaller_hooks_contrib-2026.9-py3-none-any.whl", hash = "sha256:fb3e67dd64122561724c48ad0366ac9e8c16e638b63bf7d85de03bf3fdbf06c3", size = 462931, upload-time = "2026-10-20T15:43:52.632Z" }, +] + +[[package]] +name = "pypdfium2" +version = "5.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/d0/c81d3a7c2a9af37b817ace1de0acd40cf44d15f12407c5e86b3668364a5c/pypdfium2-5.14.0.tar.gz", hash = "sha256:c5f009b3157f10e97dceb55963f5910eff92feb00587ba10a76f12b87ce1a4b6", size = 376498, upload-time = "2026-10-04T15:19:19.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/03/79e89eac9d811e83d606342e129f5f39e168442ddf23b024fea4a7ee4762/pypdfium2-5.14.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:bed597b2cea3990164e43f9003f71db18959d0abd5d73adc9c176e7be2d84b98", size = 3453370, upload-time = "2026-10-04T15:18:40.79Z" }, + { url = "https://files.pythonhosted.org/packages/cc/68/369b80e408017b18eaecaa3c730bded07d90bfb65562215df200b56fb8e2/pypdfium2-5.14.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:1951f0aed469150b13c62eabd501a9839e608ab9983ca8579be9eb73213b72b6", size = 2889924, upload-time = "2026-10-04T15:18:42.825Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ea/14673bc9d8b7beeaa1eb46e9951b22543edaf2a4676c586e3b1e032ff6ee/pypdfium2-5.14.0-py3-none-macosx_13_0_arm64.whl", hash = "sha256:2de384df66ba55fcaab0775f30f28ec1090af3dfa60276a07821efc96d993118", size = 3542294, upload-time = "2026-10-04T15:18:44.345Z" }, + { url = "https://files.pythonhosted.org/packages/a6/11/b720097b01fa0874854f2f6669cbea4e4ea4e075769687714fac64d68964/pypdfium2-5.14.0-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:e4e203ea9710fd00e5448edb6f1615dc8587035357f75f40b432dde0c33e8da1", size = 3735845, upload-time = "2026-10-04T15:18:45.975Z" }, + { url = "https://files.pythonhosted.org/packages/92/b4/0c31aa51887cd6cd032191dfe010a6d01ed43cf03204cfbd2184ebe4b715/pypdfium2-5.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b696e6901e16f114a2ec6332e5e3f8f5033a901614ead28499ab18ca6024f5", size = 3719672, upload-time = "2026-10-04T15:18:47.455Z" }, + { url = "https://files.pythonhosted.org/packages/93/a8/ae6ef96bf66559328d07b9e402ea704352ea00c49b6a73573da57e1fb378/pypdfium2-5.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:593f2c952ae3ffdca0efcbb3d9464fbccb876254386114ff900cabef21157c3f", size = 3435593, upload-time = "2026-10-04T15:18:49.131Z" }, + { url = "https://files.pythonhosted.org/packages/59/ff/a78405fab4c8bad0ec25b49c5efba2c85ed14609ec73645f95220560bd81/pypdfium2-5.14.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d436ee9e024f981e68f5775f5a9d115f93ea14ee6c2c6efd35dd17d83edf4942", size = 3868604, upload-time = "2026-10-04T15:18:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/09e9b62ab66c9acef5ad14f8a8c0d7b4d8d6ea6492e4e65b612ef146d373/pypdfium2-5.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6f13bbcc5f4adabc2676e52f662c6cb375de86b314790b0ae08f3ab62eb116a", size = 4279333, upload-time = "2026-10-04T15:18:52.948Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a3/c9cc797fc8bdfb8f37b9b0f8b9d02a5fc196b2015f408d53624cab5b0519/pypdfium2-5.14.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11f281613fa22313d9c7ab89947665e84eccf8ebe40e1198a84a88352305648d", size = 3799581, upload-time = "2026-10-04T15:18:54.913Z" }, + { url = "https://files.pythonhosted.org/packages/b9/76/54355a4bbd88bdd5ed3f4405bdc345eb593df9995daf90d285cbdf5c1410/pypdfium2-5.14.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51d9e9b64ebc34effaf57f9b6d4511b3f66ad3744bd1690d2cc6700853173dcf", size = 4113022, upload-time = "2026-10-04T15:18:56.774Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bc/ea461961ed0e0c4866df7a5610e76f769ef468bff28cd007e2aeecc8b882/pypdfium2-5.14.0-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:605ab9d0d4c5e223599c9065b88d16b2c1f131c807c80dea8adbb16f1433e95b", size = 4062832, upload-time = "2026-10-04T15:18:58.471Z" }, + { url = "https://files.pythonhosted.org/packages/32/30/dde99bc8cb3f8ace1d856095c2b4a29c80eecf9089b186a3b0845d0abc69/pypdfium2-5.14.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:382de7fe20d32c42993a274d7b6c555a5623a97570dfc1d2f5e0a16fe0d5d482", size = 5058436, upload-time = "2026-10-04T15:18:59.993Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/5314182dda2695fdf5bd414a450ee866087068cca4725703932770d4be04/pypdfium2-5.14.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dbfd6deff68cc46b134acd6be380d98d694a9f018fbb622c07229225c85db389", size = 4595505, upload-time = "2026-10-04T15:19:01.835Z" }, + { url = "https://files.pythonhosted.org/packages/63/3f/474c42e726f0020095c7d5f3fb88cfd4e5d39c1361105a72899ada0ecd1b/pypdfium2-5.14.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9f4d77db5232826dd03a63481f32164331b96c21fd68f0667b2e43dbae141a93", size = 5309775, upload-time = "2026-10-04T15:19:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/6b/0c/723a6cf11cff00f125310d8c2c08362dc6c100d05fff8f92285a4df1bd41/pypdfium2-5.14.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:b40a0913196a1483f0fdc22a53f8719c3aef87f1c4d8d9c38d2ad4e207500fdf", size = 5224565, upload-time = "2026-10-04T15:19:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/86ab02a41e77a7aa962af6545a406815aeb9abaecd9f25dec34dbc336b72/pypdfium2-5.14.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:790e2cac1641a65912b73bd7243f45195d36f1663c85a3e1a126a8f5867c82a3", size = 4704416, upload-time = "2026-10-04T15:19:07.05Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/fb75013f924c5a4dde4a4a41ec13e7495f9b80022bf35dd51baa54e05910/pypdfium2-5.14.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:09b99c8f0cb427eb17fec13c0862ed598bba34b4843df153f70fff806a2820bc", size = 5163621, upload-time = "2026-10-04T15:19:09.021Z" }, + { url = "https://files.pythonhosted.org/packages/cd/77/e59c814f10b533bc4565abe90ccef888ba29be45ada4627ebbf710961f0d/pypdfium2-5.14.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e70d87cb0577eab38f2106f9c9606b458930beef612a1b5f298772ed259f5ec0", size = 5121606, upload-time = "2026-10-04T15:19:10.609Z" }, + { url = "https://files.pythonhosted.org/packages/21/25/e067396b4bdd26c19f0997bfa3422d3975a49ceec2c59668e7599f2adcba/pypdfium2-5.14.0-py3-none-pyemscripten_2026_0_wasm32.whl", hash = "sha256:c73be14076bedebd9bcaf9b062579c95c668580043bccd29eb0db502101d5716", size = 2675501, upload-time = "2026-10-04T15:19:12.588Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0c/6c21f68a57d0c4c506b9e5f72506ba91d8dde47eef699f3fd9561f7bff0e/pypdfium2-5.14.0-py3-none-win32.whl", hash = "sha256:9fd5cc94a389d50298e4d8cb79af6b9b8e0d785606e2a937725dc6e271c9c6e6", size = 3805374, upload-time = "2026-10-04T15:19:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/ca7874924c9cfd701ad53f89529968523790e70473e0b71e834668316148/pypdfium2-5.14.0-py3-none-win_amd64.whl", hash = "sha256:149fd5c6397b8df8bf7911a93506eff0be874f877afe7ac936cf5d37d21a6a06", size = 3947280, upload-time = "2026-10-04T15:19:16.302Z" }, + { url = "https://files.pythonhosted.org/packages/46/ab/35f2276deeeebb781925e2647dd88a39f8ea1a910104a0dbb28218473502/pypdfium2-5.14.0-py3-none-win_arm64.whl", hash = "sha256:eb8aeca157808f323e39ea298cc6d6c8e080c192ea2efb1ca81daa0f0ff4d095", size = 3745021, upload-time = "2026-10-04T15:19:18.276Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pywinauto" +version = "0.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comtypes", marker = "sys_platform == 'win32'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/85/cc65e3b64e7473cc86c07f0b5c415d509402c03ef19dadc39c583835eb5f/pywinauto-0.6.9.tar.gz", hash = "sha256:94d710bfa796df245250f952ffa65d97233d9807bcd42e052b71b81af469b6de", size = 434734, upload-time = "2025-01-06T11:30:01.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/46/f2283648e0c237af451dc50ebc42121abfd198d12391fd9ad4df1c6b97d2/pywinauto-0.6.9-py2.py3-none-any.whl", hash = "sha256:5924b3072864a1d730c5546bbeb17cf4063ba518b618dbc5e43c18276c7c9356", size = 363041, upload-time = "2025-01-06T11:29:55.586Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "selenium" +version = "4.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d3/c925308af2c9b9a9ccf531cf23ffba4f38c0d750f088a06e560fb2ca0c59/selenium-4.51.0.tar.gz", hash = "sha256:6bec9bd8d9b6599f850b0f6e9fb06d44d2e4245adccd11ae02c9d5cde7596276", size = 1050794, upload-time = "2026-10-09T18:38:24.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6d/2cae01adf4db83e2862e254bc053def829a9603a1c68431b7c9aae0ecba0/selenium-4.51.0-py3-none-any.whl", hash = "sha256:5531e99df3c60a298c4bef38de825aec4aa8ca238438ac21511d650b58dbdd87", size = 11794382, upload-time = "2026-10-09T18:38:21.957Z" }, +] + +[[package]] +name = "setuptools" +version = "84.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "trio" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/dc/a2d25ed73ad49cfd79bf18d262577c3731c98e382284e28d522f49a0df35/trio-0.34.0.tar.gz", hash = "sha256:63b9485408bdfdde544fced107045a8c0086cdc4bd0ef2f797b9e0dd111b964b", size = 607457, upload-time = "2026-08-11T00:33:42.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/1f/555f1364bed52a92a864181962b77f1b15adadeacf23b86105324363e461/trio-0.34.0-py3-none-any.whl", hash = "sha256:6c7c9f49917694dcdcd5f67abd168df5599eca480d61f29854d17a61a75c2f05", size = 511840, upload-time = "2026-08-11T00:33:40.552Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +]