diff --git a/rpa_yisaiqi/apa_browser_adapter.py b/rpa_yisaiqi/apa_browser_adapter.py new file mode 100644 index 0000000..47e2269 --- /dev/null +++ b/rpa_yisaiqi/apa_browser_adapter.py @@ -0,0 +1,343 @@ +# coding=utf-8 +""" +APA (艺赛旗) Selenium Chrome BrowserAdapter 实现 +通过 Selenium WebDriver 直接操控 Chrome,不依赖 APA 浏览器模块 +""" + +import time + + +class ApaBrowserAdapter: + """Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议""" + + def __init__(self, url: str = ""): + self._url = url + self._driver = None + self._is_open = False + + def _get_driver(self): + """创建或获取 Chrome WebDriver""" + if self._driver is not None: + return self._driver + + from selenium import webdriver + from selenium.webdriver.chrome.service import Service + from selenium.webdriver.chrome.options import Options + from webdriver_manager.chrome import ChromeDriverManager + + options = Options() + # 不自动关闭浏览器,方便调试 + options.add_experimental_option("detach", True) + # 避免被检测为自动化 + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + # 基本参数 + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_argument("--no-first-run") + options.add_argument("--no-default-browser-check") + + service = Service(ChromeDriverManager().install()) + self._driver = webdriver.Chrome(service=service, options=options) + return self._driver + + def _ensure_open(self): + if self._driver is None: + driver = self._get_driver() + if self._url: + driver.get(self._url) + self._is_open = True + + # ---- 内部查找方法 ---- + + def _find(self, selector: str, timeout: int = 5): + """ + 智能查找元素:支持 CSS、XPath、text=、label=、aria=、title=、:contains() + """ + from selenium.webdriver.common.by import By + from selenium.common.exceptions import NoSuchElementException + import re + + # 1. text=xxx → 查找包含对应文本的元素 + if selector.startswith("text="): + text_val = selector[5:].strip('"').strip("'") + xpath = f'//*[contains(text(),"{text_val}")]' + return self._driver.find_element(By.XPATH, xpath) + + # 2. label=xxx → 找到对应标签的输入框 + if selector.startswith("label="): + text_val = selector[6:].strip('"').strip("'") + xpath = f'//label[contains(text(),"{text_val}")]/following-sibling::*[1][self::input or self::select or self::textarea]' + try: + return self._driver.find_element(By.XPATH, xpath) + except NoSuchElementException: + # Fallback: find the label's "for" attribute + try: + for_id = self._driver.find_element( + By.XPATH, f'//label[contains(text(),"{text_val}")]' + ).get_attribute("for") + if for_id: + return self._driver.find_element(By.ID, for_id) + except NoSuchElementException: + pass + raise + + # 3. aria=xxx → aria-label 属性 + if selector.startswith("aria="): + text_val = selector[5:].strip('"').strip("'") + return self._driver.find_element(By.CSS_SELECTOR, f'[aria-label="{text_val}"]') + + # 4. title=xxx → title 属性 + if selector.startswith("title="): + text_val = selector[6:].strip('"').strip("'") + return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]') + + # 5. :contains() → 转 XPath(支持 button:contains(文本) / a:contains("文本")) + if ":contains(" in selector: + m = re.match(r'(.+?):contains\(["\']?(.+?)["\']?\)', selector) + if m: + tag_part, text_val = m.groups() + xpath = f'//{tag_part}[contains(.,"{text_val}")]' + return self._driver.find_element(By.XPATH, xpath) + + # 6. 显式 XPath(以 / 或 // 开头)优先按 XPath 查找 + if selector.startswith("//") or selector.startswith("/"): + return self._driver.find_element(By.XPATH, selector) + + # 7. 标准 CSS selector + return self._driver.find_element(By.CSS_SELECTOR, selector) + + # ---- BrowserAdapter 协议实现 ---- + + def open_url(self, url: str) -> None: + self._url = url + self._get_driver().get(url) + self._is_open = True + + def _set_value_via_js(self, el, value: str) -> None: + """通过原生 value setter + 事件注入设置值,兼容 LUI/React 受控组件""" + self._driver.execute_script( + """ + var el = arguments[0]; + var value = arguments[1]; + var proto = el.tagName === 'TEXTAREA' + ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype; + var setter = Object.getOwnPropertyDescriptor(proto, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new Event('blur', { bubbles: true })); + """, + el, + str(value), + ) + + def fill_first(self, selectors: list, value: str) -> None: + self._ensure_open() + last_error = None + for sel in selectors: + try: + el = self._find(sel) + # 先尝试原生输入;LUI 受控组件用 JS 注入兜底 + try: + el.clear() + el.send_keys(str(value)) + except Exception: + pass + if el.get_attribute("value") != str(value): + self._set_value_via_js(el, str(value)) + time.sleep(0.3) + return + except Exception as e: + last_error = e + continue + raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}") + + def click_first(self, selectors: list) -> None: + self._ensure_open() + last_error = None + for sel in selectors: + try: + el = self._find(sel) + # LUI/委托事件用 JS 点击最可靠 + self._driver.execute_script("arguments[0].click()", el) + time.sleep(0.5) + return + except Exception as e: + last_error = e + continue + raise RuntimeError(f"click_first failed: {selectors}, last: {last_error}") + + def double_click_first(self, selectors: list) -> None: + self._ensure_open() + from selenium.webdriver.common.action_chains import ActionChains + last_error = None + for sel in selectors: + try: + el = self._find(sel) + ActionChains(self._driver).double_click(el).perform() + time.sleep(0.5) + return + except Exception as e: + last_error = e + continue + raise RuntimeError(f"double_click_first failed: {selectors}, last: {last_error}") + + def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: + self._ensure_open() + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.common.by import By + try: + WebDriverWait(self._driver, timeout_seconds).until( + EC.text_to_be_present_in_element((By.TAG_NAME, "body"), text) + ) + except Exception as e: + raise TimeoutError(f"wait_for_text '{text}' timed out: {e}") + + def wait_for_element(self, selectors: list, timeout_seconds: int = 15) -> None: + self._ensure_open() + deadline = time.time() + timeout_seconds + last_error = None + while time.time() < deadline: + for sel in selectors: + try: + self._find(sel, timeout=1) + return + except Exception as e: + last_error = e + time.sleep(0.5) + raise TimeoutError(f"wait_for_element timed out: {selectors}, last: {last_error}") + + def element_exists(self, selectors: list) -> bool: + self._ensure_open() + for sel in selectors: + try: + self._find(sel, timeout=1) + return True + except Exception: + continue + return False + + def switch_to_frame(self, selectors: list) -> None: + """切换到匹配到的 iframe(客商选择弹窗等)。""" + self._ensure_open() + last_error = None + for sel in selectors: + try: + el = self._find(sel, timeout=1) + self._driver.switch_to.frame(el) + return + except Exception as e: + last_error = e + continue + raise RuntimeError(f"switch_to_frame failed: {selectors}, last: {last_error}") + + def switch_to_default_content(self) -> None: + """切回主文档(顶层 frame)。""" + self._ensure_open() + self._driver.switch_to.default_content() + + def select_first(self, selectors: list, value: str) -> None: + self._ensure_open() + from selenium.webdriver.support.ui import Select + last_error = None + for sel in selectors: + try: + el = self._find(sel) + # 原生