chore: auto release commit (2026-07-03 12:09:55)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
All checks were successful
技能自动化发布 / release (push) Successful in 5s
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""CLI:argparse 与分发模板。"""
|
||||
"""CLI:argparse 与分发。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,7 +16,7 @@ from service.task_service import (
|
||||
)
|
||||
from util.config_bootstrap import bootstrap_skill_config
|
||||
from util.argparse_zh import ZhArgumentParser
|
||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||
from util.constants import DEFAULT_DATE_FROM, DEFAULT_DATE_TO, LOG_LOGGER_NAME, SKILL_SLUG
|
||||
from util.logging_config import get_skill_logger, setup_skill_logging
|
||||
|
||||
|
||||
@@ -28,32 +28,16 @@ def _cli_str_or_none(raw: Optional[str]) -> Optional[str]:
|
||||
|
||||
|
||||
def _handle_run(args: argparse.Namespace) -> int:
|
||||
tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()]
|
||||
if len(tail) > 2:
|
||||
print("❌ 参数过多。")
|
||||
print("用法:python main.py run [target [input_id]] | run [-t target] [-i input_id]")
|
||||
return 1
|
||||
|
||||
t_target: Optional[str] = None
|
||||
t_input: Optional[str] = None
|
||||
if len(tail) == 2:
|
||||
t_target, t_input = tail[0], tail[1]
|
||||
elif len(tail) == 1:
|
||||
if tail[0].isdigit():
|
||||
t_input = tail[0]
|
||||
else:
|
||||
t_target = tail[0]
|
||||
|
||||
pick_t = _cli_str_or_none(getattr(args, "target", None))
|
||||
pick_i = _cli_str_or_none(getattr(args, "input_id", None))
|
||||
target = pick_t or t_target
|
||||
input_id = pick_i or t_input
|
||||
return cmd_run(target=target, input_id=input_id)
|
||||
return cmd_run(
|
||||
date_from=_cli_str_or_none(getattr(args, "date_from", None)),
|
||||
date_to=_cli_str_or_none(getattr(args, "date_to", None)),
|
||||
seller_code=_cli_str_or_none(getattr(args, "seller_code", None)),
|
||||
)
|
||||
|
||||
|
||||
def _print_full_usage() -> None:
|
||||
print("通用业务技能模板(main.py)可用命令:")
|
||||
print(" python main.py run [target [input_id]] [-t target] [-i input_id]")
|
||||
print("Amazon 结算报表自动下载(main.py)可用命令:")
|
||||
print(" python main.py run --date-from YYYY-MM-DD --date-to YYYY-MM-DD [--seller-code CODE]")
|
||||
print(" python main.py logs [--limit N] [--status s] [--task-type t] [--target-id tid]")
|
||||
print(" python main.py log-get <log_id>")
|
||||
print(" python main.py health")
|
||||
@@ -64,15 +48,31 @@ def _print_full_usage() -> None:
|
||||
def build_parser() -> ZhArgumentParser:
|
||||
p = ZhArgumentParser(
|
||||
prog="main.py",
|
||||
description="通用业务技能模板:任务执行命令骨架、日志查询、健康检查、版本输出。",
|
||||
description="Amazon 结算报表自动下载:请求并下载 Settlement xlsx,写入任务日志。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
|
||||
|
||||
sp = sub.add_parser("run", help="任务执行命令骨架")
|
||||
sp.add_argument("--target", "-t", default=None, metavar="目标")
|
||||
sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id")
|
||||
sp.add_argument("run_tail", nargs="*", metavar="位置参数")
|
||||
sp = sub.add_parser("run", help="下载指定日期范围的结算报表")
|
||||
sp.add_argument(
|
||||
"--date-from",
|
||||
default=DEFAULT_DATE_FROM,
|
||||
metavar="起始日期",
|
||||
help=f"起始日期 YYYY-MM-DD(默认 {DEFAULT_DATE_FROM})",
|
||||
)
|
||||
sp.add_argument(
|
||||
"--date-to",
|
||||
default=DEFAULT_DATE_TO,
|
||||
metavar="结束日期",
|
||||
help=f"结束日期 YYYY-MM-DD(默认 {DEFAULT_DATE_TO})",
|
||||
)
|
||||
sp.add_argument(
|
||||
"--seller-code",
|
||||
default=None,
|
||||
metavar="店铺代码",
|
||||
dest="seller_code",
|
||||
help="可选;默认使用页面第一个 seller",
|
||||
)
|
||||
sp.set_defaults(handler=_handle_run)
|
||||
|
||||
sp = sub.add_parser("logs", help="查看任务日志")
|
||||
@@ -107,10 +107,6 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
if not argv:
|
||||
_print_full_usage()
|
||||
return 1
|
||||
if len(argv) == 2 and argv[0] not in {
|
||||
"run", "logs", "log-get", "health", "config-path", "version", "-h", "--help"
|
||||
}:
|
||||
return cmd_run(target=argv[0], input_id=argv[1])
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return int(args.handler(args))
|
||||
|
||||
236
scripts/service/account_client.py
Normal file
236
scripts/service/account_client.py
Normal 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)
|
||||
40
scripts/service/amazon_report_adapter/__init__.py
Normal file
40
scripts/service/amazon_report_adapter/__init__.py
Normal 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)
|
||||
34
scripts/service/amazon_report_adapter/base.py
Normal file
34
scripts/service/amazon_report_adapter/base.py
Normal 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
|
||||
44
scripts/service/amazon_report_adapter/mock.py
Normal file
44
scripts/service/amazon_report_adapter/mock.py
Normal 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},
|
||||
)
|
||||
356
scripts/service/amazon_report_adapter/simulator_rpa.py
Normal file
356
scripts/service/amazon_report_adapter/simulator_rpa.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""Amazon 仿真 Reports 页 Playwright RPA:请求并下载结算报表 xlsx。
|
||||
|
||||
定位原则:优先 data-testid(F12 实测),禁止 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 分类 Tab(reports-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
|
||||
136
scripts/service/browser_session.py
Normal file
136
scripts/service/browser_session.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""浏览器会话启动(系统 Chrome/Edge;URL 仅通过 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()
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,53 @@
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
"""技能标识、版本与 Amazon 仿真平台 URL 常量。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.32"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
SKILL_SLUG = "download-settlement-report-amazon"
|
||||
SKILL_VERSION = "1.0.0"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.download_settlement_report_amazon"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.0.17"
|
||||
|
||||
TARGET_PLATFORM = "amazon_sim"
|
||||
LEASE_HOLDER = "download-settlement-report-amazon"
|
||||
LEASE_TTL_SEC = "600"
|
||||
|
||||
DEFAULT_DATE_FROM = "2025-12-01"
|
||||
DEFAULT_DATE_TO = "2025-12-31"
|
||||
|
||||
DEFAULT_TIMEOUT_MS = 15_000
|
||||
LOGIN_NAVIGATE_TIMEOUT_MS = 30_000
|
||||
REPORTS_NAVIGATE_TIMEOUT_MS = 60_000
|
||||
JOB_POLL_TIMEOUT_SEC = 60
|
||||
JOB_POLL_INTERVAL_SEC = 2
|
||||
|
||||
ENV_AMAZON_SIM_BASE_URL = "AMAZON_SIM_BASE_URL"
|
||||
ENV_AMAZON_SIM_REPORTS_URL = "AMAZON_SIM_REPORTS_URL"
|
||||
ENV_AMAZON_SIM_LOGIN_URL = "AMAZON_SIM_LOGIN_URL"
|
||||
|
||||
DEFAULT_AMAZON_SIM_BASE_URL = "https://sandbox.jc2009.com/industries/ecommerce/amazon"
|
||||
DEFAULT_AMAZON_SIM_REPORTS_URL = (
|
||||
"https://sandbox.jc2009.com/industries/ecommerce/amazon/seller/reports"
|
||||
)
|
||||
DEFAULT_AMAZON_SIM_LOGIN_URL = (
|
||||
"https://sandbox.jc2009.com/industries/ecommerce/amazon/login"
|
||||
)
|
||||
|
||||
|
||||
def resolve_amazon_sim_base_url() -> str:
|
||||
return (
|
||||
(os.getenv(ENV_AMAZON_SIM_BASE_URL) or DEFAULT_AMAZON_SIM_BASE_URL).strip().rstrip("/")
|
||||
)
|
||||
|
||||
|
||||
def resolve_amazon_sim_reports_url() -> str:
|
||||
return (
|
||||
(os.getenv(ENV_AMAZON_SIM_REPORTS_URL) or DEFAULT_AMAZON_SIM_REPORTS_URL).strip()
|
||||
)
|
||||
|
||||
|
||||
def resolve_amazon_sim_login_url() -> str:
|
||||
return (
|
||||
(os.getenv(ENV_AMAZON_SIM_LOGIN_URL) or DEFAULT_AMAZON_SIM_LOGIN_URL).strip()
|
||||
)
|
||||
|
||||
34
scripts/util/logging.py
Normal file
34
scripts/util/logging.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""结构化日志与敏感字段脱敏。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from util.constants import LOG_LOGGER_NAME
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
return logging.getLogger(name or LOG_LOGGER_NAME)
|
||||
|
||||
|
||||
def 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