chore: auto release commit (2026-07-03 18:50:07)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
All checks were successful
技能自动化发布 / release (push) Successful in 6s
This commit is contained in:
@@ -1,40 +1,199 @@
|
||||
"""仿真账号来源封装(示例级;真实 skill 可在此集中对接 account-manager)。"""
|
||||
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from util.constants import (
|
||||
DEFAULT_DEMO_LOGIN_ID,
|
||||
DEFAULT_DEMO_PASSWORD,
|
||||
DEFAULT_DEMO_TOKEN_PIN,
|
||||
ENV_SIMULATOR_LOGIN_ID,
|
||||
ENV_SIMULATOR_PASSWORD,
|
||||
ENV_SIMULATOR_PROFILE_DIR,
|
||||
ENV_SIMULATOR_TOKEN_PIN,
|
||||
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} 账号,所以还没有打开浏览器。"
|
||||
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}、"
|
||||
"status=active、带 profile_dir 的账号后重新运行。"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulatorAccount:
|
||||
login_id: str
|
||||
password: str
|
||||
token_pin: str
|
||||
profile_dir: str = ""
|
||||
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
|
||||
|
||||
|
||||
def pick_simulator_account() -> SimulatorAccount:
|
||||
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 _resolve_account_manager_main() -> str:
|
||||
"""解析 account-manager CLI 入口路径。
|
||||
|
||||
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
|
||||
"""
|
||||
示例账号来源:优先读环境变量,否则返回内置 demo 账号。
|
||||
env_root = (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")
|
||||
|
||||
真实 skill 若依赖 account-manager,应把 subprocess / sibling_bridge 调用
|
||||
集中在本模块,不要散落到 task_service 或 RPA 主流程中。
|
||||
"""
|
||||
return SimulatorAccount(
|
||||
login_id=(os.getenv(ENV_SIMULATOR_LOGIN_ID) or DEFAULT_DEMO_LOGIN_ID).strip(),
|
||||
password=(os.getenv(ENV_SIMULATOR_PASSWORD) or DEFAULT_DEMO_PASSWORD).strip(),
|
||||
token_pin=(os.getenv(ENV_SIMULATOR_TOKEN_PIN) or DEFAULT_DEMO_TOKEN_PIN).strip(),
|
||||
profile_dir=(os.getenv(ENV_SIMULATOR_PROFILE_DIR) or "").strip(),
|
||||
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 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", LEASE_BUSY_MESSAGE)
|
||||
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
|
||||
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
|
||||
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
|
||||
if not payload.get("profile_dir"):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。")
|
||||
return payload
|
||||
|
||||
|
||||
def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
|
||||
proc = _run_argv(
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
]
|
||||
)
|
||||
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)
|
||||
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", "pick-web 返回格式异常。")
|
||||
return payload
|
||||
|
||||
|
||||
def _pick_by_id(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。")
|
||||
|
||||
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。")
|
||||
data["profile_dir"] = profile_dir
|
||||
data["lease_token"] = ""
|
||||
return data
|
||||
|
||||
|
||||
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
|
||||
"""获取网页账号(profile_dir + lease_token)。"""
|
||||
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
|
||||
|
||||
if account_id:
|
||||
try:
|
||||
aid = int(account_id)
|
||||
except (TypeError, ValueError):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
|
||||
return _pick_by_id(platform_key, aid)
|
||||
|
||||
try:
|
||||
return _pick_web_with_lease(platform_key)
|
||||
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)
|
||||
|
||||
@@ -28,5 +28,5 @@ class BatchSubmitResult:
|
||||
class BatchAdapterBase:
|
||||
name: str = "base"
|
||||
|
||||
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -11,7 +11,7 @@ from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
class MockBatchAdapter(BatchAdapterBase):
|
||||
name = "mock"
|
||||
|
||||
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
del target
|
||||
if not items:
|
||||
return BatchSubmitResult(
|
||||
|
||||
@@ -1,78 +1,45 @@
|
||||
"""仿真浏览器 RPA:操作 sandbox/demo_app.html 完成批量提交。"""
|
||||
"""仿真浏览器 RPA 薄 adapter:pick 账号、lease、委托 RPA、release lease。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
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 browser_session, find_chrome_executable
|
||||
from util.constants import (
|
||||
DEFAULT_DEMO_CAPTCHA,
|
||||
DEFAULT_DEMO_LOGIN_ID,
|
||||
DEFAULT_DEMO_PASSWORD,
|
||||
DEFAULT_DEMO_TOKEN_PIN,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
LOGIN_NAVIGATE_TIMEOUT_MS,
|
||||
SUBMIT_NAVIGATE_TIMEOUT_MS,
|
||||
resolve_simulator_base_url,
|
||||
)
|
||||
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__)
|
||||
|
||||
_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
|
||||
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_browser_rpa"
|
||||
name = "simulator_rpa"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
artifacts_dir: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
) -> None:
|
||||
self.base_url = (base_url or resolve_simulator_base_url()).rstrip("/")
|
||||
self.artifacts_dir = artifacts_dir
|
||||
if headless is None:
|
||||
self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
||||
else:
|
||||
self.headless = headless
|
||||
self.artifacts_dir = artifacts_dir
|
||||
|
||||
self._login_id: str = DEFAULT_DEMO_LOGIN_ID
|
||||
self._password: str = DEFAULT_DEMO_PASSWORD
|
||||
self._token_pin: str = DEFAULT_DEMO_TOKEN_PIN
|
||||
self._profile_dir: Optional[str] = None
|
||||
|
||||
def set_credentials(
|
||||
self,
|
||||
login_id: str,
|
||||
password: str,
|
||||
token_pin: str,
|
||||
profile_dir: str,
|
||||
) -> None:
|
||||
self._login_id = login_id
|
||||
self._password = password
|
||||
self._token_pin = token_pin
|
||||
stripped = (profile_dir or "").strip()
|
||||
self._profile_dir = stripped or None
|
||||
|
||||
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright # noqa: F401
|
||||
from playwright.async_api import async_playwright # noqa: F401
|
||||
except ImportError:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
@@ -106,236 +73,37 @@ class SimulatorBrowserRpaAdapter(BatchAdapterBase):
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
start_url = self.base_url
|
||||
lease_token: Optional[str] = None
|
||||
try:
|
||||
with browser_session(
|
||||
start_url,
|
||||
profile_dir=self._profile_dir,
|
||||
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,
|
||||
) as page:
|
||||
try:
|
||||
logger.info("rpa_login_start url=%s", start_url)
|
||||
self._login(page)
|
||||
logger.info("rpa_login_done")
|
||||
|
||||
self._goto_batch_page(page)
|
||||
logger.info("rpa_batch_page_ready")
|
||||
|
||||
self._fill_batch_form(page, target, items)
|
||||
logger.info("rpa_batch_form_filled rows=%s", len(items))
|
||||
|
||||
batch_id = self._submit_and_get_batch_id(page)
|
||||
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": self.name},
|
||||
)
|
||||
except RpaError as exc:
|
||||
logger.error("rpa_failed: %s", exc)
|
||||
arts = {"adapter": self.name}
|
||||
if exc.screenshot_path:
|
||||
arts["screenshot"] = exc.screenshot_path
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=str(exc),
|
||||
artifacts=arts,
|
||||
)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "unexpected_error")
|
||||
logger.exception("rpa_unexpected: %s", exc)
|
||||
arts = {"adapter": self.name}
|
||||
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,
|
||||
)
|
||||
except Exception as outer:
|
||||
)
|
||||
except AccountManagerError as exc:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=f"启动浏览器失败:{outer}",
|
||||
error_msg=f"ERROR:{exc.code} {exc.message}",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
def _pause(self, min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None:
|
||||
time.sleep(random.uniform(min_ms, max_ms) / 1000.0)
|
||||
|
||||
def _login(self, page) -> None:
|
||||
try:
|
||||
page.wait_for_selector('form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
|
||||
self._pause()
|
||||
page.fill('form[name="simLoginStep1"] input[name="loginId"]', self._login_id)
|
||||
self._pause()
|
||||
page.click('form[name="simLoginStep1"] button[type="submit"]')
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "login_step1_failed")
|
||||
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
|
||||
self._pause()
|
||||
page.fill('form[name="simLoginStep2"] input[name="password"]', self._password)
|
||||
page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
|
||||
self._pause()
|
||||
page.click('form[name="simLoginStep2"] button[type="submit"]')
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "login_step2_failed")
|
||||
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "login_navigate_failed")
|
||||
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
def _goto_batch_page(self, page) -> None:
|
||||
try:
|
||||
page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
|
||||
self._pause()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "goto_batch_failed")
|
||||
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
def _select_target_account(self, page, target: str) -> None:
|
||||
sel = page.locator('select[name="fromAccountNo"]')
|
||||
sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
|
||||
n = 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 = opt.get_attribute("value") or ""
|
||||
if not val.strip():
|
||||
continue
|
||||
label_txt = 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)
|
||||
sel.select_option(value=pick)
|
||||
self._pause()
|
||||
|
||||
def _fill_batch_form(self, page, target: str, items: List[BatchItem]) -> None:
|
||||
try:
|
||||
page.get_by_role("button", name=re.compile(r"页面填写")).click()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "tab_switch_failed")
|
||||
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
self._select_target_account(page, target)
|
||||
except RpaError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "select_account_failed")
|
||||
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
existing = 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):
|
||||
self._pause(400, 900)
|
||||
add_btn.click()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "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}"]')
|
||||
row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
|
||||
self._pause(300, 800)
|
||||
row.locator('input[name="name"]').fill(it.name)
|
||||
row.locator('input[name="account"]').fill(it.account)
|
||||
row.locator('input[name="note"]').fill(it.note or "")
|
||||
row.locator('input[name="amount"]').fill(f"{it.amount}")
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, f"fill_row_{it.row_index}_failed")
|
||||
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.fill('input[name="purpose"]', "仿真批量提交示例")
|
||||
except Exception as exc:
|
||||
logger.warning("填写用途失败(非致命):%s", exc)
|
||||
|
||||
def _submit_and_get_batch_id(self, page) -> str:
|
||||
try:
|
||||
self._pause(800, 1500)
|
||||
page.locator('form[name="simBatchForm"] button[type="submit"]').click()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "click_submit_failed")
|
||||
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
|
||||
try:
|
||||
page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "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"]')
|
||||
self._pause(500, 1200)
|
||||
pin_input.fill(self._token_pin)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "fill_pin_failed")
|
||||
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "click_confirm_failed")
|
||||
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "success_area_not_visible")
|
||||
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
batch_id = page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
|
||||
if not batch_id:
|
||||
batch_id = 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 = self._safe_screenshot(page, "batch_id_empty")
|
||||
raise RpaError(f"无法解析 batch_id,url={url}", screenshot_path=sp)
|
||||
return batch_id
|
||||
|
||||
def _safe_screenshot(self, page, tag: str) -> Optional[str]:
|
||||
if not self.artifacts_dir:
|
||||
return None
|
||||
try:
|
||||
os.makedirs(self.artifacts_dir, exist_ok=True)
|
||||
path = os.path.join(self.artifacts_dir, f"{tag}_{int(time.time())}.png")
|
||||
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
|
||||
logger.exception("adapter_unexpected: %s", exc)
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
finally:
|
||||
release_lease(lease_token)
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"""浏览器会话启动(系统 Chrome/Edge;URL 仅通过 page.goto 打开)。"""
|
||||
"""浏览器会话启动(系统 Chrome/Edge + async Playwright persistent context)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Generator, Iterator, List, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PAGE_TIMEOUT_MS = 15_000
|
||||
GOTO_TIMEOUT_MS = 60_000
|
||||
|
||||
CHROME_LAUNCH_ARGS: List[str] = [
|
||||
"--start-maximized",
|
||||
@@ -33,11 +31,11 @@ def _headless_from_env() -> bool:
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _find_chrome_executable() -> str | None:
|
||||
def find_chrome_executable() -> str | None:
|
||||
try:
|
||||
from jiangchang_skill_core.runtime_env import find_chrome_executable
|
||||
from jiangchang_skill_core.runtime_env import find_chrome_executable as _find
|
||||
|
||||
chrome = find_chrome_executable()
|
||||
chrome = _find()
|
||||
if chrome and os.path.isfile(chrome):
|
||||
return chrome
|
||||
except ImportError:
|
||||
@@ -59,80 +57,70 @@ def _clear_node_options() -> None:
|
||||
os.environ.pop("NODE_OPTIONS", None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def browser_session(
|
||||
start_url: str,
|
||||
async def start_browser_session(
|
||||
profile_dir: str,
|
||||
*,
|
||||
profile_dir: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
) -> Generator[Any, None, None]:
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""
|
||||
同步 Playwright 浏览器会话。
|
||||
启动持久化 Chrome profile,返回 (playwright, context, page)。
|
||||
|
||||
- Playwright Python 包由宿主共享 runtime 提供;技能侧不要 playwright install。
|
||||
- launch args 仅放 Chrome 参数,不含 URL。
|
||||
- 流程:launch → new_page → goto(start_url)
|
||||
URL 不在此文件打开;由 simulator_playwright.py 负责 page.goto。
|
||||
"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
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()
|
||||
chrome = find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。"
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。"
|
||||
)
|
||||
|
||||
args = chrome_launch_args()
|
||||
logger.info(
|
||||
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
|
||||
"browser_launch chrome=%s profile_dir=%s args=%s headless=%s",
|
||||
chrome,
|
||||
profile_dir or "",
|
||||
start_url,
|
||||
profile_dir,
|
||||
args,
|
||||
hl,
|
||||
)
|
||||
|
||||
browser_holder = None
|
||||
pw = await async_playwright().start()
|
||||
context = None
|
||||
with sync_playwright() as pw:
|
||||
try:
|
||||
if profile_dir:
|
||||
context = 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"],
|
||||
)
|
||||
else:
|
||||
browser_holder = pw.chromium.launch(
|
||||
headless=hl,
|
||||
executable_path=chrome,
|
||||
args=args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
)
|
||||
context = browser_holder.new_context(no_viewport=True, locale="zh-CN")
|
||||
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)
|
||||
|
||||
context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
||||
page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
|
||||
yield page
|
||||
finally:
|
||||
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:
|
||||
if context is not None:
|
||||
context.close()
|
||||
except Exception as exc:
|
||||
logger.warning("browser_context_close_failed: %s", exc)
|
||||
if browser_holder is not None:
|
||||
try:
|
||||
browser_holder.close()
|
||||
except Exception as exc:
|
||||
logger.warning("browser_close_failed: %s", exc)
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
await pw.stop()
|
||||
raise
|
||||
|
||||
|
||||
def find_chrome_executable() -> str | None:
|
||||
return _find_chrome_executable()
|
||||
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)
|
||||
@@ -7,8 +7,7 @@ import tempfile
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from service.account_client import pick_simulator_account
|
||||
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter, select_adapter
|
||||
from service.adapter import BatchItem, select_adapter
|
||||
from util.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -42,7 +41,7 @@ def _validate_items(items: List[Dict[str, Any]]) -> tuple[List[BatchItem], Optio
|
||||
return parsed, None
|
||||
|
||||
|
||||
def run_batch_submit(
|
||||
async def run_batch_submit(
|
||||
target: str,
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
@@ -53,6 +52,7 @@ def run_batch_submit(
|
||||
最小编排示例。
|
||||
|
||||
真实 skill 中还应:写 task_logs、entitlement、RpaVideoSession 等。
|
||||
账号 pick / lease 在 adapter 内完成,编排层不直接碰 account-manager。
|
||||
"""
|
||||
del force # 示例占位;真实 skill 可用于跳过确认
|
||||
|
||||
@@ -70,19 +70,15 @@ def run_batch_submit(
|
||||
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)
|
||||
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)
|
||||
if isinstance(adapter, SimulatorBrowserRpaAdapter):
|
||||
account = pick_simulator_account()
|
||||
adapter.set_credentials(
|
||||
account.login_id,
|
||||
account.password,
|
||||
account.token_pin,
|
||||
account.profile_dir,
|
||||
)
|
||||
|
||||
result = adapter.submit_batch(target_key, batch_items)
|
||||
result = await adapter.submit_batch(target_key, batch_items)
|
||||
summary: Dict[str, Any] = {
|
||||
"success": result.ok,
|
||||
"target": target_key,
|
||||
|
||||
@@ -5,12 +5,19 @@ 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"
|
||||
@@ -18,15 +25,26 @@ DEFAULT_DEMO_CAPTCHA = "0000"
|
||||
DEFAULT_DEMO_TOKEN_PIN = "123456"
|
||||
|
||||
ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL"
|
||||
ENV_SIMULATOR_LOGIN_ID = "SIMULATOR_LOGIN_ID"
|
||||
ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD"
|
||||
ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN"
|
||||
ENV_SIMULATOR_PROFILE_DIR = "SIMULATOR_PROFILE_DIR"
|
||||
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:
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from util.constants import LOG_LOGGER_NAME
|
||||
|
||||
@@ -13,3 +15,24 @@ def get_logger(name: str | None = None) -> logging.Logger:
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user