chore: auto release commit (2026-09-14 22:22:49)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
surface: browser
|
||||
status: placeholder_requires_real_site_inspection
|
||||
elements:
|
||||
- logical_name: search_input
|
||||
purpose: 输入搜索关键词
|
||||
scope:
|
||||
kind: main
|
||||
business_container: 待真实拾取
|
||||
primary_locator:
|
||||
strategy: role_or_label
|
||||
value: 待真实拾取
|
||||
fallback_locators: []
|
||||
expected_count: 1
|
||||
ready_condition:
|
||||
visible: true
|
||||
enabled: true
|
||||
postcondition:
|
||||
description: 搜索结果容器出现
|
||||
verified_target_version: 待填写
|
||||
|
||||
- logical_name: search_button
|
||||
purpose: 提交搜索
|
||||
scope:
|
||||
logical_parent: search_form
|
||||
primary_locator:
|
||||
strategy: role
|
||||
value: button,name=搜索
|
||||
fallback_locators: []
|
||||
expected_count: 1
|
||||
postcondition:
|
||||
description: 结果页或业务错误状态出现
|
||||
verified_target_version: 待填写
|
||||
|
||||
- logical_name: result_container
|
||||
purpose: 承载搜索结果
|
||||
scope:
|
||||
kind: main
|
||||
primary_locator:
|
||||
strategy: stable_business_container
|
||||
value: 待真实拾取
|
||||
fallback_locators: []
|
||||
expected_count: 1
|
||||
postcondition:
|
||||
description: 列表项、空结果或结束标记可区分
|
||||
verified_target_version: 待填写
|
||||
@@ -1,355 +0,0 @@
|
||||
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。
|
||||
|
||||
业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
|
||||
|
||||
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
|
||||
from util.logging import mask_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
|
||||
|
||||
ACCOUNT_SETUP_MESSAGE = (
|
||||
f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。"
|
||||
f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。"
|
||||
)
|
||||
|
||||
LEASE_BUSY_MESSAGE = (
|
||||
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。"
|
||||
)
|
||||
|
||||
DEFAULT_AUTH_STRATEGY = "qr_code_manual"
|
||||
DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认"
|
||||
DEFAULT_START_URL = "https://www.example.com/"
|
||||
|
||||
|
||||
class AccountManagerError(Exception):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def mask_login_id(login_id: str) -> str:
|
||||
return mask_text(login_id)
|
||||
|
||||
|
||||
def format_account_label(account: dict) -> str:
|
||||
"""人读账号标识:优先 login_id / 备注,最后才用数字 id。"""
|
||||
for key in ("login_id", "account_label", "label"):
|
||||
v = account.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
for key in ("id", "account_id"):
|
||||
v = account.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _is_placeholder_config(raw: str) -> bool:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return True
|
||||
if s.startswith("#"):
|
||||
return True
|
||||
low = s.lower()
|
||||
if low in ("default-account", "none", "null", "n/a", "-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]:
|
||||
"""从配置解析 (account_id, login_id)。
|
||||
|
||||
- ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id
|
||||
- ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id)
|
||||
- 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web
|
||||
"""
|
||||
account_id: Optional[str] = None
|
||||
login_id: Optional[str] = None
|
||||
|
||||
aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip()
|
||||
if not _is_placeholder_config(aid_raw):
|
||||
if aid_raw.isdigit():
|
||||
account_id = aid_raw
|
||||
else:
|
||||
login_id = aid_raw
|
||||
logger.info("default_account_id_treated_as_login_id value=%s", aid_raw)
|
||||
|
||||
lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip()
|
||||
if not _is_placeholder_config(lid_raw):
|
||||
login_id = lid_raw
|
||||
|
||||
if account_id:
|
||||
return account_id, None
|
||||
return None, login_id
|
||||
|
||||
|
||||
def _resolve_account_manager_main() -> str:
|
||||
"""解析 account-manager CLI 入口路径。
|
||||
|
||||
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
|
||||
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
|
||||
"""
|
||||
env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
|
||||
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
|
||||
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
|
||||
|
||||
scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
skills_root = get_sibling_skills_root(scripts_dir)
|
||||
candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py")
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
# 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录
|
||||
dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py"
|
||||
if os.path.isfile(dev):
|
||||
return dev
|
||||
|
||||
raise AccountManagerError(
|
||||
"ACCOUNT_NOT_FOUND",
|
||||
"未找到 account-manager,请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。",
|
||||
)
|
||||
|
||||
|
||||
def _parse_last_json(stdout: str) -> Any:
|
||||
lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()]
|
||||
for raw in reversed(lines):
|
||||
if raw.startswith("ERROR:"):
|
||||
continue
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。")
|
||||
|
||||
|
||||
def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]:
|
||||
main_py = _resolve_account_manager_main()
|
||||
env = os.environ.copy()
|
||||
return subprocess.run(
|
||||
[sys.executable, main_py, *argv_suffix],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_error_code(raw: str) -> str:
|
||||
code = (raw or "").strip()
|
||||
if code.startswith("ERROR:"):
|
||||
code = code[6:]
|
||||
return code
|
||||
|
||||
|
||||
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
if payload.get("success") is False:
|
||||
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||
code = _normalize_error_code(str(err.get("code") or ""))
|
||||
message = str(err.get("message") or "")
|
||||
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
|
||||
raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE)
|
||||
if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code:
|
||||
raise AccountManagerError(
|
||||
"AMBIGUOUS_ACCOUNT",
|
||||
message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。",
|
||||
)
|
||||
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
|
||||
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
|
||||
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
|
||||
|
||||
if not payload.get("profile_dir"):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
|
||||
if not payload.get("lease_token"):
|
||||
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
|
||||
return payload
|
||||
|
||||
|
||||
def _ensure_web_with_lease(
|
||||
platform: str,
|
||||
*,
|
||||
login_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
|
||||
lid = (login_id or "").strip() or None
|
||||
label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
|
||||
argv = [
|
||||
"account",
|
||||
"ensure-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--url",
|
||||
start_url,
|
||||
"--auth-strategy",
|
||||
DEFAULT_AUTH_STRATEGY,
|
||||
"--label",
|
||||
label,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
"--purpose",
|
||||
"rpa",
|
||||
]
|
||||
if lid:
|
||||
argv.extend(["--login-id", lid])
|
||||
|
||||
proc = _run_argv(argv)
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 and not out.strip():
|
||||
raise AccountManagerError(
|
||||
"ENSURE_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "ensure-web 子进程失败",
|
||||
)
|
||||
payload = _parse_last_json(out)
|
||||
if isinstance(payload, dict) and payload.get("success") is False:
|
||||
return _validate_pick_payload(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_existing_with_lease(
|
||||
platform: str,
|
||||
*,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
argv = [
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
"--purpose",
|
||||
"rpa",
|
||||
]
|
||||
if login_id:
|
||||
argv.extend(["--login-id", login_id])
|
||||
elif label:
|
||||
argv.extend(["--label", label])
|
||||
else:
|
||||
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
|
||||
|
||||
proc = _run_argv(argv)
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 and not out.strip():
|
||||
raise AccountManagerError(
|
||||
"PICK_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "pick-web 子进程失败",
|
||||
)
|
||||
payload = _parse_last_json(out)
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
proc = _run_argv(["account", "get", str(account_id)])
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 or out.startswith("ERROR:"):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
|
||||
raw = _parse_last_json(out)
|
||||
if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict):
|
||||
data = raw["data"]
|
||||
elif isinstance(raw, dict):
|
||||
data = raw
|
||||
else:
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
|
||||
|
||||
if data.get("success") is False:
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
|
||||
platform_key = str(data.get("platform_key") or "").lower()
|
||||
if platform_key != platform.lower():
|
||||
raise AccountManagerError(
|
||||
"ACCOUNT_NOT_FOUND",
|
||||
f"账号 {account_id} 不是 {platform} 平台账号。",
|
||||
)
|
||||
profile_dir = str(data.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
|
||||
if str(data.get("status") or "active").lower() != "active":
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
|
||||
|
||||
try:
|
||||
selected = _pick_existing_with_lease(
|
||||
platform,
|
||||
login_id=(str(data.get("login_id") or "").strip() or None),
|
||||
label=(str(data.get("account_label") or "").strip() or None),
|
||||
)
|
||||
except AccountManagerError as exc:
|
||||
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
|
||||
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
|
||||
raise
|
||||
if str(selected.get("id")) != str(account_id):
|
||||
release_lease(str(selected.get("lease_token") or ""))
|
||||
raise AccountManagerError(
|
||||
"AMBIGUOUS_ACCOUNT",
|
||||
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def pick_web_account(
|
||||
platform: str,
|
||||
account_id: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""获取网页账号(profile_dir + lease_token)。
|
||||
|
||||
优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。
|
||||
"""
|
||||
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
|
||||
|
||||
aid_raw = (account_id or "").strip()
|
||||
if aid_raw and not _is_placeholder_config(aid_raw):
|
||||
if not aid_raw.isdigit():
|
||||
if not (login_id or "").strip():
|
||||
login_id = aid_raw
|
||||
aid_raw = ""
|
||||
else:
|
||||
return _pick_by_id_with_lease(platform_key, int(aid_raw))
|
||||
|
||||
lid = (login_id or "").strip() or None
|
||||
if lid and _is_placeholder_config(lid):
|
||||
lid = None
|
||||
|
||||
try:
|
||||
return _ensure_web_with_lease(platform_key, login_id=lid)
|
||||
except AccountManagerError as exc:
|
||||
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
|
||||
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
|
||||
raise
|
||||
|
||||
|
||||
def release_lease(lease_token: Optional[str]) -> None:
|
||||
token = (lease_token or "").strip()
|
||||
if not token:
|
||||
return
|
||||
try:
|
||||
proc = _run_argv(["lease", "release", token])
|
||||
if proc.returncode != 0:
|
||||
logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip())
|
||||
except Exception:
|
||||
logger.warning("lease_release_exception", exc_info=True)
|
||||
@@ -1,135 +0,0 @@
|
||||
"""浏览器会话启动(系统 Chrome/Edge + Playwright persistent context)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from util.constants import DEFAULT_START_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PAGE_TIMEOUT_MS = 15_000
|
||||
GOTO_TIMEOUT_MS = 60_000
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def get_start_url() -> str:
|
||||
return (os.getenv("TARGET_START_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
|
||||
|
||||
|
||||
def _headless_from_env() -> bool:
|
||||
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _find_chrome_executable() -> str | None:
|
||||
try:
|
||||
from jiangchang_skill_core.runtime_env import find_chrome_executable
|
||||
|
||||
chrome = find_chrome_executable()
|
||||
if chrome and os.path.isfile(chrome):
|
||||
return chrome
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
candidates = [
|
||||
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
|
||||
]
|
||||
for path in candidates:
|
||||
if path and os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
async def start_browser_session(
|
||||
profile_dir: str,
|
||||
*,
|
||||
headless: Optional[bool] = None,
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""
|
||||
启动持久化 Chrome profile,new_page + goto 入口页,返回 (playwright, context, page)。
|
||||
|
||||
真实 skill 需要安装 playwright 与 jiangchang_skill_core。
|
||||
"""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
hl = headless if headless is not None else _headless_from_env()
|
||||
start_url = get_start_url()
|
||||
chrome = _find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。"
|
||||
)
|
||||
|
||||
launch_args = ["--start-maximized", "--disable-blink-features=AutomationControlled"]
|
||||
logger.info(
|
||||
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
|
||||
chrome,
|
||||
profile_dir,
|
||||
start_url,
|
||||
launch_args,
|
||||
hl,
|
||||
)
|
||||
print(f"[浏览器] 系统 Chrome: {chrome}")
|
||||
print(f"[浏览器] profile_dir: [{profile_dir}]")
|
||||
print(f"[浏览器] startup_url={start_url}")
|
||||
print(f"[浏览器] args={launch_args}")
|
||||
|
||||
pw = await async_playwright().start()
|
||||
context = None
|
||||
try:
|
||||
context = await pw.chromium.launch_persistent_context(
|
||||
user_data_dir=profile_dir,
|
||||
headless=hl,
|
||||
executable_path=chrome,
|
||||
locale="zh-CN",
|
||||
no_viewport=True,
|
||||
args=launch_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
)
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
|
||||
page = await context.new_page()
|
||||
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
||||
|
||||
try:
|
||||
await page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"浏览器已启动,但未能打开目标首页 {start_url}。"
|
||||
"请检查网络、Chrome profile、代理或页面访问状态。"
|
||||
f"({exc})"
|
||||
) from exc
|
||||
|
||||
return pw, context, page
|
||||
except Exception:
|
||||
if context is not None:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
await pw.stop()
|
||||
raise
|
||||
|
||||
|
||||
async def close_browser_context(pw, context) -> None:
|
||||
try:
|
||||
if context is not None:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if pw is not None:
|
||||
await pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,450 +0,0 @@
|
||||
"""人工验证等待:保存登录倒计时、滑块、短信验证码等(不自动破解)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from util.constants import LOG_LOGGER_NAME
|
||||
|
||||
logger = logging.getLogger(LOG_LOGGER_NAME)
|
||||
|
||||
TEXT_EXCERPT_MAX = 120
|
||||
|
||||
SAVE_LOGIN_STRONG_MARKERS = (
|
||||
"是否保存登录信息",
|
||||
"下次登录更便捷",
|
||||
"保存登录信息",
|
||||
)
|
||||
|
||||
SAVE_LOGIN_CONTAINER_SELECTORS = (
|
||||
"[role=\"dialog\"]",
|
||||
".semi-modal",
|
||||
".semi-modal-content",
|
||||
)
|
||||
|
||||
SLIDER_SELECTORS = [
|
||||
".vc-captcha-verify.slide",
|
||||
".captcha_verify_bar--title",
|
||||
".captcha-slider-tips",
|
||||
"#captcha_verify_image",
|
||||
".captcha-slider-btn",
|
||||
]
|
||||
|
||||
SMS_SELECTORS = [
|
||||
".second-verify-panel",
|
||||
".uc-ui-verify_sms-input",
|
||||
".uc-ui-verify-new_header-title",
|
||||
"input[placeholder='手机号']",
|
||||
"input[placeholder='请输入验证码']",
|
||||
]
|
||||
|
||||
DIALOG_TEXT_CONTAINERS = [
|
||||
"[role='dialog']",
|
||||
".semi-modal",
|
||||
".semi-modal-content",
|
||||
".vc-captcha-verify",
|
||||
".second-verify-panel",
|
||||
]
|
||||
|
||||
RISK_MARKERS = (
|
||||
"操作频繁",
|
||||
"访问过于频繁",
|
||||
"风险验证",
|
||||
"安全验证",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HumanVerificationState:
|
||||
present: bool
|
||||
kind: str
|
||||
message: str
|
||||
selector: str = ""
|
||||
text_excerpt: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HumanVerificationWaitResult:
|
||||
timed_out: bool
|
||||
kind: str = ""
|
||||
code: str = ""
|
||||
message: str = ""
|
||||
stage: str = ""
|
||||
|
||||
|
||||
def _text_excerpt(text: str) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", (text or "").strip())
|
||||
if len(cleaned) <= TEXT_EXCERPT_MAX:
|
||||
return cleaned
|
||||
return cleaned[:TEXT_EXCERPT_MAX] + "..."
|
||||
|
||||
|
||||
def _contains_any(text: str, markers: tuple[str, ...]) -> bool:
|
||||
return any(m in text for m in markers)
|
||||
|
||||
|
||||
async def _selector_visible(page, selector: str) -> bool:
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
return await loc.is_visible(timeout=500)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _selector_inner_text(page, selector: str) -> str:
|
||||
try:
|
||||
return await page.locator(selector).first.inner_text(timeout=1000)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _visible_text_from_first_visible(page, selectors: tuple[str, ...] | list[str]) -> tuple[str, str]:
|
||||
for sel in selectors:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
if await loc.is_visible(timeout=500):
|
||||
text = await loc.inner_text(timeout=1000)
|
||||
return sel, text or ""
|
||||
except Exception:
|
||||
continue
|
||||
return "", ""
|
||||
|
||||
|
||||
def _save_login_popup_from_text(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
if _contains_any(text, SAVE_LOGIN_STRONG_MARKERS):
|
||||
return True
|
||||
if "保存" in text and "取消" in text and ("登录" in text or "登录信息" in text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def detect_save_login_popup(page) -> bool:
|
||||
sel, text = await _visible_text_from_first_visible(page, SAVE_LOGIN_CONTAINER_SELECTORS)
|
||||
if sel and _save_login_popup_from_text(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def wait_save_login_countdown_if_present(page, timeout_sec: int = 8) -> bool:
|
||||
"""若出现“保存登录信息”倒计时弹窗则等待其消失;未出现则立即返回 False。"""
|
||||
if not await detect_save_login_popup(page):
|
||||
return False
|
||||
|
||||
logger.info("save_login_popup_detected")
|
||||
print("[登录] 检测到“保存登录信息”倒计时弹窗,等待其自动关闭...")
|
||||
start = time.monotonic()
|
||||
deadline = start + timeout_sec
|
||||
while time.monotonic() < deadline:
|
||||
if not await detect_save_login_popup(page):
|
||||
elapsed = round(time.monotonic() - start, 2)
|
||||
logger.info("save_login_popup_gone elapsed_sec=%s", elapsed)
|
||||
return True
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
elapsed = round(time.monotonic() - start, 2)
|
||||
logger.warning(
|
||||
"save_login_popup_still_visible elapsed_sec=%s timeout_sec=%s",
|
||||
elapsed,
|
||||
timeout_sec,
|
||||
)
|
||||
print(
|
||||
f"[登录] 警告:保存登录信息弹窗超过 {timeout_sec} 秒仍未消失,"
|
||||
"将继续后续流程(您可能已手工点击保存/取消)。"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def detect_slider_verification(page) -> Optional[HumanVerificationState]:
|
||||
sel_slide = ".vc-captcha-verify.slide"
|
||||
if await _selector_visible(page, sel_slide):
|
||||
text = await _selector_inner_text(page, sel_slide)
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="slider",
|
||||
message="滑块验证码",
|
||||
selector=sel_slide,
|
||||
text_excerpt=_text_excerpt(text),
|
||||
)
|
||||
|
||||
sel_title = ".captcha_verify_bar--title"
|
||||
if await _selector_visible(page, sel_title):
|
||||
title_text = await _selector_inner_text(page, sel_title)
|
||||
if "请完成下列验证后继续" in title_text:
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="slider",
|
||||
message="滑块验证码",
|
||||
selector=sel_title,
|
||||
text_excerpt=_text_excerpt(title_text),
|
||||
)
|
||||
|
||||
sel_tips = ".captcha-slider-tips"
|
||||
if await _selector_visible(page, sel_tips):
|
||||
tips_text = await _selector_inner_text(page, sel_tips)
|
||||
if "拖动完成上方拼图" in tips_text:
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="slider",
|
||||
message="滑块验证码",
|
||||
selector=sel_tips,
|
||||
text_excerpt=_text_excerpt(tips_text),
|
||||
)
|
||||
|
||||
if await _selector_visible(page, "#captcha_verify_image"):
|
||||
for btn_sel in (".captcha-slider-btn", ".captcha_verify_slide--button", ".captcha-slider"):
|
||||
if await _selector_visible(page, btn_sel):
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="slider",
|
||||
message="滑块验证码",
|
||||
selector=f"#captcha_verify_image+{btn_sel}",
|
||||
text_excerpt="",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def detect_sms_verification(page) -> Optional[HumanVerificationState]:
|
||||
sel_panel = ".second-verify-panel"
|
||||
if await _selector_visible(page, sel_panel):
|
||||
text = await _selector_inner_text(page, sel_panel)
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="sms",
|
||||
message="短信验证码",
|
||||
selector=sel_panel,
|
||||
text_excerpt=_text_excerpt(text),
|
||||
)
|
||||
|
||||
sel_sms = ".uc-ui-verify_sms-input"
|
||||
if await _selector_visible(page, sel_sms):
|
||||
text = await _selector_inner_text(page, sel_sms)
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="sms",
|
||||
message="短信验证码",
|
||||
selector=sel_sms,
|
||||
text_excerpt=_text_excerpt(text),
|
||||
)
|
||||
|
||||
sel_hdr = ".uc-ui-verify-new_header-title"
|
||||
if await _selector_visible(page, sel_hdr):
|
||||
hdr_text = await _selector_inner_text(page, sel_hdr)
|
||||
if "短信验证" in hdr_text:
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="sms",
|
||||
message="短信验证码",
|
||||
selector=sel_hdr,
|
||||
text_excerpt=_text_excerpt(hdr_text),
|
||||
)
|
||||
|
||||
phone_visible = await _selector_visible(
|
||||
page,
|
||||
".second-verify-panel input[placeholder='手机号'], .uc-ui-verify_sms-input input[placeholder='手机号']",
|
||||
)
|
||||
code_visible = await _selector_visible(
|
||||
page,
|
||||
".second-verify-panel input[placeholder='请输入验证码'], .uc-ui-verify_sms-input input[placeholder='请输入验证码']",
|
||||
)
|
||||
if phone_visible and code_visible:
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="sms",
|
||||
message="短信验证码",
|
||||
selector="sms-phone-and-code-inputs",
|
||||
text_excerpt="手机号+请输入验证码",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def detect_human_verification_from_containers(page) -> Optional[HumanVerificationState]:
|
||||
sel, text = await _visible_text_from_first_visible(page, DIALOG_TEXT_CONTAINERS)
|
||||
if not sel or not text:
|
||||
return None
|
||||
|
||||
if _contains_any(text, RISK_MARKERS):
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="risk",
|
||||
message="安全/频控验证",
|
||||
selector=sel,
|
||||
text_excerpt=_text_excerpt(text),
|
||||
)
|
||||
|
||||
if sel in (".vc-captcha-verify", ".vc-captcha-verify-visibility"):
|
||||
if "请完成下列验证后继续" in text or "拖动完成上方拼图" in text:
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="slider",
|
||||
message="滑块验证码",
|
||||
selector=sel,
|
||||
text_excerpt=_text_excerpt(text),
|
||||
)
|
||||
|
||||
if sel in (".second-verify-panel", ".uc-ui-verify_sms-input"):
|
||||
if "短信验证" in text or "获取验证码" in text:
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="sms",
|
||||
message="短信验证码",
|
||||
selector=sel,
|
||||
text_excerpt=_text_excerpt(text),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def detect_human_verification(page) -> HumanVerificationState:
|
||||
"""主检测入口:仅在验证码/弹窗容器级检测,不扫整页 body。"""
|
||||
state = await detect_slider_verification(page)
|
||||
if state is not None:
|
||||
return state
|
||||
state = await detect_sms_verification(page)
|
||||
if state is not None:
|
||||
return state
|
||||
state = await detect_human_verification_from_containers(page)
|
||||
if state is not None:
|
||||
return state
|
||||
return HumanVerificationState(present=False, kind="", message="", selector="", text_excerpt="")
|
||||
|
||||
|
||||
def detect_human_verification_from_text(text: str) -> HumanVerificationState:
|
||||
"""
|
||||
根据 HTML/容器文本判断验证弹窗(单元测试辅助,不能作为真实页面主检测入口)。
|
||||
只检查验证容器 DOM 标记,不根据正文关键词误判。
|
||||
"""
|
||||
html_lower = (text or "").lower()
|
||||
has_slider_dom = (
|
||||
"vc-captcha-verify" in html_lower
|
||||
and "slide" in html_lower
|
||||
and (
|
||||
"captcha-slider-tips" in html_lower
|
||||
or "captcha_verify_bar--title" in html_lower
|
||||
or "captcha_verify_image" in html_lower
|
||||
)
|
||||
)
|
||||
if has_slider_dom:
|
||||
selector = ".vc-captcha-verify.slide"
|
||||
if "captcha-slider-tips" in html_lower:
|
||||
selector = ".captcha-slider-tips"
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="slider",
|
||||
message="滑块验证码",
|
||||
selector=selector,
|
||||
text_excerpt="",
|
||||
)
|
||||
|
||||
has_sms_dom = "second-verify-panel" in html_lower or "uc-ui-verify_sms-input" in html_lower
|
||||
if has_sms_dom:
|
||||
selector = ".second-verify-panel" if "second-verify-panel" in html_lower else ".uc-ui-verify_sms-input"
|
||||
return HumanVerificationState(
|
||||
present=True,
|
||||
kind="sms",
|
||||
message="短信验证码",
|
||||
selector=selector,
|
||||
text_excerpt="",
|
||||
)
|
||||
|
||||
return HumanVerificationState(present=False, kind="", message="", selector="", text_excerpt="")
|
||||
|
||||
|
||||
def _wait_log_message(state: HumanVerificationState, wait_sec: int) -> str:
|
||||
if state.kind == "slider":
|
||||
return f"[人工验证] 检测到滑块验证码,请在浏览器中手工完成,最多等待 {wait_sec} 秒..."
|
||||
if state.kind == "sms":
|
||||
return f"[人工验证] 检测到短信验证码,请在浏览器中手工完成,最多等待 {wait_sec} 秒..."
|
||||
return f"[人工验证] 检测到安全验证,请在浏览器中手工处理,最多等待 {wait_sec} 秒..."
|
||||
|
||||
|
||||
def _timeout_error_code(kind: str) -> str:
|
||||
if kind == "slider":
|
||||
return "SLIDER_VERIFICATION_TIMEOUT"
|
||||
if kind == "sms":
|
||||
return "SMS_VERIFICATION_TIMEOUT"
|
||||
return "HUMAN_VERIFICATION_TIMEOUT"
|
||||
|
||||
|
||||
def _timeout_error_message(kind: str, wait_sec: int) -> str:
|
||||
if kind == "slider":
|
||||
return (
|
||||
f"滑块验证码未在 {wait_sec} 秒内完成,任务已停止。"
|
||||
"请重新运行任务并及时在浏览器中完成验证。"
|
||||
)
|
||||
if kind == "sms":
|
||||
return (
|
||||
f"短信验证码未在 {wait_sec} 秒内完成,任务已停止。"
|
||||
"请重新运行任务并及时完成短信验证。"
|
||||
)
|
||||
return (
|
||||
f"安全验证未在 {wait_sec} 秒内完成,任务已停止。"
|
||||
"请重新运行任务并及时在浏览器中完成验证。"
|
||||
)
|
||||
|
||||
|
||||
async def wait_human_verification_if_present(
|
||||
page,
|
||||
*,
|
||||
wait_sec: int = 180,
|
||||
stage: str = "unknown",
|
||||
) -> Optional[HumanVerificationWaitResult]:
|
||||
"""
|
||||
若存在人工验证则等待用户完成;无验证或已完成返回 None;超时返回 HumanVerificationWaitResult。
|
||||
不自动拖动滑块、不自动填写短信验证码。
|
||||
"""
|
||||
state = await detect_human_verification(page)
|
||||
if not state.present:
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"human_verification_detected stage=%s kind=%s selector=%s text=%s",
|
||||
stage,
|
||||
state.kind,
|
||||
state.selector,
|
||||
state.text_excerpt,
|
||||
)
|
||||
print(_wait_log_message(state, wait_sec))
|
||||
|
||||
last_state = state
|
||||
start = time.monotonic()
|
||||
deadline = start + wait_sec
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
state = await detect_human_verification(page)
|
||||
if not state.present:
|
||||
elapsed = round(time.monotonic() - start, 2)
|
||||
logger.info(
|
||||
"human_verification_passed stage=%s kind=%s elapsed_sec=%s",
|
||||
stage,
|
||||
last_state.kind,
|
||||
elapsed,
|
||||
)
|
||||
await asyncio.sleep(random.uniform(1.0, 2.0))
|
||||
return None
|
||||
last_state = state
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
logger.warning(
|
||||
"human_verification_timeout stage=%s kind=%s wait_sec=%s selector=%s",
|
||||
stage,
|
||||
last_state.kind,
|
||||
wait_sec,
|
||||
last_state.selector,
|
||||
)
|
||||
return HumanVerificationWaitResult(
|
||||
timed_out=True,
|
||||
kind=last_state.kind,
|
||||
code=_timeout_error_code(last_state.kind),
|
||||
message=_timeout_error_message(last_state.kind, wait_sec),
|
||||
stage=stage,
|
||||
)
|
||||
@@ -1,637 +0,0 @@
|
||||
"""真实网站关键词搜索 RPA 示例(async Playwright)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
from jiangchang_skill_core.rpa import poll_until
|
||||
|
||||
from service.browser_session import close_browser_context, get_start_url, start_browser_session
|
||||
from service.human_verification import (
|
||||
HumanVerificationWaitResult,
|
||||
wait_human_verification_if_present,
|
||||
wait_save_login_countdown_if_present,
|
||||
)
|
||||
from util.constants import (
|
||||
DEFAULT_MAX_ITEMS,
|
||||
DEFAULT_MAX_SCROLLS,
|
||||
DEFAULT_NO_NEW_ROUNDS_LIMIT,
|
||||
HUMAN_WAIT_TIMEOUT,
|
||||
LOG_LOGGER_NAME,
|
||||
RESULT_CONTAINER_SELECTOR,
|
||||
RESULT_END_TEXT,
|
||||
RESULT_ITEM_SELECTOR,
|
||||
SCROLL_CONTAINER_SELECTORS,
|
||||
SEARCH_BUTTON_SELECTOR,
|
||||
SEARCH_INPUT_SELECTORS,
|
||||
)
|
||||
from util.logging import mask_text, safe_log_value
|
||||
|
||||
logger = logging.getLogger(LOG_LOGGER_NAME)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScrapeError:
|
||||
code: str
|
||||
message: str
|
||||
browser_started: bool = False
|
||||
stage: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScrapeRunResult:
|
||||
success: bool
|
||||
items: List[Dict[str, Any]] = field(default_factory=list)
|
||||
stop_reason: Optional[str] = None
|
||||
error: Optional[ScrapeError] = None
|
||||
|
||||
|
||||
StepCallback = Callable[[str], None]
|
||||
|
||||
# 示例登录态 selector — 复制到新 skill 后必须用目标站点 F12/DOM 实测替换,禁止凭经验猜测或跨平台照抄
|
||||
LOGGED_IN_SELECTORS = (
|
||||
"a[href*='/user/self']",
|
||||
"[data-user-avatar]",
|
||||
".user-avatar",
|
||||
)
|
||||
LOGGED_OUT_SELECTOR = "button:has-text('登录')"
|
||||
|
||||
|
||||
def format_stop_reason_for_user(stop_reason: str | None, collected_count: int) -> str:
|
||||
"""将内部 stop_reason 枚举翻译为用户可理解的中文说明。"""
|
||||
if stop_reason == "max_items":
|
||||
return (
|
||||
f"已达到本次默认采集规模({collected_count} 条)。"
|
||||
"如需更多,可以继续说「继续采集更多」或「尽量多采集」。"
|
||||
)
|
||||
if stop_reason == "end_marker":
|
||||
return "页面提示没有更多结果,本次采集已完成。"
|
||||
if stop_reason == "no_new_rounds":
|
||||
return "连续多次加载没有发现新结果,本次采集已完成。"
|
||||
if stop_reason == "max_scrolls":
|
||||
return (
|
||||
"已达到本次页面加载保护范围,本次采集已完成。"
|
||||
"如需更深度采集,可以说明「尽量多采集」。"
|
||||
)
|
||||
if stop_reason == "human_verification_timeout":
|
||||
return "人工验证未在规定时间内完成,任务已停止。"
|
||||
return "本次采集已完成。"
|
||||
|
||||
|
||||
def _mask_account_ref(account: Dict[str, Any]) -> str:
|
||||
for key in ("login_id", "phone", "mobile"):
|
||||
raw = str(account.get(key) or "").strip()
|
||||
if raw:
|
||||
return mask_text(raw)
|
||||
aid = account.get("id") or account.get("account_id")
|
||||
if aid is not None:
|
||||
return f"id:{aid}"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _step_cb(cb: Optional[StepCallback], text: str) -> None:
|
||||
if cb:
|
||||
cb(text)
|
||||
|
||||
|
||||
async def _random_delay() -> None:
|
||||
lo = max(0.0, config.get_float("STEP_DELAY_MIN", 1.0))
|
||||
hi = max(lo, config.get_float("STEP_DELAY_MAX", 3.0))
|
||||
await interruptible_sleep(random.uniform(lo, hi))
|
||||
|
||||
|
||||
async def _scroll_wait() -> None:
|
||||
await interruptible_sleep(random.uniform(1.2, 3.5))
|
||||
|
||||
|
||||
def _headless() -> bool:
|
||||
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _no_new_limit() -> int:
|
||||
try:
|
||||
return int(os.getenv("RPA_NO_NEW_ROUNDS_LIMIT") or str(DEFAULT_NO_NEW_ROUNDS_LIMIT))
|
||||
except ValueError:
|
||||
return DEFAULT_NO_NEW_ROUNDS_LIMIT
|
||||
|
||||
|
||||
async def _visible(locator) -> bool:
|
||||
try:
|
||||
return await locator.count() > 0 and await locator.is_visible(timeout=500)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _is_logged_out(page) -> bool:
|
||||
try:
|
||||
btn = page.locator(LOGGED_OUT_SELECTOR).first
|
||||
return await _visible(btn)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _is_logged_in(page) -> bool:
|
||||
if await _is_logged_out(page):
|
||||
return False
|
||||
for sel in LOGGED_IN_SELECTORS:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
if await _visible(loc):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
async def _open_login_panel_if_needed(page) -> None:
|
||||
if not await _is_logged_out(page):
|
||||
return
|
||||
try:
|
||||
btn = page.locator(LOGGED_OUT_SELECTOR).first
|
||||
if await _visible(btn):
|
||||
await btn.click()
|
||||
await interruptible_sleep(random.uniform(1.0, 3.0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _ensure_logged_in(page, *, wait_sec: int) -> None:
|
||||
if await _is_logged_in(page):
|
||||
return
|
||||
|
||||
await _open_login_panel_if_needed(page)
|
||||
if await _is_logged_in(page):
|
||||
return
|
||||
|
||||
print(f"[登录] 请在浏览器中完成登录,最多等待 {wait_sec} 秒...")
|
||||
deadline = time.monotonic() + wait_sec
|
||||
while time.monotonic() < deadline:
|
||||
if await _is_logged_in(page):
|
||||
print("[登录] 检测到登录成功")
|
||||
return
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
raise RuntimeError(
|
||||
f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。"
|
||||
f"请重新运行任务并在浏览器打开后及时完成登录。"
|
||||
)
|
||||
|
||||
|
||||
async def _find_search_input(page) -> Tuple[Optional[Any], str]:
|
||||
for sel in SEARCH_INPUT_SELECTORS:
|
||||
try:
|
||||
loc = page.locator(sel)
|
||||
count = await loc.count()
|
||||
if count == 1 and await _visible(loc):
|
||||
return loc, sel
|
||||
if count > 1:
|
||||
logger.warning("search_input_ambiguous selector=%s count=%s", sel, count)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
ph = page.get_by_placeholder(re.compile("搜索"))
|
||||
count = await ph.count()
|
||||
if count == 1 and await ph.is_visible():
|
||||
return ph, "placeholder=搜索"
|
||||
if count > 1:
|
||||
logger.warning("search_input_ambiguous selector=placeholder_search count=%s", count)
|
||||
except Exception:
|
||||
pass
|
||||
return None, ""
|
||||
|
||||
|
||||
async def _find_search_button(page):
|
||||
try:
|
||||
btn = page.get_by_role("button", name="搜索")
|
||||
count = await btn.count()
|
||||
if count == 1 and await btn.is_visible():
|
||||
return btn
|
||||
if count > 1:
|
||||
logger.warning("search_button_ambiguous selector=role_button_search count=%s", count)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
btn = page.locator(SEARCH_BUTTON_SELECTOR)
|
||||
count = await btn.count()
|
||||
if count == 1 and await btn.is_visible():
|
||||
return btn
|
||||
if count > 1:
|
||||
logger.warning(
|
||||
"search_button_ambiguous selector=%s count=%s",
|
||||
SEARCH_BUTTON_SELECTOR,
|
||||
count,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool:
|
||||
"""有界等待搜索结果容器出现(kit poll_until;区分加载中与超时未就绪)。"""
|
||||
|
||||
async def _ready() -> bool:
|
||||
try:
|
||||
url = page.url or ""
|
||||
if "/search" in url or "search" in url.lower():
|
||||
if await page.locator(RESULT_CONTAINER_SELECTOR).count() > 0:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
result = await poll_until(_ready, timeout_sec=timeout_sec, interval_sec=0.8)
|
||||
return result.ok
|
||||
|
||||
|
||||
async def _has_end_marker(page) -> bool:
|
||||
try:
|
||||
container = page.locator(RESULT_CONTAINER_SELECTOR).first
|
||||
if await container.count() > 0:
|
||||
text = await container.inner_text(timeout=2000)
|
||||
if RESULT_END_TEXT in text or "暂时没有更多了" in text:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if await page.locator(f"text={RESULT_END_TEXT}").count() > 0:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
async def _mouse_wheel_scroll(page) -> Tuple[str, int]:
|
||||
wheel_delta = random.randint(650, 1200)
|
||||
for sel in SCROLL_CONTAINER_SELECTORS:
|
||||
loc = page.locator(sel)
|
||||
try:
|
||||
if await loc.count() > 0 and await loc.first.is_visible():
|
||||
await loc.first.hover()
|
||||
await _random_delay()
|
||||
await page.mouse.wheel(0, wheel_delta)
|
||||
return sel, wheel_delta
|
||||
except Exception:
|
||||
continue
|
||||
await page.mouse.wheel(0, wheel_delta)
|
||||
return "page", wheel_delta
|
||||
|
||||
|
||||
async def _parse_item_locator(item) -> Dict[str, Any]:
|
||||
item_id = await item.get_attribute("data-item-id") or await item.get_attribute("id") or ""
|
||||
title = ""
|
||||
try:
|
||||
title = (await item.inner_text(timeout=1000)).strip()[:200]
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"item_id": item_id or title[:32] or str(id(item)),
|
||||
"title": title,
|
||||
}
|
||||
|
||||
|
||||
async def _collect_visible_items(page, seen_ids: set[str]) -> List[Dict[str, Any]]:
|
||||
new_items: List[Dict[str, Any]] = []
|
||||
try:
|
||||
items = await page.locator(RESULT_ITEM_SELECTOR).all()
|
||||
except Exception:
|
||||
items = []
|
||||
for item in items:
|
||||
try:
|
||||
parsed = await _parse_item_locator(item)
|
||||
item_id = parsed.get("item_id")
|
||||
if not item_id or item_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(str(item_id))
|
||||
new_items.append(parsed)
|
||||
except Exception:
|
||||
continue
|
||||
return new_items
|
||||
|
||||
|
||||
async def _count_visible_items(page) -> int:
|
||||
try:
|
||||
return await page.locator(RESULT_ITEM_SELECTOR).count()
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _fail_result(error: ScrapeError, *, collected: int = 0) -> ScrapeRunResult:
|
||||
logger.warning(
|
||||
"rpa_failed code=%s stage=%s message=%s collected=%s",
|
||||
error.code,
|
||||
error.stage,
|
||||
error.message,
|
||||
collected,
|
||||
)
|
||||
return ScrapeRunResult(success=False, error=error)
|
||||
|
||||
|
||||
def _fail_on_verification_timeout(
|
||||
verification_timeout: HumanVerificationWaitResult,
|
||||
*,
|
||||
collected: int = 0,
|
||||
) -> ScrapeRunResult:
|
||||
return _fail_result(
|
||||
ScrapeError(
|
||||
code=verification_timeout.code,
|
||||
message=verification_timeout.message,
|
||||
browser_started=True,
|
||||
stage=verification_timeout.stage or "unknown",
|
||||
),
|
||||
collected=collected,
|
||||
)
|
||||
|
||||
|
||||
async def run_keyword_search_async(
|
||||
account: Dict[str, Any],
|
||||
keyword: str,
|
||||
*,
|
||||
max_items: int = DEFAULT_MAX_ITEMS,
|
||||
max_scrolls: int = DEFAULT_MAX_SCROLLS,
|
||||
data_dir: str = "",
|
||||
batch_id: str = "",
|
||||
step_callback: Optional[StepCallback] = None,
|
||||
) -> ScrapeRunResult:
|
||||
profile_dir = account.get("profile_dir") or ""
|
||||
account_ref = _mask_account_ref(account)
|
||||
wait_sec = int(os.getenv("HUMAN_WAIT_TIMEOUT") or str(HUMAN_WAIT_TIMEOUT))
|
||||
no_new_limit = _no_new_limit()
|
||||
|
||||
logger.info(
|
||||
"rpa_start keyword=%s max_items=%s max_scrolls=%s wait_sec=%s batch_id=%s account_ref=%s",
|
||||
safe_log_value(keyword),
|
||||
max_items,
|
||||
max_scrolls,
|
||||
wait_sec,
|
||||
batch_id,
|
||||
account_ref,
|
||||
)
|
||||
|
||||
if not profile_dir:
|
||||
err = ScrapeError(
|
||||
"ACCOUNT_NOT_FOUND",
|
||||
"账号缺少 profile_dir。",
|
||||
browser_started=False,
|
||||
stage="account_prepare",
|
||||
)
|
||||
return _fail_result(err)
|
||||
|
||||
pw = None
|
||||
context = None
|
||||
page = None
|
||||
browser_started = False
|
||||
collected_count = 0
|
||||
|
||||
try:
|
||||
_step_cb(step_callback, "启动关键词搜索采集任务")
|
||||
try:
|
||||
pw, context, page = await start_browser_session(profile_dir, headless=_headless())
|
||||
except RuntimeError as exc:
|
||||
msg = str(exc)
|
||||
err = ScrapeError(
|
||||
"MISSING_BROWSER" if "MISSING_BROWSER" in msg else "UNKNOWN_ERROR",
|
||||
msg.replace("ERROR:MISSING_BROWSER ", "").replace("ERROR:", ""),
|
||||
browser_started="未能打开目标首页" in msg,
|
||||
stage="browser_start" if "未能打开目标首页" in msg else "account_prepare",
|
||||
)
|
||||
return _fail_result(err)
|
||||
browser_started = True
|
||||
|
||||
logger.info(
|
||||
"browser_ready start_url=%s current_url=%s profile_dir_exists=%s",
|
||||
get_start_url(),
|
||||
page.url,
|
||||
os.path.isdir(profile_dir),
|
||||
)
|
||||
|
||||
_step_cb(step_callback, "打开目标站点首页")
|
||||
await _random_delay()
|
||||
|
||||
verification_timeout = await wait_human_verification_if_present(
|
||||
page, wait_sec=wait_sec, stage="browser_start"
|
||||
)
|
||||
if verification_timeout:
|
||||
return _fail_on_verification_timeout(verification_timeout)
|
||||
|
||||
_step_cb(step_callback, "检查账号登录状态")
|
||||
logger.info("login_check_start current_url=%s", page.url)
|
||||
try:
|
||||
await _ensure_logged_in(page, wait_sec=wait_sec)
|
||||
except RuntimeError as exc:
|
||||
msg = str(exc)
|
||||
code = "LOGIN_TIMEOUT" if "LOGIN_TIMEOUT" in msg else "REQUIRE_LOGIN"
|
||||
err = ScrapeError(
|
||||
code,
|
||||
msg.replace("ERROR:LOGIN_TIMEOUT ", "").replace("ERROR:", ""),
|
||||
browser_started=True,
|
||||
stage="login_check",
|
||||
)
|
||||
return _fail_result(err)
|
||||
logger.info("login_check_done current_url=%s", page.url)
|
||||
|
||||
await wait_save_login_countdown_if_present(page)
|
||||
|
||||
verification_timeout = await wait_human_verification_if_present(
|
||||
page, wait_sec=wait_sec, stage="login_check"
|
||||
)
|
||||
if verification_timeout:
|
||||
return _fail_on_verification_timeout(verification_timeout)
|
||||
|
||||
_step_cb(step_callback, "定位搜索框")
|
||||
search_input, input_selector = await _find_search_input(page)
|
||||
if search_input is None:
|
||||
err = ScrapeError(
|
||||
"SEARCH_INPUT_NOT_FOUND",
|
||||
"未找到搜索输入框。",
|
||||
browser_started=True,
|
||||
stage="search",
|
||||
)
|
||||
return _fail_result(err)
|
||||
logger.info("search_input_found selector=%s", input_selector)
|
||||
|
||||
_step_cb(step_callback, f"输入关键词:{keyword}")
|
||||
await search_input.click()
|
||||
await _random_delay()
|
||||
await page.keyboard.press("Control+A")
|
||||
await page.keyboard.type(keyword, delay=random.randint(90, 240))
|
||||
await _random_delay()
|
||||
|
||||
_step_cb(step_callback, "点击搜索")
|
||||
search_btn = await _find_search_button(page)
|
||||
search_method = "button"
|
||||
if search_btn is not None:
|
||||
await search_btn.click()
|
||||
else:
|
||||
search_method = "enter"
|
||||
await page.keyboard.press("Enter")
|
||||
logger.info(
|
||||
"search_submitted keyword=%s method=%s current_url=%s",
|
||||
safe_log_value(keyword),
|
||||
search_method,
|
||||
page.url,
|
||||
)
|
||||
await _random_delay()
|
||||
|
||||
verification_timeout = await wait_human_verification_if_present(
|
||||
page, wait_sec=wait_sec, stage="search_verification"
|
||||
)
|
||||
if verification_timeout:
|
||||
return _fail_on_verification_timeout(verification_timeout)
|
||||
|
||||
_step_cb(step_callback, "等待搜索结果")
|
||||
if not await _wait_search_results(page):
|
||||
verification_timeout = await wait_human_verification_if_present(
|
||||
page, wait_sec=wait_sec, stage="search_results"
|
||||
)
|
||||
if verification_timeout:
|
||||
return _fail_on_verification_timeout(verification_timeout)
|
||||
|
||||
if not await _wait_search_results(page, timeout_sec=10.0):
|
||||
err = ScrapeError(
|
||||
"RESULT_CONTAINER_NOT_FOUND",
|
||||
"搜索结果未出现,且未检测到需要人工处理的验证码,请检查关键词或页面状态。",
|
||||
browser_started=True,
|
||||
stage="search",
|
||||
)
|
||||
return _fail_result(err)
|
||||
|
||||
container_count = await page.locator(RESULT_CONTAINER_SELECTOR).count()
|
||||
logger.info(
|
||||
"search_results_ready current_url=%s container_count=%s",
|
||||
page.url,
|
||||
container_count,
|
||||
)
|
||||
|
||||
try:
|
||||
await page.locator(RESULT_CONTAINER_SELECTOR).first.wait_for(state="visible", timeout=15_000)
|
||||
except Exception:
|
||||
err = ScrapeError(
|
||||
"RESULT_CONTAINER_NOT_FOUND",
|
||||
"搜索结果容器未出现。",
|
||||
browser_started=True,
|
||||
stage="search",
|
||||
)
|
||||
return _fail_result(err)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
collected: List[Dict[str, Any]] = []
|
||||
no_new_rounds = 0
|
||||
stop_reason = "max_scrolls"
|
||||
rounds_executed = 0
|
||||
|
||||
for scroll_round in range(max_scrolls + 1):
|
||||
rounds_executed = scroll_round
|
||||
_step_cb(step_callback, "读取当前结果列表")
|
||||
visible_count = await _count_visible_items(page)
|
||||
batch = await _collect_visible_items(page, seen_ids)
|
||||
new_count = len(batch)
|
||||
if batch:
|
||||
collected.extend(batch)
|
||||
no_new_rounds = 0
|
||||
else:
|
||||
no_new_rounds += 1
|
||||
|
||||
collected_count = len(collected)
|
||||
end_marker = await _has_end_marker(page)
|
||||
logger.info(
|
||||
"collect_round round=%s max_scrolls=%s visible_count=%s new_count=%s "
|
||||
"collected=%s no_new_rounds=%s no_new_limit=%s end_marker=%s current_url=%s",
|
||||
scroll_round,
|
||||
max_scrolls,
|
||||
visible_count,
|
||||
new_count,
|
||||
collected_count,
|
||||
no_new_rounds,
|
||||
no_new_limit,
|
||||
end_marker,
|
||||
page.url,
|
||||
)
|
||||
|
||||
if len(collected) >= max_items:
|
||||
collected = collected[:max_items]
|
||||
stop_reason = "max_items"
|
||||
break
|
||||
|
||||
if end_marker:
|
||||
stop_reason = "end_marker"
|
||||
_step_cb(step_callback, "发现结果到底或达到采集数量")
|
||||
break
|
||||
|
||||
if no_new_rounds >= no_new_limit:
|
||||
stop_reason = "no_new_rounds"
|
||||
logger.info(
|
||||
"collect_no_new_stop round=%s no_new_rounds=%s no_new_limit=%s collected=%s",
|
||||
scroll_round,
|
||||
no_new_rounds,
|
||||
no_new_limit,
|
||||
collected_count,
|
||||
)
|
||||
break
|
||||
|
||||
if scroll_round >= max_scrolls:
|
||||
break
|
||||
|
||||
_step_cb(step_callback, "滚动加载更多结果")
|
||||
scroll_target, wheel_delta = await _mouse_wheel_scroll(page)
|
||||
logger.info(
|
||||
"scroll round=%s target=%s wheel_delta=%s",
|
||||
scroll_round,
|
||||
scroll_target,
|
||||
wheel_delta,
|
||||
)
|
||||
await _scroll_wait()
|
||||
|
||||
verification_timeout = await wait_human_verification_if_present(
|
||||
page, wait_sec=wait_sec, stage="collect"
|
||||
)
|
||||
if verification_timeout:
|
||||
return _fail_on_verification_timeout(
|
||||
verification_timeout,
|
||||
collected=collected_count,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"collect_stop reason=%s collected=%s rounds=%s no_new_rounds=%s",
|
||||
stop_reason,
|
||||
collected_count,
|
||||
rounds_executed,
|
||||
no_new_rounds,
|
||||
)
|
||||
|
||||
if not collected:
|
||||
err = ScrapeError(
|
||||
"NO_RESULTS",
|
||||
"未采集到任何结果。",
|
||||
browser_started=True,
|
||||
stage="collect",
|
||||
)
|
||||
return _fail_result(err, collected=0)
|
||||
|
||||
_step_cb(step_callback, "采集完成")
|
||||
logger.info(
|
||||
"rpa_success collected=%s stop_reason=%s",
|
||||
len(collected[:max_items]),
|
||||
stop_reason,
|
||||
)
|
||||
return ScrapeRunResult(success=True, items=collected[:max_items], stop_reason=stop_reason)
|
||||
|
||||
except Exception as exc:
|
||||
err = ScrapeError(
|
||||
"UNKNOWN_ERROR",
|
||||
str(exc),
|
||||
browser_started=browser_started,
|
||||
stage="rpa",
|
||||
)
|
||||
return _fail_result(err, collected=collected_count)
|
||||
finally:
|
||||
await close_browser_context(pw, context)
|
||||
@@ -1,146 +0,0 @@
|
||||
"""轻量任务编排示例(无真实数据库依赖)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from service.account_client import (
|
||||
AccountManagerError,
|
||||
pick_web_account,
|
||||
release_lease,
|
||||
resolve_account_selectors_from_config,
|
||||
)
|
||||
from service.task_rpa import (
|
||||
ScrapeRunResult,
|
||||
format_stop_reason_for_user,
|
||||
run_keyword_search_async,
|
||||
)
|
||||
from util.constants import (
|
||||
DEFAULT_MAX_ITEMS,
|
||||
DEFAULT_MAX_SCROLLS,
|
||||
SKILL_SLUG,
|
||||
TARGET_PLATFORM,
|
||||
)
|
||||
|
||||
|
||||
def _error_payload(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
browser_started: Optional[bool] = None,
|
||||
stage: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"success": False,
|
||||
"error": {"code": code, "message": message},
|
||||
}
|
||||
if browser_started is not None:
|
||||
payload["browser_started"] = browser_started
|
||||
if stage:
|
||||
payload["stage"] = stage
|
||||
return payload
|
||||
|
||||
|
||||
def _success_payload(
|
||||
*,
|
||||
keyword: str,
|
||||
collected_count: int,
|
||||
items: list[dict[str, Any]],
|
||||
stop_reason: Optional[str],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"success": True,
|
||||
"skill": SKILL_SLUG,
|
||||
"keyword": keyword,
|
||||
"collected_count": collected_count,
|
||||
"items": items,
|
||||
"stop_reason": stop_reason,
|
||||
"stop_reason_message": format_stop_reason_for_user(stop_reason, collected_count),
|
||||
}
|
||||
|
||||
|
||||
async def run_keyword_search_task(
|
||||
keyword: str,
|
||||
*,
|
||||
max_items: int = DEFAULT_MAX_ITEMS,
|
||||
max_scrolls: int = DEFAULT_MAX_SCROLLS,
|
||||
account_id: Optional[str] = None,
|
||||
platform: str = TARGET_PLATFORM,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
编排一次关键词搜索采集。
|
||||
|
||||
真实 skill 中还应:
|
||||
- 写 SQLite 入库(items 表)
|
||||
- 使用 RpaVideoSession 录制步骤留痕
|
||||
- 失败时 capture_failure 截图
|
||||
"""
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
data_dir = "" # 示例占位;真实 skill 使用 get_skill_data_dir()
|
||||
lease_token: Optional[str] = None
|
||||
account: Optional[dict[str, Any]] = None
|
||||
|
||||
try:
|
||||
cfg_account_id, cfg_login_id = resolve_account_selectors_from_config()
|
||||
account = pick_web_account(
|
||||
platform,
|
||||
account_id=account_id or cfg_account_id,
|
||||
login_id=cfg_login_id,
|
||||
)
|
||||
lease_token = account.get("lease_token")
|
||||
|
||||
scrape_result: ScrapeRunResult = await run_keyword_search_async(
|
||||
account,
|
||||
keyword,
|
||||
max_items=max_items,
|
||||
max_scrolls=max_scrolls,
|
||||
data_dir=data_dir,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
if not scrape_result.success:
|
||||
err = scrape_result.error
|
||||
return _error_payload(
|
||||
err.code if err else "UNKNOWN_ERROR",
|
||||
err.message if err else "未知错误",
|
||||
browser_started=err.browser_started if err else True,
|
||||
stage=err.stage if err else "rpa",
|
||||
)
|
||||
|
||||
collected_count = len(scrape_result.items)
|
||||
return _success_payload(
|
||||
keyword=keyword,
|
||||
collected_count=collected_count,
|
||||
items=scrape_result.items[:10],
|
||||
stop_reason=scrape_result.stop_reason,
|
||||
)
|
||||
except AccountManagerError as exc:
|
||||
return _error_payload(
|
||||
exc.code,
|
||||
exc.message,
|
||||
browser_started=False,
|
||||
stage="account_prepare",
|
||||
)
|
||||
finally:
|
||||
release_lease(lease_token)
|
||||
|
||||
|
||||
def run_keyword_search_task_sync(
|
||||
keyword: str,
|
||||
*,
|
||||
max_items: int = DEFAULT_MAX_ITEMS,
|
||||
max_scrolls: int = DEFAULT_MAX_SCROLLS,
|
||||
account_id: Optional[str] = None,
|
||||
platform: str = TARGET_PLATFORM,
|
||||
) -> dict[str, Any]:
|
||||
return asyncio.run(
|
||||
run_keyword_search_task(
|
||||
keyword,
|
||||
max_items=max_items,
|
||||
max_scrolls=max_scrolls,
|
||||
account_id=account_id,
|
||||
platform=platform,
|
||||
)
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
"""示例常量(复制到新 skill 时请替换为目标站点与业务字段)。"""
|
||||
|
||||
SKILL_SLUG = "example-real-browser-rpa"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.example_real_browser_rpa"
|
||||
|
||||
TARGET_PLATFORM = "target_platform"
|
||||
LEASE_HOLDER = "example-real-browser-rpa"
|
||||
LEASE_TTL_SEC = "1800"
|
||||
|
||||
DEFAULT_START_URL = "https://example.com"
|
||||
DEFAULT_MAX_ITEMS = 100
|
||||
DEFAULT_MAX_SCROLLS = 40
|
||||
DEFAULT_NO_NEW_ROUNDS_LIMIT = 5
|
||||
HUMAN_WAIT_TIMEOUT = 180
|
||||
|
||||
RESULT_END_TEXT = "没有更多结果了"
|
||||
|
||||
# 示例 selector — 复制到新 skill 时按目标站点 DOM 替换
|
||||
SEARCH_INPUT_SELECTORS = (
|
||||
'input[type="search"]',
|
||||
'input[name="q"]',
|
||||
'input[placeholder*="搜索"]',
|
||||
)
|
||||
SEARCH_BUTTON_SELECTOR = 'button:has-text("搜索")'
|
||||
RESULT_CONTAINER_SELECTOR = "#search-result-container"
|
||||
RESULT_ITEM_SELECTOR = "[data-result-item]"
|
||||
SCROLL_CONTAINER_SELECTORS = (
|
||||
"#search-result-container",
|
||||
".search-result-scroll",
|
||||
".route-scroll-container",
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""示例结构化日志工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from util.constants import LOG_LOGGER_NAME
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
return logging.getLogger(name or LOG_LOGGER_NAME)
|
||||
|
||||
|
||||
def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str:
|
||||
s = (value or "").strip()
|
||||
if len(s) >= 11 and s.isdigit():
|
||||
return s[:3] + "****" + s[-4:]
|
||||
if len(s) > keep_start + keep_end:
|
||||
return s[:keep_start] + "****" + s[-keep_end:]
|
||||
return "****"
|
||||
|
||||
|
||||
def safe_log_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
if re.fullmatch(r"\d{7,}", text):
|
||||
return mask_text(text)
|
||||
if "@" in text and len(text) > 6:
|
||||
local, _, domain = text.partition("@")
|
||||
return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}"
|
||||
return text
|
||||
@@ -1,10 +0,0 @@
|
||||
"""pytest 路径配置:将 examples/real_browser_rpa/scripts 加入 import 路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
@@ -1,63 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from service.account_client import AccountManagerError, pick_web_account
|
||||
|
||||
|
||||
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=["python", "account-manager"],
|
||||
returncode=0,
|
||||
stdout=json.dumps(payload, ensure_ascii=False),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
|
||||
class TestExplicitAccountLease(unittest.TestCase):
|
||||
@patch("service.account_client._run_argv")
|
||||
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
|
||||
account = {
|
||||
"id": 42,
|
||||
"platform_key": "target-platform",
|
||||
"account_label": "主账号",
|
||||
"login_id": "user@example.com",
|
||||
"profile_dir": "D:/data/profile-42",
|
||||
"status": "active",
|
||||
}
|
||||
leased = {**account, "lease_token": "lease-42"}
|
||||
run_argv.side_effect = [_completed(account), _completed(leased)]
|
||||
|
||||
result = pick_web_account("target-platform", account_id="42")
|
||||
|
||||
self.assertEqual(result["id"], 42)
|
||||
self.assertEqual(result["lease_token"], "lease-42")
|
||||
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
|
||||
pick_args = run_argv.call_args_list[1].args[0]
|
||||
self.assertIn("pick-web", pick_args)
|
||||
self.assertIn("--lease", pick_args)
|
||||
self.assertIn("user@example.com", pick_args)
|
||||
|
||||
@patch("service.account_client._run_argv")
|
||||
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
|
||||
account = {
|
||||
"id": 42,
|
||||
"platform_key": "target-platform",
|
||||
"account_label": "主账号",
|
||||
"login_id": "user@example.com",
|
||||
"profile_dir": "D:/data/profile-42",
|
||||
"status": "active",
|
||||
}
|
||||
run_argv.side_effect = [_completed(account), _completed(account)]
|
||||
|
||||
with self.assertRaises(AccountManagerError) as raised:
|
||||
pick_web_account("target-platform", account_id="42")
|
||||
|
||||
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,170 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""人工验证检测单元测试(DOM 精准检测,不依赖真实浏览器)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from service.human_verification import (
|
||||
HumanVerificationWaitResult,
|
||||
detect_human_verification,
|
||||
detect_human_verification_from_text,
|
||||
wait_human_verification_if_present,
|
||||
wait_save_login_countdown_if_present,
|
||||
)
|
||||
|
||||
|
||||
SLIDER_HTML = """
|
||||
<div class="vc-captcha-verify slide">
|
||||
<div class="captcha_verify_bar--title">请完成下列验证后继续</div>
|
||||
<div class="captcha-slider-tips">按住左边按钮拖动完成上方拼图</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
SMS_HTML = """
|
||||
<div class="second-verify-panel">
|
||||
<div class="uc-ui-verify_sms-input">
|
||||
<div class="uc-ui-verify-new_header-title">短信验证</div>
|
||||
<input placeholder="手机号" />
|
||||
<input placeholder="请输入验证码" />
|
||||
<button>获取验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
SEARCH_RESULTS_HTML = """
|
||||
<div id="search-result-container">
|
||||
<div data-result-item="1">RPA 滑块验证码识别教程</div>
|
||||
<div data-result-item="2">短信验证码自动化</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
class MockLocator:
|
||||
def __init__(self, selector: str, visible: set[str], texts: dict[str, str]) -> None:
|
||||
self._selector = selector
|
||||
self._visible = visible
|
||||
self._texts = texts
|
||||
|
||||
@property
|
||||
def first(self) -> MockLocator:
|
||||
return self
|
||||
|
||||
async def is_visible(self, timeout: int = 500) -> bool:
|
||||
return self._selector in self._visible
|
||||
|
||||
async def inner_text(self, timeout: int = 1000) -> str:
|
||||
return self._texts.get(self._selector, "")
|
||||
|
||||
async def count(self) -> int:
|
||||
return 1 if self._selector in self._visible else 0
|
||||
|
||||
|
||||
class MockPage:
|
||||
def __init__(self, visible: set[str], texts: dict[str, str] | None = None) -> None:
|
||||
self._visible = visible
|
||||
self._texts = texts or {}
|
||||
|
||||
def locator(self, selector: str) -> MockLocator:
|
||||
return MockLocator(selector, self._visible, self._texts)
|
||||
|
||||
|
||||
class TestHumanVerificationFromText(unittest.TestCase):
|
||||
def test_slider_dom_detect(self) -> None:
|
||||
state = detect_human_verification_from_text(SLIDER_HTML)
|
||||
self.assertTrue(state.present)
|
||||
self.assertEqual(state.kind, "slider")
|
||||
self.assertTrue(state.selector)
|
||||
|
||||
def test_sms_dom_detect(self) -> None:
|
||||
state = detect_human_verification_from_text(SMS_HTML)
|
||||
self.assertTrue(state.present)
|
||||
self.assertEqual(state.kind, "sms")
|
||||
self.assertTrue(state.selector)
|
||||
|
||||
def test_search_results_not_misclassified(self) -> None:
|
||||
state = detect_human_verification_from_text(SEARCH_RESULTS_HTML)
|
||||
self.assertFalse(state.present)
|
||||
|
||||
def test_body_keywords_without_dom_not_misclassified(self) -> None:
|
||||
text = "RPA 滑块验证码识别教程\n短信验证码自动化"
|
||||
state = detect_human_verification_from_text(text)
|
||||
self.assertFalse(state.present)
|
||||
|
||||
|
||||
class TestHumanVerificationDomDetect(unittest.TestCase):
|
||||
def test_slider_dom_visible(self) -> None:
|
||||
page = MockPage(
|
||||
{".vc-captcha-verify.slide"},
|
||||
{".vc-captcha-verify.slide": "请完成下列验证后继续"},
|
||||
)
|
||||
state = asyncio.run(detect_human_verification(page))
|
||||
self.assertTrue(state.present)
|
||||
self.assertEqual(state.kind, "slider")
|
||||
self.assertEqual(state.selector, ".vc-captcha-verify.slide")
|
||||
|
||||
def test_sms_dom_visible(self) -> None:
|
||||
page = MockPage(
|
||||
{".second-verify-panel"},
|
||||
{".second-verify-panel": "短信验证 获取验证码"},
|
||||
)
|
||||
state = asyncio.run(detect_human_verification(page))
|
||||
self.assertTrue(state.present)
|
||||
self.assertEqual(state.kind, "sms")
|
||||
|
||||
def test_no_verification_when_empty(self) -> None:
|
||||
page = MockPage(set())
|
||||
state = asyncio.run(detect_human_verification(page))
|
||||
self.assertFalse(state.present)
|
||||
|
||||
|
||||
class TestSaveLoginCountdown(unittest.TestCase):
|
||||
def test_wait_not_present_no_delay(self) -> None:
|
||||
page = MockPage(set())
|
||||
start = time.monotonic()
|
||||
result = asyncio.run(wait_save_login_countdown_if_present(page, timeout_sec=8))
|
||||
elapsed = time.monotonic() - start
|
||||
self.assertFalse(result)
|
||||
self.assertLess(elapsed, 1.0)
|
||||
|
||||
|
||||
class TestHumanVerificationWait(unittest.TestCase):
|
||||
def test_wait_timeout_slider_dom(self) -> None:
|
||||
page = MockPage(
|
||||
{".vc-captcha-verify.slide"},
|
||||
{".vc-captcha-verify.slide": "请完成下列验证后继续"},
|
||||
)
|
||||
result = asyncio.run(
|
||||
wait_human_verification_if_present(page, wait_sec=1, stage="search_verification")
|
||||
)
|
||||
self.assertIsInstance(result, HumanVerificationWaitResult)
|
||||
self.assertTrue(result.timed_out)
|
||||
self.assertEqual(result.code, "SLIDER_VERIFICATION_TIMEOUT")
|
||||
self.assertIn("滑块验证码未在 1 秒内完成", result.message)
|
||||
self.assertEqual(result.stage, "search_verification")
|
||||
|
||||
def test_wait_timeout_sms_dom(self) -> None:
|
||||
page = MockPage(
|
||||
{".second-verify-panel"},
|
||||
{".second-verify-panel": "短信验证 获取验证码"},
|
||||
)
|
||||
result = asyncio.run(
|
||||
wait_human_verification_if_present(page, wait_sec=1, stage="login_check")
|
||||
)
|
||||
self.assertIsInstance(result, HumanVerificationWaitResult)
|
||||
self.assertTrue(result.timed_out)
|
||||
self.assertEqual(result.code, "SMS_VERIFICATION_TIMEOUT")
|
||||
self.assertEqual(result.stage, "login_check")
|
||||
|
||||
def test_collect_no_verification_immediate_none(self) -> None:
|
||||
page = MockPage(set())
|
||||
start = time.monotonic()
|
||||
err = asyncio.run(wait_human_verification_if_present(page, wait_sec=180, stage="collect"))
|
||||
elapsed = time.monotonic() - start
|
||||
self.assertIsNone(err)
|
||||
self.assertLess(elapsed, 1.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,34 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""stop_reason 用户友好文案单元测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from service.task_rpa import format_stop_reason_for_user
|
||||
|
||||
|
||||
class TestFormatStopReasonForUser(unittest.TestCase):
|
||||
def test_max_items(self) -> None:
|
||||
msg = format_stop_reason_for_user("max_items", 100)
|
||||
self.assertIn("默认采集规模", msg)
|
||||
self.assertIn("100", msg)
|
||||
|
||||
def test_end_marker(self) -> None:
|
||||
msg = format_stop_reason_for_user("end_marker", 42)
|
||||
self.assertIn("没有更多结果", msg)
|
||||
|
||||
def test_no_new_rounds(self) -> None:
|
||||
msg = format_stop_reason_for_user("no_new_rounds", 30)
|
||||
self.assertIn("连续多次加载没有发现新结果", msg)
|
||||
|
||||
def test_max_scrolls(self) -> None:
|
||||
msg = format_stop_reason_for_user("max_scrolls", 80)
|
||||
self.assertIn("页面加载保护范围", msg)
|
||||
|
||||
def test_none_or_unknown(self) -> None:
|
||||
self.assertEqual(format_stop_reason_for_user(None, 10), "本次采集已完成。")
|
||||
self.assertEqual(format_stop_reason_for_user("unknown", 10), "本次采集已完成。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,117 +0,0 @@
|
||||
source_case: add-friend-wecom
|
||||
status: verified_source_redacted_example
|
||||
surface: windows
|
||||
driver: uiautomation
|
||||
notes:
|
||||
- 选择器显示名 ≠ UIA Name;可见中文常在 Value / 子 Label
|
||||
- 禁止全树第一个 status_layout / wbtn_container
|
||||
- 主窗流程先 maximize;activate 勿无条件 SW_RESTORE
|
||||
elements:
|
||||
- logical_name: main_window
|
||||
purpose: 企业微信主窗口
|
||||
scope:
|
||||
process_name: WXWork
|
||||
primary_locator:
|
||||
window_class: WeWorkWindow
|
||||
title: 企业微信
|
||||
expected_count: 1
|
||||
postcondition: UIA root 可读取;业务需要时已最大化
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: contacts_entry
|
||||
purpose: 打开通讯录
|
||||
scope:
|
||||
logical_parent: main_window
|
||||
primary_locator:
|
||||
name: org_op_new
|
||||
control_type: TreeItem
|
||||
value_hint: 通讯录
|
||||
expected_count: 1
|
||||
postcondition: 出现企业微信-通讯录业务 pane
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: contacts_pane
|
||||
purpose: 通讯录业务子层作用域
|
||||
scope:
|
||||
logical_parent: main_window
|
||||
primary_locator:
|
||||
name_contains: 通讯录
|
||||
expected_count: 1
|
||||
postcondition: 可枚举 wlist_view
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: new_customer_row
|
||||
purpose: 侧栏进入「新的客户」
|
||||
scope:
|
||||
logical_parent: contacts_pane
|
||||
ancestor_name: wlist_view
|
||||
primary_locator:
|
||||
row_leaf_name: status_layout
|
||||
child_name: main_chattext
|
||||
child_value: 新的客户
|
||||
expected_count: 1
|
||||
postcondition: panel_title_label Value=新的客户 或出现 search_external
|
||||
failure_note: 禁止点全树第一个 status_layout(常为群聊)
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: panel_title
|
||||
purpose: 校验已进入新的客户页
|
||||
scope:
|
||||
logical_parent: contacts_pane
|
||||
primary_locator:
|
||||
name: panel_title_label
|
||||
value: 新的客户
|
||||
expected_count: 1
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: add_customer_button
|
||||
purpose: 打开添加客户窗口
|
||||
scope:
|
||||
logical_parent: contacts_pane
|
||||
ancestor_name: search_external
|
||||
primary_locator:
|
||||
name: wbtn_container
|
||||
child_name: ww_btn_text
|
||||
child_value: 添加
|
||||
expected_count: 1
|
||||
postcondition: 出现 SearchExternalsWnd
|
||||
failure_note: 禁止点搜索旁 plus_button 下的 wbtn_container
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: add_customer_window
|
||||
purpose: 添加客户窗口
|
||||
scope:
|
||||
process_name: WXWork
|
||||
primary_locator:
|
||||
window_class: SearchExternalsWnd
|
||||
title: 添加客户
|
||||
expected_count: 1
|
||||
postcondition: search_input 可定位
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: search_input
|
||||
purpose: 输入待搜索账号
|
||||
scope:
|
||||
logical_parent: add_customer_window
|
||||
ancestor_name: search_layout
|
||||
primary_locator:
|
||||
name: searchedit
|
||||
expected_count: 1
|
||||
ready_condition:
|
||||
visible: true
|
||||
enabled: true
|
||||
postcondition: 结果列表或用户不存在弹窗出现
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: verify_message_input
|
||||
purpose: 填写添加验证语
|
||||
scope:
|
||||
window_class: InputReasonWnd
|
||||
title_any:
|
||||
- 输入认证信息
|
||||
- 发送添加邀请
|
||||
primary_locator:
|
||||
name: name_edit
|
||||
expected_count: 1
|
||||
postcondition: 输入值可读取或发送按钮可用
|
||||
replace_in_new_skill: true
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
脱敏教学摘录:展示“拾取器长路径”而非可直接复制的生产 selector。
|
||||
生产代码应保留稳定窗口、业务父容器和叶子属性,删除无业务含义的深层 index。
|
||||
-->
|
||||
<selector name="search_input" source="inspector-export">
|
||||
<window process="WXWork" class="SearchExternalsWnd" title="添加客户" />
|
||||
<uia name="content_container" index="1" />
|
||||
<uia name="search_layout" index="0" />
|
||||
<uia name="inner_search_layout" index="1" />
|
||||
<uia name="searchedit" index="1" />
|
||||
</selector>
|
||||
|
||||
<!--
|
||||
新的客户:长路径示意 → 生产应改为
|
||||
wlist_view 内 child main_chattext.Value=新的客户 的行,再点该行 status_layout。
|
||||
禁止全树第一个 status_layout。
|
||||
-->
|
||||
<selector name="new_customer_row" source="inspector-export">
|
||||
<window process="WXWork" class="WeWorkWindow" title="企业微信" />
|
||||
<pane name="企业微信-通讯录" />
|
||||
<uia name="wlist_view" />
|
||||
<uia name="item" index="2" />
|
||||
<uia name="status_layout" />
|
||||
<uia name="main_chattext" value="新的客户" />
|
||||
</selector>
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ntpath
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from util.selectors import WindowSpec
|
||||
|
||||
|
||||
class DesktopRpaError(RuntimeError):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(f"ERROR:{code} {message}")
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowCandidate:
|
||||
hwnd: int
|
||||
pid: int
|
||||
process_name: str
|
||||
class_name: str
|
||||
title: str
|
||||
visible: bool
|
||||
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
SW_RESTORE = 9
|
||||
SW_MAXIMIZE = 3
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
|
||||
|
||||
def _process_name(pid: int) -> str:
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return ""
|
||||
try:
|
||||
size = wintypes.DWORD(32768)
|
||||
buffer = ctypes.create_unicode_buffer(size.value)
|
||||
if not kernel32.QueryFullProcessImageNameW(
|
||||
handle,
|
||||
0,
|
||||
buffer,
|
||||
ctypes.byref(size),
|
||||
):
|
||||
return ""
|
||||
return ntpath.splitext(ntpath.basename(buffer.value))[0]
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def enumerate_top_windows() -> list[WindowCandidate]:
|
||||
found: list[WindowCandidate] = []
|
||||
|
||||
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||||
def callback(hwnd, _lparam):
|
||||
pid = wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
cls = ctypes.create_unicode_buffer(256)
|
||||
user32.GetClassNameW(hwnd, cls, 256)
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
title = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, title, length + 1)
|
||||
found.append(
|
||||
WindowCandidate(
|
||||
hwnd=int(hwnd),
|
||||
pid=int(pid.value),
|
||||
process_name=_process_name(int(pid.value)),
|
||||
class_name=cls.value,
|
||||
title=title.value,
|
||||
visible=bool(user32.IsWindowVisible(hwnd)),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
user32.EnumWindows(callback, 0)
|
||||
return found
|
||||
|
||||
|
||||
def resolve_unique_window(
|
||||
spec: WindowSpec,
|
||||
*,
|
||||
candidates: Iterable[WindowCandidate] | None = None,
|
||||
) -> WindowCandidate:
|
||||
pool = list(candidates) if candidates is not None else enumerate_top_windows()
|
||||
matched = [
|
||||
item
|
||||
for item in pool
|
||||
if item.visible
|
||||
and item.process_name.casefold() == spec.process_name.casefold()
|
||||
and item.class_name == spec.class_name
|
||||
and (spec.title is None or item.title == spec.title)
|
||||
]
|
||||
if not matched:
|
||||
raise DesktopRpaError("WINDOW_NOT_FOUND", f"{spec.logical_name} 未找到")
|
||||
if len(matched) != 1:
|
||||
raise DesktopRpaError(
|
||||
"ELEMENT_AMBIGUOUS",
|
||||
f"{spec.logical_name} 匹配 {len(matched)} 个窗口,请按 PID/账号补充作用域",
|
||||
)
|
||||
return matched[0]
|
||||
|
||||
|
||||
def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None:
|
||||
"""激活窗口。
|
||||
|
||||
maximize=True:主窗进入依赖完整侧栏的流程时使用。
|
||||
maximize=False:只前置;**禁止**对已最大化窗口无条件 SW_RESTORE。
|
||||
"""
|
||||
hwnd = window.hwnd
|
||||
iconic = bool(user32.IsIconic(hwnd))
|
||||
zoomed = bool(user32.IsZoomed(hwnd))
|
||||
if maximize:
|
||||
if iconic:
|
||||
user32.ShowWindow(hwnd, SW_RESTORE)
|
||||
if not zoomed:
|
||||
user32.ShowWindow(hwnd, SW_MAXIMIZE)
|
||||
else:
|
||||
if iconic:
|
||||
user32.ShowWindow(hwnd, SW_RESTORE)
|
||||
if not user32.SetForegroundWindow(hwnd):
|
||||
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={hwnd}")
|
||||
time.sleep(0.35 if maximize else 0.2)
|
||||
@@ -1,127 +0,0 @@
|
||||
"""作用域内 UIA 唯一定位、真实点击和逐字输入。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Iterator
|
||||
|
||||
from service.desktop_session import DesktopRpaError, WindowCandidate, activate_window
|
||||
from util.selectors import ElementSpec
|
||||
|
||||
|
||||
def require_uiautomation():
|
||||
try:
|
||||
import uiautomation as auto
|
||||
except ImportError as exc:
|
||||
raise DesktopRpaError("UIA_UNAVAILABLE", "缺少 uiautomation 依赖") from exc
|
||||
return auto
|
||||
|
||||
|
||||
def root_from_window(window: WindowCandidate):
|
||||
auto = require_uiautomation()
|
||||
return auto.ControlFromHandle(window.hwnd)
|
||||
|
||||
|
||||
def control_value(control: Any) -> str:
|
||||
try:
|
||||
auto = require_uiautomation()
|
||||
raw = control.GetPropertyValue(auto.PropertyId.ValueValueProperty)
|
||||
return str(raw or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]:
|
||||
if depth > max_depth:
|
||||
return
|
||||
yield root
|
||||
if depth == max_depth:
|
||||
return
|
||||
try:
|
||||
children = root.GetChildren()
|
||||
except Exception:
|
||||
children = []
|
||||
for child in children:
|
||||
yield from _walk(child, depth=depth + 1, max_depth=max_depth)
|
||||
|
||||
|
||||
def _child_has_value(control: Any, child_name: str, expected_value: str, *, max_depth: int = 10) -> bool:
|
||||
for node in _walk(control, max_depth=max_depth):
|
||||
if (getattr(node, "Name", None) or "") != child_name:
|
||||
continue
|
||||
if control_value(node) == expected_value:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _matches(control: Any, spec: ElementSpec) -> bool:
|
||||
if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name:
|
||||
return False
|
||||
if spec.value is not None and not spec.child_value_name:
|
||||
if control_value(control) != spec.value:
|
||||
return False
|
||||
if spec.automation_id is not None and (
|
||||
getattr(control, "AutomationId", None) or ""
|
||||
) != spec.automation_id:
|
||||
return False
|
||||
if spec.control_type is not None and (
|
||||
(getattr(control, "ControlTypeName", None) or "").removesuffix("Control")
|
||||
!= spec.control_type.removesuffix("Control")
|
||||
):
|
||||
return False
|
||||
if spec.class_name is not None and (
|
||||
getattr(control, "ClassName", None) or ""
|
||||
) != spec.class_name:
|
||||
return False
|
||||
if spec.child_value_name and spec.value:
|
||||
if not _child_has_value(control, spec.child_value_name, spec.value):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
|
||||
search_root = root
|
||||
if spec.ancestor_name:
|
||||
ancestor_spec = ElementSpec(
|
||||
logical_name=f"{spec.logical_name}.ancestor",
|
||||
name=spec.ancestor_name,
|
||||
)
|
||||
search_root = locate_unique(root, ancestor_spec, max_depth=max_depth)
|
||||
|
||||
matched = [
|
||||
control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec)
|
||||
]
|
||||
if not matched:
|
||||
raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到")
|
||||
if len(matched) != spec.expected_count:
|
||||
raise DesktopRpaError(
|
||||
"ELEMENT_AMBIGUOUS",
|
||||
f"{spec.logical_name} 期望 {spec.expected_count} 个,实际 {len(matched)} 个",
|
||||
)
|
||||
return matched[0]
|
||||
|
||||
|
||||
def click_control(
|
||||
control: Any,
|
||||
*,
|
||||
window: WindowCandidate,
|
||||
maximize: bool = False,
|
||||
) -> None:
|
||||
activate_window(window, maximize=maximize)
|
||||
rect = control.BoundingRectangle
|
||||
if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000:
|
||||
raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效")
|
||||
control.Click(
|
||||
x=random.randint(-2, 2),
|
||||
y=random.randint(-2, 2),
|
||||
simulateMove=True,
|
||||
)
|
||||
|
||||
|
||||
def type_text(control: Any, text: str, *, window: WindowCandidate) -> None:
|
||||
auto = require_uiautomation()
|
||||
click_control(control, window=window, maximize=False)
|
||||
auto.SendKeys("{Ctrl}a", waitTime=0.05)
|
||||
auto.SendKeys("{Delete}", waitTime=0.05)
|
||||
for char in text:
|
||||
auto.SendKeys(char, waitTime=random.uniform(0.04, 0.12))
|
||||
@@ -1,58 +0,0 @@
|
||||
"""企业微信成功案例的安全提炼:probe 和 dry-run,不默认发送邀请。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from service.desktop_session import activate_window, resolve_unique_window
|
||||
from service.uia_locator import locate_unique, root_from_window, type_text
|
||||
from util.selectors import (
|
||||
ADD_CUSTOMER_WINDOW,
|
||||
CONTACTS_ENTRY,
|
||||
MAIN_WINDOW,
|
||||
NEW_CUSTOMER_ROW,
|
||||
SEARCH_INPUT,
|
||||
)
|
||||
|
||||
|
||||
def probe() -> dict[str, Any]:
|
||||
"""最小 probe:主窗 + 通讯录入口。
|
||||
|
||||
生产技能应对「新的客户 / 添加」等关键节点做更深探测;本函数不等于生产完整度。
|
||||
调用前会 maximize 主窗,避免侧栏折叠导致 org_op_new 抖动。
|
||||
"""
|
||||
main = resolve_unique_window(MAIN_WINDOW)
|
||||
activate_window(main, maximize=True)
|
||||
root = root_from_window(main)
|
||||
contacts = locate_unique(root, CONTACTS_ENTRY)
|
||||
payload: dict[str, Any] = {
|
||||
"success": True,
|
||||
"minimized_probe": True,
|
||||
"main_window": asdict(main),
|
||||
"contacts_entry": {
|
||||
"name": getattr(contacts, "Name", ""),
|
||||
"control_type": getattr(contacts, "ControlTypeName", ""),
|
||||
},
|
||||
}
|
||||
# 可选:若已在通讯录页,尝试按 Value 定位「新的客户」行(失败不视为 probe 失败)
|
||||
try:
|
||||
row = locate_unique(root, NEW_CUSTOMER_ROW)
|
||||
payload["new_customer_row"] = {"name": getattr(row, "Name", "")}
|
||||
except Exception:
|
||||
payload["new_customer_row"] = None
|
||||
return payload
|
||||
|
||||
|
||||
def fill_search_dry_run(target: str) -> dict[str, Any]:
|
||||
"""要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。"""
|
||||
search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW)
|
||||
activate_window(search_window, maximize=False)
|
||||
root = root_from_window(search_window)
|
||||
search_input = locate_unique(root, SEARCH_INPUT)
|
||||
type_text(search_input, target, window=search_window)
|
||||
return {
|
||||
"success": True,
|
||||
"mode": "dry-run",
|
||||
"message": "已填写搜索条件,未发送邀请",
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"""脱敏 UIA element specs;复制到新技能后必须用真实拾取结果替换。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowSpec:
|
||||
logical_name: str
|
||||
process_name: str
|
||||
class_name: str
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementSpec:
|
||||
logical_name: str
|
||||
name: Optional[str] = None
|
||||
"""UIA Name(内部 id,未必是界面中文)。"""
|
||||
value: Optional[str] = None
|
||||
"""UIA Value(可见中文文案常在此)。"""
|
||||
automation_id: Optional[str] = None
|
||||
control_type: Optional[str] = None
|
||||
class_name: Optional[str] = None
|
||||
ancestor_name: Optional[str] = None
|
||||
"""先定位到该 Name 的祖先,再在其下搜索。"""
|
||||
child_value_name: Optional[str] = None
|
||||
"""在候选控件子树中查找该 Name 的子控件,并用 value 匹配其 Value。"""
|
||||
expected_count: int = 1
|
||||
|
||||
|
||||
MAIN_WINDOW = WindowSpec("main_window", "WXWork", "WeWorkWindow", "企业微信")
|
||||
ADD_CUSTOMER_WINDOW = WindowSpec(
|
||||
"add_customer_window",
|
||||
"WXWork",
|
||||
"SearchExternalsWnd",
|
||||
"添加客户",
|
||||
)
|
||||
CONTACTS_ENTRY = ElementSpec(
|
||||
"contacts_entry",
|
||||
name="org_op_new",
|
||||
control_type="TreeItem",
|
||||
)
|
||||
# 示意:用子控件 main_chattext 的 Value 定位「新的客户」行(禁止全树第一个 status_layout)
|
||||
NEW_CUSTOMER_ROW = ElementSpec(
|
||||
"new_customer_row",
|
||||
name="status_layout",
|
||||
ancestor_name="wlist_view",
|
||||
child_value_name="main_chattext",
|
||||
value="新的客户",
|
||||
)
|
||||
ADD_BUTTON = ElementSpec(
|
||||
"add_customer_button",
|
||||
name="wbtn_container",
|
||||
ancestor_name="search_external",
|
||||
)
|
||||
SEARCH_INPUT = ElementSpec(
|
||||
"search_input",
|
||||
name="searchedit",
|
||||
ancestor_name="search_layout",
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from service.desktop_session import ( # noqa: E402
|
||||
DesktopRpaError,
|
||||
WindowCandidate,
|
||||
activate_window,
|
||||
resolve_unique_window,
|
||||
)
|
||||
from service import uia_locator # noqa: E402
|
||||
from util.selectors import ElementSpec, MAIN_WINDOW # noqa: E402
|
||||
|
||||
|
||||
class SelectorContractTest(unittest.TestCase):
|
||||
def test_unique_window_is_required(self) -> None:
|
||||
candidate = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True)
|
||||
self.assertEqual(
|
||||
resolve_unique_window(MAIN_WINDOW, candidates=[candidate]),
|
||||
candidate,
|
||||
)
|
||||
|
||||
def test_duplicate_windows_fail_instead_of_taking_first(self) -> None:
|
||||
candidates = [
|
||||
WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True),
|
||||
WindowCandidate(2, 11, "WXWork", "WeWorkWindow", "企业微信", True),
|
||||
]
|
||||
with self.assertRaises(DesktopRpaError) as ctx:
|
||||
resolve_unique_window(MAIN_WINDOW, candidates=candidates)
|
||||
self.assertEqual(ctx.exception.code, "ELEMENT_AMBIGUOUS")
|
||||
|
||||
def test_hidden_window_is_not_a_candidate(self) -> None:
|
||||
hidden = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", False)
|
||||
with self.assertRaises(DesktopRpaError) as ctx:
|
||||
resolve_unique_window(MAIN_WINDOW, candidates=[hidden])
|
||||
self.assertEqual(ctx.exception.code, "WINDOW_NOT_FOUND")
|
||||
|
||||
def test_activate_without_maximize_skips_restore_when_zoomed(self) -> None:
|
||||
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
|
||||
with patch("service.desktop_session.user32") as user32:
|
||||
user32.IsIconic.return_value = False
|
||||
user32.IsZoomed.return_value = True
|
||||
user32.SetForegroundWindow.return_value = True
|
||||
activate_window(window, maximize=False)
|
||||
user32.ShowWindow.assert_not_called()
|
||||
user32.SetForegroundWindow.assert_called_once_with(42)
|
||||
|
||||
def test_activate_maximize_when_not_zoomed(self) -> None:
|
||||
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
|
||||
with patch("service.desktop_session.user32") as user32:
|
||||
user32.IsIconic.return_value = False
|
||||
user32.IsZoomed.return_value = False
|
||||
user32.SetForegroundWindow.return_value = True
|
||||
activate_window(window, maximize=True)
|
||||
user32.ShowWindow.assert_called()
|
||||
user32.SetForegroundWindow.assert_called_once_with(42)
|
||||
|
||||
def test_locate_unique_matches_child_value(self) -> None:
|
||||
row = MagicMock()
|
||||
row.Name = "status_layout"
|
||||
row.AutomationId = ""
|
||||
row.ControlTypeName = "TreeControl"
|
||||
row.ClassName = ""
|
||||
label = MagicMock()
|
||||
label.Name = "main_chattext"
|
||||
label.GetChildren.return_value = []
|
||||
row.GetChildren.return_value = [label]
|
||||
|
||||
root = MagicMock()
|
||||
root.Name = "wlist_view"
|
||||
root.AutomationId = ""
|
||||
root.ControlTypeName = "TreeControl"
|
||||
root.ClassName = ""
|
||||
root.GetChildren.return_value = [row]
|
||||
|
||||
with patch.object(uia_locator, "control_value", side_effect=lambda c: "新的客户" if c is label else ""):
|
||||
spec = ElementSpec(
|
||||
"new_customer_row",
|
||||
name="status_layout",
|
||||
ancestor_name="wlist_view",
|
||||
child_value_name="main_chattext",
|
||||
value="新的客户",
|
||||
)
|
||||
# ancestor is root itself when searching from root with name wlist_view
|
||||
found = uia_locator.locate_unique(root, ElementSpec("wlist", name="wlist_view"))
|
||||
self.assertIs(found, root)
|
||||
found_row = uia_locator.locate_unique(root, spec)
|
||||
self.assertIs(found_row, row)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,155 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>仿真批量提交系统(示例 Sandbox)</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 24px; max-width: 960px; }
|
||||
section { margin-bottom: 24px; padding: 16px; border: 1px solid #ccc; }
|
||||
.hidden { display: none; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
td, th { border: 1px solid #ddd; padding: 8px; }
|
||||
dialog-like { display: block; border: 2px solid #333; padding: 16px; margin-top: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>仿真业务系统 · 批量提交(示例)</h1>
|
||||
|
||||
<section id="portal-section">
|
||||
<h2>平台门户登录(示例)</h2>
|
||||
<form name="portalGate">
|
||||
<label>门户账号 <input id="portal-user" name="portalUser" /></label>
|
||||
<label>门户密码 <input id="portal-pass" name="portalPass" type="password" /></label>
|
||||
<button type="button" id="portal-enter">进入业务系统</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="login-step1-section" class="hidden">
|
||||
<h2>登录 · 第一步</h2>
|
||||
<form name="simLoginStep1" data-testid="login-step1">
|
||||
<label>账号 <input name="loginId" data-testid="login-id" /></label>
|
||||
<button type="submit">下一步</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="login-step2-section" class="hidden">
|
||||
<h2>登录 · 第二步</h2>
|
||||
<form name="simLoginStep2" data-testid="login-step2">
|
||||
<label>密码 <input name="password" type="password" data-testid="password" /></label>
|
||||
<label>验证码 <input name="captcha" data-testid="captcha" /></label>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="batch-section" class="hidden">
|
||||
<h2>批量提交</h2>
|
||||
<form name="simBatchForm" data-testid="batch-form">
|
||||
<label>来源账户
|
||||
<select name="fromAccountNo" data-testid="from-account">
|
||||
<option value="">请选择</option>
|
||||
<option value="acct-demo-001">演示账户 acct-demo-001</option>
|
||||
<option value="acct-demo-002">演示账户 acct-demo-002</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" id="tab-page-fill" data-testid="tab-page-fill">页面填写</button>
|
||||
<button type="button" id="btn-add-row" data-testid="add-row">新增一行</button>
|
||||
<table id="batch-table" data-testid="batch-table">
|
||||
<thead><tr><th>#</th><th>姓名</th><th>账号</th><th>备注</th><th>金额</th></tr></thead>
|
||||
<tbody id="batch-rows"></tbody>
|
||||
</table>
|
||||
<label>用途 <input name="purpose" data-testid="purpose" /></label>
|
||||
<button type="submit" data-testid="batch-submit">提交批次</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="success-section" class="hidden">
|
||||
<h2>提交成功</h2>
|
||||
<p data-testid="batch-id" id="batch-id-display"></p>
|
||||
<p data-testid="success-message">批次已受理,等待后续处理。</p>
|
||||
</section>
|
||||
|
||||
<div id="pin-dialog" class="hidden" role="dialog" aria-modal="true" data-testid="pin-dialog">
|
||||
<h3>确认 PIN</h3>
|
||||
<input type="password" inputmode="numeric" data-testid="pin-input" />
|
||||
<button type="button" id="pin-confirm" data-testid="pin-confirm">确认提交</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DEMO_CAPTCHA = "0000";
|
||||
let rowCount = 0;
|
||||
|
||||
document.getElementById('portal-enter').addEventListener('click', function () {
|
||||
document.getElementById('portal-section').classList.add('hidden');
|
||||
document.getElementById('login-step1-section').classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.querySelector('form[name="simLoginStep1"]').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('login-step1-section').classList.add('hidden');
|
||||
document.getElementById('login-step2-section').classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.querySelector('form[name="simLoginStep2"]').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
const cap = document.querySelector('input[name="captcha"]').value;
|
||||
if (cap !== DEMO_CAPTCHA) {
|
||||
alert('验证码错误(演示值 0000)');
|
||||
return;
|
||||
}
|
||||
document.getElementById('login-step2-section').classList.add('hidden');
|
||||
document.getElementById('batch-section').classList.remove('hidden');
|
||||
ensureRow(1);
|
||||
});
|
||||
|
||||
document.getElementById('tab-page-fill').addEventListener('click', function () {
|
||||
document.getElementById('batch-table').scrollIntoView();
|
||||
});
|
||||
|
||||
document.getElementById('btn-add-row').addEventListener('click', function () {
|
||||
rowCount += 1;
|
||||
addRow(rowCount);
|
||||
});
|
||||
|
||||
function ensureRow(index) {
|
||||
if (!document.querySelector('tr[data-row-index="' + index + '"]')) {
|
||||
addRow(index);
|
||||
}
|
||||
rowCount = Math.max(rowCount, index);
|
||||
}
|
||||
|
||||
function addRow(index) {
|
||||
rowCount = Math.max(rowCount, index);
|
||||
const tbody = document.getElementById('batch-rows');
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-row-index', String(index));
|
||||
tr.innerHTML =
|
||||
'<td>' + index + '</td>' +
|
||||
'<td><input name="name" data-testid="row-name-' + index + '" /></td>' +
|
||||
'<td><input name="account" data-testid="row-account-' + index + '" /></td>' +
|
||||
'<td><input name="note" data-testid="row-note-' + index + '" /></td>' +
|
||||
'<td><input name="amount" data-testid="row-amount-' + index + '" /></td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
document.querySelector('form[name="simBatchForm"]').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('pin-dialog').classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('pin-confirm').addEventListener('click', function () {
|
||||
const pin = document.querySelector('[data-testid="pin-input"]').value;
|
||||
if (!pin || pin.length < 4) {
|
||||
alert('请输入演示 PIN');
|
||||
return;
|
||||
}
|
||||
document.getElementById('pin-dialog').classList.add('hidden');
|
||||
const batchId = 'SIM-' + Date.now();
|
||||
document.getElementById('batch-section').classList.add('hidden');
|
||||
document.getElementById('success-section').classList.remove('hidden');
|
||||
document.getElementById('batch-id-display').textContent = batchId;
|
||||
document.getElementById('batch-id-display').dataset.batchId = batchId;
|
||||
window.location.hash = '#/batch/' + batchId;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,355 +0,0 @@
|
||||
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。
|
||||
|
||||
业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
|
||||
|
||||
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
|
||||
from util.logging import mask_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
|
||||
|
||||
ACCOUNT_SETUP_MESSAGE = (
|
||||
f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。"
|
||||
f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。"
|
||||
)
|
||||
|
||||
LEASE_BUSY_MESSAGE = (
|
||||
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。"
|
||||
)
|
||||
|
||||
DEFAULT_AUTH_STRATEGY = "qr_code_manual"
|
||||
DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认"
|
||||
DEFAULT_START_URL = "https://www.example.com/"
|
||||
|
||||
|
||||
class AccountManagerError(Exception):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def mask_login_id(login_id: str) -> str:
|
||||
return mask_text(login_id)
|
||||
|
||||
|
||||
def format_account_label(account: dict) -> str:
|
||||
"""人读账号标识:优先 login_id / 备注,最后才用数字 id。"""
|
||||
for key in ("login_id", "account_label", "label"):
|
||||
v = account.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
for key in ("id", "account_id"):
|
||||
v = account.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _is_placeholder_config(raw: str) -> bool:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return True
|
||||
if s.startswith("#"):
|
||||
return True
|
||||
low = s.lower()
|
||||
if low in ("default-account", "none", "null", "n/a", "-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]:
|
||||
"""从配置解析 (account_id, login_id)。
|
||||
|
||||
- ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id
|
||||
- ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id)
|
||||
- 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web
|
||||
"""
|
||||
account_id: Optional[str] = None
|
||||
login_id: Optional[str] = None
|
||||
|
||||
aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip()
|
||||
if not _is_placeholder_config(aid_raw):
|
||||
if aid_raw.isdigit():
|
||||
account_id = aid_raw
|
||||
else:
|
||||
login_id = aid_raw
|
||||
logger.info("default_account_id_treated_as_login_id value=%s", aid_raw)
|
||||
|
||||
lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip()
|
||||
if not _is_placeholder_config(lid_raw):
|
||||
login_id = lid_raw
|
||||
|
||||
if account_id:
|
||||
return account_id, None
|
||||
return None, login_id
|
||||
|
||||
|
||||
def _resolve_account_manager_main() -> str:
|
||||
"""解析 account-manager CLI 入口路径。
|
||||
|
||||
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
|
||||
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
|
||||
"""
|
||||
env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
|
||||
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
|
||||
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
|
||||
|
||||
scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
skills_root = get_sibling_skills_root(scripts_dir)
|
||||
candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py")
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
# 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录
|
||||
dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py"
|
||||
if os.path.isfile(dev):
|
||||
return dev
|
||||
|
||||
raise AccountManagerError(
|
||||
"ACCOUNT_NOT_FOUND",
|
||||
"未找到 account-manager,请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。",
|
||||
)
|
||||
|
||||
|
||||
def _parse_last_json(stdout: str) -> Any:
|
||||
lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()]
|
||||
for raw in reversed(lines):
|
||||
if raw.startswith("ERROR:"):
|
||||
continue
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。")
|
||||
|
||||
|
||||
def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]:
|
||||
main_py = _resolve_account_manager_main()
|
||||
env = os.environ.copy()
|
||||
return subprocess.run(
|
||||
[sys.executable, main_py, *argv_suffix],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_error_code(raw: str) -> str:
|
||||
code = (raw or "").strip()
|
||||
if code.startswith("ERROR:"):
|
||||
code = code[6:]
|
||||
return code
|
||||
|
||||
|
||||
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
if payload.get("success") is False:
|
||||
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||
code = _normalize_error_code(str(err.get("code") or ""))
|
||||
message = str(err.get("message") or "")
|
||||
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
|
||||
raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE)
|
||||
if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code:
|
||||
raise AccountManagerError(
|
||||
"AMBIGUOUS_ACCOUNT",
|
||||
message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。",
|
||||
)
|
||||
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
|
||||
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
|
||||
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
|
||||
|
||||
if not payload.get("profile_dir"):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
|
||||
if not payload.get("lease_token"):
|
||||
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
|
||||
return payload
|
||||
|
||||
|
||||
def _ensure_web_with_lease(
|
||||
platform: str,
|
||||
*,
|
||||
login_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
|
||||
lid = (login_id or "").strip() or None
|
||||
label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
|
||||
argv = [
|
||||
"account",
|
||||
"ensure-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--url",
|
||||
start_url,
|
||||
"--auth-strategy",
|
||||
DEFAULT_AUTH_STRATEGY,
|
||||
"--label",
|
||||
label,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
"--purpose",
|
||||
"rpa",
|
||||
]
|
||||
if lid:
|
||||
argv.extend(["--login-id", lid])
|
||||
|
||||
proc = _run_argv(argv)
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 and not out.strip():
|
||||
raise AccountManagerError(
|
||||
"ENSURE_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "ensure-web 子进程失败",
|
||||
)
|
||||
payload = _parse_last_json(out)
|
||||
if isinstance(payload, dict) and payload.get("success") is False:
|
||||
return _validate_pick_payload(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_existing_with_lease(
|
||||
platform: str,
|
||||
*,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
argv = [
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
"--purpose",
|
||||
"rpa",
|
||||
]
|
||||
if login_id:
|
||||
argv.extend(["--login-id", login_id])
|
||||
elif label:
|
||||
argv.extend(["--label", label])
|
||||
else:
|
||||
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
|
||||
|
||||
proc = _run_argv(argv)
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 and not out.strip():
|
||||
raise AccountManagerError(
|
||||
"PICK_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "pick-web 子进程失败",
|
||||
)
|
||||
payload = _parse_last_json(out)
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
proc = _run_argv(["account", "get", str(account_id)])
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 or out.startswith("ERROR:"):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
|
||||
raw = _parse_last_json(out)
|
||||
if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict):
|
||||
data = raw["data"]
|
||||
elif isinstance(raw, dict):
|
||||
data = raw
|
||||
else:
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
|
||||
|
||||
if data.get("success") is False:
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
|
||||
platform_key = str(data.get("platform_key") or "").lower()
|
||||
if platform_key != platform.lower():
|
||||
raise AccountManagerError(
|
||||
"ACCOUNT_NOT_FOUND",
|
||||
f"账号 {account_id} 不是 {platform} 平台账号。",
|
||||
)
|
||||
profile_dir = str(data.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
|
||||
if str(data.get("status") or "active").lower() != "active":
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
|
||||
|
||||
try:
|
||||
selected = _pick_existing_with_lease(
|
||||
platform,
|
||||
login_id=(str(data.get("login_id") or "").strip() or None),
|
||||
label=(str(data.get("account_label") or "").strip() or None),
|
||||
)
|
||||
except AccountManagerError as exc:
|
||||
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
|
||||
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
|
||||
raise
|
||||
if str(selected.get("id")) != str(account_id):
|
||||
release_lease(str(selected.get("lease_token") or ""))
|
||||
raise AccountManagerError(
|
||||
"AMBIGUOUS_ACCOUNT",
|
||||
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def pick_web_account(
|
||||
platform: str,
|
||||
account_id: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""获取网页账号(profile_dir + lease_token)。
|
||||
|
||||
优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。
|
||||
"""
|
||||
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
|
||||
|
||||
aid_raw = (account_id or "").strip()
|
||||
if aid_raw and not _is_placeholder_config(aid_raw):
|
||||
if not aid_raw.isdigit():
|
||||
if not (login_id or "").strip():
|
||||
login_id = aid_raw
|
||||
aid_raw = ""
|
||||
else:
|
||||
return _pick_by_id_with_lease(platform_key, int(aid_raw))
|
||||
|
||||
lid = (login_id or "").strip() or None
|
||||
if lid and _is_placeholder_config(lid):
|
||||
lid = None
|
||||
|
||||
try:
|
||||
return _ensure_web_with_lease(platform_key, login_id=lid)
|
||||
except AccountManagerError as exc:
|
||||
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
|
||||
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
|
||||
raise
|
||||
|
||||
|
||||
def release_lease(lease_token: Optional[str]) -> None:
|
||||
token = (lease_token or "").strip()
|
||||
if not token:
|
||||
return
|
||||
try:
|
||||
proc = _run_argv(["lease", "release", token])
|
||||
if proc.returncode != 0:
|
||||
logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip())
|
||||
except Exception:
|
||||
logger.warning("lease_release_exception", exc_info=True)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
from service.adapter.mock import MockBatchAdapter
|
||||
from service.adapter.simulator_rpa import SimulatorBrowserRpaAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"select_adapter",
|
||||
"BatchAdapterBase",
|
||||
"BatchItem",
|
||||
"BatchSubmitResult",
|
||||
"MockBatchAdapter",
|
||||
"SimulatorBrowserRpaAdapter",
|
||||
]
|
||||
|
||||
|
||||
def select_adapter(artifacts_dir: Optional[str] = None) -> BatchAdapterBase:
|
||||
target = (config.get("OPENCLAW_TEST_TARGET") or "").strip().lower()
|
||||
|
||||
if target in ("mock", "unit"):
|
||||
logger.info("target '%s': MockBatchAdapter", target)
|
||||
return MockBatchAdapter()
|
||||
|
||||
if target == "real_rpa":
|
||||
logger.warning("target real_rpa 未实现,回退 MockBatchAdapter")
|
||||
return MockBatchAdapter()
|
||||
|
||||
if target and target != "simulator_rpa":
|
||||
logger.warning("未知 target '%s',回退 simulator_rpa", target)
|
||||
|
||||
logger.info("target '%s': SimulatorBrowserRpaAdapter", target or "(unset=default)")
|
||||
return SimulatorBrowserRpaAdapter(artifacts_dir=artifacts_dir)
|
||||
@@ -1,32 +0,0 @@
|
||||
"""批量提交 adapter 基类与数据契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchItem:
|
||||
row_index: int
|
||||
name: str
|
||||
account: str
|
||||
amount: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchSubmitResult:
|
||||
ok: bool
|
||||
batch_id: Optional[str]
|
||||
submitted_count: int
|
||||
submitted_amount: float
|
||||
error_msg: Optional[str]
|
||||
artifacts: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class BatchAdapterBase:
|
||||
name: str = "base"
|
||||
|
||||
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
raise NotImplementedError
|
||||
@@ -1,36 +0,0 @@
|
||||
"""mock / unit 档位:不启动浏览器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
|
||||
|
||||
class MockBatchAdapter(BatchAdapterBase):
|
||||
name = "mock"
|
||||
|
||||
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
del target
|
||||
if not items:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="items 为空",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
total = sum(it.amount for it in items)
|
||||
fake_batch_id = f"MOCK-{int(time.time())}"
|
||||
return BatchSubmitResult(
|
||||
ok=True,
|
||||
batch_id=fake_batch_id,
|
||||
submitted_count=len(items),
|
||||
submitted_amount=total,
|
||||
error_msg=None,
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
@@ -1,109 +0,0 @@
|
||||
"""仿真浏览器 RPA 薄 adapter:pick 账号、lease、委托 RPA、release lease。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
from service.account_client import AccountManagerError, pick_web_account, release_lease
|
||||
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
from service.browser_session import find_chrome_executable
|
||||
from service.simulator_playwright import submit_batch_rpa
|
||||
from util.constants import TARGET_PLATFORM, resolve_simulator_base_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_entry_url(account: dict) -> str:
|
||||
url = str(account.get("url") or "").strip()
|
||||
if url:
|
||||
return url.rstrip("/")
|
||||
return resolve_simulator_base_url()
|
||||
|
||||
|
||||
class SimulatorBrowserRpaAdapter(BatchAdapterBase):
|
||||
name = "simulator_rpa"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
artifacts_dir: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
) -> None:
|
||||
self.artifacts_dir = artifacts_dir
|
||||
if headless is None:
|
||||
self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
||||
else:
|
||||
self.headless = headless
|
||||
|
||||
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
try:
|
||||
from playwright.async_api import async_playwright # noqa: F401
|
||||
except ImportError:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=(
|
||||
"playwright Python 包不可用。"
|
||||
"生产/宿主运行由共享 runtime 提供;技能侧不要 pip install playwright。"
|
||||
),
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
if not items:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="items 为空",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
if not find_chrome_executable():
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
lease_token: Optional[str] = None
|
||||
try:
|
||||
account = pick_web_account(TARGET_PLATFORM)
|
||||
lease_token = account.get("lease_token")
|
||||
base_url = _resolve_entry_url(account)
|
||||
return await submit_batch_rpa(
|
||||
account,
|
||||
target=target,
|
||||
items=items,
|
||||
base_url=base_url,
|
||||
artifacts_dir=self.artifacts_dir,
|
||||
headless=self.headless,
|
||||
)
|
||||
except AccountManagerError as exc:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=f"ERROR:{exc.code} {exc.message}",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("adapter_unexpected: %s", exc)
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
finally:
|
||||
release_lease(lease_token)
|
||||
@@ -1,126 +0,0 @@
|
||||
"""浏览器会话启动(系统 Chrome/Edge + async Playwright persistent context)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PAGE_TIMEOUT_MS = 15_000
|
||||
|
||||
CHROME_LAUNCH_ARGS: List[str] = [
|
||||
"--start-maximized",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
]
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def chrome_launch_args() -> List[str]:
|
||||
return list(CHROME_LAUNCH_ARGS)
|
||||
|
||||
|
||||
def _headless_from_env() -> bool:
|
||||
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def find_chrome_executable() -> str | None:
|
||||
try:
|
||||
from jiangchang_skill_core.runtime_env import find_chrome_executable as _find
|
||||
|
||||
chrome = _find()
|
||||
if chrome and os.path.isfile(chrome):
|
||||
return chrome
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
candidates = [
|
||||
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
|
||||
]
|
||||
for path in candidates:
|
||||
if path and os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _clear_node_options() -> None:
|
||||
os.environ.pop("NODE_OPTIONS", None)
|
||||
|
||||
|
||||
async def start_browser_session(
|
||||
profile_dir: str,
|
||||
*,
|
||||
headless: Optional[bool] = None,
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""
|
||||
启动持久化 Chrome profile,返回 (playwright, context, page)。
|
||||
|
||||
URL 不在此文件打开;由 simulator_playwright.py 负责 page.goto。
|
||||
"""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
_clear_node_options()
|
||||
hl = headless if headless is not None else _headless_from_env()
|
||||
chrome = find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。"
|
||||
)
|
||||
|
||||
args = chrome_launch_args()
|
||||
logger.info(
|
||||
"browser_launch chrome=%s profile_dir=%s args=%s headless=%s",
|
||||
chrome,
|
||||
profile_dir,
|
||||
args,
|
||||
hl,
|
||||
)
|
||||
|
||||
pw = await async_playwright().start()
|
||||
context = None
|
||||
try:
|
||||
context = await pw.chromium.launch_persistent_context(
|
||||
user_data_dir=profile_dir,
|
||||
headless=hl,
|
||||
executable_path=chrome,
|
||||
locale="zh-CN",
|
||||
no_viewport=True,
|
||||
args=args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
)
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
||||
return pw, context, page
|
||||
except Exception:
|
||||
if context is not None:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
await pw.stop()
|
||||
raise
|
||||
|
||||
|
||||
async def close_browser_session(playwright: Any, context: Any) -> None:
|
||||
try:
|
||||
if context is not None:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if playwright is not None:
|
||||
await playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,400 +0,0 @@
|
||||
"""仿真批量提交 RPA 主流程(async Playwright)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
from service.adapter.base import BatchItem, BatchSubmitResult
|
||||
from service.browser_session import (
|
||||
close_browser_session,
|
||||
find_chrome_executable,
|
||||
start_browser_session,
|
||||
)
|
||||
from util.constants import (
|
||||
DEFAULT_DEMO_CAPTCHA,
|
||||
DEFAULT_DEMO_LOGIN_ID,
|
||||
DEFAULT_DEMO_PASSWORD,
|
||||
DEFAULT_DEMO_TOKEN_PIN,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
ENV_SIMULATOR_PASSWORD,
|
||||
ENV_SIMULATOR_TOKEN_PIN,
|
||||
LOGIN_NAVIGATE_TIMEOUT_MS,
|
||||
SUBMIT_NAVIGATE_TIMEOUT_MS,
|
||||
portal_login_wait_sec,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GOTO_TIMEOUT_MS = 60_000
|
||||
|
||||
_PAUSE_MIN_MS = 500
|
||||
_PAUSE_MAX_MS = 2000
|
||||
|
||||
|
||||
class RpaError(Exception):
|
||||
def __init__(self, message: str, screenshot_path: Optional[str] = None):
|
||||
super().__init__(message)
|
||||
self.screenshot_path = screenshot_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Credentials:
|
||||
login_id: str
|
||||
password: str
|
||||
token_pin: str
|
||||
|
||||
|
||||
def _resolve_credentials(account: dict[str, Any]) -> _Credentials:
|
||||
login_id = str(
|
||||
account.get("login_id") or account.get("username") or DEFAULT_DEMO_LOGIN_ID
|
||||
).strip()
|
||||
password = str(
|
||||
account.get("password")
|
||||
or os.getenv(ENV_SIMULATOR_PASSWORD)
|
||||
or config.get(ENV_SIMULATOR_PASSWORD)
|
||||
or DEFAULT_DEMO_PASSWORD
|
||||
).strip()
|
||||
token_pin = str(
|
||||
account.get("token_pin")
|
||||
or account.get("pin")
|
||||
or os.getenv(ENV_SIMULATOR_TOKEN_PIN)
|
||||
or config.get(ENV_SIMULATOR_TOKEN_PIN)
|
||||
or DEFAULT_DEMO_TOKEN_PIN
|
||||
).strip()
|
||||
return _Credentials(login_id=login_id, password=password, token_pin=token_pin)
|
||||
|
||||
|
||||
def _headless() -> bool:
|
||||
return config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
||||
|
||||
|
||||
async def _pause(min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None:
|
||||
await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0)
|
||||
|
||||
|
||||
async def _visible(locator) -> bool:
|
||||
try:
|
||||
return await locator.count() > 0 and await locator.is_visible(timeout=500)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _wait_portal_gate(page, wait_sec: int) -> None:
|
||||
portal_user = page.locator("#portal-user")
|
||||
try:
|
||||
if not await _visible(portal_user):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
print(f"[门户] 检测到平台门户登录门闩,请手工完成门户登录,最多等待 {wait_sec} 秒...")
|
||||
deadline = time.monotonic() + wait_sec
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
portal_section = page.locator("#portal-section")
|
||||
login_step1 = page.locator('form[name="simLoginStep1"]')
|
||||
if not await _visible(portal_section) or await _visible(login_step1):
|
||||
print("[门户] 门户门闩已通过,进入业务系统登录")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
raise RpaError("ERROR:LOGIN_TIMEOUT 门户登录超时,请重新运行并及时完成门户登录。")
|
||||
|
||||
|
||||
async def _login(page, creds: _Credentials, artifacts_dir: str | None) -> None:
|
||||
try:
|
||||
await page.wait_for_selector(
|
||||
'form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS
|
||||
)
|
||||
await _pause()
|
||||
await page.fill('form[name="simLoginStep1"] input[name="loginId"]', creds.login_id)
|
||||
await _pause()
|
||||
await page.click('form[name="simLoginStep1"] button[type="submit"]')
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "login_step1_failed")
|
||||
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
await page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
|
||||
await _pause()
|
||||
await page.fill('form[name="simLoginStep2"] input[name="password"]', creds.password)
|
||||
await page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
|
||||
await _pause()
|
||||
await page.click('form[name="simLoginStep2"] button[type="submit"]')
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "login_step2_failed")
|
||||
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
await page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "login_navigate_failed")
|
||||
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
|
||||
async def _goto_batch_page(page, artifacts_dir: str | None) -> None:
|
||||
try:
|
||||
await page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
|
||||
await _pause()
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "goto_batch_failed")
|
||||
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
|
||||
async def _select_target_account(page, target: str) -> None:
|
||||
sel = page.locator('select[name="fromAccountNo"]')
|
||||
await sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
|
||||
n = await sel.locator("option").count()
|
||||
matched_value: Optional[str] = None
|
||||
first_value: Optional[str] = None
|
||||
for i in range(n):
|
||||
opt = sel.locator("option").nth(i)
|
||||
val = (await opt.get_attribute("value")) or ""
|
||||
if not val.strip():
|
||||
continue
|
||||
label_txt = await opt.inner_text()
|
||||
if first_value is None:
|
||||
first_value = val
|
||||
if target.strip() and target.strip() in label_txt:
|
||||
matched_value = val
|
||||
break
|
||||
pick = matched_value or first_value
|
||||
if not pick:
|
||||
raise RpaError("来源账户下拉无可选项")
|
||||
if not matched_value and target.strip():
|
||||
logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick)
|
||||
await sel.select_option(value=pick)
|
||||
await _pause()
|
||||
|
||||
|
||||
async def _fill_batch_form(
|
||||
page, target: str, items: List[BatchItem], artifacts_dir: str | None
|
||||
) -> None:
|
||||
try:
|
||||
await page.get_by_role("button", name=re.compile(r"页面填写")).click()
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "tab_switch_failed")
|
||||
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
await _select_target_account(page, target)
|
||||
except RpaError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "select_account_failed")
|
||||
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
existing = await page.locator("tr[data-row-index]").count()
|
||||
need_more = max(0, len(items) - existing)
|
||||
add_btn = page.get_by_role("button", name=re.compile(r"新增一行"))
|
||||
for _ in range(need_more):
|
||||
await _pause(400, 900)
|
||||
await add_btn.click()
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "add_rows_failed")
|
||||
raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
for it in items:
|
||||
try:
|
||||
row = page.locator(f'tr[data-row-index="{it.row_index}"]')
|
||||
await row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
|
||||
await _pause(300, 800)
|
||||
await row.locator('input[name="name"]').fill(it.name)
|
||||
await row.locator('input[name="account"]').fill(it.account)
|
||||
await row.locator('input[name="note"]').fill(it.note or "")
|
||||
await row.locator('input[name="amount"]').fill(f"{it.amount}")
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, f"fill_row_{it.row_index}_failed")
|
||||
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
await page.fill('input[name="purpose"]', "仿真批量提交示例")
|
||||
except Exception as exc:
|
||||
logger.warning("填写用途失败(非致命):%s", exc)
|
||||
|
||||
|
||||
async def _submit_and_get_batch_id(page, token_pin: str, artifacts_dir: str | None) -> str:
|
||||
try:
|
||||
await _pause(800, 1500)
|
||||
await page.locator('form[name="simBatchForm"] button[type="submit"]').click()
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "click_submit_failed")
|
||||
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
|
||||
try:
|
||||
await page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "pin_dialog_not_open")
|
||||
raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]')
|
||||
await _pause(500, 1200)
|
||||
await pin_input.fill(token_pin)
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "fill_pin_failed")
|
||||
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
await page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "click_confirm_failed")
|
||||
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
await page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "success_area_not_visible")
|
||||
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
batch_id = await page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
|
||||
if not batch_id:
|
||||
batch_id = (await page.locator('[data-testid="batch-id"]').inner_text()).strip()
|
||||
url = page.url
|
||||
if not batch_id and "#/batch/" in url:
|
||||
batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/")
|
||||
if not batch_id:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "batch_id_empty")
|
||||
raise RpaError(f"无法解析 batch_id,url={url}", screenshot_path=sp)
|
||||
return batch_id
|
||||
|
||||
|
||||
async def _safe_screenshot(page, artifacts_dir: str | None, tag: str) -> Optional[str]:
|
||||
if not artifacts_dir:
|
||||
return None
|
||||
try:
|
||||
os.makedirs(artifacts_dir, exist_ok=True)
|
||||
path = os.path.join(artifacts_dir, f"{tag}_{int(time.time())}.png")
|
||||
await page.screenshot(path=path, full_page=True)
|
||||
logger.info("screenshot_saved path=%s", path)
|
||||
return path
|
||||
except Exception as exc:
|
||||
logger.warning("screenshot_failed tag=%s err=%s", tag, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def submit_batch_rpa(
|
||||
account: dict[str, Any],
|
||||
*,
|
||||
target: str,
|
||||
items: list[BatchItem],
|
||||
base_url: str,
|
||||
artifacts_dir: str | None,
|
||||
video_session: Any = None,
|
||||
headless: bool | None = None,
|
||||
) -> BatchSubmitResult:
|
||||
del video_session # 示例占位;真实 skill 可接入 RpaVideoSession
|
||||
|
||||
profile_dir = str(account.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="账号缺少 profile_dir",
|
||||
artifacts={"adapter": "simulator_rpa"},
|
||||
)
|
||||
|
||||
if not items:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="items 为空",
|
||||
artifacts={"adapter": "simulator_rpa"},
|
||||
)
|
||||
|
||||
if not find_chrome_executable():
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。",
|
||||
artifacts={"adapter": "simulator_rpa"},
|
||||
)
|
||||
|
||||
creds = _resolve_credentials(account)
|
||||
portal_wait = portal_login_wait_sec()
|
||||
hl = headless if headless is not None else _headless()
|
||||
|
||||
pw = None
|
||||
context = None
|
||||
page = None
|
||||
try:
|
||||
pw, context, page = await start_browser_session(profile_dir, headless=hl)
|
||||
logger.info("rpa_goto url=%s", base_url)
|
||||
await page.goto(base_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
|
||||
|
||||
await _wait_portal_gate(page, portal_wait)
|
||||
logger.info("rpa_login_start")
|
||||
await _login(page, creds, artifacts_dir)
|
||||
logger.info("rpa_login_done")
|
||||
|
||||
await _goto_batch_page(page, artifacts_dir)
|
||||
logger.info("rpa_batch_page_ready")
|
||||
|
||||
await _fill_batch_form(page, target, items, artifacts_dir)
|
||||
logger.info("rpa_batch_form_filled rows=%s", len(items))
|
||||
|
||||
batch_id = await _submit_and_get_batch_id(page, creds.token_pin, artifacts_dir)
|
||||
logger.info("rpa_submit_success batch_id=%s", batch_id)
|
||||
|
||||
total = sum(it.amount for it in items)
|
||||
return BatchSubmitResult(
|
||||
ok=True,
|
||||
batch_id=batch_id,
|
||||
submitted_count=len(items),
|
||||
submitted_amount=total,
|
||||
error_msg=None,
|
||||
artifacts={"adapter": "simulator_rpa"},
|
||||
)
|
||||
except RpaError as exc:
|
||||
logger.error("rpa_failed: %s", exc)
|
||||
arts = {"adapter": "simulator_rpa"}
|
||||
if exc.screenshot_path:
|
||||
arts["screenshot"] = exc.screenshot_path
|
||||
elif page is not None:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "rpa_error")
|
||||
if sp:
|
||||
arts["screenshot"] = sp
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=str(exc),
|
||||
artifacts=arts,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("rpa_unexpected: %s", exc)
|
||||
arts = {"adapter": "simulator_rpa"}
|
||||
if page is not None:
|
||||
sp = await _safe_screenshot(page, artifacts_dir, "unexpected_error")
|
||||
if sp:
|
||||
arts["screenshot"] = sp
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
|
||||
artifacts=arts,
|
||||
)
|
||||
finally:
|
||||
await close_browser_session(pw, context)
|
||||
@@ -1,100 +0,0 @@
|
||||
"""轻量任务编排:校验输入 → 选 adapter → 提交批次。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from service.adapter import BatchItem, select_adapter
|
||||
from util.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _validate_items(items: List[Dict[str, Any]]) -> tuple[List[BatchItem], Optional[str]]:
|
||||
if not items:
|
||||
return [], "items 不能为空"
|
||||
parsed: List[BatchItem] = []
|
||||
for idx, raw in enumerate(items, start=1):
|
||||
name = str(raw.get("name") or "").strip()
|
||||
account = str(raw.get("account") or "").strip()
|
||||
note = str(raw.get("note") or "").strip()
|
||||
try:
|
||||
amount = float(raw.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return [], f"第 {idx} 行 amount 无效"
|
||||
if amount <= 0:
|
||||
return [], f"第 {idx} 行 amount 必须大于 0"
|
||||
if not name or not account:
|
||||
return [], f"第 {idx} 行 name/account 不能为空"
|
||||
parsed.append(
|
||||
BatchItem(
|
||||
row_index=int(raw.get("row_index") or idx),
|
||||
name=name,
|
||||
account=account,
|
||||
amount=amount,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
return parsed, None
|
||||
|
||||
|
||||
async def run_batch_submit(
|
||||
target: str,
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
force: bool = False,
|
||||
artifacts_dir: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
最小编排示例。
|
||||
|
||||
真实 skill 中还应:写业务表、entitlement、RpaVideoSession 等。
|
||||
账号 pick / lease 在 adapter 内完成,编排层不直接碰 account-manager。
|
||||
"""
|
||||
del force # 示例占位;真实 skill 可用于跳过确认
|
||||
|
||||
target_key = (target or "").strip()
|
||||
if not target_key:
|
||||
return {"success": False, "error": {"code": "INVALID_TARGET", "message": "target 不能为空"}}
|
||||
|
||||
batch_items, err = _validate_items(items)
|
||||
if err:
|
||||
return {"success": False, "error": {"code": "INVALID_ITEMS", "message": err}}
|
||||
|
||||
art_dir = artifacts_dir or os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
"openclaw-simulator-rpa-artifacts",
|
||||
uuid.uuid4().hex[:8],
|
||||
)
|
||||
os.makedirs(art_dir, exist_ok=True)
|
||||
logger.info(
|
||||
"batch_submit_start target=%s item_count=%s artifacts_dir=%s",
|
||||
target_key,
|
||||
len(batch_items),
|
||||
art_dir,
|
||||
)
|
||||
|
||||
adapter = select_adapter(artifacts_dir=art_dir)
|
||||
result = await adapter.submit_batch(target_key, batch_items)
|
||||
summary: Dict[str, Any] = {
|
||||
"success": result.ok,
|
||||
"target": target_key,
|
||||
"adapter": result.artifacts.get("adapter") or adapter.name,
|
||||
"submitted_count": result.submitted_count,
|
||||
"submitted_amount": result.submitted_amount,
|
||||
"batch_id": result.batch_id,
|
||||
"artifacts": result.artifacts,
|
||||
"artifacts_dir": art_dir,
|
||||
}
|
||||
if not result.ok:
|
||||
summary["error"] = {"code": "BATCH_SUBMIT_FAILED", "message": result.error_msg or "提交失败"}
|
||||
logger.info(
|
||||
"batch_submit_done success=%s batch_id=%s adapter=%s",
|
||||
result.ok,
|
||||
result.batch_id,
|
||||
adapter.name,
|
||||
)
|
||||
return summary
|
||||
@@ -1,52 +0,0 @@
|
||||
"""仿真浏览器 RPA 示例常量。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
SKILL_SLUG = "example-simulator-browser-rpa"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa"
|
||||
|
||||
TARGET_PLATFORM = "sim_batch"
|
||||
LEASE_HOLDER = "example-simulator-browser-rpa"
|
||||
LEASE_TTL_SEC = "1800"
|
||||
|
||||
DEFAULT_TIMEOUT_MS = 15_000
|
||||
LOGIN_NAVIGATE_TIMEOUT_MS = 30_000
|
||||
SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000
|
||||
DEFAULT_PORTAL_LOGIN_WAIT_SEC = 120
|
||||
|
||||
DEFAULT_DEMO_LOGIN_ID = "demo001"
|
||||
DEFAULT_DEMO_PASSWORD = "demo-password"
|
||||
DEFAULT_DEMO_CAPTCHA = "0000"
|
||||
DEFAULT_DEMO_TOKEN_PIN = "123456"
|
||||
|
||||
ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL"
|
||||
ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD"
|
||||
ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN"
|
||||
ENV_PORTAL_LOGIN_WAIT_SEC = "PORTAL_LOGIN_WAIT_SEC"
|
||||
|
||||
_EXAMPLE_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html"
|
||||
|
||||
|
||||
def portal_login_wait_sec() -> int:
|
||||
raw = (
|
||||
os.getenv(ENV_PORTAL_LOGIN_WAIT_SEC)
|
||||
or config.get(ENV_PORTAL_LOGIN_WAIT_SEC)
|
||||
or str(DEFAULT_PORTAL_LOGIN_WAIT_SEC)
|
||||
)
|
||||
try:
|
||||
return max(1, int(str(raw).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_PORTAL_LOGIN_WAIT_SEC
|
||||
|
||||
|
||||
def resolve_simulator_base_url() -> str:
|
||||
env = (os.getenv(ENV_SIMULATOR_BASE_URL) or "").strip()
|
||||
if env:
|
||||
return env.rstrip("/")
|
||||
return DEFAULT_DEMO_APP_PATH.as_uri()
|
||||
@@ -1,38 +0,0 @@
|
||||
"""示例日志工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from util.constants import LOG_LOGGER_NAME
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
return logging.getLogger(name or LOG_LOGGER_NAME)
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> None:
|
||||
logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s")
|
||||
|
||||
|
||||
def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str:
|
||||
s = (value or "").strip()
|
||||
if len(s) >= 11 and s.isdigit():
|
||||
return s[:3] + "****" + s[-4:]
|
||||
if len(s) > keep_start + keep_end:
|
||||
return s[:keep_start] + "****" + s[-keep_end:]
|
||||
return "****"
|
||||
|
||||
|
||||
def safe_log_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
if re.fullmatch(r"\d{7,}", text):
|
||||
return mask_text(text)
|
||||
if "@" in text and len(text) > 6:
|
||||
local, _, domain = text.partition("@")
|
||||
return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}"
|
||||
return text
|
||||
@@ -1,10 +0,0 @@
|
||||
"""pytest 路径:将 examples/simulator_browser_rpa/scripts 加入 import 路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
@@ -1,63 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from service.account_client import AccountManagerError, pick_web_account
|
||||
|
||||
|
||||
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=["python", "account-manager"],
|
||||
returncode=0,
|
||||
stdout=json.dumps(payload, ensure_ascii=False),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
|
||||
class TestExplicitAccountLease(unittest.TestCase):
|
||||
@patch("service.account_client._run_argv")
|
||||
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
|
||||
account = {
|
||||
"id": 42,
|
||||
"platform_key": "target-platform",
|
||||
"account_label": "主账号",
|
||||
"login_id": "user@example.com",
|
||||
"profile_dir": "D:/data/profile-42",
|
||||
"status": "active",
|
||||
}
|
||||
leased = {**account, "lease_token": "lease-42"}
|
||||
run_argv.side_effect = [_completed(account), _completed(leased)]
|
||||
|
||||
result = pick_web_account("target-platform", account_id="42")
|
||||
|
||||
self.assertEqual(result["id"], 42)
|
||||
self.assertEqual(result["lease_token"], "lease-42")
|
||||
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
|
||||
pick_args = run_argv.call_args_list[1].args[0]
|
||||
self.assertIn("pick-web", pick_args)
|
||||
self.assertIn("--lease", pick_args)
|
||||
self.assertIn("user@example.com", pick_args)
|
||||
|
||||
@patch("service.account_client._run_argv")
|
||||
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
|
||||
account = {
|
||||
"id": 42,
|
||||
"platform_key": "target-platform",
|
||||
"account_label": "主账号",
|
||||
"login_id": "user@example.com",
|
||||
"profile_dir": "D:/data/profile-42",
|
||||
"status": "active",
|
||||
}
|
||||
run_argv.side_effect = [_completed(account), _completed(account)]
|
||||
|
||||
with self.assertRaises(AccountManagerError) as raised:
|
||||
pick_web_account("target-platform", account_id="42")
|
||||
|
||||
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,64 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""adapter 工厂 dispatch 单元测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from service.adapter import (
|
||||
BatchItem,
|
||||
MockBatchAdapter,
|
||||
SimulatorBrowserRpaAdapter,
|
||||
select_adapter,
|
||||
)
|
||||
|
||||
|
||||
class TestAdapterDispatch(unittest.TestCase):
|
||||
def test_default_returns_simulator_rpa(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("OPENCLAW_TEST_TARGET", None)
|
||||
adapter = select_adapter()
|
||||
self.assertIsInstance(adapter, SimulatorBrowserRpaAdapter)
|
||||
|
||||
def test_unit_returns_mock(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "unit"}):
|
||||
self.assertIsInstance(select_adapter(), MockBatchAdapter)
|
||||
|
||||
def test_mock_returns_mock(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "mock"}):
|
||||
self.assertIsInstance(select_adapter(), MockBatchAdapter)
|
||||
|
||||
def test_simulator_rpa_returns_simulator(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_rpa"}):
|
||||
self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter)
|
||||
|
||||
def test_real_rpa_falls_back_to_mock(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "real_rpa"}):
|
||||
self.assertIsInstance(select_adapter(), MockBatchAdapter)
|
||||
|
||||
def test_unknown_target_falls_back_simulator_rpa(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_api"}):
|
||||
self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter)
|
||||
|
||||
|
||||
class TestMockAdapter(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_mock_success(self) -> None:
|
||||
items = [
|
||||
BatchItem(row_index=1, name="Alice", account="1001", amount=10.5),
|
||||
BatchItem(row_index=2, name="Bob", account="1002", amount=20.0),
|
||||
]
|
||||
result = await MockBatchAdapter().submit_batch("acct-demo-001", items)
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual(result.submitted_count, 2)
|
||||
self.assertEqual(result.submitted_amount, 30.5)
|
||||
self.assertTrue((result.batch_id or "").startswith("MOCK-"))
|
||||
|
||||
async def test_mock_empty_items(self) -> None:
|
||||
result = await MockBatchAdapter().submit_batch("acct-demo-001", [])
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.error_msg, "items 为空")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,39 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""禁止 import account-manager 内部 RPA 辅助模块。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
FORBIDDEN_PATTERNS = (
|
||||
re.compile(r"\brpa_helpers\b"),
|
||||
re.compile(r"\binject_account_manager_scripts_path\b"),
|
||||
re.compile(r"\bget_account_credential\b"),
|
||||
)
|
||||
|
||||
SCRIPTS_ROOT = Path(__file__).resolve().parents[1] / "scripts"
|
||||
|
||||
|
||||
class TestNoRpaHelpersImport(unittest.TestCase):
|
||||
def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None:
|
||||
violations: list[str] = []
|
||||
for root, _dirs, files in os.walk(SCRIPTS_ROOT):
|
||||
for name in files:
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
path = Path(root) / name
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for pattern in FORBIDDEN_PATTERNS:
|
||||
if pattern.search(text):
|
||||
violations.append(f"{path.relative_to(SCRIPTS_ROOT)}: {pattern.pattern}")
|
||||
self.assertEqual(
|
||||
violations,
|
||||
[],
|
||||
msg="Forbidden account-manager internal imports found:\n" + "\n".join(violations),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,134 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
|
||||
from service.browser_session import CHROME_LAUNCH_ARGS
|
||||
from service.simulator_playwright import RpaError
|
||||
|
||||
|
||||
class TestSimulatorRpaUnit(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_submit_batch_empty_items(self) -> None:
|
||||
adapter = SimulatorBrowserRpaAdapter()
|
||||
result = await adapter.submit_batch("acct-demo-001", [])
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("items", result.error_msg or "")
|
||||
|
||||
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value=None)
|
||||
async def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None:
|
||||
adapter = SimulatorBrowserRpaAdapter()
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
result = await adapter.submit_batch("acct-demo-001", items)
|
||||
self.assertFalse(result.ok)
|
||||
msg = result.error_msg or ""
|
||||
self.assertTrue("Chrome" in msg or "Edge" in msg or "浏览器" in msg)
|
||||
|
||||
def test_chrome_launch_args_must_not_contain_url(self) -> None:
|
||||
for arg in CHROME_LAUNCH_ARGS:
|
||||
lowered = arg.lower()
|
||||
self.assertFalse(lowered.startswith("http://"))
|
||||
self.assertFalse(lowered.startswith("https://"))
|
||||
self.assertFalse(lowered.startswith("file://"))
|
||||
|
||||
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
|
||||
@patch("service.adapter.simulator_rpa.pick_web_account")
|
||||
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
|
||||
async def test_adapter_delegates_to_playwright_with_account_url(
|
||||
self,
|
||||
_chrome: MagicMock,
|
||||
mock_pick: MagicMock,
|
||||
mock_rpa: AsyncMock,
|
||||
) -> None:
|
||||
from service.adapter.base import BatchSubmitResult
|
||||
|
||||
mock_pick.return_value = {
|
||||
"login_id": "demo001",
|
||||
"profile_dir": "/tmp/profile",
|
||||
"url": "file:///demo/demo_app.html",
|
||||
"lease_token": "tok-1",
|
||||
}
|
||||
mock_rpa.return_value = BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="login fail",
|
||||
artifacts={"adapter": "simulator_rpa"},
|
||||
)
|
||||
|
||||
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
result = await adapter.submit_batch("acct-demo-001", items)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
mock_rpa.assert_awaited_once()
|
||||
call_kwargs = mock_rpa.call_args.kwargs
|
||||
self.assertEqual(call_kwargs["base_url"], "file:///demo/demo_app.html")
|
||||
for arg in CHROME_LAUNCH_ARGS:
|
||||
self.assertNotIn(arg, call_kwargs["base_url"])
|
||||
|
||||
@patch("service.simulator_playwright.start_browser_session", new_callable=AsyncMock)
|
||||
async def test_playwright_opens_url_via_goto_not_launch_args(self, mock_start: AsyncMock) -> None:
|
||||
from service.simulator_playwright import submit_batch_rpa
|
||||
|
||||
page = AsyncMock()
|
||||
page.goto = AsyncMock()
|
||||
mock_start.return_value = (MagicMock(), MagicMock(), page)
|
||||
|
||||
account = {"profile_dir": "/tmp/p", "login_id": "demo001"}
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
|
||||
with patch(
|
||||
"service.simulator_playwright._wait_portal_gate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
with patch(
|
||||
"service.simulator_playwright._login",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RpaError("login fail"),
|
||||
):
|
||||
with patch(
|
||||
"service.simulator_playwright.find_chrome_executable",
|
||||
return_value="C:/Chrome/chrome.exe",
|
||||
):
|
||||
with patch(
|
||||
"service.simulator_playwright.close_browser_session",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
result = await submit_batch_rpa(
|
||||
account,
|
||||
target="acct-demo-001",
|
||||
items=items,
|
||||
base_url="file:///demo/demo_app.html",
|
||||
artifacts_dir=None,
|
||||
)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
page.goto.assert_awaited_once()
|
||||
goto_url = page.goto.call_args[0][0]
|
||||
self.assertEqual(goto_url, "file:///demo/demo_app.html")
|
||||
for arg in CHROME_LAUNCH_ARGS:
|
||||
self.assertNotIn(arg, goto_url)
|
||||
|
||||
async def test_safe_screenshot_without_artifacts_dir(self) -> None:
|
||||
from service.simulator_playwright import _safe_screenshot
|
||||
|
||||
page = AsyncMock()
|
||||
self.assertIsNone(await _safe_screenshot(page, None, "tag"))
|
||||
|
||||
|
||||
class TestSyncHelpers(unittest.TestCase):
|
||||
def test_asyncio_run_smoke(self) -> None:
|
||||
async def _noop() -> bool:
|
||||
return True
|
||||
|
||||
self.assertTrue(asyncio.run(_noop()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,74 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RPA 失败结果与 artifacts 单元测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
|
||||
from service.adapter.base import BatchSubmitResult
|
||||
|
||||
|
||||
class TestBatchSubmitFailureResult(unittest.IsolatedAsyncioTestCase):
|
||||
@patch("service.adapter.simulator_rpa.release_lease")
|
||||
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
|
||||
@patch("service.adapter.simulator_rpa.pick_web_account")
|
||||
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
|
||||
async def test_rpa_error_converts_to_failed_result(
|
||||
self,
|
||||
_chrome: MagicMock,
|
||||
mock_pick: MagicMock,
|
||||
mock_rpa: AsyncMock,
|
||||
_release: MagicMock,
|
||||
) -> None:
|
||||
mock_pick.return_value = {
|
||||
"profile_dir": "/tmp/profile",
|
||||
"lease_token": "tok",
|
||||
}
|
||||
mock_rpa.return_value = BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="登录第一步失败:timeout",
|
||||
artifacts={"adapter": "simulator_rpa", "screenshot": "/tmp/art/x.png"},
|
||||
)
|
||||
|
||||
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
result = await adapter.submit_batch("acct-demo-001", items)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("登录第一步失败", result.error_msg or "")
|
||||
self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa")
|
||||
self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png")
|
||||
|
||||
@patch("service.adapter.simulator_rpa.release_lease")
|
||||
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
|
||||
@patch("service.adapter.simulator_rpa.pick_web_account")
|
||||
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
|
||||
async def test_unexpected_error_includes_adapter_name(
|
||||
self,
|
||||
_chrome: MagicMock,
|
||||
mock_pick: MagicMock,
|
||||
mock_rpa: AsyncMock,
|
||||
_release: MagicMock,
|
||||
) -> None:
|
||||
mock_pick.return_value = {
|
||||
"profile_dir": "/tmp/profile",
|
||||
"lease_token": "tok",
|
||||
}
|
||||
mock_rpa.side_effect = RuntimeError("boom")
|
||||
|
||||
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
result = await adapter.submit_batch("acct-demo-001", items)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("未预期异常", result.error_msg or "")
|
||||
self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa")
|
||||
self.assertNotIn("screenshot", result.artifacts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user