chore: auto release commit (2026-07-03 12:09:55)
All checks were successful
技能自动化发布 / release (push) Successful in 5s

This commit is contained in:
2026-07-03 12:09:55 +08:00
parent 7136689efe
commit 4a0be93b4a
22 changed files with 1613 additions and 575 deletions

View File

@@ -0,0 +1,236 @@
"""account-manager CLI 集成(仅 subprocess不 import 兄弟 skill 内部模块)。"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
from typing import Any, Dict, List, Optional
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 的账号后重新运行。"
)
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
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:
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")
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 inject_account_manager_scripts_path() -> str:
"""将 account-manager/scripts 注入 sys.path供 rpa_helpers 同进程 import。"""
main_py = _resolve_account_manager_main()
scripts_dir = os.path.dirname(os.path.abspath(main_py))
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
return scripts_dir
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 get_account_credential(account_id: int, lease_token: str) -> dict[str, Any]:
"""通过租约 reveal 账号凭据 plaintext。"""
tok = (lease_token or "").strip()
if not tok:
raise AccountManagerError("ERR_LEASE_INVALID", "reveal 需要有效 lease_token。")
proc = _run_argv(
[
"account",
"get-credential",
str(account_id),
"--reveal",
"--lease-token",
tok,
]
)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"CREDENTIAL_FAILED",
(proc.stderr or "").strip() or "get-credential 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and 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 "CREDENTIAL_FAILED"))
message = str(err.get("message") or "获取凭据失败")
raise AccountManagerError(code, message)
if not isinstance(payload, dict):
raise AccountManagerError("CREDENTIAL_FAILED", "get-credential 返回格式异常。")
return payload
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,40 @@
"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。"""
from __future__ import annotations
import logging
from typing import Optional
from jiangchang_skill_core import config
from service.amazon_report_adapter.base import DownloadSettlementAdapterBase, DownloadSettlementResult
from service.amazon_report_adapter.mock import MockDownloadSettlementAdapter
from service.amazon_report_adapter.simulator_rpa import SimulatorRpaDownloadSettlementAdapter
logger = logging.getLogger(__name__)
__all__ = [
"get_adapter",
"DownloadSettlementAdapterBase",
"DownloadSettlementResult",
"MockDownloadSettlementAdapter",
"SimulatorRpaDownloadSettlementAdapter",
]
def get_adapter(*, artifacts_dir: Optional[str] = None) -> DownloadSettlementAdapterBase:
target = (config.get("OPENCLAW_TEST_TARGET") or "").strip().lower()
if target in ("mock", "unit"):
logger.info("target '%s': MockDownloadSettlementAdapter", target)
return MockDownloadSettlementAdapter()
if target == "real_rpa":
logger.warning("target real_rpa 未实现,回退 MockDownloadSettlementAdapter")
return MockDownloadSettlementAdapter()
if target and target != "simulator_rpa":
logger.warning("未知 target '%s',回退 simulator_rpa", target)
logger.info("target '%s': SimulatorRpaDownloadSettlementAdapter", target or "(unset=default)")
return SimulatorRpaDownloadSettlementAdapter(artifacts_dir=artifacts_dir)

View File

