# Selenium Chrome 浏览器适配。合同和返利共用这一份。 # # 通过 WebDriver 操控 Chrome。用户目录必须在仓库外,避免 list_dir 扫到数 GB 的 profile。 # 端口探测和 DevToolsActivePort 用短超时轮询,禁止无条件 sleep(1)。 # 返利用户 Chrome 的 junction / 端口由 rpa_rebate.apa_browser_adapter 覆盖。 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 = { "CONTRACT_CREATE": 9333, "REBATE_FIND": 9335, "default": 9334, } _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; """ # 判断 path 是否落在仓库目录树内。 def _is_inside_repo(path: Path, repo_root: Path) -> bool: try: path.resolve().relative_to(repo_root.resolve()) except ValueError: return False return True # 把 flow 名收成目录名安全的一段。空则 default。 def _sanitize_flow(flow: str) -> str: safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (flow or "default").strip()) return safe or "default" # 解析 Chrome user-data 根目录,必须留在仓库外。 # # 未指定时用仓库父目录下的 rpa-chrome-profile。RPA_CHROME_PROFILE 指到仓库内则改回这个默认。 def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path: 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 # 按 flow 给出稳定的 Chrome 用户目录,后续班次复用登录态。 def resolve_chrome_profile_dir( flow: str = "default", requested: str | None = None, *, repo_root: Path | None = None, ) -> Path: base = resolve_chrome_profile_base(requested, repo_root=repo_root) return (base / _sanitize_flow(flow)).resolve() # 给出本 flow 的调试口。RPA_CHROME_DEBUG_PORT 优先;否则按班次表,未知回 9333。 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 return _FLOW_DEBUG_PORTS.get(_sanitize_flow(flow), 9333) # 探测 127.0.0.1:port 此刻能否连上。 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 # 从 profile 的 DevToolsActivePort 读出当前调试口,没有或坏文件返回 None。 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 # 轮询本机调试口直到可连上或超时。间隔 0.3 秒,不 sleep(1)。 def wait_debug_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 # 轮询 DevToolsActivePort 直到读到正端口或超时。间隔 0.3 秒。 def wait_devtools_port(profile_dir: Path, timeout: float = 20) -> int | None: deadline = time.time() + timeout while time.time() <= deadline: port = _read_devtools_port(profile_dir) if port: return port time.sleep(0.3) return None # 构造 ChromeDriver Service。有 CHROMEDRIVER_PATH 用本地,否则 webdriver_manager 下载。 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()) # 找到本机 chrome 可执行文件。CHROME_PATH 优先,找不到则失败。 def _chrome_binary() -> str: raw = (os.getenv("CHROME_PATH") or "").strip() local = Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "Application" / "chrome.exe" candidates = [ raw, r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", str(local), "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ] for path in candidates: if path and os.path.isfile(path): return path raise RuntimeError("未找到 chrome.exe") # 附着到已在调试口上的 Chrome,并跑一句脚本确认活着。 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 # 独立进程拉起 Chrome,不跟 Python / chromedriver 一起退出。 def _spawn_chrome(profile_dir: Path, port: int, *, restore_session: bool = False) -> None: chrome = _chrome_binary() 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") kwargs: dict = { "stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, } if os.name == "nt": kwargs["close_fds"] = False kwargs["creationflags"] = 0x00000008 | 0x00000200 | 0x01000000 subprocess.Popen(cmd, **kwargs) # 强制结束 chrome.exe,给用户目录重开调试口腾位置。仅 Windows。 def _kill_chrome() -> None: if os.name != "nt": return 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, ) # 等到 profile 的 SingletonLock / lockfile 消失,或超时放弃。 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) # 独立拉起 Chrome 再附着调试口。端口和 DevTools 用轮询,不 sleep(1)。 # # 口已开则直接附着。restore_session 时先结束旧进程、等 profile 解锁再拉起。 def launch_independent_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) _spawn_chrome(profile_dir, port, restore_session=restore_session) if not wait_debug_port(port): raise RuntimeError(f"Chrome 未在端口 {port} 起来") wait_devtools_port(profile_dir, timeout=5) return _attach_chrome(service, port) # 用 chromedriver 新开带调试口的 Chrome,起来后再轮询调试口。 def _launch_chrome_selenium(service, profile_dir: Path, port: int): from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.page_load_strategy = "eager" options.add_experimental_option("detach", True) options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"]) 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") options.add_argument("--disable-gpu") options.add_argument("--disable-extensions") options.add_argument("--disable-popup-blocking") options.add_argument("--start-maximized") options.add_argument("--remote-allow-origins=*") options.add_argument(f"--remote-debugging-port={port}") options.add_argument("--user-data-dir=" + str(profile_dir)) driver = webdriver.Chrome(service=service, options=options) wait_debug_port(port, timeout=5) return driver # Selenium Chrome 浏览器适配器,实现合同 / 返利页面流用的 BrowserAdapter 协议。 # # 子类可覆盖 profile / 调试口 / 拉起方式。返利用户 Chrome 走这条扩展点。 class ApaBrowserAdapter: # 记下起始 URL 和班次名。真正开浏览器推迟到第一次页面操作。 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 # 本班次 Chrome 用户目录。默认按 flow 分子目录。 def _profile_dir(self) -> Path: return resolve_chrome_profile_dir(self._flow) # 本班次调试口。默认按 flow 查表。 def _debug_port(self) -> int: return chrome_debug_port(self._flow) # 是否按日常 Chrome 用户目录重开并恢复标签。默认否。 def _restore_session(self) -> bool: return False # 若已有目标页签则切过去。默认不切。 def _focus_existing_tab(self, driver) -> bool: return False # 新开 Chrome。默认 chromedriver;返利子类改成独立进程。 def _launch_driver(self, service, profile_dir: Path, port: int): return _launch_chrome_selenium(service, profile_dir, port) # 复用本班次已打开的 Chrome;没有就按稳定 profile 新开。 def _get_driver(self): if self._driver is not None: return self._driver profile_dir = self._profile_dir() if _is_inside_repo(profile_dir, REPO_ROOT): raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_dir}") os.makedirs(profile_dir, exist_ok=True) port = self._debug_port() if not self._restore_session(): 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 = self._debug_port() try: driver = self._launch_driver(_chrome_service(), profile_dir, launch_port) print( f"Chrome 新开实例 flow={self._flow} port={launch_port} profile={profile_dir}", flush=True, ) except Exception as exc: retry_port = wait_devtools_port(profile_dir, timeout=2) or _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 # 保证浏览器已开。空白页才打开起始 URL;子类可先切到已有页签。 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 self._focus_existing_tab(driver): print("Chrome 切到已打开的采购协议页", flush=True) elif self._url and blank: driver.get(self._url) self._is_open = True # 按 CSS / XPath / text= / label= / aria= / title= / :contains() 找第一个元素。 # # timeout 保留给调用方语义,底层 find_element 本身不等待。 def _find(self, selector: str, timeout: int = 5): from selenium.webdriver.common.by import By from selenium.common.exceptions import NoSuchElementException import re if selector.startswith("text="): text_val = selector[5:].strip('"').strip("'") xpath = f'//*[contains(text(),"{text_val}")]' return self._driver.find_element(By.XPATH, xpath) if selector.startswith("label="): text_val = selector[6:].strip('"').strip("'") xpath = ( f'//label[contains(text(),"{text_val}")]' f"/following-sibling::*[1][self::input or self::select or self::textarea]" ) try: return self._driver.find_element(By.XPATH, xpath) except NoSuchElementException: 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 if selector.startswith("aria="): text_val = selector[5:].strip('"').strip("'") return self._driver.find_element(By.CSS_SELECTOR, f'[aria-label="{text_val}"]') if selector.startswith("title="): text_val = selector[6:].strip('"').strip("'") return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]') 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) if selector.startswith("//") or selector.startswith("/"): return self._driver.find_element(By.XPATH, selector) return self._driver.find_element(By.CSS_SELECTOR, selector) # 打开指定 URL。eager 超时但文档已出来时继续,不把长轮询当失败。 def open_url(self, url: str) -> None: self._url = url driver = self._get_driver() try: driver.get(url) except Exception as exc: 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 # 用原生 value setter 加 input/change/blur 注入,兼容 LUI/React 受控组件。 def _set_value_via_js(self, el, value: str) -> None: 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), ) # 按选择器列表填第一个能写上的输入框。原生失败则改 JS 注入。 def fill_first(self, selectors: list, value: str) -> None: self._ensure_open() last_error = None for sel in selectors: try: el = self._find(sel) 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}") # 真实鼠标点击,给只监听 mousedown 的 LUI 页签/工具栏用。 def click_native(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) 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}") # 按选择器列表 JS 点击第一个能点到的元素。 def click_first(self, selectors: list) -> None: self._ensure_open() last_error = None for sel in selectors: try: el = self._find(sel) 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}") # 等到页面 body 出现指定文本,超时抛 TimeoutError。 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}") # 等到任一选择器能找到元素,超时抛 TimeoutError。 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}") # 任一选择器此刻能找到元素则 True。 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 # 切换到匹配到的 iframe。优先可见弹窗,避免切到上次留下的隐藏 iframe。 def switch_to_frame(self, selectors: list) -> None: 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}") # 切回主文档(顶层 frame)。 def switch_to_default_content(self) -> None: self._ensure_open() self._driver.switch_to.default_content() # 看见「货源选择」bug 弹层就关,不参与任何选择。 def _ignore_source_bug(self) -> None: 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 # 在当前页执行一段 JS 并返回结果。 def execute_script(self, script: str, *args): self._ensure_open() result = self._driver.execute_script(script, *args) self._ignore_source_bug() return result # 选中 Landray xform fancy-select(.fs-wrap / .fs-option)。 def select_xform(self, name: str, value: str) -> None: 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) # 按选择器列表选中原生 select 的可见文本,失败再用 JS 点 option。 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) try: Select(el).select_by_visible_text(value) time.sleep(0.3) if self._selected_text_matches(el, value): return except Exception: pass self._select_via_js(el, value) time.sleep(0.3) if self._selected_text_matches(el, value): return raise RuntimeError(f"select_first 未选中目标项: {value}") except Exception as e: last_error = e continue raise RuntimeError(f"select_first failed: {selectors}, last: {last_error}") # 当前选中项的可见文本是否等于 value。 def _selected_text_matches(self, el, value: str) -> bool: try: script = ( "var el = arguments[0];" "if (el.selectedIndex != null) {" " var o = el.options[el.selectedIndex];" " return o ? (o.textContent || o.text || '').trim() : '';" "}" "return (el.value || '').trim();" ) selected = self._driver.execute_script(script, el) return (selected or "").strip() == str(value).strip() except Exception: return False # 用 JS 按可见文本或 value 选中 option 并派发 change。 def _select_via_js(self, el, value: str) -> None: self._driver.execute_script( """ var el = arguments[0]; var value = arguments[1]; var options = el.options || []; for (var i = 0; i < options.length; i++) { var t = (options[i].textContent || options[i].text || '').trim(); if (t === value || options[i].value === value) { el.selectedIndex = i; el.dispatchEvent(new Event('change', { bubbles: true })); return; } } """, el, str(value), ) # 按选择器列表给第一个 file 输入框送本地路径。 def upload_file_first(self, selectors: list, file_path: str) -> None: self._ensure_open() last_error = None for sel in selectors: try: el = self._find(sel) el.send_keys(file_path) time.sleep(1) return except Exception as e: last_error = e continue raise RuntimeError(f"upload_file_first failed: {selectors}, last: {last_error}") # 把浏览器下载目录指到 path,并记下当前已有文件名以便 wait_for_download 认新文件。 def set_download_dir(self, path: str) -> None: self._ensure_open() dest = str(Path(path).expanduser().resolve()) Path(dest).mkdir(parents=True, exist_ok=True) self._download_dir = dest self._download_before = {p.name for p in Path(dest).iterdir()} try: self._driver.execute_cdp_cmd( "Page.setDownloadBehavior", {"behavior": "allow", "downloadPath": dest, "eventsEnabled": True}, ) except Exception: try: self._driver.execute_cdp_cmd( "Browser.setDownloadBehavior", {"behavior": "allow", "downloadPath": dest, "eventsEnabled": True}, ) except Exception as exc: print(f"set_download_dir skipped: {exc}", flush=True) # 等到下载目录出现新的 suffix 文件(忽略 .crdownload),返回最新那条路径。 def wait_for_download(self, suffix: str = ".xlsx", timeout_seconds: int = 20) -> str: dest = Path(self._download_dir or "") if not dest.is_dir(): raise RuntimeError("未设置下载目录") before = set(self._download_before) deadline = time.time() + timeout_seconds wanted = (suffix or ".xlsx").lower() while time.time() <= deadline: hits = [ p for p in dest.iterdir() if p.name not in before and p.suffix.lower() == wanted and not str(p).endswith(".crdownload") and not Path(str(p) + ".crdownload").exists() ] if hits: return str(max(hits, key=lambda p: p.stat().st_mtime)) time.sleep(0.3) raise RuntimeError(f"未下到 {wanted} 文件") # 按选择器列表读第一个非空 text/value。 def read_first(self, selectors: list, value_name: str = "") -> str: self._ensure_open() last_error = None for sel in selectors: try: el = self._find(sel) result = el.text or el.get_attribute("value") or "" if result: return result except Exception as e: last_error = e continue raise RuntimeError(f"read_first failed: {selectors}, last: {last_error}") # 读当前页 body 可见文本,读不到返回空串。 def read_body_text(self) -> str: self._ensure_open() try: return self._driver.find_element("tag name", "body").text except Exception: return "" # 把当前页截到本包 accept.artifact_dir(flow) 下的 name.png,返回路径。 def screenshot(self, name: str) -> str: from accept import artifact_dir save_dir = artifact_dir(self._flow) save_dir.mkdir(parents=True, exist_ok=True) path = save_dir / f"{name}.png" if self._driver: self._driver.save_screenshot(str(path)) return str(path) # 接受原生 alert/confirm 并返回其文本。没有弹窗则空串。 def accept_alert(self) -> str: self._ensure_open() try: alert = self._driver.switch_to.alert text = (alert.text or "").strip() alert.accept() return text except Exception: return "" # 关掉当前标签,在同一 Chrome 里新开一标签再打开 url。 def reopen_url(self, url: str) -> None: driver = self._get_driver() old = None try: old = driver.current_window_handle driver.switch_to.new_window("tab") new = driver.current_window_handle if old and old != new: try: driver.switch_to.window(old) driver.close() except Exception: pass try: driver.switch_to.window(new) except Exception: handles = driver.window_handles if handles: driver.switch_to.window(handles[-1]) except Exception: pass self.open_url(url)