163 lines
5.9 KiB
Python
163 lines
5.9 KiB
Python
"""DigiPay-specific flow — search + find via onclick goToProduct URL."""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any
|
|
|
|
import undetected_chromedriver as uc
|
|
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 crawler.driver import human_delay
|
|
from crawler.dynamic_flow import DynamicFlow, StepResult, _wait_for
|
|
from logger import get_logger
|
|
|
|
log = get_logger(__name__)
|
|
|
|
_RESULTS_WAIT_CSS = "a[onclick*='goToProduct']"
|
|
|
|
# Ordered list of selectors tried to locate the search input
|
|
_SEARCH_INPUT_CANDIDATES = [
|
|
(By.XPATH, "/html/body/header/header/div[2]//input"),
|
|
(By.XPATH, "//header//input[@type='search']"),
|
|
(By.XPATH, "//header//input[contains(@class,'search')]"),
|
|
(By.XPATH, "//input[@type='search']"),
|
|
(By.CSS_SELECTOR, "input[type='search']"),
|
|
(By.XPATH, "//input[contains(@placeholder,'جست')]"), # 'جست' = search (Farsi prefix)
|
|
]
|
|
|
|
# Selectors tried to trigger / open the search box before locating the input
|
|
_SEARCH_TRIGGER_CANDIDATES = [
|
|
(By.XPATH, "/html/body/header/header/div[2]"),
|
|
(By.XPATH, "//header//*[contains(@class,'search')]"),
|
|
(By.CSS_SELECTOR, "header [class*='search']"),
|
|
]
|
|
|
|
|
|
_CONTAINER_FIX_JS = """
|
|
(function() {
|
|
function inject() {
|
|
if (document.getElementById('__seed_fix')) return;
|
|
var s = document.createElement('style');
|
|
s.id = '__seed_fix';
|
|
s.textContent = '.container{margin-left:0!important;margin-right:0!important}';
|
|
document.head.appendChild(s);
|
|
}
|
|
inject();
|
|
new MutationObserver(inject).observe(document.head, {childList: true});
|
|
new MutationObserver(inject).observe(document.documentElement, {childList: true});
|
|
})();
|
|
"""
|
|
|
|
|
|
def _inject_container_fix(driver: uc.Chrome) -> None:
|
|
driver.execute_script(_CONTAINER_FIX_JS)
|
|
|
|
|
|
def _wait_spa_ready(driver: uc.Chrome, timeout: float = 20.0) -> None:
|
|
"""Wait for DOM ready + Vue/React hydration (no pending network requests)."""
|
|
# Force desktop viewport — --window-size sets outer chrome, not inner viewport
|
|
driver.execute_cdp_cmd("Emulation.setDeviceMetricsOverride", {
|
|
"width": 1920,
|
|
"height": 1080,
|
|
"deviceScaleFactor": 1,
|
|
"mobile": False,
|
|
})
|
|
WebDriverWait(driver, timeout).until(
|
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
|
)
|
|
# Extra pause for SPA JS to hydrate
|
|
time.sleep(1.5)
|
|
_inject_container_fix(driver)
|
|
|
|
|
|
def _find_search_input(driver: uc.Chrome, wait: WebDriverWait) -> Any:
|
|
"""Try each candidate selector until one is clickable."""
|
|
for by, sel in _SEARCH_INPUT_CANDIDATES:
|
|
try:
|
|
el = wait.until(EC.element_to_be_clickable((by, sel)))
|
|
log.debug("Search input found via [%s:%s]", by, sel)
|
|
return el
|
|
except Exception:
|
|
continue
|
|
raise RuntimeError("Search input not found — tried all candidate selectors")
|
|
|
|
|
|
class DigiPayFlow(DynamicFlow):
|
|
"""
|
|
DigiPay-specific flow:
|
|
- Waits for SPA hydration before interacting.
|
|
- Tries multiple selectors to find/open search input.
|
|
- Waits for `a[onclick*='goToProduct']` cards after submit.
|
|
- Item matching via onclick URL fragment or product title text.
|
|
"""
|
|
|
|
def _run_search(self, text: str) -> StepResult:
|
|
t0 = time.monotonic()
|
|
name = f"search:{text}"
|
|
log.info("[%s] DigiPay search for '%s'", name, text)
|
|
try:
|
|
wait_long = WebDriverWait(self.driver, 25)
|
|
wait_short = WebDriverWait(self.driver, 5)
|
|
|
|
# Wait for SPA to fully hydrate
|
|
_wait_spa_ready(self.driver)
|
|
log.debug("[%s] SPA ready", name)
|
|
|
|
# Try to click a trigger element to open/reveal the search input
|
|
for by, sel in _SEARCH_TRIGGER_CANDIDATES:
|
|
try:
|
|
trigger = wait_short.until(EC.element_to_be_clickable((by, sel)))
|
|
trigger.click()
|
|
log.debug("[%s] Clicked trigger [%s:%s]", name, by, sel)
|
|
human_delay(0.4, 0.7)
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
# Locate the actual input (may now be in an overlay)
|
|
box = _find_search_input(self.driver, wait_long)
|
|
box.clear()
|
|
human_delay(0.2, 0.4)
|
|
|
|
for ch in text:
|
|
box.send_keys(ch)
|
|
time.sleep(0.05)
|
|
|
|
human_delay(0.3, 0.6)
|
|
box.send_keys(Keys.RETURN)
|
|
log.debug("[%s] Enter sent", name)
|
|
|
|
# Wait for product cards
|
|
wait_long.until(
|
|
EC.presence_of_element_located((By.CSS_SELECTOR, _RESULTS_WAIT_CSS))
|
|
)
|
|
_inject_container_fix(self.driver)
|
|
human_delay(1.0, 2.0)
|
|
|
|
ms = int((time.monotonic() - t0) * 1000)
|
|
log.info("[%s] Search OK — %d ms", name, ms)
|
|
return StepResult(name=name, success=True, data={"text": text}, duration_ms=ms)
|
|
|
|
except Exception as exc:
|
|
ms = int((time.monotonic() - t0) * 1000)
|
|
log.error("[%s] Failed — %s", name, exc)
|
|
return StepResult(name=name, success=False, error=str(exc), duration_ms=ms)
|
|
|
|
def _build_item_selector(self, item_id: str) -> tuple[str, str]:
|
|
sel_type: str = self.cfg.get("item_selector_type", "onclick")
|
|
template: str = self.cfg.get("item_selector_template", "")
|
|
fragment = template.replace("{item_id}", item_id) if template else item_id
|
|
|
|
if sel_type == "onclick":
|
|
escaped = fragment.replace("'", "\\'")
|
|
return f"//a[contains(@onclick, '{escaped}')]", "xpath"
|
|
|
|
if sel_type == "text":
|
|
escaped = fragment.replace("'", "\\'")
|
|
return f"//a[.//*[contains(normalize-space(.), '{escaped}')]]", "xpath"
|
|
|
|
return super()._build_item_selector(item_id)
|