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 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 18:27:46 -04:00
co-authored by Claude Opus 5.5
parent 011869f641
commit 13be6e97d5
6 changed files with 474 additions and 89 deletions
@@ -63,20 +63,46 @@ async function processBcbsMaResult(
? seleniumResult.lastName.trim() ? seleniumResult.lastName.trim()
: (formLastName ?? ""); : (formLastName ?? "");
const normalizedInsuranceId = insuranceId.replace(/\s+/g, ""); // Address from the Patient Information section — only fields BCBS MA actually returned
const demographicFields: Record<string, string> = {};
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. // 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. // Dependents (e.g. Maria) share the subscriber's member ID but have a different DOB.
const dobDate = formDob ? new Date(formDob) : null; const dobDate = formDob ? new Date(formDob) : null;
let patient = dobDate const findPatient = (id: string) =>
? await storage.getPatientByInsuranceIdAndDob(normalizedInsuranceId, dobDate) dobDate
: await storage.getPatientByInsuranceId(normalizedInsuranceId); ? 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) { 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}`); 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 { } else {
// No match — create a brand new patient record. // No match — create a brand new patient record.
// Do NOT use createOrUpdatePatientByInsuranceId here because it looks up by // Do NOT use createOrUpdatePatientByInsuranceId here because it looks up by
@@ -91,6 +117,7 @@ async function processBcbsMaResult(
dateOfBirth: dobDate ?? undefined, dateOfBirth: dobDate ?? undefined,
gender: "", gender: "",
phone: "", phone: "",
...demographicFields,
userId, userId,
} as any); } as any);
} catch (createErr: any) { } catch (createErr: any) {
@@ -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 = { const enrichedData = {
...rawData, ...rawData,
memberId,
bcbsMaUsername: credentials.username, bcbsMaUsername: credentials.username,
bcbsMaPassword: credentials.password, bcbsMaPassword: credentials.password,
providerNpi: npiNumber, providerNpi: npiNumber,
@@ -67,13 +71,13 @@ router.post("/bcbs-ma-eligibility", async (req: Request, res: Response): Promise
userId: req.user.id, userId: req.user.id,
socketId, socketId,
enrichedPayload: enrichedData, enrichedPayload: enrichedData,
insuranceId: String(rawData.memberId ?? "").trim(), insuranceId: memberId,
formFirstName: rawData.firstName, formFirstName: rawData.firstName,
formLastName: rawData.lastName, formLastName: rawData.lastName,
formDob: rawData.dateOfBirth, 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 }); return res.json({ status: "queued", jobId });
} catch (err: any) { } catch (err: any) {
console.error("[bcbs-ma-route] enqueue failed:", err); console.error("[bcbs-ma-route] enqueue failed:", err);
+13 -2
View File
@@ -37,6 +37,7 @@ import helpers_metlife_claim as hmetlife_claim
# Import startup session-clear functions # Import startup session-clear functions
from ddma_browser_manager import clear_ddma_session_on_startup 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 deltaindiv_browser_manager import clear_deltaindiv_session_on_startup
from deltains_browser_manager import clear_deltains_session_on_startup from deltains_browser_manager import clear_deltains_session_on_startup
from unitedsco_browser_manager import clear_unitedsco_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): 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 global active_jobs, waiting_jobs
async with semaphore: async with semaphore:
async with lock: 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): async def bcbs_ma_eligibility(request: Request):
""" """
Starts a BCBS MA eligibility session in the background. 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 } } Body: { "data": { memberId, dateOfBirth, firstName, lastName, bcbsMaUsername, bcbsMaPassword } }
Returns: { status: "started", session_id: "<uuid>" } Returns: { status: "started", session_id: "<uuid>" }
""" """
@@ -1322,6 +1323,16 @@ async def clear_ddma_session_endpoint():
return {"status": "error", "message": str(e)} 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") @app.post("/clear-deltaindiv-session")
async def clear_deltaindiv_session_endpoint(): async def clear_deltaindiv_session_endpoint():
"""Clears the Delta Indiv browser session. Call when credentials are deleted or changed.""" """Clears the Delta Indiv browser session. Call when credentials are deleted or changed."""
@@ -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()
@@ -6,6 +6,7 @@ from typing import Dict, Any
from selenium.common.exceptions import WebDriverException from selenium.common.exceptions import WebDriverException
from selenium_BCBS_MA_eligibilityCheckWorker import AutomationBCBSMAEligibilityCheck from selenium_BCBS_MA_eligibilityCheckWorker import AutomationBCBSMAEligibilityCheck
from bcbs_ma_browser_manager import get_browser_manager
# In-memory session store # In-memory session store
sessions: Dict[str, Dict[str, Any]] = {} 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): async def start_bcbs_ma_run(sid: str, data: dict, url: str):
""" """
Full BCBS MA eligibility workflow for one session. Full BCBS MA eligibility workflow for one session.
Always fresh Chrome — no persistent session because BCBS MA always Uses the persistent BCBS MA browser — if the portal login is still
requires OTP (the prefix changes each login). 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) 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 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) ────────────────────────────────── # ── Already logged in (no OTP path) ──────────────────────────────────
elif isinstance(login_result, str) and login_result == "SUCCESS": 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" 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 ────────────────────────────────────────────── # ── Step 1: search member ──────────────────────────────────────────────
step1_result = bot.step1() step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"): 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"]} return {"status": "error", "message": s["message"]}
finally: finally:
# Always close the disposable browser # Never quit Chrome here — the persistent browser keeps the BCBS MA
try: # login for the next patient and stays open on the results page
if bot:
bot.close_driver()
except Exception:
pass
s["driver"] = None s["driver"] = None
@@ -7,54 +7,30 @@ from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException from selenium.common.exceptions import TimeoutException, WebDriverException
from selenium import webdriver 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")) DOWNLOAD_DIR = get_browser_manager().download_dir
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
class AutomationBCBSMAEligibilityCheck: class AutomationBCBSMAEligibilityCheck:
""" """
BCBS MA Provider Central eligibility check. BCBS MA Provider Central eligibility check.
No persistent session — fresh Chrome every run because BCBS MA Uses the persistent BCBS MA browser (bcbs_ma_browser_manager) — one Chrome
always requires OTP on new login (the OTP prefix changes each time). 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] → 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/" 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): def __init__(self, data: dict):
raw = data.get("data", data) raw = data.get("data", data)
@@ -70,21 +46,44 @@ class AutomationBCBSMAEligibilityCheck:
# ── Driver lifecycle ────────────────────────────────────────────────────── # ── Driver lifecycle ──────────────────────────────────────────────────────
def config_driver(self): 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: try:
if self.driver: handles = self.driver.window_handles
self.driver.quit() for handle in handles[1:]:
except Exception: self.driver.switch_to.window(handle)
pass self.driver.close()
self.driver = None 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 ─────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────
def _wait(self, timeout=15): def _wait(self, timeout=15):
return WebDriverWait(self.driver, timeout) 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: def _dob_mmddyyyy(self) -> str:
"""Convert YYYY-MM-DD → MM/DD/YYYY.""" """Convert YYYY-MM-DD → MM/DD/YYYY."""
try: try:
@@ -97,18 +96,38 @@ class AutomationBCBSMAEligibilityCheck:
def login(self, url: str = "") -> str: 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: Returns:
"OTP_REQUIRED" – redirected to MFA challenge page (/mga/sps/authsvc) "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 "ERROR:..." – something went wrong
""" """
target = url or self.LOGIN_URL target = url or self.LOGIN_URL
try: 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) self.driver.get(target)
# Wait for username field: id="txtUsername0"
username_input = self._wait(20).until( username_input = self._wait(20).until(
EC.presence_of_element_located((By.ID, "txtUsername0")) EC.presence_of_element_located((By.ID, "txtUsername0"))
) )
@@ -149,17 +168,10 @@ class AutomationBCBSMAEligibilityCheck:
print("[BCBS MA] OTP page detected") print("[BCBS MA] OTP page detected")
return "OTP_REQUIRED" return "OTP_REQUIRED"
if "providerhome" in url or "portal" in url: # eTools shows on the public page too — only /myportal/ proves login
# Check if we're on a real portal page (not login form) if self._is_logged_in_page():
try: print("[BCBS MA] Logged in without OTP")
self.driver.find_element(By.XPATH, return "SUCCESS"
"//*[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
print("[BCBS MA] Could not determine post-login state") print("[BCBS MA] Could not determine post-login state")
return "OTP_REQUIRED" # default assumption for BCBS MA return "OTP_REQUIRED" # default assumption for BCBS MA
@@ -211,7 +223,7 @@ class AutomationBCBSMAEligibilityCheck:
def step1(self) -> str: def step1(self) -> str:
""" """
After OTP login: After login, on the Provider Central dashboard:
1. Click eTools menu 1. Click eTools menu
2. Click ConnectCenter in the dropdown 2. Click ConnectCenter in the dropdown
3. Click Go Now on the ConnectCenter launch page 3. Click Go Now on the ConnectCenter launch page
@@ -311,6 +323,90 @@ class AutomationBCBSMAEligibilityCheck:
# ── Step 2: Fill Eligibility Identifier form and get results ───────────── # ── 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: <span ng-click="expandCollapseViews(false)"><h6>Expand All</h6></span>.
# 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: def step2(self) -> dict:
""" """
On the Eligibility Identifier page: On the Eligibility Identifier page:
@@ -430,17 +526,7 @@ class AutomationBCBSMAEligibilityCheck:
print("[BCBS MA step2] Results page loaded") print("[BCBS MA step2] Results page loaded")
# Click Expand All to expand all eligibility sections before PDFing # Click Expand All to expand all eligibility sections before PDFing
try: self._expand_all_sections()
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}")
# Extract eligibility status and patient name from live page text # Extract eligibility status and patient name from live page text
page_text = self.driver.find_element(By.TAG_NAME, "body").text page_text = self.driver.find_element(By.TAG_NAME, "body").text
@@ -458,6 +544,7 @@ class AutomationBCBSMAEligibilityCheck:
import re import re
first_name = self.first_name first_name = self.first_name
last_name = self.last_name last_name = self.last_name
street = city = state = zip_code = gender = ""
try: try:
# Slice out just the Patient Information section # Slice out just the Patient Information section
patient_match = re.search( patient_match = re.search(
@@ -490,6 +577,27 @@ class AutomationBCBSMAEligibilityCheck:
capturing_last = False capturing_last = False
if first_name and last_name and not capturing_last: if first_name and last_name and not capturing_last:
break 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: else:
print("[BCBS MA step2] Patient section not found in page text") print("[BCBS MA step2] Patient section not found in page text")
@@ -533,6 +641,11 @@ class AutomationBCBSMAEligibilityCheck:
"firstName": first_name, "firstName": first_name,
"lastName": last_name, "lastName": last_name,
"memberId": self.member_id, "memberId": self.member_id,
"address": street,
"city": city,
"state": state,
"zipCode": zip_code,
"gender": gender,
"insurerName": "BCBS MA", "insurerName": "BCBS MA",
"pdfBase64": pdf_base64, "pdfBase64": pdf_base64,
"pdf_path": pdf_path, "pdf_path": pdf_path,