From 13be6e97d5e19a0bd1d5d506eb26d79447aac9ea Mon Sep 17 00:00:00 2001 From: Gitead Date: Thu, 29 Oct 2026 18:27:46 -0400 Subject: [PATCH] feat: BCBS MA persistent session, Expand All fix, address/gender extraction - Persistent Chrome profile (bcbs_ma_browser_manager) reused across patients; login probes /myportal/ to skip login + OTP while the session is active - Expand All: JS-click the expandCollapseViews span, verify sections opened, and unclip fixed-height result tables so the PDF includes every row - Extract patient address and gender from the results page and save to patient - Uppercase member IDs (XXA...), matching older lowercase records - Add /clear-bcbs-ma-session endpoint Co-Authored-By: Claude Opus 5.5 --- .../processors/bcbsMaEligibilityProcessor.ts | 39 ++- .../src/routes/insuranceStatusBcbsMa.ts | 8 +- apps/SeleniumService/agent.py | 15 +- .../bcbs_ma_browser_manager.py | 229 ++++++++++++++++ .../helpers_bcbs_ma_eligibility.py | 21 +- ...selenium_BCBS_MA_eligibilityCheckWorker.py | 251 +++++++++++++----- 6 files changed, 474 insertions(+), 89 deletions(-) create mode 100644 apps/SeleniumService/bcbs_ma_browser_manager.py diff --git a/apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts b/apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts index 38fb3b62..b658520a 100644 --- a/apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts +++ b/apps/Backend/src/queue/processors/bcbsMaEligibilityProcessor.ts @@ -63,20 +63,46 @@ async function processBcbsMaResult( ? seleniumResult.lastName.trim() : (formLastName ?? ""); - const normalizedInsuranceId = insuranceId.replace(/\s+/g, ""); + // Address from the Patient Information section — only fields BCBS MA actually returned + const demographicFields: Record = {}; + for (const key of ["address", "city", "state", "zipCode"] as const) { + const val = seleniumResult?.[key]; + if (typeof val === "string" && val.trim()) demographicFields[key] = val.trim(); + } + + // Gender from the Patient Information section ("M"/"F") → patient form values + const rawGender = String(seleniumResult?.gender ?? "").trim().toUpperCase(); + const gender = rawGender.startsWith("M") ? "male" : rawGender.startsWith("F") ? "female" : ""; + if (gender) demographicFields.gender = gender; + + // BCBS MA member IDs are uppercase (XXA962094414) + const normalizedInsuranceId = insuranceId.replace(/\s+/g, "").toUpperCase(); // Identify patient by member ID + DOB — not member ID alone. // Dependents (e.g. Maria) share the subscriber's member ID but have a different DOB. const dobDate = formDob ? new Date(formDob) : null; - let patient = dobDate - ? await storage.getPatientByInsuranceIdAndDob(normalizedInsuranceId, dobDate) - : await storage.getPatientByInsuranceId(normalizedInsuranceId); + const findPatient = (id: string) => + dobDate + ? storage.getPatientByInsuranceIdAndDob(id, dobDate) + : storage.getPatientByInsuranceId(id); + + // Lookup is case-sensitive — also try lowercase so patients saved before + // the uppercase fix (xxa962094414) are matched instead of duplicated + let patient = + (await findPatient(normalizedInsuranceId)) ?? + (await findPatient(normalizedInsuranceId.toLowerCase())); if (patient) { - // Existing patient found by insuranceId + DOB — update name + // Existing patient found by insuranceId + DOB — update name, address and + // uppercase the stored member ID log("bcbs-ma-processor", `Patient matched by insuranceId+DOB id=${patient.id}`); - await storage.updatePatient(patient.id, { firstName, lastName }); + await storage.updatePatient(patient.id, { + firstName, + lastName, + insuranceId: normalizedInsuranceId, + ...demographicFields, + }); } else { // No match — create a brand new patient record. // Do NOT use createOrUpdatePatientByInsuranceId here because it looks up by @@ -91,6 +117,7 @@ async function processBcbsMaResult( dateOfBirth: dobDate ?? undefined, gender: "", phone: "", + ...demographicFields, userId, } as any); } catch (createErr: any) { diff --git a/apps/Backend/src/routes/insuranceStatusBcbsMa.ts b/apps/Backend/src/routes/insuranceStatusBcbsMa.ts index fe911591..57c211d4 100644 --- a/apps/Backend/src/routes/insuranceStatusBcbsMa.ts +++ b/apps/Backend/src/routes/insuranceStatusBcbsMa.ts @@ -53,8 +53,12 @@ router.post("/bcbs-ma-eligibility", async (req: Request, res: Response): Promise }); } + // BCBS MA member IDs use an uppercase prefix (e.g. XXA962094414) + const memberId = String(rawData.memberId ?? "").trim().toUpperCase(); + const enrichedData = { ...rawData, + memberId, bcbsMaUsername: credentials.username, bcbsMaPassword: credentials.password, providerNpi: npiNumber, @@ -67,13 +71,13 @@ router.post("/bcbs-ma-eligibility", async (req: Request, res: Response): Promise userId: req.user.id, socketId, enrichedPayload: enrichedData, - insuranceId: String(rawData.memberId ?? "").trim(), + insuranceId: memberId, formFirstName: rawData.firstName, formLastName: rawData.lastName, formDob: rawData.dateOfBirth, }); - log("bcbs-ma-route", "job enqueued", { jobId, insuranceId: rawData.memberId }); + log("bcbs-ma-route", "job enqueued", { jobId, insuranceId: memberId }); return res.json({ status: "queued", jobId }); } catch (err: any) { console.error("[bcbs-ma-route] enqueue failed:", err); diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index f4474a76..02dd9d5d 100755 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -37,6 +37,7 @@ import helpers_metlife_claim as hmetlife_claim # Import startup session-clear functions from ddma_browser_manager import clear_ddma_session_on_startup +from bcbs_ma_browser_manager import clear_bcbs_ma_session from deltaindiv_browser_manager import clear_deltaindiv_session_on_startup from deltains_browser_manager import clear_deltains_session_on_startup from unitedsco_browser_manager import clear_unitedsco_session_on_startup @@ -792,7 +793,7 @@ async def cca_eligibility(request: Request): async def _bcbs_ma_worker_wrapper(sid: str, data: dict, url: str): - """Background worker for BCBS MA eligibility — fresh browser, always OTP.""" + """Background worker for BCBS MA eligibility — persistent browser, OTP only when login expired.""" global active_jobs, waiting_jobs async with semaphore: async with lock: @@ -809,7 +810,7 @@ async def _bcbs_ma_worker_wrapper(sid: str, data: dict, url: str): async def bcbs_ma_eligibility(request: Request): """ Starts a BCBS MA eligibility session in the background. - Fresh Chrome each time — no persistent session (OTP always required). + Persistent Chrome profile — OTP only when the BCBS MA login has expired. Body: { "data": { memberId, dateOfBirth, firstName, lastName, bcbsMaUsername, bcbsMaPassword } } Returns: { status: "started", session_id: "" } """ @@ -1322,6 +1323,16 @@ async def clear_ddma_session_endpoint(): return {"status": "error", "message": str(e)} +@app.post("/clear-bcbs-ma-session") +async def clear_bcbs_ma_session_endpoint(): + """Clears the BCBS MA browser session. Call when credentials are deleted or changed.""" + try: + clear_bcbs_ma_session() + return {"status": "success", "message": "BCBS MA session cleared"} + except Exception as e: + return {"status": "error", "message": str(e)} + + @app.post("/clear-deltaindiv-session") async def clear_deltaindiv_session_endpoint(): """Clears the Delta Indiv browser session. Call when credentials are deleted or changed.""" diff --git a/apps/SeleniumService/bcbs_ma_browser_manager.py b/apps/SeleniumService/bcbs_ma_browser_manager.py new file mode 100644 index 00000000..c443f0d3 --- /dev/null +++ b/apps/SeleniumService/bcbs_ma_browser_manager.py @@ -0,0 +1,229 @@ +""" +Browser manager for BCBS MA (Provider Central) - persistent profile with session management. +- Uses --user-data-dir for persistent profile (login cookies + device trust) +- One Chrome is reused across patients, so an active login skips OTP entirely +- Tracks credentials to detect changes mid-session (triggers logout) +- Anti-detection options to avoid bot detection + +Unlike DDMA, cookies are NOT cleared on startup: BCBS MA (IBM Security Verify) +keeps its login / device-trust state in cookies, so clearing them would force +an OTP on the first check after every restart. +""" +import os +import hashlib +import threading +from selenium import webdriver +from selenium.webdriver.chrome.service import Service +from webdriver_manager.chrome import ChromeDriverManager + +# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background) +if not os.environ.get("DISPLAY"): + os.environ["DISPLAY"] = ":0" + + +class BCBSMABrowserManager: + """ + Singleton that manages a persistent Chrome browser instance. + - Uses --user-data-dir for persistent profile + - Tracks credentials to detect changes mid-session + """ + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + base = os.path.dirname(os.path.abspath(__file__)) + cls._instance = super().__new__(cls) + cls._instance._driver = None + cls._instance.profile_dir = os.path.join(base, "chrome_profile_bcbs_ma") + cls._instance.download_dir = os.path.join(base, "downloads", "bcbs_ma") + cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials") + os.makedirs(cls._instance.profile_dir, exist_ok=True) + os.makedirs(cls._instance.download_dir, exist_ok=True) + return cls._instance + + def clear_session(self): + """ + Log out by removing the profile's cookie / login-data files. + Use when BCBS MA credentials are deleted or changed. Quits the browser + first so Chrome doesn't rewrite the files on exit. + """ + print("[BCBS MA BrowserManager] Clearing session...") + self.quit_driver() + self._kill_existing_chrome_for_profile() + self.clear_credentials_hash() + + for filename in ["Cookies", "Cookies-journal", "Login Data", "Login Data-journal"]: + for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]: + filepath = os.path.join(base, filename) + if os.path.exists(filepath): + try: + os.remove(filepath) + print(f"[BCBS MA BrowserManager] Removed {filename}") + except Exception as e: + print(f"[BCBS MA BrowserManager] Could not remove {filename}: {e}") + + # ── Credential hash tracking ────────────────────────────────────────────── + + def _hash_credentials(self, username: str) -> str: + return hashlib.sha256(username.encode()).hexdigest()[:16] + + def get_last_credentials_hash(self) -> str | None: + try: + if os.path.exists(self._credentials_file): + with open(self._credentials_file, 'r') as f: + return f.read().strip() + except Exception: + pass + return None + + def save_credentials_hash(self, username: str): + try: + cred_hash = self._hash_credentials(username) + with open(self._credentials_file, 'w') as f: + f.write(cred_hash) + except Exception as e: + print(f"[BCBS MA BrowserManager] Failed to save credentials hash: {e}") + + def credentials_changed(self, username: str) -> bool: + last_hash = self.get_last_credentials_hash() + if last_hash is None: + return False # No previous credentials stored — not a change + changed = last_hash != self._hash_credentials(username) + if changed: + print("[BCBS MA BrowserManager] Credentials changed — logout required") + return changed + + def clear_credentials_hash(self): + try: + if os.path.exists(self._credentials_file): + os.remove(self._credentials_file) + except Exception as e: + print(f"[BCBS MA BrowserManager] Failed to clear credentials hash: {e}") + + # ── Chrome process management ───────────────────────────────────────────── + + def _kill_existing_chrome_for_profile(self): + """Kill any existing Chrome processes using this profile and remove lock files.""" + import subprocess + import time as time_module + try: + result = subprocess.run( + ["pgrep", "-f", f"user-data-dir={self.profile_dir}"], + capture_output=True, text=True + ) + if result.stdout.strip(): + for pid in result.stdout.strip().split('\n'): + try: + subprocess.run(["kill", "-9", pid], check=False) + except Exception: + pass + time_module.sleep(1) + except Exception: + pass + + for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]: + lock_path = os.path.join(self.profile_dir, lock_file) + try: + if os.path.islink(lock_path) or os.path.exists(lock_path): + os.remove(lock_path) + except Exception: + pass + + # ── Driver lifecycle ────────────────────────────────────────────────────── + + def get_driver(self, headless=False): + """Get or create the persistent browser instance.""" + with self._lock: + if self._driver is None: + print("[BCBS MA BrowserManager] Driver is None, creating new driver") + self._kill_existing_chrome_for_profile() + self._create_driver(headless) + elif not self._is_alive(): + print("[BCBS MA BrowserManager] Driver not alive, recreating") + self._kill_existing_chrome_for_profile() + self._create_driver(headless) + else: + print("[BCBS MA BrowserManager] Reusing existing driver") + return self._driver + + def _is_alive(self): + try: + if self._driver is None: + return False + url = self._driver.current_url + print(f"[BCBS MA BrowserManager] Driver alive, current URL: {url[:50]}...") + return True + except Exception as e: + print(f"[BCBS MA BrowserManager] Driver not alive: {e}") + return False + + def _create_driver(self, headless=False): + """Create browser with persistent profile and anti-detection options.""" + if self._driver: + try: + self._driver.quit() + except Exception: + pass + + options = webdriver.ChromeOptions() + if headless: + options.add_argument("--headless=new") + + # Persistent profile — keeps login cookies / device trust between runs + options.add_argument(f"--user-data-dir={self.profile_dir}") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + + # Anti-detection + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_argument("--disable-infobars") + + prefs = { + "download.default_directory": self.download_dir, + "plugins.always_open_pdf_externally": True, + "download.prompt_for_download": False, + "download.directory_upgrade": True, + } + options.add_experimental_option("prefs", prefs) + + service = Service(ChromeDriverManager().install()) + self._driver = webdriver.Chrome(service=service, options=options) + self._driver.maximize_window() + + # Remove webdriver property to avoid detection + try: + self._driver.execute_script( + "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" + ) + except Exception: + pass + + def quit_driver(self): + """Quit browser (only call on shutdown / logout — NOT between patients).""" + with self._lock: + if self._driver: + try: + self._driver.quit() + except Exception: + pass + self._driver = None + + +# ── Singleton accessor ──────────────────────────────────────────────────────── + +_manager = None + +def get_browser_manager() -> BCBSMABrowserManager: + global _manager + if _manager is None: + _manager = BCBSMABrowserManager() + return _manager + + +def clear_bcbs_ma_session(): + """Log out of BCBS MA. Call when credentials are deleted or changed.""" + get_browser_manager().clear_session() diff --git a/apps/SeleniumService/helpers_bcbs_ma_eligibility.py b/apps/SeleniumService/helpers_bcbs_ma_eligibility.py index 29b417e9..03beaa35 100644 --- a/apps/SeleniumService/helpers_bcbs_ma_eligibility.py +++ b/apps/SeleniumService/helpers_bcbs_ma_eligibility.py @@ -6,6 +6,7 @@ from typing import Dict, Any from selenium.common.exceptions import WebDriverException from selenium_BCBS_MA_eligibilityCheckWorker import AutomationBCBSMAEligibilityCheck +from bcbs_ma_browser_manager import get_browser_manager # In-memory session store sessions: Dict[str, Dict[str, Any]] = {} @@ -39,10 +40,10 @@ async def _remove_session_later(sid: str, delay: int = 30): async def start_bcbs_ma_run(sid: str, data: dict, url: str): """ Full BCBS MA eligibility workflow for one session. - Always fresh Chrome — no persistent session because BCBS MA always - requires OTP (the prefix changes each login). + Uses the persistent BCBS MA browser — if the portal login is still + active, login() returns SUCCESS and the OTP step is skipped. - OTP handling: + OTP handling (only when the saved login has expired): a) Accept OTP submitted from the app via /submit-otp (sets otp_value) b) Poll the browser directly to detect user entry in the open window """ @@ -130,9 +131,13 @@ async def start_bcbs_ma_run(sid: str, data: dict, url: str): # ── Already logged in (no OTP path) ────────────────────────────────── elif isinstance(login_result, str) and login_result == "SUCCESS": - print("[BCBS MA] Login succeeded without OTP") + print("[BCBS MA] Logged in without OTP") s["status"] = "running" + # Remember which account this browser profile is logged in as + if bot.username: + get_browser_manager().save_credentials_hash(bot.username) + # ── Step 1: search member ────────────────────────────────────────────── step1_result = bot.step1() if isinstance(step1_result, str) and step1_result.startswith("ERROR"): @@ -160,12 +165,8 @@ async def start_bcbs_ma_run(sid: str, data: dict, url: str): return {"status": "error", "message": s["message"]} finally: - # Always close the disposable browser - try: - if bot: - bot.close_driver() - except Exception: - pass + # Never quit Chrome here — the persistent browser keeps the BCBS MA + # login for the next patient and stays open on the results page s["driver"] = None diff --git a/apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py index 3668238f..5ccd3f64 100644 --- a/apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_BCBS_MA_eligibilityCheckWorker.py @@ -7,54 +7,30 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException, WebDriverException from selenium import webdriver -from selenium.webdriver.chrome.options import Options -from selenium.webdriver.chrome.service import Service + +from bcbs_ma_browser_manager import get_browser_manager -DOWNLOAD_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "downloads", "bcbs_ma")) - - -def _fresh_driver() -> webdriver.Chrome: - """Create a disposable Chrome instance for a single BCBS MA session.""" - os.makedirs(DOWNLOAD_DIR, exist_ok=True) - options = Options() - options.add_argument("--no-sandbox") - options.add_argument("--disable-dev-shm-usage") - options.add_argument("--disable-blink-features=AutomationControlled") - options.add_experimental_option("excludeSwitches", ["enable-automation"]) - options.add_experimental_option("useAutomationExtension", False) - options.add_experimental_option("prefs", { - "download.default_directory": DOWNLOAD_DIR, - "download.prompt_for_download": False, - "plugins.always_open_pdf_externally": True, - }) - headless = os.getenv("SELENIUM_HEADLESS", "false").lower() == "true" - if headless: - options.add_argument("--headless=new") - - try: - from webdriver_manager.chrome import ChromeDriverManager - service = Service(ChromeDriverManager().install()) - except Exception: - service = Service() - - driver = webdriver.Chrome(service=service, options=options) - driver.maximize_window() - return driver +DOWNLOAD_DIR = get_browser_manager().download_dir class AutomationBCBSMAEligibilityCheck: """ BCBS MA Provider Central eligibility check. - No persistent session — fresh Chrome every run because BCBS MA - always requires OTP on new login (the OTP prefix changes each time). + Uses the persistent BCBS MA browser (bcbs_ma_browser_manager) — one Chrome + with a saved profile, reused across patients. While the portal login is + still active, login() returns SUCCESS and no OTP is needed. - Flow: login(url) → OTP_REQUIRED → [caller polls for OTP] → submit_otp_step(otp) + Flow: login(url) → OTP_REQUIRED | SUCCESS → [caller polls for OTP] → submit_otp_step(otp) → step1() [search member] → step2() [extract + PDF] """ + # Public portal — always shows the login form, even when already logged in LOGIN_URL = "https://provider.bluecrossma.com/ProviderHome/portal/" + # Logged-in portal — only loads with an active session; otherwise BCBS + # redirects away from /myportal/ + MYPORTAL_URL = "https://provider.bluecrossma.com/ProviderHome/myportal/" def __init__(self, data: dict): raw = data.get("data", data) @@ -70,21 +46,44 @@ class AutomationBCBSMAEligibilityCheck: # ── Driver lifecycle ────────────────────────────────────────────────────── def config_driver(self): - self.driver = _fresh_driver() + # Persistent browser from manager (keeps the BCBS MA login between patients) + headless = os.getenv("SELENIUM_HEADLESS", "false").lower() == "true" + self.driver = get_browser_manager().get_driver(headless) + self._close_extra_tabs() - def close_driver(self): + def _close_extra_tabs(self): + """Close ConnectCenter tabs left over from the previous patient, keep the first tab.""" try: - if self.driver: - self.driver.quit() - except Exception: - pass - self.driver = None + handles = self.driver.window_handles + for handle in handles[1:]: + self.driver.switch_to.window(handle) + self.driver.close() + self.driver.switch_to.window(handles[0]) + if len(handles) > 1: + print(f"[BCBS MA] Closed {len(handles) - 1} leftover tab(s)") + except Exception as e: + print(f"[BCBS MA] Could not close leftover tabs: {e}") + + def _force_logout(self): + """Clear all BCBS MA / ConnectCenter cookies when the saved username changes.""" + try: + print("[BCBS MA login] Forcing logout due to credential change...") + self.driver.execute_cdp_cmd("Network.clearBrowserCookies", {}) + get_browser_manager().clear_credentials_hash() + print("[BCBS MA login] Cookies cleared") + except Exception as e: + print(f"[BCBS MA login] Error during forced logout: {e}") # ── Helpers ─────────────────────────────────────────────────────────────── def _wait(self, timeout=15): return WebDriverWait(self.driver, timeout) + def _is_logged_in_page(self) -> bool: + """On the logged-in portal (/myportal/) with no login form showing.""" + return ("/myportal/" in self.driver.current_url.lower() + and not self.driver.find_elements(By.ID, "txtUsername0")) + def _dob_mmddyyyy(self) -> str: """Convert YYYY-MM-DD → MM/DD/YYYY.""" try: @@ -97,18 +96,38 @@ class AutomationBCBSMAEligibilityCheck: def login(self, url: str = "") -> str: """ - Navigate to BCBS MA Provider Central, fill credentials, click Log in. + Check for an active session on the logged-in portal (/myportal/); if + there is one, return SUCCESS straight away. Otherwise open the public + portal, fill credentials and click Log in. Returns: "OTP_REQUIRED" – redirected to MFA challenge page (/mga/sps/authsvc) - "SUCCESS" – landed on dashboard without OTP + "SUCCESS" – on the logged-in portal (existing session, or login without OTP) "ERROR:..." – something went wrong """ target = url or self.LOGIN_URL try: - print(f"[BCBS MA login] Navigating to {target}") + if self.username and get_browser_manager().credentials_changed(self.username): + self._force_logout() + + # 1. Existing session? The public portal shows the login form even when + # logged in, so probe /myportal/, which only loads with a live session. + print(f"[BCBS MA login] Checking for active session at {self.MYPORTAL_URL}") + self.driver.get(self.MYPORTAL_URL) + self._wait(20).until(lambda d: d.execute_script("return document.readyState") == "complete") + time.sleep(2) # let any session-expired redirect finish + print(f"[BCBS MA login] Landed on {self.driver.current_url[:80]}") + + if self._is_logged_in_page(): + print("[BCBS MA login] Session still active — skipping login and OTP") + return "SUCCESS" + if "authsvc" in self.driver.current_url.lower(): + print("[BCBS MA login] Landed on OTP page") + return "OTP_REQUIRED" + + # 2. Not logged in — log in from the public portal + print(f"[BCBS MA login] Not logged in — navigating to {target}") self.driver.get(target) - # Wait for username field: id="txtUsername0" username_input = self._wait(20).until( EC.presence_of_element_located((By.ID, "txtUsername0")) ) @@ -149,17 +168,10 @@ class AutomationBCBSMAEligibilityCheck: print("[BCBS MA] OTP page detected") return "OTP_REQUIRED" - if "providerhome" in url or "portal" in url: - # Check if we're on a real portal page (not login form) - try: - self.driver.find_element(By.XPATH, - "//*[contains(@href,'eTools') or contains(text(),'eTools') or " - "contains(@href,'eligibility') or contains(text(),'Eligibility')]" - ) - print("[BCBS MA] Dashboard detected — logged in without OTP") - return "SUCCESS" - except Exception: - pass + # eTools shows on the public page too — only /myportal/ proves login + if self._is_logged_in_page(): + print("[BCBS MA] Logged in without OTP") + return "SUCCESS" print("[BCBS MA] Could not determine post-login state") return "OTP_REQUIRED" # default assumption for BCBS MA @@ -211,7 +223,7 @@ class AutomationBCBSMAEligibilityCheck: def step1(self) -> str: """ - After OTP login: + After login, on the Provider Central dashboard: 1. Click eTools menu 2. Click ConnectCenter in the dropdown 3. Click Go Now on the ConnectCenter launch page @@ -311,6 +323,90 @@ class AutomationBCBSMAEligibilityCheck: # ── Step 2: Fill Eligibility Identifier form and get results ───────────── + def _expand_all_sections(self): + """ + Click "Expand All" on the results page and confirm the sections opened. + The label may be nested inside an h6/a/span, so match the innermost + element whose visible text is "Expand All", then fall back to a JS + click on it and its clickable ancestors until the page height grows. + """ + label_xpath = ( + "//*[normalize-space(.)='Expand All' and " + "not(*[normalize-space(.)='Expand All'])]" + ) + try: + label = self._wait(15).until( + EC.presence_of_element_located((By.XPATH, label_xpath)) + ) + except TimeoutException: + print("[BCBS MA step2] Expand All not found — PDF will have collapsed sections") + return + + # Each benefit section (Copay, Coinsurance, ...) is an ng-grid table that + # only becomes visible once expanded — count them to verify the click + def visible_grids(): + return self.driver.execute_script( + "return [...document.querySelectorAll('.ngViewport')]" + ".filter(v => v.offsetParent && v.clientHeight > 0).length;" + ) + + # Let the benefit sections finish rendering before expanding them + time.sleep(2) + before = visible_grids() + + # Markup:
Expand All
. + # JS-click the span (an overlapping View Options panel intercepts native + # clicks); fall back to the label itself. Only re-click while nothing has + # expanded, so a click can never collapse sections that already opened. + for attempt in range(1, 4): + try: + targets = self.driver.find_elements(By.XPATH, + label_xpath + "/ancestor::*[starts-with(@ng-click,'expandCollapseViews')][1]" + ) or [label] + self.driver.execute_script("arguments[0].click();", targets[0]) + except Exception as e: + print(f"[BCBS MA step2] Expand All click attempt {attempt} failed: {e}") + + for _ in range(10): + time.sleep(0.5) + if visible_grids() > before: + time.sleep(1.5) # let the rows inside each section render + print(f"[BCBS MA step2] Expand All worked (attempt {attempt}, " + f"visible sections {before} → {visible_grids()})") + self._unclip_grids() + return + print(f"[BCBS MA step2] Expand All attempt {attempt} did not expand sections " + f"(visible sections still {visible_grids()})") + + print("[BCBS MA step2] Expand All failed — PDF will have collapsed sections") + + def _unclip_grids(self): + """ + Each expanded section's table is a fixed-height (368px) scroll box, so a + PDF only shows its first rows. All rows are already in the DOM — grow each + box (and its ng-grid wrapper) to full height so the PDF includes them. + """ + try: + count = self.driver.execute_script(""" + let n = 0; + document.querySelectorAll('.ngViewport').forEach(v => { + if (!v.offsetParent || v.scrollHeight <= v.clientHeight + 2) return; + const grow = v.scrollHeight - v.clientHeight; + v.style.height = v.scrollHeight + 'px'; + v.style.overflow = 'visible'; + const grid = v.closest('.ngGrid'); + if (grid) { + grid.style.height = (grid.offsetHeight + grow) + 'px'; + grid.style.overflow = 'visible'; + } + n++; + }); + return n; + """) + print(f"[BCBS MA step2] Unclipped {count} scrolling table(s) for the PDF") + except Exception as e: + print(f"[BCBS MA step2] Could not unclip tables: {e}") + def step2(self) -> dict: """ On the Eligibility Identifier page: @@ -430,17 +526,7 @@ class AutomationBCBSMAEligibilityCheck: print("[BCBS MA step2] Results page loaded") # Click Expand All to expand all eligibility sections before PDFing - try: - expand_all = self._wait(10).until( - EC.element_to_be_clickable((By.XPATH, - "//h6[normalize-space(text())='Expand All']" - )) - ) - expand_all.click() - print("[BCBS MA step2] Expand All clicked, waiting for sections to expand...") - time.sleep(3) - except Exception as e: - print(f"[BCBS MA step2] Expand All not found or failed: {e}") + self._expand_all_sections() # Extract eligibility status and patient name from live page text page_text = self.driver.find_element(By.TAG_NAME, "body").text @@ -458,6 +544,7 @@ class AutomationBCBSMAEligibilityCheck: import re first_name = self.first_name last_name = self.last_name + street = city = state = zip_code = gender = "" try: # Slice out just the Patient Information section patient_match = re.search( @@ -490,6 +577,27 @@ class AutomationBCBSMAEligibilityCheck: capturing_last = False if first_name and last_name and not capturing_last: break + + # Address — "Street: 1041 MADISON PLACE" / "City State Zip: SOUTHBOROUGH MA 01772" + street_match = re.search(r"Street:[ \t]*([^\n]*)", patient_text) + if street_match: + street = street_match.group(1).strip() + csz_match = re.search( + r"City State Zip:\s*(.+?)\s+([A-Z]{2})\s+(\d{5}(?:-?\d{4})?)", + patient_text + ) + if csz_match: + city = csz_match.group(1).strip() + state = csz_match.group(2) + zip_code = csz_match.group(3) + print(f"[BCBS MA step2] Extracted address — Street: '{street}', " + f"City: '{city}', State: '{state}', Zip: '{zip_code}'") + + # Gender — "Gender: M" + gender_match = re.search(r"Gender:[ \t]*([^\n]*)", patient_text) + if gender_match: + gender = gender_match.group(1).strip() + print(f"[BCBS MA step2] Extracted gender: '{gender}'") else: print("[BCBS MA step2] Patient section not found in page text") @@ -533,6 +641,11 @@ class AutomationBCBSMAEligibilityCheck: "firstName": first_name, "lastName": last_name, "memberId": self.member_id, + "address": street, + "city": city, + "state": state, + "zipCode": zip_code, + "gender": gender, "insurerName": "BCBS MA", "pdfBase64": pdf_base64, "pdf_path": pdf_path,