chore: auto release commit (2026-06-15 17:32:47)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""仿真账号来源封装(示例级;真实 skill 可在此集中对接 account-manager)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import 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,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulatorAccount:
|
||||
login_id: str
|
||||
password: str
|
||||
token_pin: str
|
||||
profile_dir: str = ""
|
||||
|
||||
|
||||
def pick_simulator_account() -> SimulatorAccount:
|
||||
"""
|
||||
示例账号来源:优先读环境变量,否则返回内置 demo 账号。
|
||||
|
||||
真实 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(),
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
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 = os.environ.get("OPENCLAW_TEST_TARGET", "").strip().lower()
|
||||
|
||||
if target in ("mock", "unit"):
|
||||
logger.info("target '%s': MockBatchAdapter", target)
|
||||
return MockBatchAdapter()
|
||||
|
||||
if target == "real_rpa":
|
||||
logger.warning("target real_rpa 未实现,回退 MockBatchAdapter")
|
||||
return MockBatchAdapter()
|
||||
|
||||
if target and target != "simulator_rpa":
|
||||
logger.warning("未知 target '%s',回退 simulator_rpa", target)
|
||||
|
||||
logger.info("target '%s': SimulatorBrowserRpaAdapter", target or "(unset=default)")
|
||||
return SimulatorBrowserRpaAdapter(artifacts_dir=artifacts_dir)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""批量提交 adapter 基类与数据契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchItem:
|
||||
row_index: int
|
||||
name: str
|
||||
account: str
|
||||
amount: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchSubmitResult:
|
||||
ok: bool
|
||||
batch_id: Optional[str]
|
||||
submitted_count: int
|
||||
submitted_amount: float
|
||||
error_msg: Optional[str]
|
||||
artifacts: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class BatchAdapterBase:
|
||||
name: str = "base"
|
||||
|
||||
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,36 @@
|
||||
"""mock / unit 档位:不启动浏览器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
|
||||
|
||||
class MockBatchAdapter(BatchAdapterBase):
|
||||
name = "mock"
|
||||
|
||||
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
del target
|
||||
if not items:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="items 为空",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
total = sum(it.amount for it in items)
|
||||
fake_batch_id = f"MOCK-{int(time.time())}"
|
||||
return BatchSubmitResult(
|
||||
ok=True,
|
||||
batch_id=fake_batch_id,
|
||||
submitted_count=len(items),
|
||||
submitted_amount=total,
|
||||
error_msg=None,
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""仿真浏览器 RPA:操作 sandbox/demo_app.html 完成批量提交。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
class SimulatorBrowserRpaAdapter(BatchAdapterBase):
|
||||
name = "simulator_browser_rpa"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
artifacts_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
self.base_url = (base_url or resolve_simulator_base_url()).rstrip("/")
|
||||
if headless is None:
|
||||
env = os.environ.get("OPENCLAW_BROWSER_HEADLESS", "").strip().lower()
|
||||
self.headless = env in ("1", "true", "yes")
|
||||
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:
|
||||
try:
|
||||
from playwright.sync_api import sync_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},
|
||||
)
|
||||
|
||||
start_url = self.base_url
|
||||
try:
|
||||
with browser_session(
|
||||
start_url,
|
||||
profile_dir=self._profile_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:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=f"启动浏览器失败:{outer}",
|
||||
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
|
||||
@@ -0,0 +1,138 @@
|
||||
"""浏览器会话启动(系统 Chrome/Edge;URL 仅通过 page.goto 打开)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Generator, Iterator, 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",
|
||||
"--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
|
||||
|
||||
chrome = find_chrome_executable()
|
||||
if chrome and os.path.isfile(chrome):
|
||||
return chrome
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
candidates = [
|
||||
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
|
||||
]
|
||||
for path in candidates:
|
||||
if path and os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _clear_node_options() -> None:
|
||||
os.environ.pop("NODE_OPTIONS", None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def browser_session(
|
||||
start_url: str,
|
||||
*,
|
||||
profile_dir: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
) -> Generator[Any, None, None]:
|
||||
"""
|
||||
同步 Playwright 浏览器会话。
|
||||
|
||||
- Playwright Python 包由宿主共享 runtime 提供;技能侧不要 playwright install。
|
||||
- launch args 仅放 Chrome 参数,不含 URL。
|
||||
- 流程:launch → new_page → goto(start_url)
|
||||
"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
_clear_node_options()
|
||||
hl = headless if headless is not None else _headless_from_env()
|
||||
chrome = _find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。"
|
||||
)
|
||||
|
||||
args = chrome_launch_args()
|
||||
logger.info(
|
||||
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
|
||||
chrome,
|
||||
profile_dir or "",
|
||||
start_url,
|
||||
args,
|
||||
hl,
|
||||
)
|
||||
|
||||
browser_holder = None
|
||||
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")
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
def find_chrome_executable() -> str | None:
|
||||
return _find_chrome_executable()
|
||||
104
examples/simulator_browser_rpa/scripts/service/task_service.py
Normal file
104
examples/simulator_browser_rpa/scripts/service/task_service.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""轻量任务编排:校验输入 → 选 adapter → 提交批次。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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 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
|
||||
|
||||
|
||||
def run_batch_submit(
|
||||
target: str,
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
force: bool = False,
|
||||
artifacts_dir: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
最小编排示例。
|
||||
|
||||
真实 skill 中还应:写 task_logs、entitlement、RpaVideoSession 等。
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
summary: Dict[str, Any] = {
|
||||
"success": result.ok,
|
||||
"target": target_key,
|
||||
"adapter": result.artifacts.get("adapter") or adapter.name,
|
||||
"submitted_count": result.submitted_count,
|
||||
"submitted_amount": result.submitted_amount,
|
||||
"batch_id": result.batch_id,
|
||||
"artifacts": result.artifacts,
|
||||
"artifacts_dir": art_dir,
|
||||
}
|
||||
if not result.ok:
|
||||
summary["error"] = {"code": "BATCH_SUBMIT_FAILED", "message": result.error_msg or "提交失败"}
|
||||
logger.info(
|
||||
"batch_submit_done success=%s batch_id=%s adapter=%s",
|
||||
result.ok,
|
||||
result.batch_id,
|
||||
adapter.name,
|
||||
)
|
||||
return summary
|
||||
Reference in New Issue
Block a user