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:
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user