Mirrors the DDMA flow: persistent Chrome profile + credential-change logout, PingOne login with email MFA selection and OTP from app or browser, Eligibility & Plan Details search (masked Subscriber ID/SSN), family member picked by DOB and re-verified on the plan details page, PDF saved to the patient's Eligibility folder and status set ACTIVE/INACTIVE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
835 lines
38 KiB
Python
835 lines
38 KiB
Python
from selenium.common.exceptions import WebDriverException, TimeoutException
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.common.keys import Keys
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
from datetime import datetime
|
|
import time
|
|
import os
|
|
import re
|
|
import base64
|
|
|
|
from metlife_browser_manager import get_browser_manager
|
|
|
|
# MetDental provider portal (React SPA, PingOne DaVinci login)
|
|
METLIFE_BASE_URL = "https://dentalprovider.metlife.com"
|
|
METLIFE_HOME_URL = f"{METLIFE_BASE_URL}/home"
|
|
METLIFE_PRESIGNIN_URL = f"{METLIFE_BASE_URL}/presignin"
|
|
|
|
# Home page search form: "Subscriber ID or SSN" (max 11 alphanumeric chars)
|
|
# The portal's design-system components may put the id on a wrapper instead of the
|
|
# <input>/<button> itself, so accept both.
|
|
# Actual input: <input id="subscriber-id-input-input"> (label for="subscriber-id-input-input")
|
|
SUBSCRIBER_INPUT_XPATH = (
|
|
"//input[@id='subscriber-id-input-input']"
|
|
" | //input[@id='subscriber-id-input'] | //*[@id='subscriber-id-input']//input"
|
|
" | //input[contains(@aria-label,'Subscriber ID') or contains(@placeholder,'Subscriber ID')]"
|
|
)
|
|
SEARCH_SUBMIT_XPATH = (
|
|
"//button[@id='submit-button'] | //*[@id='submit-button']//button"
|
|
" | //form[.//input[starts-with(@id,'subscriber-id-input')]]//button[@type='submit']"
|
|
)
|
|
# Home page card link: <a id="benefit-link">Eligibility & Plan Details</a> (rendered several times)
|
|
BENEFIT_LINK_XPATH = "//a[@id='benefit-link']"
|
|
# Extra inputs the portal asks for when the ID alone is ambiguous (reason codes 200/205/225)
|
|
# Same "<id>-input" naming as the subscriber box
|
|
EXTRA_LASTNAME_XPATH = "//input[@id='input-lastname-input' or @id='input-lastname'] | //*[@id='input-lastname']//input"
|
|
EXTRA_ZIPCODE_XPATH = "//input[@id='input-zipcode-input' or @id='input-zipcode'] | //*[@id='input-zipcode']//input"
|
|
# One tile per covered person on /eligibility
|
|
RESULT_TILE_CSS = ".eligibility-result__tile-content"
|
|
# "Sign in" button on /presignin (goes to the PingOne login page)
|
|
PRESIGNIN_BUTTON_XPATH = (
|
|
"//button[@id='mldc-react-button-secondary-white-signin']"
|
|
" | //*[@id='hero-section__text--login']/following::button[1]"
|
|
)
|
|
|
|
# Finds a visible element of a given kind in the current document.
|
|
# The PingOne login widget markup isn't published, so match on attributes/text
|
|
# rather than fixed ids.
|
|
FIND_ELEMENT_JS = r"""
|
|
const kind = arguments[0];
|
|
const vis = el => {
|
|
if (!el) return false;
|
|
const st = getComputedStyle(el);
|
|
if (st.visibility === 'hidden' || st.display === 'none') return false;
|
|
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
|
};
|
|
const attrs = el => [el.id, el.name, el.getAttribute('autocomplete'), el.getAttribute('placeholder'),
|
|
el.getAttribute('aria-label'), el.getAttribute('data-id'), el.getAttribute('data-testid')]
|
|
.filter(Boolean).join(' ').toLowerCase();
|
|
const textInputs = Array.from(document.querySelectorAll('input')).filter(i =>
|
|
vis(i) && !['hidden', 'checkbox', 'radio', 'submit', 'button'].includes((i.type || '').toLowerCase()));
|
|
|
|
if (kind === 'password') {
|
|
return textInputs.find(i => i.type === 'password') || null;
|
|
}
|
|
if (kind === 'otp') {
|
|
return textInputs.find(i =>
|
|
i.getAttribute('autocomplete') === 'one-time-code' ||
|
|
(i.type !== 'password' && /otp|passcode|verification|one.?time|mfa|security.?code|\bcode\b/.test(attrs(i)))
|
|
) || null;
|
|
}
|
|
if (kind === 'username') {
|
|
return textInputs.find(i => i.type === 'email' || (i.type !== 'password' && /user|login|email/.test(attrs(i))))
|
|
|| textInputs.find(i => (i.type || 'text') === 'text')
|
|
|| null;
|
|
}
|
|
if (kind === 'submit') {
|
|
const btns = Array.from(document.querySelectorAll('button, input[type=submit], [role=button]')).filter(vis);
|
|
const label = b => ((b.innerText || b.value || b.getAttribute('aria-label') || '') + '').trim().toLowerCase();
|
|
const rx = /^(sign ?in|log ?in|next|continue|submit|verify|confirm)\b/;
|
|
return btns.find(b => rx.test(label(b)))
|
|
|| btns.find(b => (b.type || '').toLowerCase() === 'submit')
|
|
|| null;
|
|
}
|
|
return null;
|
|
"""
|
|
|
|
|
|
class AutomationMetLifeEligibilityCheck:
|
|
def __init__(self, data):
|
|
self.headless = False
|
|
self.driver = None
|
|
|
|
self.data = data.get("data", {}) if isinstance(data, dict) else {}
|
|
|
|
# Flatten values for convenience
|
|
self.memberId = self.data.get("memberId", "")
|
|
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
|
self.firstName = self.data.get("firstName", "")
|
|
self.lastName = self.data.get("lastName", "")
|
|
self.subscriberLastName = self.data.get("subscriberLastName", "") or self.lastName
|
|
self.zipCode = self.data.get("zipCode", "")
|
|
self.metlife_username = self.data.get("metlifeUsername", "")
|
|
self.metlife_password = self.data.get("metlifePassword", "")
|
|
|
|
# Use browser manager's download dir
|
|
self.download_dir = get_browser_manager().download_dir
|
|
os.makedirs(self.download_dir, exist_ok=True)
|
|
|
|
def config_driver(self):
|
|
# Use persistent browser from manager (keeps device trust tokens)
|
|
self.driver = get_browser_manager().get_driver(self.headless)
|
|
|
|
# ── Element helpers ───────────────────────────────────────────────────────
|
|
|
|
def _find(self, kind):
|
|
"""
|
|
Find a visible element of `kind` in the main document or any iframe.
|
|
On success the driver is left switched into the frame holding it.
|
|
"""
|
|
self.driver.switch_to.default_content()
|
|
try:
|
|
el = self.driver.execute_script(FIND_ELEMENT_JS, kind)
|
|
if el:
|
|
return el
|
|
except Exception:
|
|
pass
|
|
for frame in self.driver.find_elements(By.TAG_NAME, "iframe"):
|
|
try:
|
|
self.driver.switch_to.default_content()
|
|
self.driver.switch_to.frame(frame)
|
|
el = self.driver.execute_script(FIND_ELEMENT_JS, kind)
|
|
if el:
|
|
return el
|
|
except Exception:
|
|
continue
|
|
self.driver.switch_to.default_content()
|
|
return None
|
|
|
|
def _type_into(self, el, value):
|
|
try:
|
|
el.click()
|
|
except Exception:
|
|
pass
|
|
el.send_keys(Keys.CONTROL, "a")
|
|
el.send_keys(Keys.BACKSPACE)
|
|
el.send_keys(value)
|
|
|
|
def _click_submit(self, fallback_el=None):
|
|
btn = self._find("submit")
|
|
if btn:
|
|
try:
|
|
btn.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", btn)
|
|
return True
|
|
if fallback_el is not None:
|
|
fallback_el.send_keys(Keys.ENTER)
|
|
return True
|
|
return False
|
|
|
|
def _select_email_mfa_method(self):
|
|
"""
|
|
On the "Select the authentication method" screen (federate.sso.metlife.com),
|
|
click the email tile (<div class="tile-button__content">KQ****@GMAIL.COM</div>)
|
|
so the code gets sent. Returns True if a tile was clicked.
|
|
"""
|
|
js = r"""
|
|
const tiles = Array.from(document.querySelectorAll('.tile-button__content'))
|
|
.filter(el => el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
|
const email = tiles.find(el => (el.innerText || '').includes('@'));
|
|
if (!email) return null;
|
|
return email.closest('button, a, [role=button], .tile-button') || email;
|
|
"""
|
|
self.driver.switch_to.default_content()
|
|
frames = [None] + self.driver.find_elements(By.TAG_NAME, "iframe")
|
|
for frame in frames:
|
|
try:
|
|
self.driver.switch_to.default_content()
|
|
if frame is not None:
|
|
self.driver.switch_to.frame(frame)
|
|
tile = self.driver.execute_script(js)
|
|
if not tile:
|
|
continue
|
|
try:
|
|
tile.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", tile)
|
|
print("[MetLife login] Selected email authentication method")
|
|
self.driver.switch_to.default_content()
|
|
return True
|
|
except Exception:
|
|
continue
|
|
self.driver.switch_to.default_content()
|
|
return False
|
|
|
|
def _on_portal(self):
|
|
try:
|
|
return METLIFE_BASE_URL in self.driver.current_url and "/presignin" not in self.driver.current_url
|
|
except Exception:
|
|
return False
|
|
|
|
def _search_form_present(self, timeout=5):
|
|
"""True once the logged-in portal UI (search box or Eligibility link) has rendered."""
|
|
self.driver.switch_to.default_content()
|
|
try:
|
|
WebDriverWait(self.driver, timeout).until(
|
|
lambda d: d.find_elements(By.XPATH, SUBSCRIBER_INPUT_XPATH)
|
|
or d.find_elements(By.XPATH, BENEFIT_LINK_XPATH)
|
|
)
|
|
return True
|
|
except TimeoutException:
|
|
return False
|
|
|
|
def is_logged_in(self, timeout=5):
|
|
return self._on_portal() and self._search_form_present(timeout)
|
|
|
|
# ── Login ─────────────────────────────────────────────────────────────────
|
|
|
|
def _force_logout(self):
|
|
"""Force logout by clearing cookies when credentials change."""
|
|
try:
|
|
print("[MetLife login] Forcing logout due to credential change...")
|
|
browser_manager = get_browser_manager()
|
|
|
|
try:
|
|
self.driver.get(METLIFE_HOME_URL)
|
|
time.sleep(2)
|
|
logout_selectors = [
|
|
"//button[contains(translate(normalize-space(.),'LOGUT','logut'),'logout') or contains(translate(normalize-space(.),'LOGUT','logut'),'log out')]",
|
|
"//a[contains(translate(normalize-space(.),'LOGUT','logut'),'logout') or contains(translate(normalize-space(.),'LOGUT','logut'),'log out')]",
|
|
]
|
|
for selector in logout_selectors:
|
|
try:
|
|
logout_btn = WebDriverWait(self.driver, 3).until(
|
|
EC.element_to_be_clickable((By.XPATH, selector))
|
|
)
|
|
logout_btn.click()
|
|
print("[MetLife login] Clicked logout button")
|
|
time.sleep(2)
|
|
break
|
|
except TimeoutException:
|
|
continue
|
|
except Exception as e:
|
|
print(f"[MetLife login] Could not click logout button: {e}")
|
|
|
|
# Login spans the portal and the PingOne domain, so clear cookies for all domains
|
|
try:
|
|
self.driver.execute_cdp_cmd("Network.clearBrowserCookies", {})
|
|
print("[MetLife login] Cleared all cookies")
|
|
except Exception as e:
|
|
print(f"[MetLife login] Error clearing cookies: {e}")
|
|
|
|
browser_manager.clear_credentials_hash()
|
|
print("[MetLife login] Logout complete")
|
|
return True
|
|
except Exception as e:
|
|
print(f"[MetLife login] Error during forced logout: {e}")
|
|
return False
|
|
|
|
def _open_login_page(self):
|
|
"""From /presignin, click "Sign in" to reach the PingOne login page."""
|
|
if "/presignin" not in self.driver.current_url:
|
|
return
|
|
try:
|
|
btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.XPATH, PRESIGNIN_BUTTON_XPATH))
|
|
)
|
|
btn.click()
|
|
print("[MetLife login] Clicked 'Sign in' on presignin page")
|
|
except TimeoutException:
|
|
print("[MetLife login] 'Sign in' button not found on presignin page")
|
|
return
|
|
try:
|
|
WebDriverWait(self.driver, 20).until(lambda d: "/presignin" not in d.current_url)
|
|
except TimeoutException:
|
|
pass
|
|
time.sleep(2)
|
|
|
|
def login(self, url):
|
|
browser_manager = get_browser_manager()
|
|
|
|
try:
|
|
# Check if credentials changed — force logout first
|
|
if self.metlife_username and browser_manager.credentials_changed(self.metlife_username):
|
|
self._force_logout()
|
|
|
|
# Persistent session: home page shows the subscriber search form when logged in
|
|
self.driver.get(url)
|
|
time.sleep(3)
|
|
print(f"[MetLife login] URL after navigation: {self.driver.current_url}")
|
|
if self.is_logged_in(timeout=8):
|
|
print("[MetLife login] Session valid - ALREADY_LOGGED_IN")
|
|
return "ALREADY_LOGGED_IN"
|
|
|
|
self._open_login_page()
|
|
|
|
# The SSO page may bounce straight back to the portal if the IdP session is alive
|
|
if self.is_logged_in(timeout=5):
|
|
print("[MetLife login] IdP session valid - ALREADY_LOGGED_IN")
|
|
return "ALREADY_LOGGED_IN"
|
|
|
|
# Login may already be at the MFA step (e.g. a previous attempt was interrupted)
|
|
if self._select_email_mfa_method():
|
|
self._wait_for_otp_input()
|
|
print("[MetLife login] Email code requested -> OTP_REQUIRED")
|
|
return "OTP_REQUIRED"
|
|
if self._find("otp") and not self._find("password"):
|
|
self.driver.switch_to.default_content()
|
|
print("[MetLife login] OTP input already showing -> OTP_REQUIRED")
|
|
return "OTP_REQUIRED"
|
|
|
|
# Username (PingOne may ask for username and password on separate screens)
|
|
username_el = None
|
|
for _ in range(20):
|
|
username_el = self._find("username")
|
|
if username_el:
|
|
break
|
|
time.sleep(1)
|
|
if not username_el:
|
|
self.driver.switch_to.default_content()
|
|
print(f"[MetLife login] Login form not found. URL: {self.driver.current_url}")
|
|
return "ERROR: Login form not found"
|
|
|
|
self._type_into(username_el, self.metlife_username)
|
|
print("[MetLife login] Filled username")
|
|
|
|
password_el = self._find("password")
|
|
if not password_el:
|
|
# Two-step flow: submit username, then wait for password screen
|
|
username_el = self._find("username")
|
|
self._click_submit(username_el)
|
|
for _ in range(15):
|
|
time.sleep(1)
|
|
password_el = self._find("password")
|
|
if password_el:
|
|
break
|
|
if not password_el:
|
|
self.driver.switch_to.default_content()
|
|
return "ERROR:LOGIN FAILED: Password field not found"
|
|
|
|
self._type_into(password_el, self.metlife_password)
|
|
print("[MetLife login] Filled password")
|
|
self._click_submit(password_el)
|
|
self.driver.switch_to.default_content()
|
|
|
|
# Save credentials hash after login attempt
|
|
if self.metlife_username:
|
|
browser_manager.save_credentials_hash(self.metlife_username)
|
|
|
|
# Wait up to 30s for: portal search form (success), OTP / MFA screen, or an error
|
|
for _ in range(30):
|
|
time.sleep(1)
|
|
if self.is_logged_in(timeout=1):
|
|
print("[MetLife login] Login successful without OTP")
|
|
return "SUCCESS"
|
|
if self._on_portal():
|
|
continue # SPA still loading after redirect
|
|
# "Select the authentication method" screen — pick the email tile
|
|
if self._select_email_mfa_method():
|
|
self._wait_for_otp_input()
|
|
print("[MetLife login] Email code requested -> OTP_REQUIRED")
|
|
return "OTP_REQUIRED"
|
|
if self._find("otp"):
|
|
self.driver.switch_to.default_content()
|
|
print("[MetLife login] OTP input detected -> OTP_REQUIRED")
|
|
return "OTP_REQUIRED"
|
|
self.driver.switch_to.default_content()
|
|
try:
|
|
page_text = self.driver.execute_script("return document.body.innerText;").lower()
|
|
except Exception:
|
|
page_text = ""
|
|
if re.search(r"incorrect|invalid (user|password|credentials)|not recognized|locked", page_text):
|
|
snippet = re.search(r"[^\n]*(incorrect|invalid|not recognized|locked)[^\n]*", page_text)
|
|
msg = snippet.group(0).strip() if snippet else "invalid credentials"
|
|
print(f"[MetLife login] Login failed: {msg}")
|
|
return f"ERROR:LOGIN FAILED: {msg}"
|
|
# MFA method picker ("send code by email / text") — the user picks it in the
|
|
# browser window; treat it as the OTP step so the app shows the OTP modal.
|
|
if re.search(r"verification|verify your identity|one-time|passcode|send code|authenticat", page_text):
|
|
print("[MetLife login] MFA screen detected -> OTP_REQUIRED")
|
|
return "OTP_REQUIRED"
|
|
|
|
if self.is_logged_in(timeout=5):
|
|
return "SUCCESS"
|
|
if self._find("password"):
|
|
self.driver.switch_to.default_content()
|
|
return "ERROR:LOGIN FAILED: Still on login page"
|
|
self.driver.switch_to.default_content()
|
|
# Unknown intermediate screen — let the user finish it in the browser
|
|
print(f"[MetLife login] Unrecognized post-login screen ({self.driver.current_url}) -> OTP_REQUIRED")
|
|
return "OTP_REQUIRED"
|
|
|
|
except Exception as e:
|
|
try:
|
|
self.driver.switch_to.default_content()
|
|
except Exception:
|
|
pass
|
|
print("[MetLife login] Exception during login:", e)
|
|
return f"ERROR:LOGIN FAILED: {e}"
|
|
|
|
def _wait_for_otp_input(self, timeout=20):
|
|
"""After choosing the email method, wait for the code input to appear."""
|
|
for _ in range(timeout):
|
|
time.sleep(1)
|
|
if self._find("otp"):
|
|
self.driver.switch_to.default_content()
|
|
print("[MetLife login] OTP input is showing")
|
|
return True
|
|
self.driver.switch_to.default_content()
|
|
print("[MetLife login] OTP input did not appear yet — user can finish in the browser")
|
|
return False
|
|
|
|
def submit_otp_code(self, otp):
|
|
"""Type an OTP submitted from the app into the PingOne OTP input."""
|
|
otp_el = self._find("otp")
|
|
if not otp_el:
|
|
self.driver.switch_to.default_content()
|
|
return False
|
|
self._type_into(otp_el, otp)
|
|
self._click_submit(otp_el)
|
|
self.driver.switch_to.default_content()
|
|
return True
|
|
|
|
# ── Step 1: search ────────────────────────────────────────────────────────
|
|
|
|
def _wait_for_search_outcome(self, timeout=30):
|
|
"""Return 'tiles', 'extra_inputs', 'no_results' or 'timeout'."""
|
|
end = time.time() + timeout
|
|
while time.time() < end:
|
|
if self.driver.find_elements(By.CSS_SELECTOR, RESULT_TILE_CSS):
|
|
return "tiles"
|
|
if self.driver.find_elements(By.XPATH, EXTRA_LASTNAME_XPATH) or \
|
|
self.driver.find_elements(By.XPATH, EXTRA_ZIPCODE_XPATH):
|
|
return "extra_inputs"
|
|
try:
|
|
text = self.driver.execute_script("return document.body.innerText;")
|
|
except Exception:
|
|
text = ""
|
|
if "Your search for" in text or "making sure that the information you've entered is correct" in text:
|
|
return "no_results"
|
|
time.sleep(0.5)
|
|
return "timeout"
|
|
|
|
def _fill_input(self, el, value):
|
|
"""
|
|
Type into a portal input that Selenium may consider not interactable
|
|
(masked SSN box under a floating label). Focus it via JS, type with key
|
|
actions, and fall back to React's native value setter. Returns True if
|
|
the input ends up holding `value`.
|
|
"""
|
|
from selenium.webdriver.common.action_chains import ActionChains
|
|
|
|
try:
|
|
self.driver.execute_script(
|
|
"arguments[0].scrollIntoView({block:'center'}); arguments[0].focus();", el
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# 1) Normal typing
|
|
try:
|
|
self._type_into(el, value)
|
|
except Exception as e:
|
|
print(f"[MetLife] direct typing failed ({type(e).__name__}); using key actions")
|
|
# 2) Click the label / focus, then type into whatever has focus
|
|
try:
|
|
el_id = el.get_attribute("id")
|
|
labels = self.driver.find_elements(By.XPATH, f"//label[@for='{el_id}']") if el_id else []
|
|
if labels:
|
|
self.driver.execute_script("arguments[0].click();", labels[0])
|
|
self.driver.execute_script("arguments[0].focus();", el)
|
|
ActionChains(self.driver).key_down(Keys.CONTROL).send_keys("a").key_up(Keys.CONTROL) \
|
|
.send_keys(Keys.BACKSPACE).send_keys(value).perform()
|
|
except Exception as e2:
|
|
print(f"[MetLife] key actions failed: {e2}")
|
|
|
|
current = self.driver.execute_script("return arguments[0].value;", el) or ""
|
|
if self._value_matches(current, value):
|
|
return True
|
|
if current:
|
|
# Something was typed but reads back differently (masking variants) —
|
|
# don't overwrite it; the search result will tell if it was wrong.
|
|
print(f"[MetLife] input reads back as '{current}' (masked?) — keeping it")
|
|
return True
|
|
|
|
# 3) Nothing landed — React-controlled input: set via the native setter and fire input/change
|
|
print("[MetLife] input is empty after typing, setting it via native setter")
|
|
self.driver.execute_script(
|
|
"""
|
|
const el = arguments[0], v = arguments[1];
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
setter.call(el, v);
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
""",
|
|
el, value,
|
|
)
|
|
current = self.driver.execute_script("return arguments[0].value;", el) or ""
|
|
return self._value_matches(current, value)
|
|
|
|
@staticmethod
|
|
def _value_matches(current, value):
|
|
"""
|
|
The Subscriber ID / SSN box masks what was typed (dots until the eye
|
|
button is clicked), so a same-length run of mask characters counts as a match.
|
|
"""
|
|
if current == value:
|
|
return True
|
|
return len(current) == len(value) and all(c in "•●*·∙◦xX" for c in current)
|
|
|
|
def _click_search(self):
|
|
btn = WebDriverWait(self.driver, 15).until(
|
|
EC.presence_of_element_located((By.XPATH, SEARCH_SUBMIT_XPATH))
|
|
)
|
|
try:
|
|
btn.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", btn)
|
|
print("[MetLife step1] Clicked Search button")
|
|
|
|
def _open_eligibility_page(self):
|
|
"""From the home page, click "Eligibility & Plan Details" to reach /eligibility."""
|
|
if "/eligibility" in self.driver.current_url:
|
|
return
|
|
if "/home" not in self.driver.current_url:
|
|
self.driver.get(METLIFE_HOME_URL)
|
|
try:
|
|
WebDriverWait(self.driver, 20).until(
|
|
EC.presence_of_element_located((By.XPATH, BENEFIT_LINK_XPATH))
|
|
)
|
|
links = [l for l in self.driver.find_elements(By.XPATH, BENEFIT_LINK_XPATH) if l.is_displayed()]
|
|
link = links[0] if links else self.driver.find_element(By.XPATH, BENEFIT_LINK_XPATH)
|
|
try:
|
|
link.click()
|
|
except Exception:
|
|
# href="javascript:;" — the React onClick does the navigation
|
|
self.driver.execute_script("arguments[0].click();", link)
|
|
print("[MetLife step1] Clicked 'Eligibility & Plan Details'")
|
|
WebDriverWait(self.driver, 15).until(lambda d: "/eligibility" in d.current_url)
|
|
print(f"[MetLife step1] On eligibility page: {self.driver.current_url}")
|
|
time.sleep(1.5)
|
|
except TimeoutException:
|
|
# Fall back to the search box on the home page
|
|
print(f"[MetLife step1] Could not open eligibility page; searching from {self.driver.current_url}")
|
|
|
|
def step1(self):
|
|
"""Open Eligibility & Plan Details and search by Subscriber ID / SSN."""
|
|
wait = WebDriverWait(self.driver, 30)
|
|
|
|
try:
|
|
# Portal accepts letters and digits only (SSNs are entered without dashes)
|
|
search_id = re.sub(r"[^A-Za-z0-9]", "", self.memberId or "")
|
|
if not search_id:
|
|
return "ERROR: Member ID (Subscriber ID / SSN) is required for MetLife"
|
|
print(f"[MetLife step1] Searching SubscriberID: {search_id}")
|
|
|
|
self._open_eligibility_page()
|
|
# Wait for presence only: the masked input sits under its floating label, so
|
|
# Selenium may never consider it "clickable" even though it's usable.
|
|
try:
|
|
subscriber_input = WebDriverWait(self.driver, 10).until(
|
|
EC.presence_of_element_located((By.XPATH, SUBSCRIBER_INPUT_XPATH))
|
|
)
|
|
except TimeoutException:
|
|
print("[MetLife step1] No search box on eligibility page — using the home page search")
|
|
self.driver.get(METLIFE_HOME_URL)
|
|
try:
|
|
subscriber_input = wait.until(EC.presence_of_element_located((By.XPATH, SUBSCRIBER_INPUT_XPATH)))
|
|
except TimeoutException:
|
|
inputs = self.driver.execute_script(
|
|
"return Array.from(document.querySelectorAll('input')).map(i => "
|
|
"[i.id, i.name, i.type, i.offsetWidth > 0, i.getAttribute('aria-label')].join(' | '));"
|
|
)
|
|
print(f"[MetLife step1] Search box not found. Inputs on page: {inputs}")
|
|
return "ERROR: MetLife search box not found"
|
|
time.sleep(0.5)
|
|
if not self._fill_input(subscriber_input, search_id):
|
|
return "ERROR: Could not type the Subscriber ID into the MetLife search box"
|
|
time.sleep(0.3)
|
|
|
|
self._click_search()
|
|
|
|
outcome = self._wait_for_search_outcome()
|
|
|
|
# Ambiguous ID — portal asks for the subscriber's last name and/or zip code
|
|
if outcome == "extra_inputs":
|
|
print("[MetLife step1] Portal requested extra subscriber info")
|
|
filled_any = False
|
|
lastname_inputs = self.driver.find_elements(By.XPATH, EXTRA_LASTNAME_XPATH)
|
|
if lastname_inputs and self.subscriberLastName:
|
|
filled_any = self._fill_input(lastname_inputs[0], self.subscriberLastName) or filled_any
|
|
zip_inputs = self.driver.find_elements(By.XPATH, EXTRA_ZIPCODE_XPATH)
|
|
if zip_inputs and self.zipCode:
|
|
filled_any = self._fill_input(zip_inputs[0], self.zipCode) or filled_any
|
|
if not filled_any:
|
|
return "ERROR: MetLife requires the subscriber's last name or zip code for this ID"
|
|
self._click_search()
|
|
outcome = self._wait_for_search_outcome()
|
|
|
|
if outcome == "tiles":
|
|
time.sleep(1.5) # let all family tiles render
|
|
print("[MetLife step1] Search completed successfully")
|
|
return "Success"
|
|
if outcome == "no_results":
|
|
return "ERROR: INVALID SEARCH CRITERIA"
|
|
if outcome == "extra_inputs":
|
|
return "ERROR: MetLife could not match the subscriber with the provided last name / zip code"
|
|
print(f"[MetLife step1] No results within timeout. URL: {self.driver.current_url}")
|
|
return "ERROR: INVALID SEARCH CRITERIA"
|
|
|
|
except Exception as e:
|
|
print(f"[MetLife step1] Exception: {e}")
|
|
return f"ERROR:STEP1 - {e}"
|
|
|
|
# ── Step 2: pick patient, open plan details, PDF ──────────────────────────
|
|
|
|
@staticmethod
|
|
def _parse_tile(text):
|
|
"""Extract name, DOB, status and coverage dates from a tile's innerText."""
|
|
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
|
info = {"name": lines[0] if lines else "", "dob": "", "status": "", "start": "", "end": ""}
|
|
m = re.search(r"DOB:\s*(\d{2}/\d{2}/\d{4})", text)
|
|
if m:
|
|
info["dob"] = m.group(1)
|
|
m = re.search(r"Status:\s*([^|\n]+)", text)
|
|
if m:
|
|
info["status"] = m.group(1).strip()
|
|
m = re.search(r"Start Date:\s*([^|\n]+)", text)
|
|
if m:
|
|
info["start"] = m.group(1).strip()
|
|
m = re.search(r"End Date:\s*([^|\n]+)", text)
|
|
if m:
|
|
info["end"] = m.group(1).strip()
|
|
return info
|
|
|
|
@staticmethod
|
|
def _eligibility_from(status, end_date):
|
|
s = (status or "").lower()
|
|
if any(w in s for w in ("inactive", "termin", "termed", "cancel", "expired", "not eligible")):
|
|
return "inactive"
|
|
try:
|
|
if end_date and datetime.strptime(end_date, "%m/%d/%Y").date() < datetime.now().date():
|
|
return "inactive"
|
|
except ValueError:
|
|
pass
|
|
if "active" in s or "eligible" in s:
|
|
return "active"
|
|
return s or "unknown"
|
|
|
|
STATUS_WORDS = ("active", "inactive", "cancelled", "canceled", "terminated", "termed", "expired")
|
|
|
|
def _read_detail_page(self):
|
|
"""
|
|
Read the plan details page: patient card ("Subscriber | 68 | DOB: 03/19/1958 | Female"),
|
|
status badge (e.g. "Cancelled"), End Date, and the
|
|
"coverage is no longer in effect" notice.
|
|
"""
|
|
out = {"dob": "", "status": "", "end": "", "no_longer_in_effect": False}
|
|
try:
|
|
text = self.driver.execute_script("return document.body.innerText;") or ""
|
|
except Exception:
|
|
return out
|
|
m = re.search(r"DOB:\s*(\d{2}/\d{2}/\d{4})", text)
|
|
if m:
|
|
out["dob"] = m.group(1)
|
|
# The badge is its own line containing only the status word
|
|
for line in text.split("\n"):
|
|
if line.strip().lower() in self.STATUS_WORDS:
|
|
out["status"] = line.strip()
|
|
break
|
|
m = re.search(r"End Date\s*:?\s*\n?\s*(\d{2}/\d{2}/\d{4})", text)
|
|
if m:
|
|
out["end"] = m.group(1)
|
|
out["no_longer_in_effect"] = "no longer in effect" in text.lower()
|
|
return out
|
|
|
|
def step2(self):
|
|
try:
|
|
eligibilityText = "unknown"
|
|
foundMemberId = self.memberId or ""
|
|
patientName = f"{self.firstName} {self.lastName}".strip() if self.firstName and self.lastName else ""
|
|
known_patient_name = bool(patientName)
|
|
|
|
tiles = self.driver.find_elements(By.CSS_SELECTOR, RESULT_TILE_CSS)
|
|
print(f"[MetLife step2] Found {len(tiles)} covered person tile(s)")
|
|
parsed = [(t, self._parse_tile(t.text)) for t in tiles]
|
|
|
|
target = None
|
|
if self.dateOfBirth:
|
|
try:
|
|
y, mo, d = self.dateOfBirth.split("-")[:3]
|
|
expected_dob = f"{mo.zfill(2)}/{d.zfill(2)}/{y}"
|
|
matches = [p for p in parsed if p[1]["dob"] == expected_dob]
|
|
# Same DOB (e.g. twins) — narrow by first name
|
|
if len(matches) > 1 and self.firstName:
|
|
by_name = [p for p in matches if self.firstName.lower() in p[1]["name"].lower()]
|
|
matches = by_name or matches
|
|
if matches:
|
|
target = matches[0]
|
|
except Exception as e:
|
|
print(f"[MetLife step2] DOB matching error (non-fatal): {e}")
|
|
if target is None and self.firstName:
|
|
target = next((p for p in parsed if self.firstName.lower() in p[1]["name"].lower()), None)
|
|
if target is None and parsed:
|
|
target = parsed[0]
|
|
print("[MetLife step2] No DOB/name match — defaulting to first tile")
|
|
if target is None:
|
|
return {"status": "error", "message": "No covered persons found for this subscriber"}
|
|
|
|
tile, info = target
|
|
print(f"[MetLife step2] Selected tile: {info}")
|
|
if not known_patient_name and info["name"]:
|
|
patientName = re.sub(r"\s+", " ", info["name"]).strip()
|
|
eligibilityText = self._eligibility_from(info["status"], info["end"])
|
|
print(f"[MetLife step2] Eligibility: {eligibilityText} (status='{info['status']}', end='{info['end']}')")
|
|
|
|
# Selecting a tile navigates to /eligibility/plandetails
|
|
try:
|
|
tile.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", tile)
|
|
try:
|
|
WebDriverWait(self.driver, 20).until(lambda d: "/eligibility/plandetails" in d.current_url)
|
|
print(f"[MetLife step2] On plan details page: {self.driver.current_url}")
|
|
except TimeoutException:
|
|
print(f"[MetLife step2] Warning: plan details page did not open; printing {self.driver.current_url}")
|
|
|
|
try:
|
|
WebDriverWait(self.driver, 30).until(
|
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
|
)
|
|
except Exception:
|
|
pass
|
|
# Plan details sections load asynchronously after navigation
|
|
time.sleep(5)
|
|
|
|
# Double-check the detail page: DOB must match the input, and its status
|
|
# badge / coverage notice is the final word on eligibility.
|
|
details = self._read_detail_page()
|
|
print(f"[MetLife step2] Detail page: {details}")
|
|
if self.dateOfBirth and details["dob"]:
|
|
y, mo, d = self.dateOfBirth.split("-")[:3]
|
|
expected_dob = f"{mo.zfill(2)}/{d.zfill(2)}/{y}"
|
|
if details["dob"] != expected_dob:
|
|
return {
|
|
"status": "error",
|
|
"message": f"DOB mismatch on MetLife detail page: portal shows {details['dob']}, expected {expected_dob}",
|
|
}
|
|
print(f"[MetLife step2] DOB verified: {expected_dob}")
|
|
if details["no_longer_in_effect"]:
|
|
eligibilityText = "inactive"
|
|
elif details["status"]:
|
|
eligibilityText = self._eligibility_from(details["status"], details["end"] or info["end"])
|
|
print(f"[MetLife step2] Final eligibility: {eligibilityText}")
|
|
|
|
print("[MetLife step2] Generating PDF of plan details page...")
|
|
pdf_options = {
|
|
"landscape": False,
|
|
"displayHeaderFooter": False,
|
|
"printBackground": True,
|
|
"preferCSSPageSize": True,
|
|
"paperWidth": 8.5,
|
|
"paperHeight": 11,
|
|
"marginTop": 0.4,
|
|
"marginBottom": 0.4,
|
|
"marginLeft": 0.4,
|
|
"marginRight": 0.4,
|
|
"scale": 0.9,
|
|
}
|
|
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
|
|
pdf_data = base64.b64decode(result.get("data", ""))
|
|
pdf_id = re.sub(r"[^A-Za-z0-9]", "", foundMemberId) or "unknown"
|
|
pdf_path = os.path.join(self.download_dir, f"eligibility_metlife_{pdf_id}.pdf")
|
|
with open(pdf_path, "wb") as f:
|
|
f.write(pdf_data)
|
|
print(f"[MetLife step2] PDF saved at: {pdf_path}")
|
|
print(f"[MetLife step2] Final — PatientName: '{patientName}', MemberID: '{foundMemberId}'")
|
|
|
|
return {
|
|
"status": "success",
|
|
"eligibility": eligibilityText,
|
|
"ss_path": pdf_path, # kept for parity with other workers
|
|
"pdf_path": pdf_path,
|
|
"patientName": patientName,
|
|
"memberId": foundMemberId,
|
|
"dateOfBirth": self.dateOfBirth,
|
|
"coverageStart": info["start"],
|
|
"coverageEnd": info["end"],
|
|
}
|
|
|
|
except Exception as e:
|
|
print("ERROR in MetLife step2:", e)
|
|
# Cleanup download dir on error
|
|
try:
|
|
dl = os.path.abspath(self.download_dir)
|
|
if os.path.isdir(dl):
|
|
for name in os.listdir(dl):
|
|
item = os.path.join(dl, name)
|
|
try:
|
|
if os.path.isfile(item) or os.path.islink(item):
|
|
os.remove(item)
|
|
except Exception as rm_err:
|
|
print(f"[cleanup] failed to remove {item}: {rm_err}")
|
|
except Exception as cleanup_exc:
|
|
print(f"[cleanup] unexpected error: {cleanup_exc}")
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
def main_workflow(self, url):
|
|
try:
|
|
self.config_driver()
|
|
self.driver.maximize_window()
|
|
time.sleep(3)
|
|
|
|
login_result = self.login(url)
|
|
if login_result.startswith("ERROR"):
|
|
return {"status": "error", "message": login_result}
|
|
if login_result == "OTP_REQUIRED":
|
|
return {"status": "otp_required", "message": "OTP required after login"}
|
|
|
|
step1_result = self.step1()
|
|
if step1_result.startswith("ERROR"):
|
|
return {"status": "error", "message": step1_result}
|
|
|
|
step2_result = self.step2()
|
|
if step2_result.get("status") == "error":
|
|
return {"status": "error", "message": step2_result.get("message")}
|
|
|
|
return step2_result
|
|
except WebDriverException as e:
|
|
return {"status": "error", "message": str(e)}
|
|
except Exception as e:
|
|
return {"status": "error", "message": str(e)}
|
|
# NOTE: Do NOT quit driver — keep browser alive for next patient
|