@@ -0,0 +1,34 @@
"""Amazon 结算报表 adapter 基类与数据契约。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
@dataclass
class DownloadSettlementResult:
ok: bool
job_id: Optional[str]
file_path: Optional[str]
filename: Optional[str]
date_from: str
date_to: str
seller_code: Optional[str] = None
error_msg: Optional[str] = None
artifacts: Dict[str, Any] = field(default_factory=dict)
class DownloadSettlementAdapterBase:
name: str = "base"
def download_settlement(
self,
*,
date_from: str,
date_to: str,
seller_code: Optional[str] = None,
downloads_dir: str = "",
artifacts_dir: str = "",
) -> DownloadSettlementResult:
raise NotImplementedError

View File

@@ -0,0 +1,44 @@
"""mock / unit 档位:不启动浏览器,返回假 job_id 与假 xlsx 路径。"""
from __future__ import annotations
import os
import time
from typing import Optional
from service.amazon_report_adapter.base import DownloadSettlementAdapterBase, DownloadSettlementResult
class MockDownloadSettlementAdapter(DownloadSettlementAdapterBase):
name = "mock"
def download_settlement(
self,
*,
date_from: str,
date_to: str,
seller_code: Optional[str] = None,
downloads_dir: str = "",
artifacts_dir: str = "",
) -> DownloadSettlementResult:
del artifacts_dir
os.makedirs(downloads_dir, exist_ok=True)
time.sleep(0.05)
ts = int(time.time())
job_id = f"MOCK-{ts}"
seller = seller_code or "MOCK-SELLER"
filename = f"settlement_{seller}_{date_from}_{date_to}_{ts}.xlsx"
file_path = os.path.join(downloads_dir, filename)
with open(file_path, "wb") as fh:
fh.write(b"PK\x03\x04mock-settlement-xlsx")
return DownloadSettlementResult(
ok=True,
job_id=job_id,
file_path=file_path,
filename=filename,
date_from=date_from,
date_to=date_to,
seller_code=seller,
error_msg=None,
artifacts={"adapter": self.name},
)

View File

@@ -0,0 +1,356 @@
"""Amazon 仿真 Reports 页 Playwright RPA请求并下载结算报表 xlsx。
定位原则:优先 data-testidF12 实测),禁止 el.value= / JS 跳转 / REST API 下载。
"""
from __future__ import annotations
import logging
import os
import random
import time
from typing import Optional
from jiangchang_skill_core import config
from service.account_client import (
AccountManagerError,
get_account_credential,
inject_account_manager_scripts_path,
pick_web_account,
release_lease,
)
from service.amazon_report_adapter.base import DownloadSettlementAdapterBase, DownloadSettlementResult
from service.browser_session import find_chrome_executable
from util.constants import (
DEFAULT_TIMEOUT_MS,
JOB_POLL_INTERVAL_SEC,
JOB_POLL_TIMEOUT_SEC,
REPORTS_NAVIGATE_TIMEOUT_MS,
TARGET_PLATFORM,
resolve_amazon_sim_login_url,
resolve_amazon_sim_reports_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 SimulatorRpaDownloadSettlementAdapter(DownloadSettlementAdapterBase):
name = "simulator_rpa"
def __init__(
self,
*,
reports_url: Optional[str] = None,
login_url: Optional[str] = None,
headless: Optional[bool] = None,
artifacts_dir: Optional[str] = None,
) -> None:
self.reports_url = (reports_url or resolve_amazon_sim_reports_url()).strip()
self.login_url = (login_url or resolve_amazon_sim_login_url()).strip()
if headless is None:
self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
else:
self.headless = headless
self.artifacts_dir = artifacts_dir
def download_settlement(
self,
*,
date_from: str,
date_to: str,
seller_code: Optional[str] = None,
downloads_dir: str = "",
artifacts_dir: str = "",
) -> DownloadSettlementResult:
art_dir = artifacts_dir or self.artifacts_dir or ""
os.makedirs(downloads_dir, exist_ok=True)
if art_dir:
os.makedirs(art_dir, exist_ok=True)
try:
from playwright.sync_api import sync_playwright # noqa: F401
except ImportError:
return DownloadSettlementResult(
ok=False,
job_id=None,
file_path=None,
filename=None,
date_from=date_from,
date_to=date_to,
seller_code=seller_code,
error_msg=(
"playwright Python 包不可用。"
"生产/宿主运行由共享 runtime 提供;技能侧不要 pip install playwright。"
),
artifacts={"adapter": self.name},
)
if not find_chrome_executable():
return DownloadSettlementResult(
ok=False,
job_id=None,
file_path=None,
filename=None,
date_from=date_from,
date_to=date_to,
seller_code=seller_code,
error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。",
artifacts={"adapter": self.name},
)
lease_token: Optional[str] = None
ctx = None
try:
account = pick_web_account(TARGET_PLATFORM)
lease_token = str(account.get("lease_token") or "").strip() or None
profile_dir = str(account.get("profile_dir") or "").strip()
account_id = account.get("id") or account.get("account_id")
credentials: dict = {}
if lease_token and account_id is not None:
try:
cred = get_account_credential(int(account_id), lease_token)
plaintext = cred.get("plaintext")
if plaintext:
credentials = {"plaintext": plaintext}
except AccountManagerError as exc:
logger.warning("get_credential_failed: %s", exc.message)
inject_account_manager_scripts_path()
from service.rpa_helpers import close_browser, ensure_logged_in, launch_browser_with_profile
login_start = str(account.get("url") or self.login_url).strip()
ctx, page = launch_browser_with_profile(profile_dir, headless=self.headless)
try:
if login_start:
page.goto(login_start, wait_until="domcontentloaded", timeout=REPORTS_NAVIGATE_TIMEOUT_MS)
login_result = ensure_logged_in(page, account, credentials=credentials or None)
if not login_result.ok:
sp = self._safe_screenshot(page, "login_failed", art_dir)
raise RpaError(login_result.message or "登录失败", screenshot_path=sp)
self._random_delay()
page.goto(self.reports_url, wait_until="domcontentloaded", timeout=REPORTS_NAVIGATE_TIMEOUT_MS)
job_id, file_path, filename = self._run_reports_flow(
page, date_from, date_to, seller_code, downloads_dir, art_dir
)
return DownloadSettlementResult(
ok=True,
job_id=job_id,
file_path=file_path,
filename=filename,
date_from=date_from,
date_to=date_to,
seller_code=seller_code,
error_msg=None,
artifacts={"adapter": self.name},
)
except RpaError as exc:
arts = {"adapter": self.name}
if exc.screenshot_path:
arts["screenshot"] = exc.screenshot_path
return DownloadSettlementResult(
ok=False,
job_id=None,
file_path=None,
filename=None,
date_from=date_from,
date_to=date_to,
seller_code=seller_code,
error_msg=str(exc),
artifacts=arts,
)
except Exception as exc:
sp = self._safe_screenshot(page, "unexpected_error", art_dir)
logger.exception("rpa_unexpected: %s", exc)
arts = {"adapter": self.name}
if sp:
arts["screenshot"] = sp
return DownloadSettlementResult(
ok=False,
job_id=None,
file_path=None,
filename=None,
date_from=date_from,
date_to=date_to,
seller_code=seller_code,
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
artifacts=arts,
)
finally:
close_browser(ctx)
except AccountManagerError as exc:
return DownloadSettlementResult(
ok=False,
job_id=None,
file_path=None,
filename=None,
date_from=date_from,
date_to=date_to,
seller_code=seller_code,
error_msg=f"ERROR:{exc.code} {exc.message}",
artifacts={"adapter": self.name},
)
except Exception as outer:
return DownloadSettlementResult(
ok=False,
job_id=None,
file_path=None,
filename=None,
date_from=date_from,
date_to=date_to,
seller_code=seller_code,
error_msg=f"启动浏览器失败:{outer}",
artifacts={"adapter": self.name},
)
finally:
release_lease(lease_token)
def _random_delay(self) -> None:
lo = float(config.get("STEP_DELAY_MIN") or "1.0")
hi = float(config.get("STEP_DELAY_MAX") or "5.0")
if hi < lo:
hi = lo
time.sleep(random.uniform(lo, hi))
def _switch_to_payments_tab(self, page) -> None:
"""Settlement 在 Payments 分类下;默认 Tab 为 Fulfillment。"""
tab = page.locator('[data-testid="reports-tab-payments"]')
if tab.count() > 0:
self._random_delay()
tab.first.click()
return
btn = page.get_by_role("button", name="Payments")
if btn.count() > 0:
self._random_delay()
btn.first.click()
return
raise RpaError(
"未找到 Payments 分类 Tabreports-tab-payments 或 Payments 按钮不可见)。"
"线上仿真 Reports 页可能尚未渲染 Tab 切换 UI见 development/REQUIREMENTS.md §9。"
)
def _run_reports_flow(
self,
page,
date_from: str,
date_to: str,
seller_code: Optional[str],
downloads_dir: str,
art_dir: str,
) -> tuple[str, str, str]:
try:
page.wait_for_selector('[data-testid="reports-page-root"]', timeout=DEFAULT_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "reports_page_not_ready", art_dir)
raise RpaError(f"Reports 页未就绪:{exc}", screenshot_path=sp) from exc
self._random_delay()
self._switch_to_payments_tab(page)
try:
settlement = page.locator('[data-testid="reports-type-item-settlement"]')
settlement.wait_for(timeout=DEFAULT_TIMEOUT_MS)
self._random_delay()
settlement.click()
except Exception as exc:
sp = self._safe_screenshot(page, "settlement_type_not_visible", art_dir)
raise RpaError(f"未找到结算报表类型按钮:{exc}", screenshot_path=sp) from exc
try:
page.locator('[data-testid="reports-form-date-from"]').fill(date_from)
self._random_delay()
page.locator('[data-testid="reports-form-date-to"]').fill(date_to)
except Exception as exc:
sp = self._safe_screenshot(page, "fill_dates_failed", art_dir)
raise RpaError(f"填写日期失败:{exc}", screenshot_path=sp) from exc
if seller_code:
try:
page.locator('[data-testid="reports-form-seller-select"]').select_option(value=seller_code)
self._random_delay()
except Exception as exc:
sp = self._safe_screenshot(page, "select_seller_failed", art_dir)
raise RpaError(f"选择店铺失败:{exc}", screenshot_path=sp) from exc
try:
self._random_delay()
page.locator('[data-testid="reports-form-submit"]').click()
except Exception as exc:
sp = self._safe_screenshot(page, "submit_failed", art_dir)
raise RpaError(f"点击请求报表失败:{exc}", screenshot_path=sp) from exc
job_id = self._wait_for_done_job(page, art_dir)
file_path, filename = self._download_xlsx(page, job_id, downloads_dir, art_dir)
return job_id, file_path, filename
def _wait_for_done_job(self, page, art_dir: str) -> str:
deadline = time.time() + JOB_POLL_TIMEOUT_SEC
last_err: Optional[Exception] = None
while time.time() < deadline:
try:
done = page.locator('[data-testid^="reports-jobs-status-"][data-status="DONE"]')
if done.count() > 0:
testid = done.first.get_attribute("data-testid") or ""
prefix = "reports-jobs-status-"
if testid.startswith(prefix):
return testid[len(prefix) :]
return testid
except Exception as exc:
last_err = exc
time.sleep(JOB_POLL_INTERVAL_SEC)
sp = self._safe_screenshot(page, "job_poll_timeout", art_dir)
msg = f"等待报表生成超时({JOB_POLL_TIMEOUT_SEC}s"
if last_err:
msg = f"{msg}{last_err}"
raise RpaError(msg, screenshot_path=sp)
def _download_xlsx(
self,
page,
job_id: str,
downloads_dir: str,
art_dir: str,
) -> tuple[str, str]:
download_sel = f'[data-testid="reports-jobs-download-{job_id}"]'
try:
link = page.locator(download_sel)
if link.count() == 0:
link = page.locator('[data-testid^="reports-jobs-download-"]').first
link.wait_for(timeout=DEFAULT_TIMEOUT_MS)
suggested = link.get_attribute("data-filename") or f"settlement_{job_id}.xlsx"
self._random_delay()
with page.expect_download(timeout=REPORTS_NAVIGATE_TIMEOUT_MS) as dl_info:
link.click()
download = dl_info.value
filename = download.suggested_filename or suggested
file_path = os.path.join(downloads_dir, filename)
download.save_as(file_path)
logger.info("download_saved path=%s job_id=%s", file_path, job_id)
return file_path, filename
except Exception as exc:
sp = self._safe_screenshot(page, "download_failed", art_dir)
raise RpaError(f"下载 xlsx 失败:{exc}", screenshot_path=sp) from exc
def _safe_screenshot(self, page, tag: str, artifacts_dir: 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")
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

View File

@@ -0,0 +1,136 @@
"""浏览器会话启动(系统 Chrome/EdgeURL 仅通过 page.goto 打开)。"""
from __future__ import annotations
import logging
import os
from contextlib import contextmanager
from typing import Any, Generator, List, Optional
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()

View File

@@ -1,14 +1,11 @@
"""任务编排、日志查询模板。
通用业务编排层:负责参数校验、鉴权、调用具体业务实现、写入任务日志。
复制后在 cmd_run 中实现真正的业务逻辑。
"""
"""任务编排:下载 Amazon 结算报表、写入 task_logs。"""
from __future__ import annotations
import asyncio
import json
import os
import re
import uuid
from typing import Any, Dict, Optional
@@ -16,71 +13,186 @@ from jiangchang_skill_core import collect_runtime_diagnostics, config, format_ru
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
from db import task_logs_repository as tlr
from service.amazon_report_adapter import get_adapter
from service.entitlement_service import check_entitlement
from service.task_run_support import (
_print_video_summary,
build_video_info,
merge_video_into_result_summary,
)
from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION
from util.constants import (
DEFAULT_DATE_FROM,
DEFAULT_DATE_TO,
PLATFORM_KIT_MIN_VERSION,
SKILL_SLUG,
SKILL_VERSION,
)
from util.runtime_paths import get_skill_data_dir, get_skill_root
from util.timeutil import unix_to_iso
async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]:
"""最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。"""
batch_id = uuid.uuid4().hex[:12]
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _validate_date(label: str, value: str) -> Optional[str]:
v = (value or "").strip()
if not v:
return f"{label} 不能为空"
if not _DATE_RE.match(v):
return f"{label} 格式须为 YYYY-MM-DD"
return None
def _artifacts_dir(batch_id: str) -> str:
return os.path.join(get_skill_data_dir(), "rpa-artifacts", batch_id)
def _downloads_dir() -> str:
path = os.path.join(get_skill_data_dir(), "downloads")
os.makedirs(path, exist_ok=True)
return path
async def _run_with_video(
*,
date_from: str,
date_to: str,
seller_code: Optional[str],
batch_id: str,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""可选 RpaVideoSession 包裹 adapter 执行。"""
data_dir = get_skill_data_dir()
art_dir = _artifacts_dir(batch_id)
os.makedirs(art_dir, exist_ok=True)
async with RpaVideoSession(
skill_slug=SKILL_SLUG,
skill_data_dir=data_dir,
batch_id=batch_id,
title="开始执行示例任务",
closing_title="示例任务执行完成",
title="开始下载 Amazon 结算报表",
closing_title="结算报表下载完成",
) as video:
video.add_step("准备执行示例任务")
if target:
video.add_step(f"接收目标参数:{target}")
if input_id:
video.add_step(f"接收输入标识:{input_id}")
video.add_step("示例任务执行完成")
video.add_step("准备下载结算报表")
video.add_step(f"日期范围:{date_from}{date_to}")
if seller_code:
video.add_step(f"指定店铺:{seller_code}")
adapter = get_adapter(artifacts_dir=art_dir)
video.add_step(f"使用 adapter{adapter.name}")
result = adapter.download_settlement(
date_from=date_from,
date_to=date_to,
seller_code=seller_code,
downloads_dir=_downloads_dir(),
artifacts_dir=art_dir,
)
if result.ok:
video.add_step(f"下载成功:{result.filename or ''}")
else:
video.add_step(f"下载失败:{result.error_msg or '未知错误'}")
video_summary = video.summary()
video_info = build_video_info(video_summary, {}, {})
return 1, video_info
payload: dict[str, Any] = {
"ok": result.ok,
"job_id": result.job_id,
"file_path": result.file_path,
"filename": result.filename,
"date_from": result.date_from,
"date_to": result.date_to,
"seller_code": result.seller_code or seller_code,
"adapter": result.artifacts.get("adapter") or adapter.name,
"artifacts": result.artifacts,
}
if not result.ok:
payload["error"] = result.error_msg
return payload, video_info
def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int:
"""通用任务执行入口模板。复制后请实现真实业务逻辑。"""
def cmd_run(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
seller_code: Optional[str] = None,
) -> int:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
print(f" {reason}")
print(f"ERROR:ENTITLEMENT {reason}")
return 1
rc, video_info = asyncio.run(_run_template_demo(target, input_id))
_print_video_summary(video_info)
d_from = (date_from or DEFAULT_DATE_FROM).strip()
d_to = (date_to or DEFAULT_DATE_TO).strip()
seller = (seller_code or "").strip() or None
summary_payload = merge_video_into_result_summary(
{
"template_demo": True,
"target": target,
"input_id": input_id,
},
video_info,
)
for label, val in (("date_from", d_from), ("date_to", d_to)):
err = _validate_date(label, val)
if err:
print(f"ERROR:INVALID_ARGS {err}")
return 1
batch_id = uuid.uuid4().hex[:12]
record_video = config.get_bool("OPENCLAW_RECORD_VIDEO", default=True)
if record_video:
payload, video_info = asyncio.run(
_run_with_video(
date_from=d_from,
date_to=d_to,
seller_code=seller,
batch_id=batch_id,
)
)
else:
art_dir = _artifacts_dir(batch_id)
os.makedirs(art_dir, exist_ok=True)
adapter = get_adapter(artifacts_dir=art_dir)
result = adapter.download_settlement(
date_from=d_from,
date_to=d_to,
seller_code=seller,
downloads_dir=_downloads_dir(),
artifacts_dir=art_dir,
)
payload = {
"ok": result.ok,
"job_id": result.job_id,
"file_path": result.file_path,
"filename": result.filename,
"date_from": result.date_from,
"date_to": result.date_to,
"seller_code": result.seller_code or seller,
"adapter": result.artifacts.get("adapter") or adapter.name,
"artifacts": result.artifacts,
}
if not result.ok:
payload["error"] = result.error_msg
video_info = {}
_print_video_summary(video_info if record_video else None)
summary_payload = merge_video_into_result_summary(payload, video_info if record_video else None)
status = "success" if payload.get("ok") else "failed"
tlr.save_task_log(
task_type="demo",
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg="模板仓库未实现真实业务",
task_type="download_settlement",
target_id=seller or "default",
input_id=f"{d_from}_{d_to}",
input_title=f"结算报表 {d_from} ~ {d_to}",
status=status,
error_msg=None if payload.get("ok") else str(payload.get("error") or ""),
result_summary=json.dumps(summary_payload, ensure_ascii=False),
)
print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
return rc
stdout_payload = {
"ok": payload.get("ok"),
"job_id": payload.get("job_id"),
"file_path": payload.get("file_path"),
"filename": payload.get("filename"),
"date_from": payload.get("date_from"),
"date_to": payload.get("date_to"),
}
print(json.dumps(stdout_payload, ensure_ascii=False))
return 0 if payload.get("ok") else 1
def cmd_logs(