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:
@@ -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: "<uuid>" }
|
||||
"""
|
||||
@@ -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."""
|
||||
|
||||
@@ -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_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
|
||||
|
||||
|
||||
|
||||
@@ -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: <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:
|
||||
"""
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user