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