FactorsAiAutomation/scraper.py
2026-07-01 18:00:54 +05:30

834 lines
35 KiB
Python

"""
scraper.py — Selenium automation to log in to Factors.AI and download the
Account Profiles CSV.
"""
import glob
import logging
import os
import random
import time
from pathlib import Path
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException, TimeoutException, StaleElementReferenceException
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from webdriver_manager.chrome import ChromeDriverManager
from selenium.common.exceptions import TimeoutException
logger = logging.getLogger(__name__)
_PAGE_LOAD_TIMEOUT = 30 # seconds to wait for page transitions
_ELEMENT_TIMEOUT = 20 # seconds to wait for elements
_DOWNLOAD_TIMEOUT = 90 # seconds to wait for file download
class FactorsAIScraper:
"""Downloads the Account Profiles CSV export from app.factors.ai."""
def __init__(self, download_dir: str, headless: bool = True):
self.download_dir = str(Path(download_dir).resolve())
self.headless = headless
self.driver: webdriver.Chrome | None = None
# ── Driver setup ──────────────────────────────────────────────────────────
def _setup_driver(self) -> None:
options = Options()
if self.headless:
options.add_argument("--headless=new")
# Use a persistent Chrome profile to keep cookies and session active
profile_dir = os.path.join(self.download_dir, "chrome_profile")
os.makedirs(profile_dir, exist_ok=True)
# Clean up stale chrome locks
lock_file = os.path.join(profile_dir, "SingletonLock")
if os.path.islink(lock_file) or os.path.exists(lock_file):
try:
os.remove(lock_file)
logger.info("Cleaned up stale SingletonLock from Chrome profile.")
except Exception as e:
logger.warning(f"Could not remove SingletonLock: {e}")
options.add_argument(f"--user-data-dir={profile_dir}")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080")
options.add_argument("--disable-extensions")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
prefs = {
"download.default_directory": self.download_dir,
"download.prompt_for_download": False,
"download.directory_upgrade": True,
"safebrowsing.enabled": False,
"safebrowsing.disable_download_protection": True,
}
options.add_experimental_option("prefs", prefs)
service = Service(ChromeDriverManager().install())
self.driver = webdriver.Chrome(service=service, options=options)
self.driver.set_page_load_timeout(_PAGE_LOAD_TIMEOUT)
# Enable downloads explicitly when headless (CDP call)
if self.headless:
try:
self.driver.execute_cdp_cmd(
"Page.setDownloadBehavior",
{"behavior": "allow", "downloadPath": self.download_dir},
)
except Exception:
pass # Older ChromeDriver builds may not support this
# ── Helpers ───────────────────────────────────────────────────────────────
def _wait(
self,
by: str,
value: str,
timeout: int = _ELEMENT_TIMEOUT,
condition: str = "presence",
):
ec_map = {
"presence": EC.presence_of_element_located,
"clickable": EC.element_to_be_clickable,
"visible": EC.visibility_of_element_located,
}
return WebDriverWait(self.driver, timeout).until(
ec_map[condition]((by, value))
)
def _try_selectors(self, selectors: list[tuple], timeout: int = 8):
"""Return the first element found from a list of (By.*, selector) pairs, waiting at most timeout seconds total."""
deadline = time.time() + timeout
while True:
for by, sel in selectors:
try:
elements = self.driver.find_elements(by, sel)
for el in elements:
if el.is_displayed() and el.is_enabled():
return el
except Exception:
pass
if time.time() > deadline:
break
time.sleep(0.2)
return None
def _click(self, element) -> None:
"""Click an element, falling back to JS click if intercepted."""
try:
element.click()
except Exception as e:
logger.warning(f"Standard click failed ({e}), attempting JS click...")
self.driver.execute_script("arguments[0].click();", element)
def _type_humanlike(self, element, text: str) -> None:
"""Type text into an element with a short random delay between keystrokes."""
element.click()
from selenium.webdriver.common.keys import Keys
try:
element.send_keys(Keys.CONTROL + "a")
element.send_keys(Keys.BACKSPACE)
except Exception:
element.clear()
for char in text:
element.send_keys(char)
time.sleep(random.uniform(0.04, 0.12))
try:
self.driver.execute_script("arguments[0].dispatchEvent(new Event('input', { bubbles: true }));", element)
self.driver.execute_script("arguments[0].dispatchEvent(new Event('change', { bubbles: true }));", element)
except Exception:
pass
# ── Login ─────────────────────────────────────────────────────────────────
def login(self, email: str, password: str) -> None:
logger.info("Opening https://app.factors.ai/ …")
self.driver.get("https://app.factors.ai/")
time.sleep(3)
# Check if we are already logged in
current_url = self.driver.current_url.lower()
if all(kw not in current_url for kw in ("login", "signin", "sign-in")):
logger.info("Already logged in (session restored from profile).")
return
max_attempts = 3
for attempt in range(1, max_attempts + 1):
logger.info(f"Login attempt {attempt}/{max_attempts}")
# Find input fields
email_input = self._try_selectors([
(By.CSS_SELECTOR, "input[name='email']"),
(By.CSS_SELECTOR, "input[type='email']"),
(By.XPATH, "//input[@placeholder[contains(translate(.,'EMAIL','email'),'email')]]"),
(By.XPATH, "//input[@autocomplete='username']"),
(By.XPATH, "//input[@autocomplete='email']"),
], timeout=5)
if email_input is None:
self._save_screenshot("login_page_not_found.png")
raise RuntimeError("Could not find email input on the login page.")
self._type_humanlike(email_input, email)
logger.info("Email entered.")
pwd_input = self._wait(
By.CSS_SELECTOR, "input[type='password']", timeout=10
)
self._type_humanlike(pwd_input, password)
logger.info("Password entered.")
submit = self._try_selectors([
(By.CSS_SELECTOR, "button[type='submit']"),
(By.XPATH, "//button[contains(translate(.,'LOGIN','login'),'login') or contains(translate(.,'SIGN IN','sign in'),'sign in')]"),
(By.CSS_SELECTOR, "button.ant-btn-primary"),
], timeout=10)
if submit is None:
raise RuntimeError("Could not find login submit button.")
self._click(submit)
logger.info("Login form submitted.")
time.sleep(5)
# Check if login failed (e.g. error message displayed)
try:
err = WebDriverWait(self.driver, 5).until(
EC.visibility_of_element_located((
By.XPATH,
"//*[self::div or self::span or self::p or contains(@class,'error') or contains(@class,'message')][contains(text(), 'Login failed') or contains(text(), 'invalid') or contains(text(), 'incorrect')]"
))
)
logger.warning(f"Login failed message detected: {err.text}")
if attempt < max_attempts:
sleep_time = 45 * attempt
logger.info(f"Waiting {sleep_time} seconds before retrying login...")
time.sleep(sleep_time)
# Refresh the page to start clean
self.driver.get("https://app.factors.ai/")
time.sleep(3)
continue
else:
raise RuntimeError(f"Login rejected by Factors.AI after {max_attempts} attempts: {err.text}")
except TimeoutException:
# No failure message found, verify if we are logged in
current_url = self.driver.current_url.lower()
if all(kw not in current_url for kw in ("login", "signin", "sign-in")):
logger.info(f"Logged in successfully. URL: {self.driver.current_url}")
return
else:
# No error message, but still on login page. Wait and check again.
time.sleep(3)
if all(kw not in self.driver.current_url.lower() for kw in ("login", "signin", "sign-in")):
logger.info(f"Logged in successfully. URL: {self.driver.current_url}")
return
raise RuntimeError("Still on login page after submission.")
# ── Navigate to Account Profiles ──────────────────────────────────────────
def _navigate_to_accounts(self) -> None:
"""Find and open the Account Profiles page."""
logger.info("Navigating to Account Profiles page …")
# Derive base URL (e.g. https://app.factors.ai) from current URL
parts = self.driver.current_url.split("/")
base_url = "/".join(parts[:3]) # ['https:', '', 'app.factors.ai']
# 1. If we are already on the base URL, just wait for the download button to be present
from urllib.parse import urlparse
parsed = urlparse(self.driver.current_url)
path = parsed.path.rstrip("/")
if path in ("", "/", "/v2/accounts"):
logger.info("Already on the home/accounts URL. Waiting for the Accounts page to load...")
self._dismiss_upgrade_modal()
if self._download_button_present(timeout=25):
logger.info("Accounts page loaded successfully on the home page. Waiting 5s for data loading/hydration...")
time.sleep(5)
return
# 2. Check if the download button is already present (e.g. from a previous navigation)
self._dismiss_upgrade_modal()
if self._download_button_present(timeout=3):
logger.info("Already on the Account Profiles page. Waiting 5s for data loading/hydration...")
time.sleep(5)
return
# 3. Try clicking a sidebar / nav link first (fastest)
nav_candidates = [
(By.ID, "fa-at-link--accounts"),
(By.XPATH, "//a[contains(translate(@href,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'account')]"),
(By.XPATH, "//span[contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'account')]/ancestor::a"),
(By.XPATH, "//li[contains(translate(.,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'account')]//a"),
]
link = self._try_selectors(nav_candidates, timeout=5)
if link:
try:
self._click(link)
time.sleep(1)
if self._download_button_present(timeout=5):
logger.info(f"Accounts page found via nav. URL: {self.driver.current_url}. Waiting 5s for data loading/hydration...")
time.sleep(5)
return
except Exception:
pass
# 4. Try known URL patterns
for slug in [
"/v2/accounts",
"/accounts",
"/",
]:
url = base_url + slug
logger.info(f" Trying {url}")
try:
self.driver.get(url)
if self._download_button_present(timeout=3):
logger.info(f"Accounts page found at: {url}. Waiting 5s for data loading/hydration...")
time.sleep(5)
return
except Exception:
pass
# Last resort: dump screenshot and raise
self._save_screenshot("accounts_not_found.png")
raise RuntimeError(
"Could not navigate to the Account Profiles page. "
"Screenshot saved → tmp_download/accounts_not_found.png"
)
def _download_button_present(self, timeout: int = 6) -> bool:
"""Return True if the toolbar download button is visible on the page."""
combined_selector = (
"button#fa-at-btn--export-csv, "
"button[id*='export-csv'], "
"[class*='profileToolbarIconBtn'], "
"button[title*='Download'], "
"button[title*='Export']"
)
try:
WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((By.CSS_SELECTOR, combined_selector))
)
return True
except TimeoutException:
# Fallback to xpath check
try:
WebDriverWait(self.driver, 1).until(
EC.presence_of_element_located((
By.XPATH,
"//button[.//svg[@data-icon='download'] or contains(@class,'profileToolbarIconBtn')]"
))
)
return True
except TimeoutException:
return False
def _click_download_button(self) -> None:
logger.info("Clicking the download toolbar button …")
for attempt in range(3):
self._dismiss_upgrade_modal()
# Find all profile toolbar buttons
candidates = self.driver.find_elements(By.CSS_SELECTOR, "[class*='profileToolbarIconBtn']")
btn = None
for c in candidates:
try:
html = c.get_attribute("outerHTML") or ""
if "download" in html or "fa-download" in html:
if c.is_displayed() and c.is_enabled():
btn = c
break
except Exception:
pass
if btn is None:
logger.warning(f"Download button not found on attempt {attempt + 1}")
time.sleep(2)
continue
try:
self.driver.execute_script("arguments[0].scrollIntoView(true);", btn)
time.sleep(0.5)
self.driver.execute_script("arguments[0].click();", btn)
logger.info(f"Download button clicked via JS (attempt {attempt + 1}).")
except Exception as e:
logger.warning(f"Failed to click button on attempt {attempt + 1}: {e}")
time.sleep(2)
continue
time.sleep(2.0)
modals = self.driver.find_elements(By.CSS_SELECTOR, ".ant-modal-content")
if any(m.is_displayed() for m in modals):
logger.info("Modal/dropdown detected after click.")
return
logger.warning("Modal/dropdown did not open. Retrying click...")
self._save_screenshot("download_btn_click_failed.png")
raise RuntimeError("Failed to open export modal after multiple clicks.")
# ── Handle the export modal ───────────────────────────────────────────────
def _handle_modal(self, fields_to_select: list[str] | None) -> None:
logger.info("Waiting for CSV export modal …")
self._dismiss_upgrade_modal()
# Find the visible modal that is actually the export modal
time.sleep(2) # Give time for the modal to open
modals = self.driver.find_elements(By.CSS_SELECTOR, ".ant-modal-content")
modal = None
for m in modals:
if m.is_displayed():
text = m.text.lower()
if any(kw in text for kw in ("export", "field", "select", "column", "include")):
modal = m
break
if modal is None:
# Fallback to waiting for the first visible modal
modal = WebDriverWait(self.driver, _ELEMENT_TIMEOUT).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, ".ant-modal-content"))
)
time.sleep(1.0) # let the virtual list fully render
# --- Optionally override field selection ---
if fields_to_select is not None:
# 1. Clear all
try:
clear_btn = modal.find_element(
By.XPATH,
".//button[.//*[contains(text(),'Clear All')]]",
)
self._click(clear_btn)
time.sleep(0.6)
logger.info("Cleared all field selections.")
except NoSuchElementException:
logger.warning("'Clear All' button not found — skipping.")
# 2. Scroll through the virtual list and check each desired field
for field_val in fields_to_select:
self._select_field_in_modal(modal, field_val)
# --- Click Export CSV ---
export_btn = None
# 1. Look for any button with "export" or "csv" in text anywhere inside the modal
try:
buttons = modal.find_elements(By.TAG_NAME, "button")
for btn in buttons:
text = (btn.text or "").lower()
if "export" in text or "csv" in text:
export_btn = btn
break
except Exception:
pass
# 2. Fallback to primary button inside the modal
if export_btn is None:
try:
export_btn = modal.find_element(By.CSS_SELECTOR, ".ant-btn-primary")
except NoSuchElementException:
pass
if export_btn is None:
self._save_screenshot("export_btn_not_found.png")
raise RuntimeError("Could not find the 'Export CSV' button in modal.")
self._click(export_btn)
logger.info("'Export CSV' button clicked.")
time.sleep(1.5)
self._dismiss_upgrade_modal()
# def _select_field_in_modal(self, modal, field_val: str) -> None:
# """Scroll inside the modal virtual list until the checkbox is found, then check it."""
# scrollable = modal.find_element(By.CSS_SELECTOR, ".rc-virtual-list-holder")
# max_scrolls = 20
# for _ in range(max_scrolls):
# try:
# # Find the label or span containing the exact field name
# label_el = modal.find_element(
# By.XPATH,
# f".//span[text()='{field_val}'] | .//label[contains(.,'{field_val}')] | .//*[text()='{field_val}']"
# )
# # Scroll it into view inside the virtual list container
# self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", label_el)
# time.sleep(0.1)
# # Locate the parent/wrapper label and the checkbox input
# parent_label = label_el.find_element(By.XPATH, "./ancestor-or-self::label[contains(@class,'ant-checkbox-wrapper')]")
# checkbox = parent_label.find_element(By.CSS_SELECTOR, "input[type='checkbox']")
# if not checkbox.is_selected():
# self.driver.execute_script("arguments[0].click();", checkbox)
# time.sleep(0.2)
# logger.info(f"Selected field: {field_val}")
# return
# except (NoSuchElementException, StaleElementReferenceException):
# # Scroll down inside the modal list to reveal more items
# self.driver.execute_script(
# "arguments[0].scrollTop += 100;", scrollable
# )
# time.sleep(1.5)
# logger.warning(f"Field not found in modal: {field_val}")
def _select_field_in_modal(self, modal, field_val: str) -> None:
"""
Select a field inside the Export CSV modal.
Uses the modal's built-in search box instead of scrolling the
virtual list. This is much more reliable with Ant Design's
rc-virtual-list because only matching rows are rendered.
"""
wait = WebDriverWait(self.driver, 15)
try:
# Locate search input
search_box = wait.until(
lambda d: modal.find_element(
By.CSS_SELECTOR,
"input[placeholder='Search properties']"
)
)
# Clear any previous search
search_box.click()
search_box.send_keys(Keys.CONTROL, "a")
search_box.send_keys(Keys.DELETE)
# Type desired field
search_box.send_keys(field_val)
# Wait until matching checkbox appears
checkbox = wait.until(
lambda d: modal.find_element(
By.XPATH,
f"""
.//label[
.//*[normalize-space(text())="{field_val}"]
]//input[@type='checkbox']
"""
)
)
# Tick if needed
if not checkbox.is_selected():
self.driver.execute_script(
"arguments[0].click();",
checkbox
)
wait.until(lambda d: checkbox.is_selected())
logger.info(f"Selected field: {field_val}")
except TimeoutException:
logger.warning(f"Field not found in modal: {field_val}")
finally:
# Clear search so next iteration starts fresh
try:
search_box = modal.find_element(
By.CSS_SELECTOR,
"input[placeholder='Search properties']"
)
search_box.send_keys(Keys.CONTROL, "a")
search_box.send_keys(Keys.DELETE)
wait.until(
lambda d: search_box.get_attribute("value") == ""
)
except Exception:
pass
# ── Wait for the CSV download to finish ───────────────────────────────────
def _wait_for_download(self, timeout: int = _DOWNLOAD_TIMEOUT) -> str:
logger.info(f"Waiting up to {timeout}s for CSV download …")
deadline = time.time() + timeout
while time.time() < deadline:
self._dismiss_upgrade_modal()
all_csvs = glob.glob(os.path.join(self.download_dir, "*.csv"))
in_flight = glob.glob(os.path.join(self.download_dir, "*.crdownload"))
logger.info(f"Checking downloads. CSVs: {len(all_csvs)}, in-flight: {len(in_flight)}")
complete = [f for f in all_csvs if not f.endswith(".crdownload")]
if complete and not in_flight:
latest = max(complete, key=os.path.getmtime)
logger.info(f"Download complete → {latest}")
return latest
time.sleep(1)
self._save_screenshot("download_timeout.png")
raise TimeoutError(
f"CSV download did not complete within {timeout} seconds."
)
# ── Utilities ─────────────────────────────────────────────────────────────
def _save_screenshot(self, filename: str) -> None:
if self.driver:
path = os.path.join(self.download_dir, filename)
try:
self.driver.save_screenshot(path)
logger.info(f"Screenshot saved → {path}")
except Exception:
pass
def _cleanup_download_dir(self) -> None:
"""Remove any leftover CSVs from previous runs."""
for f in glob.glob(os.path.join(self.download_dir, "*.csv")):
try:
os.remove(f)
except OSError:
pass
# ── Project switching and Modals ──────────────────────────────────────────
def _dismiss_upgrade_modal(self) -> None:
"""Find and dismiss the 'Upgrade to experience the full power of Factors' / 'Go beyond signals' modal if present."""
try:
modals = self.driver.find_elements(
By.XPATH,
"//div[contains(@class, 'ant-modal-content')][.//*[contains(text(), 'Upgrade to experience') or contains(text(), 'Go beyond signals') or contains(text(), 'Book a Demo')]]"
)
for m in modals:
if m.is_displayed():
logger.info("Upgrade/Paywall modal detected! Dismissing...")
try:
close_btn = m.find_element(
By.CSS_SELECTOR,
"button.ant-modal-close, button[aria-label='Close'], .ant-modal-close-x, button[class*='close']"
)
self._click(close_btn)
logger.info("Successfully dismissed upgrade modal.")
time.sleep(1)
except Exception as click_err:
logger.warning(f"Could not click close button on upgrade modal: {click_err}")
except Exception as e:
logger.debug(f"Error checking/dismissing upgrade modal: {e}")
def _dismiss_popups(self) -> None:
"""Attempt to dismiss any promotional or feedback modals that might block interaction."""
self._dismiss_upgrade_modal()
logger.info("Checking for any blocking popups/modals to dismiss...")
# 1. Look for G2 review popup / "Don't show this again" buttons
try:
g2_buttons = self.driver.find_elements(
By.XPATH,
"//button[contains(translate(text(), 'DONT SHOW', 'dont show'), 'dont show') or contains(., \"Don't show this again\")]"
)
for btn in g2_buttons:
if btn.is_displayed():
logger.info("Dismissing G2 / 'Don't show this again' popup.")
self._click(btn)
time.sleep(1)
except Exception as e:
logger.debug(f"Error checking/dismissing G2 popup: {e}")
# 2. Look for general modal close buttons (e.g. ant-modal-close, ant-modal-close-x)
try:
close_buttons = self.driver.find_elements(
By.CSS_SELECTOR,
".ant-modal-close, button.ant-modal-close, .ant-modal-close-x, [class*='ant-modal-close']"
)
for btn in close_buttons:
if btn.is_displayed():
# We do NOT want to close the project switch modal or fields modal!
parent_modal_text = ""
try:
parent_modal = btn.find_element(By.XPATH, "./ancestor::div[contains(@class, 'ant-modal-content')]")
parent_modal_text = parent_modal.text.lower()
except Exception:
pass
if "switch" in parent_modal_text or "export" in parent_modal_text or "csv" in parent_modal_text:
logger.info("Skipping modal close for functional modal (switch project/export).")
continue
logger.info("Clicking modal close button to dismiss popup.")
self._click(btn)
time.sleep(1)
except Exception as e:
logger.debug(f"Error dismissing modal popups: {e}")
def _switch_project(self, target_project: str) -> None:
"""Switch the current project to the target_project if not already active."""
if not target_project:
logger.info("No target project specified; skipping switch.")
return
# Dismiss any initially shown modals/popups
self._dismiss_popups()
def clean_name(name: str) -> str:
return name.replace(" ", "").replace("-", "").replace("_", "").lower().strip()
clean_target = clean_name(target_project)
logger.info(f"Ensuring target project is set to: '{target_project}'")
# 1. Locate the main project dropdown button
try:
dropdown_btn = self._wait(
By.ID, "fa-at-dropdown--projects", timeout=20, condition="clickable"
)
except TimeoutException:
self._save_screenshot("project_dropdown_not_found.png")
raise RuntimeError("Could not find the project dropdown button (#fa-at-dropdown--projects).")
current_project_text = dropdown_btn.text
logger.info(f"Main project button text: {current_project_text!r}")
# If it's already active on the top nav, we're done
if clean_target in clean_name(current_project_text):
logger.info(f"Project '{target_project}' is already active.")
return
# 2. Click the dropdown button to open the project selector
logger.info("Clicking project dropdown button...")
self._click(dropdown_btn)
time.sleep(2)
# 3. Check the active project inside the popover/modal
try:
active_proj_div = self._wait(
By.CSS_SELECTOR,
"div[class*='ProjectModal__active_project_div']",
timeout=10,
condition="clickable"
)
except TimeoutException:
active_proj_div = None
if active_proj_div:
active_proj_text = active_proj_div.text
logger.info(f"Active project in modal/dropdown menu: {active_proj_text!r}")
if clean_target in clean_name(active_proj_text):
logger.info(f"Project '{target_project}' is already selected in the active div.")
# Close the popover by pressing escape
self.driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)
time.sleep(1)
return
# Click active project div to reveal the project list
logger.info("Clicking active project div to open the list of all projects...")
self._click(active_proj_div)
time.sleep(2)
# 4. Search/filter using the search input
try:
search_input = self.driver.find_element(By.CSS_SELECTOR, "input[placeholder='Search Project']")
search_input.clear()
search_input.send_keys(target_project)
logger.info(f"Typed '{target_project}' into project search input.")
time.sleep(1.5)
except NoSuchElementException:
logger.info("Project list search input not found; scanning list directly.")
# Find the project item that matches the target project
project_items = self.driver.find_elements(
By.CSS_SELECTOR, "div[class*='ProjectModal__project_item']"
)
target_item = None
for item in project_items:
item_text = item.text
if clean_target in clean_name(item_text):
target_item = item
break
if not target_item:
self._save_screenshot("project_item_not_found.png")
raise RuntimeError(
f"Could not find project '{target_project}' in the project list. "
"Screenshot saved → tmp_download/project_item_not_found.png"
)
logger.info(f"Selecting project item: {target_item.text!r}")
self._click(target_item)
time.sleep(2)
# 5. Handle the project switch confirmation modal if it appears
try:
switch_modal = WebDriverWait(self.driver, 5).until(
EC.visibility_of_element_located((
By.XPATH,
"//div[contains(@class, 'ant-modal-content')][.//h4[contains(translate(text(), 'SWITCH', 'switch'), 'switch')]]"
))
)
logger.info("Switch confirmation modal appeared.")
switch_btn = switch_modal.find_element(
By.XPATH,
".//button[contains(@class, 'ant-btn-primary') or .//span[text()='Switch']]"
)
self._click(switch_btn)
logger.info("Clicked 'Switch' on confirmation modal.")
time.sleep(5) # Wait for page to reload/redirect
except TimeoutException:
logger.info("No switch confirmation modal appeared; switch may have been instant.")
# Dismiss any popups after switching
self._dismiss_popups()
# ── Public entry point ────────────────────────────────────────────────────
def run(
self,
email: str,
password: str,
target_project: str = "Inbound-Tech4Biz",
fields_to_select: list[str] | None = None,
) -> str:
"""
Full end-to-end automation run.
Returns:
Path to the freshly downloaded CSV file.
"""
self._cleanup_download_dir()
try:
self._setup_driver()
self.login(email, password)
self._switch_project(target_project)
self._navigate_to_accounts()
self._click_download_button()
self._handle_modal(fields_to_select)
return self._wait_for_download()
except Exception as exc:
self._save_screenshot("scraper_failure.png")
logger.error(f"Scraper failed: {exc}", exc_info=True)
raise
finally:
if self.driver:
self.driver.quit()
logger.info("Browser closed.")