# coding=utf-8 """ Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。 """ from __future__ import annotations import os import socket import subprocess import time from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] _FLOW_DEBUG_PORTS = { "REBATE_FIND": 9335, "default": 9335, } _USER_CHROME_PORT = 9222 def use_user_chrome() -> bool: raw = (os.getenv("RPA_USE_USER_CHROME") or "").strip().lower() return raw in {"1", "true", "yes", "on"} def user_chrome_real_profile_dir() -> Path: return (Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "User Data").resolve() def user_chrome_profile_dir() -> Path: """Chrome 禁止在默认 User Data 上开调试口,用仓库外 junction 指向同一目录。不要 resolve,否则又变回默认路径。""" link = REPO_ROOT.parent / "rpa-chrome-profile" / "daily-chrome" real = user_chrome_real_profile_dir() if not link.exists(): link.parent.mkdir(parents=True, exist_ok=True) subprocess.run( ["cmd.exe", "/c", "mklink", "/J", str(link), str(real)], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return link def _is_inside_repo(path: Path, repo_root: Path) -> bool: try: path.resolve().relative_to(repo_root.resolve()) except ValueError: return False return True def chrome_session_flow(flow: str) -> str: """REBATE_HEADER 复用查号已登录的 Chrome。""" name = (flow or "default").strip() or "default" if name.startswith("REBATE"): return "REBATE_FIND" return name def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path: """Chrome user-data dir must stay outside the repo so list_dir never walks 3GB.""" root = (repo_root or REPO_ROOT).resolve() outside = (root.parent / "rpa-chrome-profile").resolve() raw = os.getenv("RPA_CHROME_PROFILE") if requested is None else requested if raw: candidate = Path(raw).expanduser().resolve() if _is_inside_repo(candidate, root): print(f"RPA_CHROME_PROFILE 指向仓库内 {candidate},已改用 {outside}", flush=True) return outside return candidate return outside def resolve_chrome_profile_dir( flow: str = "default", requested: str | None = None, *, repo_root: Path | None = None, ) -> Path: """Stable profile per Chrome session so later rebate nodes reuse login.""" if use_user_chrome(): return user_chrome_profile_dir() base = resolve_chrome_profile_base(requested, repo_root=repo_root) session = chrome_session_flow(flow) safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in session) return (base / (safe or "default")).resolve() def chrome_debug_port(flow: str = "default") -> int: raw = (os.getenv("RPA_CHROME_DEBUG_PORT") or "").strip() if raw: try: return int(raw) except ValueError: pass if use_user_chrome(): return _USER_CHROME_PORT session = chrome_session_flow(flow) return _FLOW_DEBUG_PORTS.get(session, 9335) def _port_open(port: int) -> bool: try: with socket.create_connection(("127.0.0.1", port), timeout=0.4): return True except OSError: return False def _read_devtools_port(profile_dir: Path) -> int | None: marker = profile_dir / "DevToolsActivePort" if not marker.is_file(): return None try: first = marker.read_text(encoding="utf-8").splitlines()[0].strip() port = int(first) except (OSError, ValueError, IndexError): return None return port if port > 0 else None def _chrome_service(): from selenium.webdriver.chrome.service import Service chromedriver = os.getenv("CHROMEDRIVER_PATH") if chromedriver and os.path.exists(chromedriver): return Service(chromedriver) from webdriver_manager.chrome import ChromeDriverManager return Service(ChromeDriverManager().install()) def _chrome_binary() -> str: raw = (os.getenv("CHROME_PATH") or "").strip() candidates = [ raw, r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", str(Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "Application" / "chrome.exe"), ] for path in candidates: if path and os.path.isfile(path): return path raise RuntimeError("未找到 chrome.exe") def _attach_chrome(service, port: int): from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_experimental_option("debuggerAddress", f"127.0.0.1:{port}") driver = webdriver.Chrome(service=service, options=options) driver.execute_script("return 1") return driver def _wait_port(port: int, timeout: float = 20) -> bool: deadline = time.time() + timeout while time.time() <= deadline: if _port_open(port): return True time.sleep(0.3) return False def _spawn_chrome(profile_dir: Path, port: int, *, restore_session: bool = False) -> None: """独立拉起 Chrome,不跟 Python / chromedriver 一起退出。""" chrome = _chrome_binary() flags = 0x00000008 | 0x00000200 | 0x01000000 cmd = [ chrome, f"--remote-debugging-port={str(port)}", "--user-data-dir=" + str(profile_dir), "--remote-allow-origins=*", "--start-maximized", "--no-first-run", "--no-default-browser-check", "--disable-popup-blocking", ] if restore_session: cmd.append("--restore-last-session") subprocess.Popen( cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=False, creationflags=flags, ) def _kill_chrome() -> None: creation = getattr(subprocess, "CREATE_NO_WINDOW", 0) subprocess.run( ["taskkill", "/IM", "chrome.exe", "/F"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, creationflags=creation, ) def _wait_profile_unlocked(profile_dir: Path, timeout: float = 20) -> None: deadline = time.time() + timeout while time.time() <= deadline: locked = (profile_dir / "SingletonLock").exists() or (profile_dir / "lockfile").exists() if not locked: return time.sleep(0.3) def _launch_chrome(service, profile_dir: Path, port: int, *, restore_session: bool = False): if not _port_open(port): if restore_session: print(f"Chrome 日常窗口无调试口,按同一用户目录重开 port={port} profile={profile_dir}", flush=True) _kill_chrome() _wait_profile_unlocked(profile_dir) time.sleep(0.8) _spawn_chrome(profile_dir, port, restore_session=restore_session) if not _wait_port(port): raise RuntimeError(f"Chrome 未在端口 {port} 起来") return _attach_chrome(service, port) def _focus_agreement_tab(driver) -> bool: try: handles = list(driver.window_handles) except Exception: return False for handle in handles: try: driver.switch_to.window(handle) href = str(driver.current_url or "") title = str(driver.title or "") except Exception: continue if "procurementAgree" in href or "采购协议" in title: return True return False _IGNORE_SOURCE_JS = """ function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } const hits = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { if (!vis(el)) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return title === '货源选择'; }); for (const modal of hits) { const btn = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el).replace(/ /g, ''); return vis(el) && (t === '关闭' || t === '取消'); }); if (btn) { btn.click(); continue; } const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close'); if (x) { x.click(); } } return hits.length; """ class ApaBrowserAdapter: """Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议""" def __init__(self, url: str = "", *, flow: str = "default"): self._url = url self._flow = flow self._driver = None self._is_open = False self._download_dir = "" self._download_before: set[str] = set() self._ignoring_source = False self._hold_source_choice = False def _get_driver(self): """复用查号已打开的 Chrome;没有就按稳定 profile 新开。""" if self._driver is not None: return self._driver profile_dir = resolve_chrome_profile_dir(self._flow) if _is_inside_repo(profile_dir, REPO_ROOT): raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_dir}") os.makedirs(profile_dir, exist_ok=True) port = chrome_debug_port(self._flow) if not use_user_chrome(): port = _read_devtools_port(profile_dir) or port driver = None if _port_open(port): try: driver = _attach_chrome(_chrome_service(), port) print(f"Chrome 复用已有实例 flow={self._flow} port={port}", flush=True) except Exception as exc: print(f"Chrome 复用失败 port={port}:{exc}", flush=True) driver = None if driver is None: launch_port = chrome_debug_port(self._flow) try: driver = _launch_chrome( _chrome_service(), profile_dir, launch_port, restore_session=use_user_chrome(), ) print( f"Chrome 新开实例 flow={self._flow} port={launch_port} profile={profile_dir}", flush=True, ) except Exception as exc: retry_port = _read_devtools_port(profile_dir) if retry_port and _port_open(retry_port): driver = _attach_chrome(_chrome_service(), retry_port) print(f"Chrome 锁定后改为复用 port={retry_port}", flush=True) else: raise RuntimeError(f"打开 Chrome 失败:{exc}") from exc driver.set_page_load_timeout(45) driver.set_script_timeout(30) self._driver = driver return self._driver def _ensure_open(self): if self._driver is None: driver = self._get_driver() href = "" try: href = str(driver.execute_script("return location.href") or "") except Exception: href = "" blank = (not href) or href.startswith("chrome://") or href.startswith("about:") if use_user_chrome() and _focus_agreement_tab(driver): print("Chrome 切到已打开的采购协议页", flush=True) elif self._url and blank: 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 driver = self._get_driver() try: driver.get(url) except Exception as exc: # eager 仍可能因长轮询资源超时;页面文档已出来就继续 current = "" try: current = driver.current_url or "" except Exception: current = "" if not current or current in ("data:,", "about:blank"): raise RuntimeError(f"打开页面失败:{url} ({exc})") from exc 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) self._ignore_source_bug() return except Exception as e: last_error = e continue raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}") def click_native(self, selectors: list) -> None: """真实鼠标点击,给只监听 mousedown 的 LUI 页签/工具栏用。""" self._ensure_open() from selenium.webdriver.common.action_chains import ActionChains last_error = None for sel in selectors: try: el = self._find(sel) self._driver.execute_script( "arguments[0].scrollIntoView({block:'center', inline:'center'});", el, ) time.sleep(0.2) ActionChains(self._driver).move_to_element(el).pause(0.15).click().perform() time.sleep(0.4) self._ignore_source_bug() return except Exception as e: last_error = e continue raise RuntimeError(f"click_native 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。优先可见弹窗,避免切到上次留下的隐藏 iframe。""" self._ensure_open() from selenium.webdriver.common.by import By last_error = None found = [] for sel in selectors: try: if sel.startswith("//") or sel.startswith("/"): found.extend(self._driver.find_elements(By.XPATH, sel)) else: found.extend(self._driver.find_elements(By.CSS_SELECTOR, sel)) except Exception as e: last_error = e visible = [] hidden = [] for el in found: try: (visible if el.is_displayed() else hidden).append(el) except Exception: hidden.append(el) for el in visible + hidden: try: self._driver.switch_to.default_content() self._driver.switch_to.frame(el) return except Exception as e: last_error = e 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 _ignore_source_bug(self) -> None: """货源选择是 bug 弹层,看见就关,不参与任何选择。""" if self._hold_source_choice or self._ignoring_source or self._driver is None: return self._ignoring_source = True try: n = self._driver.execute_script(_IGNORE_SOURCE_JS) if n: time.sleep(0.2) except Exception: pass finally: self._ignoring_source = False def execute_script(self, script: str, *args): self._ensure_open() result = self._driver.execute_script(script, *args) self._ignore_source_bug() return result def select_xform(self, name: str, value: str) -> None: """选中 Landray xform fancy-select(.fs-wrap / .fs-option)。""" self._ensure_open() result = self._driver.execute_script( """ const name = arguments[0]; const wanted = String(arguments[1] || '').trim(); const flag = document.querySelector('xformflag[property="' + name + '"]'); if (!flag) { return {ok: false, error: 'flag not found: ' + name}; } const norm = (s) => String(s || '').replace(/\\s+/g, '').trim(); const wantedN = norm(wanted); const opts = Array.from(flag.querySelectorAll('.fs-option')); let target = opts.find((o) => { const text = norm((o.querySelector('.fs-option-label') || o).textContent); const val = norm(o.getAttribute('data-value')); return text === wantedN || val === wantedN; }); if (!target && wantedN.length >= 2) { target = opts.find((o) => { const text = norm((o.querySelector('.fs-option-label') || o).textContent); return text.indexOf(wantedN) >= 0; }); } if (!target) { return { ok: false, error: 'option not found: ' + wanted, available: opts.map((o) => ((o.querySelector('.fs-option-label') || o).textContent || '').trim()), }; } const labelWrap = flag.querySelector('.fs-label-wrap'); if (labelWrap) { labelWrap.click(); } target.click(); const input = flag.querySelector('input[name="' + name + '"]'); const selected = target.getAttribute('data-value') || ''; if (input) { const proto = window.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; setter.call(input, selected); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); if (typeof __xformDispatch === 'function') { try { __xformDispatch(selected, input); } catch (e) {} } } return { ok: true, value: selected, label: ((flag.querySelector('.fs-label') || {}).textContent || '').trim(), }; """, name, str(value), ) if not result or not result.get("ok"): raise RuntimeError(f"select_xform failed: {name}={value} {result}") time.sleep(0.3) 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) # 原生