812 lines
30 KiB
Python
812 lines
30 KiB
Python
# 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)
|
||
# 原生 <select>
|
||
try:
|
||
Select(el).select_by_visible_text(value)
|
||
time.sleep(0.3)
|
||
if self._selected_text_matches(el, value):
|
||
return
|
||
except Exception:
|
||
pass
|
||
# 兜底:JS 定位 option 并设置选中
|
||
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}")
|
||
|
||
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
|
||
|
||
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),
|
||
)
|
||
|
||
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}")
|
||
|
||
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)
|
||
|
||
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} 文件")
|
||
|
||
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}")
|
||
|
||
def read_body_text(self) -> str:
|
||
self._ensure_open()
|
||
try:
|
||
return self._driver.find_element("tag name", "body").text
|
||
except Exception:
|
||
return ""
|
||
|
||
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)
|