feat: 申报赛道推荐助手首版 v1.0.1(match-guide-track-default)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
All checks were successful
技能自动化发布 / release (push) Successful in 6s
This commit is contained in:
355
examples/simulator_browser_rpa/scripts/service/account_client.py
Normal file
355
examples/simulator_browser_rpa/scripts/service/account_client.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""批量提交 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
|
||||
@@ -0,0 +1,36 @@
|
||||
"""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},
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""仿真浏览器 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)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""浏览器会话启动(系统 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
|
||||
@@ -0,0 +1,400 @@
|
||||
"""仿真批量提交 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)
|
||||
100
examples/simulator_browser_rpa/scripts/service/task_service.py
Normal file
100
examples/simulator_browser_rpa/scripts/service/task_service.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""轻量任务编排:校验输入 → 选 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
|
||||
Reference in New Issue
Block a user