AutoRpa/rpa_browser/adapter.py

880 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)