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
@@ -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()