chore: auto release commit (2026-07-03 18:50:07)
All checks were successful
技能自动化发布 / release (push) Successful in 6s

This commit is contained in:
2026-07-03 18:50:08 +08:00
parent 7136689efe
commit 48a86e56f1
31 changed files with 1605 additions and 549 deletions

View File

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

View File

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

View File

@@ -1,78 +1,45 @@
"""仿真浏览器 RPA:操作 sandbox/demo_app.html 完成批量提交"""
"""仿真浏览器 RPA 薄 adapterpick 账号、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_idurl={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)