AutoRpa/rpa_rebate/apa_browser_adapter.py

812 lines
30 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.

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