feat: 初始化高校人事请假考勤技能 (process-leave-attendance-default)
All checks were successful
技能自动化发布 / release (push) Successful in 15s

基于 skill-template 脚手架开发,包含:
- 5 张业务表(教职工、请假政策、考勤日历、请假记录、假期余额)
- 请假核算引擎(扣薪计算、审批层级判定、年假抵扣)
- CLI 入口(import-employees / calculate-leave / render-html)
- HTML 审批与扣薪报告生成
- 示例数据与完整测试套件(295 tests passed)
This commit is contained in:
chenfuqiang
2026-09-07 16:59:24 +08:00
commit ee2a4d9e22
206 changed files with 29510 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
# Simulator Browser RPA Example
这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 **async Playwright** 完成门户门闩、两步登录、表单填写、PIN 弹窗、结果解析。
本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制。
本示例只教学授权范围内的正常界面自动化;禁止把 sandbox 做法扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避真实平台限制的实现。
## 适合什么场景
- 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html`
- 需要 **浏览器 RPA** 做端到端演示或回归
- 表单较复杂:门户 HITL、多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析
- 需要 **adapter 分层**:`mock`(CI)与 `simulator_rpa`(半集成)可切换
## 不适合什么场景
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
- 复制前先读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`;即使 DOM 可控也要定义作用域、唯一性和后置状态
## 目录结构
```
simulator_browser_rpa/
├── README.md
├── sandbox/
│ └── demo_app.html # 本地仿真页面(门户门闩 + 批量提交教学 DOM)
├── scripts/
│ ├── service/
│ │ ├── browser_session.py # async 启动 persistent context(不含 goto)
│ │ ├── account_client.py # account-manager subprocess(对齐 real_browser_rpa)
│ │ ├── simulator_playwright.py # RPA 主流程(厚)
│ │ ├── task_service.py # async 编排层
│ │ └── adapter/
│ │ ├── base.py # BatchItem / BatchSubmitResult 契约
│ │ ├── mock.py # mock/unit 档
│ │ ├── simulator_rpa.py # 薄 adapter:pick / lease / 委托 RPA
│ │ └── __init__.py # select_adapter
│ └── util/
│ ├── constants.py
│ └── logging.py
└── tests/
├── test_adapter_dispatch.py
├── test_no_rpa_helpers_import.py
├── test_simulator_rpa_unit.py
└── test_stop_reason.py
```
## 文件职责
| 文件 | 职责 |
|---|---|
| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** |
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease`) |
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
| `sandbox/demo_app.html` | 可控 DOM;含可选门户门闩 + 原批量提交流程 |
## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致)
- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account`(ensure-web)拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录
- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0`
- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager
## 核心流程
1. `await run_batch_submit(target, items)` 校验参数
2. `select_adapter()` 按 `OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA
3. `simulator_rpa` 档:`SimulatorBrowserRpaAdapter.submit_batch` 内 `pick_web_account(sim_batch)` → `submit_batch_rpa` → `release_lease`
4. RPA:门户门闩(若可见则 HITL 轮询)→ 登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id`
5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult`
## 两档仿真目标
| 档位 | 目标 | 说明 |
|---|---|---|
| 本地教学 | `sandbox/demo_app.html`(`file://`) | 结构教学 + mock 单测默认 |
| 线上联调 | `https://sandbox.jc2009.com/...` | 设 `SIMULATOR_BASE_URL` + account-manager `sim_batch` 账号 |
联调时账号 `url` 用行业根路径,**不要**写 `/login` 子路径;`auth_strategy=per_session_manual`。
## account-manager 注册示例(泛化)
```bash
python .../account-manager/scripts/main.py account add-web \
--platform sim_batch \
--login-id demo001 \
--url file:///.../demo_app.html \
--environment simulator \
--auth-strategy per_session_manual
```
线上 sandbox 时将 `--url` 换为 `https://sandbox.jc2009.com/...` 占位路径(按团队部署填写)。
**PIN/密码演示值**:RPA 从 account 的 `login_id` 取账号;`password` / `token_pin` 允许从 account 字段或环境变量 `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` 回退;验证码固定演示值 `0000`(仅 sandbox 用)。
## Copy map(复制到新技能 `scripts/`)
| 来源(examples) | 目标(新技能) | 说明 |
|---|---|---|
| `browser_session.py` | `scripts/service/browser_session.py` | async;URL 仅 `page.goto`,不进 launch args |
| `account_client.py` | `scripts/service/account_client.py` | subprocess,对齐 `real_browser_rpa` |
| `simulator_playwright.py` | `scripts/service/<platform>_playwright.py` | RPA 主流程(厚) |
| `adapter/` | `scripts/service/<domain>_adapter/` | 薄 `simulator_rpa` + mock |
| `task_service.py` | `scripts/service/task_service.py` | async 编排 |
| `util/constants.py` | `scripts/util/constants.py` | 须替换 `TARGET_PLATFORM` 等业务常量 |
| `tests/` | `tests/` | 可按需复制 dispatch / unit / 禁止 import 守护 |
| `sandbox/demo_app.html` | — | **仅仿真测试**,不进入生产 skill |
## 明确禁止
- **不要** `import account-manager` 内部模块(`rpa_helpers`、`inject_account_manager_scripts_path`、`get_account_credential`)
- **不要** sync Playwright 用于完整 RPA 主路径
- **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess
- **不要在技能侧**执行 `playwright install` / `pip install playwright`
- **不要把 URL 放进** `launch_persistent_context(args=[...])`
- **不要** vendor `scripts/jiangchang_skill_core/`
- **不要**照抄 slug、logger 名、演示账号到生产 skill
> 旧版 `pick_simulator_account()` / `SimulatorAccount` / `set_credentials()` 已废弃;账号统一由 account-manager 下发。
## 联调检查表
- [ ] 数据目录 `.env` 中 `OPENCLAW_TEST_TARGET=simulator_rpa`(非 mock)
- [ ] account-manager:`platform ensure` + 账号 `active` + 有 `profile_dir`
- [ ] 目标 sandbox UI 已部署(跨团队)
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(可用 `--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `tests/run_tests.py` / 本 example `pytest` 仍全部通过(mock 离线)
## 依赖说明
- Python 3.10+
- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要 `pip install playwright`
- 默认使用**系统 Chrome/Edge**;技能侧不要 `playwright install`
- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选)
## 环境变量(示例)
| 变量 | 说明 |
|---|---|
| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock;`simulator_rpa` → 仿真 RPA |
| `SIMULATOR_BASE_URL` | 仿真页 URL;默认本地 `demo_app.html` 的 file URI |
| `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` | 演示密码/PIN 回退(账号字段优先) |
| `PORTAL_LOGIN_WAIT_SEC` | 门户 HITL 超时(默认 120) |
| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头(CI 可用) |
## 运行测试
```bash
cd examples/simulator_browser_rpa
python -m pytest tests/ -v
```
测试为 mock/纯函数,**不启动真实浏览器**。
## 当前状态
- async Playwright + 薄 adapter + `simulator_playwright.py` 工程范式已对齐 `real_browser_rpa`。
- 未接入完整 CLI、数据库与 entitlement。

View File

@@ -0,0 +1,155 @@
<!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>

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

View File

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

View File

@@ -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

View File

@@ -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},
)

View File

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

View File

@@ -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

View File

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

View 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

View File

@@ -0,0 +1,52 @@
"""仿真浏览器 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()

View File

@@ -0,0 +1,38 @@
"""示例日志工具。"""
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

View File

@@ -0,0 +1,10 @@
"""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))

View File

@@ -0,0 +1,63 @@
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()

View File

@@ -0,0 +1,64 @@
# -*- 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()

View File

@@ -0,0 +1,39 @@
# -*- 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()

View File

@@ -0,0 +1,134 @@
# -*- 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()

View File

@@ -0,0 +1,74 @@
# -*- 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()