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

@@ -1,11 +1,10 @@
# ── 运行模式 / adapter 档位(见 references/ADAPTER.md── # ── 运行模式 / adapter 档位(见 development/ADAPTER.md──
OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_api | real_rpa OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_rpa
# ── 目标系统 ── # ── Amazon 仿真(线上 sandbox禁止 localhost:5173──
TARGET_BASE_URL=https://sandbox.jc2009.com # 仿真/生产地址,可被进程环境变量覆盖 AMAZON_SIM_BASE_URL=https://sandbox.jc2009.com/industries/ecommerce/amazon
AMAZON_SIM_REPORTS_URL=https://sandbox.jc2009.com/industries/ecommerce/amazon/seller/reports
# ── 默认账号(仅非敏感标识;密码走 account-manager── AMAZON_SIM_LOGIN_URL=https://sandbox.jc2009.com/industries/ecommerce/amazon/login
DEFAULT_LOGIN_ID=04110001
# ── 浏览器 / RPA ── # ── 浏览器 / RPA ──
OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI) OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI)

101
README.md
View File

@@ -1,99 +1,76 @@
--- ---
description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。" description: "自动从 Amazon 卖家中心下载结算报表,省去重复登录与手工导出。"
--- ---
> **模板说明(复制后删除本段)** # Amazon 结算报表自动下载
> 本文是技能市场详情与普通用户说明,**不是** Agent 参考索引、CLI 契约或开发文档。
> frontmatter 的 `description` 必须是用户能看懂的一句话能力描述;**不得**写成 CLI、schema、references、development、契约、数据结构、开发规范或 Agent 参考索引。
> 正文只回答用户关心的问题,**不要**把 Playwright、DOM、Python、RPA、references、development、schema 等作为面向用户的主说明内容;若业务上必须出现技术词,须用用户能理解的方式解释,且不能喧宾夺主。
# 【技能名称】 帮你从 Amazon 卖家中心自动申请并下载结算报表Settlement保存为 Excel 文件,并记录每次下载结果。
> 用一两句话说明这个技能帮助用户完成什么工作。
> 这里不要解释技术实现,要从用户的业务目标出发描述价值。
--- ---
## 这个技能能帮你做什么 ## 这个技能能帮你做什么
用 3 到 6 条业务能力说明技能价值,例如: - 按你指定的日期范围,自动申请结算报表
- 等待报表生成完成后自动下载,保存到本地
- 【自动整理/查询/生成/同步/核对/提交】某类业务数据 - 记录每次下载是否成功、文件路径与失败原因
- 帮你减少重复操作,降低人工录入或核对成本 - 适合跨境财务实训中定期拉取 Amazon 结算数据
- 在任务完成后给出清晰的处理结果、失败原因或后续建议
--- ---
## 适合什么场景 ## 适合什么场景
列出典型使用场景。请使用用户熟悉的业务语言,例如: - 月末或周度需要从 Amazon 导出结算报表做对账
- 实训/教学中反复练习「登录 → 选日期 → 下载报表」流程
- 每天需要重复处理同一类订单、单据、报表或客户资料 - 希望 Agent 代为完成重复性的报表下载操作
- 需要从多个系统中查询信息并汇总结果
- 需要按固定规则检查数据是否完整、是否异常
- 需要把处理结果保存为文件、记录或任务报告
--- ---
## 使用前需要准备什么 ## 使用前需要准备什么
说明用户在使用前要准备的东西,例如: - 已在账号管理中添加 **Amazon 仿真卖家** 账号(平台标识 `amazon_sim`),且状态为可用
- 账号已完成登录(或具备自动登录凭据)
- 确认要下载的**起止日期**(例如 2025-12-01 至 2025-12-31
- 可选:指定店铺代码;不指定时使用页面默认第一个店铺
- 相关账号已登录,且具备查询、导出或提交权限 ### 添加 Amazon 仿真账号(若尚未配置)
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
- 如果涉及外部系统,请确认网络、账号、验证码或审批流程可正常使用 `amazon_sim` 平台尚未在 account-manager 中注册,需由运维先注册平台;然后执行:
```text
python D:\OpenClaw\client-commons\account-manager\scripts\main.py account add-web \
--platform amazon_sim \
--login-id <邮箱> \
--url https://sandbox.jc2009.com/industries/ecommerce/amazon/login \
--environment simulator \
--auth-strategy password_auto
```
建议 `extra_json``{"logged_in_url_pattern": "/seller"}`
联调前须存在 **active** 账号且带 **profile_dir**(浏览器登录态目录)。
--- ---
## 你可以这样告诉 Agent ## 你可以这样告诉 Agent
用自然语言示例展示用户可以怎么发起任务,例如: > 「帮我下载 2025 年 12 月的 Amazon 结算报表。」
> “帮我查询今天的【业务对象】,整理成表格。” > 「从 Amazon 卖家中心导出 12 月 1 日到 12 月 31 日的结算单。」
>
> “帮我核对这些【数据/单据】是否有异常,并告诉我问题在哪里。”
>
> “帮我把【时间范围】内的【业务数据】导出并生成处理报告。”
>
> “帮我根据这个文件里的内容,逐条完成【业务动作】。”
> 注意:示例应是自然语言,不要写命令行命令。 > 「用 AMA1OF6T 店铺下载上周的 settlement 报表。」
--- ---
## 执行后你会得到什么 ## 执行后你会得到什么
说明用户会看到什么结果,例如: - 成功时:本地 xlsx 文件路径、任务编号、日期范围JSON 摘要)
- 失败时:错误说明;必要时可在数据目录查看失败截图
- 任务是否成功完成 - 任务日志中可查询历史下载记录
- 处理了多少条数据
- 哪些内容成功、哪些失败
- 失败原因和建议处理方式
- 生成的文件、表格、报告或记录
--- ---
## 注意事项 ## 注意事项
用用户能理解的方式说明边界和风险,例如: - 请确认日期范围正确,避免下载错期间的数据
- 仿真环境与真实 Amazon 卖家中心界面类似,但数据为实训用途
- 请确认输入范围准确,避免处理错数据 - 本技能仅下载报表文件,**不会**自动解析 Excel 内容或入账
- 涉及提交、付款、删除、审批等高风险动作时,技能应在执行前请求确认
- 如果外部系统登录失效、权限不足或页面变化,任务可能需要用户介入
- 不建议在未核对数据的情况下直接执行批量操作
---
## 常见问题
### 为什么任务没有查到数据?
> 可能是时间范围、关键词、账号权限或系统筛选条件不正确。请先确认输入信息是否准确。
### 为什么任务执行到一半需要我操作?
> 可能遇到了登录、验证码、权限确认、二次审批或系统异常。请根据 Agent 的提示完成必要操作。
### 结果文件在哪里查看?
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。

101
SKILL.md
View File

@@ -1,101 +1,54 @@
--- ---
name: 技能开发模板(通用业务版) name: Amazon 结算报表自动下载
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" description: "在 Amazon 卖家中心自动请求并下载结算报表Settlement"
version: 1.0.32 version: 1.0.0
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
slug: your-skill-slug slug: download-settlement-report-amazon
platform_kit_min_version: "1.0.17" platform_kit_min_version: "1.0.17"
emoji: "📦" emoji: "📊"
category: "通用" category: "跨境电商"
developer_ids: developer_ids:
- 10032 - 10032
- 12428 - 12500
allowed-tools: allowed-tools:
- bash - bash
dependencies:
required:
- account-manager
--- ---
# 技能开发模板skill-template # Amazon 结算报表自动下载
这是一个**用于复制的新技能模板**,不是业务 skill 本身。复制后请替换占位内容,实现你的真实业务逻辑 在 Amazon 卖家仿真中心自动请求并下载结算报表Settlement产出 xlsx 文件并写入任务日志
## 面向用户问答LLM 规则) ## 面向用户问答LLM 规则)
- 本文(`SKILL.md`)是 LLM / OpenClaw 平台读取的技能入口,**不是**用户市场说明。 - 本文(`SKILL.md`)是 LLM / OpenClaw 平台读取的技能入口,**不是**用户市场说明。
- 根目录 `README.md` 是面向普通用户的说明。 - 根目录 `README.md` 是面向普通用户的说明(用户市场详情页主来源)
当用户询问以下问题时,**必须优先读取**根目录 `README.md`,并用用户能理解的业务语言回答 当用户询问技能用途、场景、准备事项、结果形态等业务问题时,**必须优先读取**根目录 `README.md`,并用用户能理解的业务语言回答
- 这个技能是做什么的
- 这个技能怎么用
- 适合什么场景
- 使用前要准备什么
- 执行后会得到什么
- 能不能帮我完成某个业务任务
- **不要**把 `SKILL.md``references/``development/` 中的技术细节直接作为用户回答。 - **不要**把 `SKILL.md``references/``development/` 中的技术细节直接作为用户回答。
- **只有当**用户明确询问命令、参数、输出结构、开发、调试、集成或排错时,才读取 `references/``development/` - 用户未问技术实现时,**不要**讨论 Playwright、RPA 等细节
-`README.md``SKILL.md` / `references` 表述冲突:对用户展示与市场说明以 `README.md` 为准对执行契约、CLI、schema、运行约束以 `SKILL.md` / `references` 为准。
- 回答用户时**不要**暴露 Playwright、DOM、Python、RPA 等实现细节,除非用户明确询问技术实现。
- 用户未问技术实现时,**不要**讨论 slug 命名规则;开发者问命名或 slug 时,读取 `development/NAMING.md`
## 文档分工
| 文档 | 读者 | 用途 |
|------|------|------|
| 根目录 `README.md` | 普通用户 | 技能市场详情页说明(`metadata.readme_md` 主来源) |
| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 |
| `references/` | Agent 编排/调用 | 渐进式加载CLI 契约、字段 schema 等 |
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、技术规范 |
## 目录约定
复制后建议保留:
- `assets/``development/``evals/``references/``scripts/``tests/`
- CLI 入口固定为 `scripts/main.py`
- 业务逻辑按 `cli / db / service / util` 分层
## 最小命令
```bash
python {baseDir}/scripts/main.py health
python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py version
```
配置:仓库 `.env.example` 为模板;用户 `.env``{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`,启动时自动 bootstrap。优先级进程环境变量 > 用户 `.env` > `.env.example`
## 运行依赖 ## 运行依赖
- Python 运行环境由匠厂宿主注入**共享 runtime**`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv` - Python 运行环境由匠厂宿主注入**共享 runtime**`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
- 公共能力来自共享 runtime 安装的 `jiangchang-platform-kit>=1.0.17``jiangchang_skill_core` 包);**不要 vendor** `scripts/jiangchang_skill_core/`,新技能不得在仓库内保留该目录副本。 - 公共能力来自共享 runtime 安装的 `jiangchang-platform-kit>=1.0.17``jiangchang_skill_core` 包);**不要 vendor** `scripts/jiangchang_skill_core/`,新技能不得在仓库内保留该目录副本。
- config、logging、runtime_env、rpa、media-assets、video_session、runtime_diagnostics 等均从共享 venv 的 `jiangchang_skill_core` import,而非技能目录副本 - config、logging、runtime_env、rpa、video_session 等均从共享 venv 的 `jiangchang_skill_core` import。
- 根目录 `requirements.txt` **只声明技能特有** Python 三方依赖;`jiangchang-platform-kit``playwright` 等公共能力由宿主共享 runtime 提供**不要**写入技能 requirements - 根目录 `requirements.txt` **只声明技能特有** Python 三方依赖;`playwright` 等公共能力由宿主共享 runtime 提供。
- `metadata.openclaw.platform_kit_min_version`(当前 `1.0.17`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不**技能 pip 依赖声明 - Skill 代码**不要**自行 `pip install playwright`**不**技能`playwright install`
- Skill 代码**不要**自行 `pip install`;系统级依赖(如 VC++ Runtime仅在 `health` / preflight 中提示用户安装 - 浏览器 RPA 档位:`OPENCLAW_TEST_TARGET=simulator_rpa`CI 默认 `mock`
- `health` 使用 `collect_runtime_diagnostics` 输出统一 runtime 诊断(只读,不下载/修复 media-assets)。 - 账号由 `account-manager` 提供platform_key=`amazon_sim`)。
## 定制入口 ## 最小命令
1. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词。 ```bash
2.`development/DEVELOPMENT.md` 完成开发与文档定制。 python {baseDir}/scripts/main.py health
3. 用户市场说明写入根 `README.md`Agent 调用契约见 `references/CLI.md``references/SCHEMA.md` python {baseDir}/scripts/main.py version
4. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION` python {baseDir}/scripts/main.py run --date-from 2025-12-01 --date-to 2025-12-31
5. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`(元数据表 `_jiangchang_*` + `CREATE TABLE` 物理顺序)。 ```
## 技能命名与 slug 执行契约见 `references/CLI.md`
- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。
- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case`name``description`、根 `README.md`**中文**。
- 复制为新业务技能前**必须**阅读 `development/NAMING.md`
- `name`frontmatter**用户可见的中文业务名称**`description` 是说明,不是名称。
- `metadata.openclaw.slug`**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug不使用中文。
- 复制后全局替换 `your-skill-slug`;正式技能不得保留英文占位 `name` 或 slug。
## 平台元数据
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。
-`access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`

View File

@@ -1,4 +1,4 @@
{ {
"version": "1.0.0", "version": "1.0.0",
"skill": "your-skill-slug" "skill": "download-settlement-report-amazon"
} }

View File

@@ -1,259 +1,200 @@
# 技能需求文档模板
这份文档用于给技术人员、产品人员和实施人员统一描述某个 skill 的研发需求。
建议原则:
- 一个 skill 对应一份主需求文档
- 需求先写清楚,再进入开发
- 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节
- 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md``references/``development/` 下的技术规范
如果你是从 `skill-template` 复制新技能,请复制本文件后,把下面所有占位内容替换成你的真实项目内容。
---
# REQUIREMENTS # REQUIREMENTS
## 0. 技能标识(复制 REQUIREMENTS 时必填) ## 0. 技能标识(复制 REQUIREMENTS 时必填)
- 拟定 slug英文符合 `development/NAMING.md` | 项 | 值 |
- 中文 name |----|-----|
- 中文 description一句话 | slug | `download-settlement-report-amazon` |
- account-manager platform_key如有 | 中文 name | Amazon 结算报表自动下载 |
- 命名形态:标准型 / scope 型 | 中文 description | 在 Amazon 卖家中心自动请求并下载结算报表Settlement |
| account-manager platform_key | `amazon_sim`(若尚未注册,见 README / 本文 §7 |
| 命名形态 | 标准型verb-noun-platform |
## 1. 文档目标 ## 1. 文档目标
说明这份需求文档的作用,以及它解决什么问题 - 本文档用于明确 `download-settlement-report-amazon` 的研发范围、交付标准、依赖关系和验收要求
模板写法:
- 本文档用于明确 `your-skill-slug` 的研发范围、交付标准、依赖关系和验收要求。
- 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。 - 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。
- 本文档同时作为**老师/实训样板**:学员可复制本技能结构开发同类 Amazon / 跨境报表技能。
## 2. 业务背景 ## 2. 业务背景
说明为什么要做这个 skill它服务什么业务场景解决什么实际问题 - 跨境财务实训需定期从 Amazon 下载**结算报表Settlement**,用于对账、利润分析与凭证附件
- 人工操作路径:登录卖家中心 → Reports → **Payments** 分类 → 选择 Settlement → 填写日期 → 请求 → 等待生成 → 下载 xlsx。
模板写法: - 本技能用 **Playwright RPA** 在仿真环境 `https://sandbox.jc2009.com/industries/ecommerce/amazon` 自动完成上述流程,产出 xlsx 并写入 `task_logs``task_type=download_settlement`)。
- **禁止**调用 `GET/POST /api/ecommerce/amazon/reports` 等 REST API 下载;必须走浏览器页面操作。
- 当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
- 现有流程主要依赖人工处理,存在 `<效率低 / 容易出错 / 不可批量 / 不可追踪>` 等问题。
- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 `task_logs` 便于追踪。
填写提示:
- `<业务场景>`例如“每日批量导入外部系统结果”“Agent 触发的单次查询”
- `<输入数据>`:例如“关键词列表”“批次 ID”“账号标识”
- `<核心动作>`:例如“调用 adapter 提交”“采集并入库”“对账摘要生成”
- `<输出结果>`:例如“结构化 JSON”“任务摘要”“本地 SQLite 记录”
## 3. 开发目标 ## 3. 开发目标
说明本次开发希望最终交付什么结果。 - 提供标准 CLI`health``version``run``logs``log-get`
- 实现四档 adapter 中的 **mock**CI 默认)与 **simulator_rpa**(联调)
模板写法(按 skill 类型勾选/改写): - 集成 **account-manager**`pick-web --platform amazon_sim --lease` 获取 `profile_dir` 与租约
- 登录:`launch_browser_with_profile` + `ensure_logged_in`account-manager `rpa_helpers`
- 解析并校验 `<输入数据>` 的关键字段 - 下载文件保存至 `{skill_data_dir}/downloads/`;失败截图至 `{skill_data_dir}/rpa-artifacts/{batch_id}/`
- 通过 adapter 完成 mock / simulator / real 档位下的 `<核心动作>` - 可选 `RpaVideoSession` 录屏(中文步骤标题)
- 输出结构化结果stdout JSON 或固定文本 + 机读字段)
- 写入 `task_logs`(及必要的业务表,见 `references/SCHEMA.md`
- 支持失败可诊断(稳定错误码、`ERROR:` 前缀、必要截图/日志)
- 完成 CLI 入口与 `health` / `version` 最小可运行验证
- 完成正式环境发布与安装验证(如适用)
## 4. 功能范围 ## 4. 功能范围
说明这次开发明确要实现哪些功能。
模板写法:
- 支持 `<命令1>`(如 `run`
- 支持 `<命令2>`(如 `logs` / `log-get`
- 支持读取 `<数据来源>`(兄弟 skill / 本地 DB / 外部 API
- 支持执行 `<核心业务动作>`
- 支持记录 `<日志 / 状态 / 结果>`
建议将功能拆成“必须实现”和“可后续扩展”。
### 必须实现 ### 必须实现
- 支持 `python scripts/main.py health` - `python scripts/main.py health`
- 支持 `python scripts/main.py version` - `python scripts/main.py version`
- 支持 `python scripts/main.py <your-main-command>` - `python scripts/main.py run --date-from YYYY-MM-DD --date-to YYYY-MM-DD [--seller-code CODE]`
- 支持核心业务编排HTTP / 批处理 / 可选 RPA按四档 adapter 选型 - Playwright RPA`OPENCLAW_TEST_TARGET=simulator_rpa`
- 支持写入任务日志(`task_logs` - mock adapterCI 默认,`OPENCLAW_TEST_TARGET=mock`
- 写入 `task_logs``task_type=download_settlement`
- 下载文件保存到 `{skill_data_dir}/downloads/`
- 失败截图到 `rpa-artifacts/`
### 可后续扩展 ### 第一版不做
- 支持定时批跑 - 解析 xlsx 内容
- 支持失败自动重试 - 多店铺批量下载
- 支持多 `<目标系统>` 轮询 - 直接调 `/api/ecommerce/amazon/reports` API
- `real_rpa` 真实 Amazon 生产站点
## 5. 非功能要求 ## 5. 非功能要求
说明除功能外,对稳定性、可维护性、目录规范、日志、编码等方面的要求。 - 目录结构符合 `skill-template` 规范;入口 `scripts/main.py`
- Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`
- 目录结构必须符合当前 `skill-template` 规范 - URL **不要**放进 `launch_persistent_context(args=[...])`Reports 页通过 `page.goto(REPORTS_URL)` 打开
- 入口必须统一为 `scripts/main.py` - 元素定位优先 `data-testid`F12 原则)
- 用户说明写在根 `README.md`LLM/运行契约写在 `SKILL.md`;开发规范在 `development/` - 步骤间 `random_delay`;禁止 `el.value=`、JS 跳转
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:` - Windows UTF-8 输出;错误前缀 `ERROR:`
- Windows 环境下需保证 UTF-8 输出兼容
- 必须具备基本日志能力;敏感字段脱敏
- RPA 类 skillPlaywright 由宿主共享 runtime 提供;技能侧不要 `playwright install`URL 不要放进 `launch_persistent_context``args`
## 6. 输入输出要求 ## 6. 输入输出要求
### 输入 ### 输入
填写本 skill 实际接受的参数与数据来源: | 参数 | 必填 | 默认 | 说明 |
|------|------|------|------|
| `--date-from` | 是* | `2025-12-01` | 报表起始日期 |
| `--date-to` | 是* | `2025-12-31` | 报表结束日期 |
| `--seller-code` | 否 | 页面第一个 seller | 店铺代码,如 `AMA1OF6T` |
- `<参数名>`:含义、是否必填、默认值 \* CLI 提供默认值,业务上仍须为合法 `YYYY-MM-DD`
- `<依赖系统>` 返回的结构化数据(如有)
- 环境变量 / `.env` 中的关键配置(引用 `CONFIG.md`
### 输出 环境变量见 `.env.example``AMAZON_SIM_BASE_URL``AMAZON_SIM_REPORTS_URL``OPENCLAW_TEST_TARGET` 等。
- 成功结果stdout / JSON 字段说明 ### 成功输出stdout JSON
- 错误输出(`ERROR:` 稳定码 + 中文 message
- 数据库记录(表名、关键字段,见 `references/SCHEMA.md`
- 可选RPA 截图/视频路径artifacts
## 7. 依赖的兄弟技能或外部系统 ```json
{
### 兄弟技能依赖 "ok": true,
"job_id": "...",
- `<sibling-skill-slug>``<用途>`(例如 account-manager 提供 profile_dir / 租约) "file_path": "...",
"filename": "settlement_....xlsx",
### 外部系统依赖 "date_from": "2025-12-01",
"date_to": "2025-12-31"
- `<目标系统>``<用途>`HTTP API / 仿真 sandbox / 真实网页) }
- 浏览器(如适用):系统 Chrome/Edge非 Playwright 内置 Chromium
- Gitea / 匠厂客户端:发布与正式环境验证
### Python 包依赖requirements.txt
- 本技能若需**特有** Python 三方包,写入根目录 `requirements.txt`
- `jiangchang-platform-kit``playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。
- `SKILL.md``platform_kit_min_version` 是运行契约/兼容性声明,**不是** pip 依赖声明。
- 版本须尽量收窄(如 `requests>=2.31.0,<3`),避免共享 venv 冲突。
- **不要**把系统运行时组件写进 requirements.txt`health` / preflight 中检测并提示。
## 8. 不在本次范围内的内容
明确本次“不做什么”,避免范围蔓延:
- 本次不实现 `<功能A>`
- 本次不处理 `<平台B>` / `<档位>`
- 本次不做 `<高级能力>`
- 本次不兼容旧模板历史结构(`docs/``optional/``skill_main.py` 等)
## 9. 验收标准
什么情况下才算开发完成:
- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致
- `health``version` 命令执行正常
- 主命令(如 `run`)在 mock / simulator 档位可重复验证
- `python tests/run_tests.py -v` 必跑测试全部通过
- `task_logs` 写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order`
- `tests/test_display_metadata.py` 通过
- 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联
- 发布后 Gitea 工作流成功;匠厂技能市场可见最新版本;安装后可在“新建任务”中调用
## 10. 开发注意事项
- 只修改当前 skill 仓库,不要改动无关兄弟项目
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`
- 发布前完成本地验证、工作流验证和正式环境安装验证
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| YYYY-MM-DD | v1.0 | `<姓名>` | 初版需求文档 |
---
## 建议使用方式
1. 先写 `REQUIREMENTS.md`
2. 再按 `DEVELOPMENT.md` 进入开发
3. 开发过程中补充 `references/CLI.md``references/SCHEMA.md``development/` 技术规范
4. 发布前对照第 9 节验收标准逐项检查
## 最小模板示例
下面给出一个可直接参考的简化版(复制后替换占位符):
```md
# REQUIREMENTS
## 1. 文档目标
本文档用于明确 `your-skill-slug` 的研发需求、范围和验收标准,作为开发与测试的统一依据。
## 2. 业务背景
当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
因此需要开发 `your-skill-slug`,自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 task_logs。
## 3. 开发目标
- 解析并校验输入
- 通过 adapter 完成 mock / simulator / real 档位动作
- 输出结构化结果并写入 task_logs
- 支持失败可诊断
## 4. 功能范围
- 支持 health / version / run
- 支持 logs / log-get
## 5. 非功能要求
- 入口统一为 scripts/main.py
- 保持 UTF-8 输出;结构符合 skill-template
## 6. 输入输出要求
### 输入
- target_id、input_id按业务定义
### 输出
- 成功 JSON / 摘要
- ERROR: 稳定错误码
- task_logs 记录
## 7. 依赖的兄弟技能或外部系统
- `<sibling-skill>`(按需)
- `<目标系统>` API 或 sandbox
## 8. 不在本次范围内的内容
- 不实现自动重试
- 不实现 `<本次不做>`
## 9. 验收标准
- 命令可运行tests/run_tests.py -v 通过
- task_logs 符合 SCHEMA
- 正式环境安装验证通过
## 10. 开发注意事项
- 不修改无关项目;不引入旧模板结构
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| YYYY-MM-DD | v1.0 | 张三 | 初版 |
``` ```
### 失败输出
- 退出码非 0
- stdout JSON 中 `ok: false` 或前置 `ERROR:` 行(如 `ERROR:INVALID_ARGS`
- `task_logs.status=failed``error_msg` 含原因
- 可选:`rpa-artifacts/` 下 PNG 截图
## 7. 依赖的兄弟技能或外部系统
### 兄弟技能
- **account-manager**`platform_key=amazon_sim`;提供 `profile_dir`、租约、`get-credential --reveal`
-`amazon_sim` 平台不存在,需运维在 account-manager 注册后再添加账号
添加账号示例:
```bash
python D:\OpenClaw\client-commons\account-manager\scripts\main.py account add-web \
--platform amazon_sim \
--login-id <邮箱> \
--url https://sandbox.jc2009.com/industries/ecommerce/amazon/login \
--environment simulator \
--auth-strategy password_auto
```
`extra_json` 建议:`{"logged_in_url_pattern": "/seller"}`
Amazon 仿真登录:登录页为**邮箱 + Continue**,无复杂验证码。
### 外部系统
| 页面 | URL |
|------|-----|
| 卖家首页 | `https://sandbox.jc2009.com/industries/ecommerce/amazon/seller` |
| 登录页 | `https://sandbox.jc2009.com/industries/ecommerce/amazon/login` |
| Reports | `https://sandbox.jc2009.com/industries/ecommerce/amazon/seller/reports` |
**禁止**使用 `localhost:5173`
## 8. 不在本次范围内的内容
- 不解析 xlsx、不入账 Kingdee / 星航
- 不实现多店铺批跑
- 不修改 `client-sandbox` 源码
- 不修改 `skill-template` 源仓库
- 不实现 `real_api` / 生产 Amazon `real_rpa`
## 9. 验收标准与 RPA 步骤
### RPA 步骤(与 sandbox 联调文档一致)
参考:`client-sandbox/sandbox-docs/migration/from-monolith-docs/skill-integration-guide.md`
1. `account-manager pick-web --platform amazon_sim --lease``profile_dir``url``lease_token`
2. `launch_browser_with_profile` + `ensure_logged_in`
3. `page.goto(REPORTS_URL)`URL 不进 launch args
4. 等待 `[data-testid="reports-page-root"]`
5. 切换到 **Payments** 分类Settlement 在该 Tab默认 Tab 为 Fulfillment
- 优先 `[data-testid="reports-tab-payments"]`
- 若无 testid`get_by_role("button", name="Payments")`
6. 点击 `[data-testid="reports-type-item-settlement"]`
7. 填写 `[data-testid="reports-form-date-from"]` / `reports-form-date-to`
8. 可选:`[data-testid="reports-form-seller-select"]`
9. 点击 `[data-testid="reports-form-submit"]`
10. 轮询 `[data-testid^="reports-jobs-status-"][data-status="DONE"]`(超时 60s间隔 2s
11. 点击 `[data-testid^="reports-jobs-download-"]``expect_download` 保存 xlsx
12. `lease release`
13.`task_logs` + 返回 JSON
### 仿真环境已知依赖Payments Tab
截至开发时,`sim-ecommerce-amazon/frontend/pages/ReportsPage.jsx` 中:
- 已定义 `REPORT_TABS`(含 Payments`settlement` 类型挂载在 `payments` Tab 下
- **但页面未渲染 Tab 切换按钮**(无 `reports-tab-payments` DOM默认 `activeTab=fulfillment`,页面上仅显示 Orders 类型
因此 **线上仿真** 联调时RPA 可能在步骤 5 失败Payments Tab 不可见)。处理方式:
- **mock 测试**必须全部通过CI 不依赖 Tab UI
- **simulator_rpa** 代码已实现 Tab 切换逻辑,待 sandbox 前端补充 Tab UI 后即可联调
- 联调前可请 sandbox 团队在 Reports 页渲染 Tab 并添加 `data-testid="reports-tab-payments"`
### 命令验收
- `python tests/run_tests.py -v` 全部通过
- `python scripts/main.py health` 退出码 0
- `OPENCLAW_TEST_TARGET=mock python scripts/main.py run --date-from 2025-12-01 --date-to 2025-12-31` 退出码 0 且产出 mock xlsx
### 联调验收(有账号时)
```powershell
$env:OPENCLAW_TEST_TARGET="simulator_rpa"
$env:JIANGCHANG_DATA_ROOT="D:\claw-data"
$env:JIANGCHANG_USER_ID="10032"
python scripts/main.py run --date-from 2025-12-01 --date-to 2025-12-31
```
成功:产出 xlsx + `task_logs``download_settlement` 记录 + 退出码 0。
## 10. 开发注意事项
- 只修改本 skill 仓库;不改动其他 61 个空目录
- adapter 目录:`scripts/service/amazon_report_adapter/`
- 真实浏览器联调样例:`tests/integration/test_simulator_rpa_contract.py.sample`(不进默认套件)
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| 2026-07-03 | v1.0.0 | 样板实现 | 初版mock + simulator_rpa + REQUIREMENTS 填实 |

22
evals/rpa-flow.md Normal file
View File

@@ -0,0 +1,22 @@
# Amazon 结算报表下载 — RPA 操作流程(老师参考)
共 9 步,对应 `scripts/service/amazon_report_adapter/simulator_rpa.py``development/REQUIREMENTS.md §9`
1. **获取账号**:通过 account-manager 租用 `amazon_sim` 网页账号,取得浏览器 profile 与租约 token。
2. **启动浏览器**:使用持久化 profile 启动 Chrome/EdgeURL 不写入 launch 参数)。
3. **确保登录**:打开仿真登录页,按账号策略完成登录(邮箱 + Continue已登录则跳过
4. **进入 Reports 页**`page.goto` 打开线上 Reports URL等待 `reports-page-root`
5. **切换 Payments 分类**Settlement 在 Payments Tab 下(默认 Fulfillment 需切换)。
6. **选择报表类型**:点击 `reports-type-item-settlement`All Statements
7. **填写并提交**:输入起止日期、可选店铺,点击 Request report。
8. **等待生成**:轮询任务列表直至状态 `DONE`(约 13 秒,最长 60 秒)。
9. **下载文件**:点击 Download保存 xlsx 到 `{skill_data_dir}/downloads/`,释放租约,写入任务日志。
## 联调截图 / 录屏(占位)
- 成功截图:`evals/screenshots/settlement-download-success.png`(联调后补充)
- 录屏:`{skill_data_dir}/videos/download-settlement-report-amazon_*.mp4``OPENCLAW_RECORD_VIDEO=1` 时)
## 已知阻塞
线上 Reports 页若未渲染 Payments Tab 按钮,步骤 5 将失败mock 档位不受影响。详见 `development/REQUIREMENTS.md` §9。

View File

@@ -1,6 +1,6 @@
# 通用业务技能模板 CLI 标准 # Amazon 结算报表自动下载 CLI
`{baseDir}` 替换为技能根目录(含 `SKILL.md``scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。 `{baseDir}` 替换为技能根目录。入口:`python {baseDir}/scripts/main.py`
## 最小命令 ## 最小命令
@@ -10,21 +10,60 @@ python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py version python {baseDir}/scripts/main.py version
``` ```
## 标准行为 ## run — 下载结算报表
- **任意 CLI 启动前**执行 `bootstrap_skill_config()``main.py``cli.app.main()` 均触发)。 ```bash
- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets不执行业务动作**;不输出敏感值。 python {baseDir}/scripts/main.py run \
- **`config-path`**:输出 JSON包含 `skill``env_path``example_path` --date-from 2025-12-01 \
- **`version`**:输出 JSON`version``skill`)。 --date-to 2025-12-31 \
- **`run`**:长时间无 stdout **不代表卡死**;应通过 `logs` / `log-get` 排查。 [--seller-code AMA1OF6T]
- **任务完成后**若有 video artifactCLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`)。 ```
## config-path配置路径 | 参数 | 必填 | 默认 | 说明 |
|------|------|------|------|
| `--date-from` | 否 | `2025-12-01` | 起始日期 YYYY-MM-DD |
| `--date-to` | 否 | `2025-12-31` | 结束日期 YYYY-MM-DD |
| `--seller-code` | 否 | (页面首个 seller | Amazon 店铺代码 |
### 成功 stdoutJSON最后一行
```json ```json
{ {
"skill": "your-skill-slug", "ok": true,
"env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env", "job_id": "abc123",
"file_path": "{DATA_ROOT}/{USER_ID}/download-settlement-report-amazon/downloads/settlement_....xlsx",
"filename": "settlement_....xlsx",
"date_from": "2025-12-01",
"date_to": "2025-12-31"
}
```
### 失败
- 退出码 `1`
- `ERROR:INVALID_ARGS` / `ERROR:ENTITLEMENT` 等前缀,或 JSON 中 `"ok": false`
## logs / log-get
```bash
python {baseDir}/scripts/main.py logs [--limit 10] [--task-type download_settlement] [--status success]
python {baseDir}/scripts/main.py log-get <log_id>
```
## 运行档位
| OPENCLAW_TEST_TARGET | 行为 |
|----------------------|------|
| `mock`(默认) | 离线 mock不写真实浏览器 |
| `simulator_rpa` | Playwright 操作线上 sandbox Reports 页 |
| `real_rpa` | 未实现,回退 mock |
## config-path 示例
```json
{
"skill": "download-settlement-report-amazon",
"env_path": "{DATA_ROOT}/{USER_ID}/download-settlement-report-amazon/.env",
"example_path": "{skill_root}/.env.example" "example_path": "{skill_root}/.env.example"
} }
``` ```
@@ -35,36 +74,10 @@ python {baseDir}/scripts/main.py version
- `python_executable` — 当前 Python 解释器路径 - `python_executable` — 当前 Python 解释器路径
- `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok` - `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok`
- `jiangchang_skill_core_file` — 公共库加载来源(若来自技能目录副本会报 warning - `jiangchang_skill_core_file` — 公共库加载来源
- `media_assets_root``ffmpeg_path``ffmpeg_available``background_music_mp3_count` - `env_path` / `env_exists` / `example_path`
- `runtime_issue[warning|error]` — 非致命/致命问题列表
- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径
## 通用业务命令骨架 ## 手工排查(共享 python-runtime
```bash
python {baseDir}/scripts/main.py run
python {baseDir}/scripts/main.py run -t <target> -i <input_id>
python {baseDir}/scripts/main.py logs
python {baseDir}/scripts/main.py logs --task-type demo --status failed
python {baseDir}/scripts/main.py log-get <log_id>
```
| 命令 | 用途 |
|------|------|
| `run` | 执行一次任务 |
| `logs` | 列出最近 N 条任务日志 |
| `log-get` | 按 log_id 查看单条任务日志JSON |
## 查询/导出类(可选)
复制后按需增加,例如:`contacts``contact-get``export` 或业务等价命令。
## 若你的技能依赖兄弟技能
使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)`,不要硬编码兄弟技能名到 `sibling_bridge.py` 自身;并在 `SKILL.md` 声明 `dependencies.required`
## 手工排查命令(推荐)
**建议使用宿主共享 python-runtime**,避免技能目录内临时 venv 加载不到公共库: **建议使用宿主共享 python-runtime**,避免技能目录内临时 venv 加载不到公共库:
@@ -79,9 +92,3 @@ Windows:
`<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv` `<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
**不建议**在生产/测试机使用技能目录内 `uv run python`,以免加载不到共享 runtime 中的 `jiangchang-platform-kit` / `playwright` **不建议**在生产/测试机使用技能目录内 `uv run python`,以免加载不到共享 runtime 中的 `jiangchang-platform-kit` / `playwright`
## 模板约定
- 最小模板至少保留 `health` / `config-path` / `version`
- 通用业务技能建议使用 `run` / `logs` / `log-get`
- 不要再用旧模板的 `publish` / `--account-id` / `--article-id` 命名

View File

@@ -190,6 +190,21 @@ CREATE TABLE IF NOT EXISTS task_logs (
| 工资代发 | disburse | 付款账户 | 批次 ID | | 工资代发 | disburse | 付款账户 | 批次 ID |
| 对账 | reconcile | 银行/平台 | 对账批次 ID | | 对账 | reconcile | 银行/平台 | 对账批次 ID |
| 发票验真 | verify | 税务地区 | 发票批次 ID | | 发票验真 | verify | 税务地区 | 发票批次 ID |
| **Amazon 结算报表下载** | **download_settlement** | **seller_code 或 default** | **`{date_from}_{date_to}`** |
### download_settlement 的 result_summary 字段JSON
| 字段 | 说明 |
|------|------|
| `ok` | 是否成功 |
| `job_id` | 仿真/报表任务 ID |
| `file_path` | 本地 xlsx 绝对路径 |
| `filename` | 文件名 |
| `date_from` / `date_to` | 请求日期范围 |
| `seller_code` | 店铺代码(可选) |
| `adapter` | `mock` / `simulator_rpa` |
| `artifacts` | 截图路径等 |
| `video` / `video_path` | 可选录屏信息 |
业务特有字段优先放入 `result_summary`JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。 业务特有字段优先放入 `result_summary`JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。

View File

@@ -1,4 +1,4 @@
"""CLIargparse 与分发模板""" """CLIargparse 与分发。"""
from __future__ import annotations from __future__ import annotations
@@ -16,7 +16,7 @@ from service.task_service import (
) )
from util.config_bootstrap import bootstrap_skill_config from util.config_bootstrap import bootstrap_skill_config
from util.argparse_zh import ZhArgumentParser 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 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: def _handle_run(args: argparse.Namespace) -> int:
tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()] return cmd_run(
if len(tail) > 2: date_from=_cli_str_or_none(getattr(args, "date_from", None)),
print("❌ 参数过多。") date_to=_cli_str_or_none(getattr(args, "date_to", None)),
print("用法python main.py run [target [input_id]] | run [-t target] [-i input_id]") seller_code=_cli_str_or_none(getattr(args, "seller_code", None)),
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)
def _print_full_usage() -> None: def _print_full_usage() -> None:
print("通用业务技能模板main.py可用命令") print("Amazon 结算报表自动下载main.py可用命令")
print(" python main.py run [target [input_id]] [-t target] [-i input_id]") 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 logs [--limit N] [--status s] [--task-type t] [--target-id tid]")
print(" python main.py log-get <log_id>") print(" python main.py log-get <log_id>")
print(" python main.py health") print(" python main.py health")
@@ -64,15 +48,31 @@ def _print_full_usage() -> None:
def build_parser() -> ZhArgumentParser: def build_parser() -> ZhArgumentParser:
p = ZhArgumentParser( p = ZhArgumentParser(
prog="main.py", prog="main.py",
description="通用业务技能模板:任务执行命令骨架、日志查询、健康检查、版本输出", description="Amazon 结算报表自动下载:请求并下载 Settlement xlsx写入任务日志",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser) sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
sp = sub.add_parser("run", help="任务执行命令骨架") sp = sub.add_parser("run", help="下载指定日期范围的结算报表")
sp.add_argument("--target", "-t", default=None, metavar="目标") sp.add_argument(
sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id") "--date-from",
sp.add_argument("run_tail", nargs="*", metavar="位置参数") 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.set_defaults(handler=_handle_run)
sp = sub.add_parser("logs", help="查看任务日志") sp = sub.add_parser("logs", help="查看任务日志")
@@ -107,10 +107,6 @@ def main(argv: Optional[List[str]] = None) -> int:
if not argv: if not argv:
_print_full_usage() _print_full_usage()
return 1 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() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
return int(args.handler(args)) return int(args.handler(args))

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 @@
"""任务编排、日志查询模板。 """任务编排:下载 Amazon 结算报表、写入 task_logs。"""
通用业务编排层:负责参数校验、鉴权、调用具体业务实现、写入任务日志。
复制后在 cmd_run 中实现真正的业务逻辑。
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import re
import uuid import uuid
from typing import Any, Dict, Optional 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 jiangchang_skill_core.rpa.video_session import RpaVideoSession
from db import task_logs_repository as tlr 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.entitlement_service import check_entitlement
from service.task_run_support import ( from service.task_run_support import (
_print_video_summary, _print_video_summary,
build_video_info, build_video_info,
merge_video_into_result_summary, 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.runtime_paths import get_skill_data_dir, get_skill_root
from util.timeutil import unix_to_iso from util.timeutil import unix_to_iso
async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]: _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
"""最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。"""
batch_id = uuid.uuid4().hex[:12]
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() data_dir = get_skill_data_dir()
art_dir = _artifacts_dir(batch_id)
os.makedirs(art_dir, exist_ok=True)
async with RpaVideoSession( async with RpaVideoSession(
skill_slug=SKILL_SLUG, skill_slug=SKILL_SLUG,
skill_data_dir=data_dir, skill_data_dir=data_dir,
batch_id=batch_id, batch_id=batch_id,
title="开始执行示例任务", title="开始下载 Amazon 结算报表",
closing_title="示例任务执行完成", closing_title="结算报表下载完成",
) as video: ) as video:
video.add_step("准备执行示例任务") video.add_step("准备下载结算报表")
if target: video.add_step(f"日期范围:{date_from}{date_to}")
video.add_step(f"接收目标参数:{target}") if seller_code:
if input_id: video.add_step(f"指定店铺:{seller_code}")
video.add_step(f"接收输入标识:{input_id}")
video.add_step("示例任务执行完成") 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_summary = video.summary()
video_info = build_video_info(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) ok, reason = check_entitlement(SKILL_SLUG)
if not ok: if not ok:
print(f" {reason}") print(f"ERROR:ENTITLEMENT {reason}")
return 1 return 1
rc, video_info = asyncio.run(_run_template_demo(target, input_id)) d_from = (date_from or DEFAULT_DATE_FROM).strip()
_print_video_summary(video_info) d_to = (date_to or DEFAULT_DATE_TO).strip()
seller = (seller_code or "").strip() or None
summary_payload = merge_video_into_result_summary( for label, val in (("date_from", d_from), ("date_to", d_to)):
{ err = _validate_date(label, val)
"template_demo": True, if err:
"target": target, print(f"ERROR:INVALID_ARGS {err}")
"input_id": input_id, return 1
},
video_info, 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( tlr.save_task_log(
task_type="demo", task_type="download_settlement",
target_id=target, target_id=seller or "default",
input_id=input_id, input_id=f"{d_from}_{d_to}",
input_title="模板示例任务", input_title=f"结算报表 {d_from} ~ {d_to}",
status="failed", status=status,
error_msg="模板仓库未实现真实业务", error_msg=None if payload.get("ok") else str(payload.get("error") or ""),
result_summary=json.dumps(summary_payload, ensure_ascii=False), result_summary=json.dumps(summary_payload, ensure_ascii=False),
) )
print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。") stdout_payload = {
return rc "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( def cmd_logs(

View File

@@ -1,6 +1,53 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger""" """技能标识、版本与 Amazon 仿真平台 URL 常量"""
SKILL_SLUG = "your-skill-slug" from __future__ import annotations
SKILL_VERSION = "1.0.32"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" 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" 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
View 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

View File

@@ -1,20 +1,40 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# 样例:复制为 test_simulator_rpa_contract.py 后按需运行;默认跳过。 """
"""仿真页面 / RPA 契约(不默认启动浏览器)。""" Amazon 仿真 Reports RPA 联调契约样例(不进入默认 unittest 套件)。
用法(需账号 + 浏览器 + OPENCLAW_TEST_TARGET=simulator_rpa
copy tests\\integration\\test_simulator_rpa_contract.py.sample \\
tests\\integration\\test_simulator_rpa_contract.py
set OPENCLAW_TEST_TARGET=simulator_rpa
python tests\\integration\\test_simulator_rpa_contract.py
"""
from __future__ import annotations from __future__ import annotations
import os
import sys
import unittest import unittest
from adapter_test_utils import should_skip_profile _TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, "..", ".."))
_SCRIPTS = os.path.join(_SKILL_ROOT, "scripts")
for _p in (_SCRIPTS, _TESTS_DIR):
if _p not in sys.path:
sys.path.insert(0, _p)
from jiangchang_skill_core import config # noqa: E402
from service.task_service import cmd_run # noqa: E402
class TestSimulatorRpaContractSample(unittest.TestCase): @unittest.skipUnless(
def test_skip_unless_simulator_rpa_target(self) -> None: os.environ.get("OPENCLAW_TEST_TARGET") == "simulator_rpa",
skip, reason = should_skip_profile("simulator_rpa") "requires OPENCLAW_TEST_TARGET=simulator_rpa",
if skip: )
raise unittest.SkipTest(reason) class TestSimulatorRpaContract(unittest.TestCase):
# 真实技能中:可替换为 Playwright 连接 http://localhost:5180/select 等仿真入口。 def test_run_downloads_settlement_xlsx(self) -> None:
self.assertFalse(skip) config.reset_cache()
rc = cmd_run(date_from="2025-12-01", date_to="2025-12-31")
self.assertEqual(rc, 0)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""adapter 工厂 dispatch 单元测试。"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from service.amazon_report_adapter import (
DownloadSettlementResult,
MockDownloadSettlementAdapter,
SimulatorRpaDownloadSettlementAdapter,
get_adapter,
)
class TestAdapterDispatch(unittest.TestCase):
def test_default_returns_simulator_rpa(self) -> None:
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("OPENCLAW_TEST_TARGET", None)
from jiangchang_skill_core import config
config.reset_cache()
adapter = get_adapter()
self.assertIsInstance(adapter, SimulatorRpaDownloadSettlementAdapter)
def test_unit_returns_mock(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "unit"}):
from jiangchang_skill_core import config
config.reset_cache()
self.assertIsInstance(get_adapter(), MockDownloadSettlementAdapter)
def test_mock_returns_mock(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "mock"}):
from jiangchang_skill_core import config
config.reset_cache()
self.assertIsInstance(get_adapter(), MockDownloadSettlementAdapter)
def test_simulator_rpa_returns_simulator(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_rpa"}):
from jiangchang_skill_core import config
config.reset_cache()
self.assertIsInstance(get_adapter(), SimulatorRpaDownloadSettlementAdapter)
def test_real_rpa_falls_back_to_mock(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "real_rpa"}):
from jiangchang_skill_core import config
config.reset_cache()
self.assertIsInstance(get_adapter(), MockDownloadSettlementAdapter)
def test_unknown_target_falls_back_simulator_rpa(self) -> None:
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_api"}):
from jiangchang_skill_core import config
config.reset_cache()
self.assertIsInstance(get_adapter(), SimulatorRpaDownloadSettlementAdapter)
class TestMockAdapter(unittest.TestCase):
def test_mock_success(self) -> None:
import tempfile
downloads = tempfile.mkdtemp()
result = MockDownloadSettlementAdapter().download_settlement(
date_from="2025-12-01",
date_to="2025-12-31",
seller_code="AMA1OF6T",
downloads_dir=downloads,
)
self.assertTrue(result.ok)
self.assertTrue((result.job_id or "").startswith("MOCK-"))
self.assertTrue(result.file_path and os.path.isfile(result.file_path))
self.assertTrue((result.filename or "").endswith(".xlsx"))
self.assertIsInstance(result, DownloadSettlementResult)
if __name__ == "__main__":
unittest.main()

View File

@@ -25,7 +25,7 @@ class TestCliSmoke(unittest.TestCase):
rc = main([]) rc = main([])
self.assertEqual(rc, 1) self.assertEqual(rc, 1)
out = buf.getvalue() out = buf.getvalue()
self.assertIn("通用业务技能模板", out) self.assertIn("Amazon 结算报表", out)
self.assertIn("health", out) self.assertIn("health", out)
def test_health_zero(self) -> None: def test_health_zero(self) -> None:
@@ -72,19 +72,32 @@ class TestCliSmoke(unittest.TestCase):
self.assertNotEqual(rc, 0) self.assertNotEqual(rc, 0)
self.assertIn("数字", buf.getvalue()) self.assertIn("数字", buf.getvalue())
def test_run_placeholder_returns_nonzero_without_network(self) -> None: def test_run_mock_returns_ok_json(self) -> None:
"""占位 runRpaVideoSession 示范 + 模板提示OPENCLAW_RECORD_VIDEO=0 时不启 ffmpeg""" """mock 档位 run返回 ok JSON退出码 0"""
old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None) old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None)
old_record = os.environ.get("OPENCLAW_RECORD_VIDEO") old_record = os.environ.get("OPENCLAW_RECORD_VIDEO")
old_target = os.environ.get("OPENCLAW_TEST_TARGET")
os.environ["OPENCLAW_RECORD_VIDEO"] = "0" os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
os.environ["OPENCLAW_TEST_TARGET"] = "mock"
try: try:
with IsolatedDataRoot(user_id="_cli_run"): with IsolatedDataRoot(user_id="_cli_run"):
config.reset_cache() config.reset_cache()
buf = io.StringIO() buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()): with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["run"]) rc = main(
self.assertNotEqual(rc, 0) [
self.assertIn("模板", buf.getvalue()) "run",
"--date-from",
"2025-12-01",
"--date-to",
"2025-12-31",
]
)
self.assertEqual(rc, 0)
payload = json.loads(buf.getvalue().strip().splitlines()[-1])
self.assertTrue(payload.get("ok"))
self.assertIn("job_id", payload)
self.assertIn("file_path", payload)
finally: finally:
if old_auth is not None: if old_auth is not None:
os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth
@@ -92,6 +105,11 @@ class TestCliSmoke(unittest.TestCase):
os.environ.pop("OPENCLAW_RECORD_VIDEO", None) os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
else: else:
os.environ["OPENCLAW_RECORD_VIDEO"] = old_record os.environ["OPENCLAW_RECORD_VIDEO"] = old_record
if old_target is None:
os.environ.pop("OPENCLAW_TEST_TARGET", None)
else:
os.environ["OPENCLAW_TEST_TARGET"] = old_target
config.reset_cache()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""RpaVideoSession 模板示范与 result_summary / CLI 输出测试。""" """RpaVideoSession 与 cmd_run 测试。"""
from __future__ import annotations from __future__ import annotations
import io import io
@@ -26,6 +26,7 @@ class TestEnvExampleVideoDefaults(unittest.TestCase):
"OPENCLAW_ARTIFACTS_ON_FAILURE=1", "OPENCLAW_ARTIFACTS_ON_FAILURE=1",
"OPENCLAW_BROWSER_HEADLESS=0", "OPENCLAW_BROWSER_HEADLESS=0",
"OPENCLAW_PLAYWRIGHT_STEALTH=1", "OPENCLAW_PLAYWRIGHT_STEALTH=1",
"AMAZON_SIM_REPORTS_URL=https://sandbox.jc2009.com",
): ):
self.assertIn(key, text, msg=f"missing {key!r} in .env.example") self.assertIn(key, text, msg=f"missing {key!r} in .env.example")
@@ -39,70 +40,35 @@ class TestPrintVideoSummary(unittest.TestCase):
_print_video_summary( _print_video_summary(
{ {
"enabled": True, "enabled": True,
"path": r"D:\data\videos\your-skill-slug_demo.mp4", "path": r"D:\data\videos\download-settlement-report-amazon_demo.mp4",
"record_log_path": r"D:\data\logs\ffmpeg-record.log", "record_log_path": r"D:\data\logs\ffmpeg-record.log",
"warnings": [], "warnings": [],
} }
) )
out = buf.getvalue() out = buf.getvalue()
self.assertIn("录屏路径", out) self.assertIn("录屏路径", out)
self.assertIn("your-skill-slug_demo.mp4", out) self.assertIn("download-settlement-report-amazon_demo.mp4", out)
self.assertIn("录屏日志", out) self.assertIn("录屏日志", out)
def test_disabled_video_prints_nothing(self) -> None:
from service.task_run_support import _print_video_summary
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
_print_video_summary({"enabled": False, "path": r"D:\tmp\x.mp4"})
self.assertEqual(buf.getvalue(), "")
class TestMergeVideoIntoResultSummary(unittest.TestCase):
def test_result_summary_includes_video_fields(self) -> None:
from service.task_run_support import merge_video_into_result_summary
video_info = {
"enabled": True,
"path": r"D:\data\videos\demo.mp4",
"capture_path": r"D:\data\capture.mp4",
"record_log_path": r"D:\data\logs\ffmpeg-record.log",
"warnings": ["test_warning"],
"voiceover_path": r"D:\data\voiceover.wav",
"music_path": r"D:\data\music.mp3",
"audio_warnings": ["tts_skipped"],
}
payload = merge_video_into_result_summary({"demo": True}, video_info)
self.assertIn("video", payload)
self.assertEqual(payload["video_path"], video_info["path"])
self.assertEqual(payload["raw_video"], video_info["capture_path"])
self.assertEqual(payload["video_log"], video_info["record_log_path"])
self.assertEqual(payload["video_warnings"], ["test_warning"])
self.assertEqual(payload["voiceover_path"], video_info["voiceover_path"])
self.assertEqual(payload["music_path"], video_info["music_path"])
self.assertEqual(payload["audio_warnings"], ["tts_skipped"])
class TestTemplateRunVideoSession(unittest.TestCase):
def _fake_video_session(self, mock_video: MagicMock):
class _FakeVideoSession:
def __init__(self, **kwargs):
self.kwargs = kwargs
async def __aenter__(self):
return mock_video
async def __aexit__(self, *args):
return None
return _FakeVideoSession
class TestCmdRunVideoSession(unittest.TestCase):
def test_cmd_run_creates_video_session_with_chinese_titles(self) -> None: def test_cmd_run_creates_video_session_with_chinese_titles(self) -> None:
with IsolatedDataRoot(user_id="_video_run"): with IsolatedDataRoot(user_id="_video_run"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0" os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
os.environ["OPENCLAW_TEST_TARGET"] = "mock"
config.reset_cache() config.reset_cache()
from service import task_service from service import task_service
from service.amazon_report_adapter.base import DownloadSettlementResult
mock_result = DownloadSettlementResult(
ok=True,
job_id="MOCK-1",
file_path="/tmp/settlement.xlsx",
filename="settlement.xlsx",
date_from="2025-12-01",
date_to="2025-12-31",
)
mock_video = MagicMock() mock_video = MagicMock()
mock_video.add_step = MagicMock() mock_video.add_step = MagicMock()
@@ -129,32 +95,35 @@ class TestTemplateRunVideoSession(unittest.TestCase):
async def __aexit__(self, *args): async def __aexit__(self, *args):
return None return None
with patch.object(task_service, "RpaVideoSession", _CapturingFakeSession): mock_adapter = MagicMock()
buf = io.StringIO() mock_adapter.name = "mock"
with redirect_stdout(buf), redirect_stderr(io.StringIO()): mock_adapter.download_settlement.return_value = mock_result
rc = task_service.cmd_run(target="demo-target", input_id="42")
self.assertEqual(rc, 1) with patch.object(task_service, "RpaVideoSession", _CapturingFakeSession):
with patch.object(task_service, "get_adapter", return_value=mock_adapter):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(
date_from="2025-12-01",
date_to="2025-12-31",
)
self.assertEqual(rc, 0)
self.assertTrue(created, "RpaVideoSession should be constructed") self.assertTrue(created, "RpaVideoSession should be constructed")
kwargs = created[0] kwargs = created[0]
self.assertEqual(kwargs.get("title"), "开始执行示例任务") self.assertEqual(kwargs.get("title"), "开始下载 Amazon 结算报表")
self.assertEqual(kwargs.get("closing_title"), "示例任务执行完成") self.assertEqual(kwargs.get("closing_title"), "结算报表下载完成")
self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("title", ""))) self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("title", "")))
self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("closing_title", "")))
mock_video.add_step.assert_any_call("准备执行示例任务")
mock_video.add_step.assert_any_call("示例任务执行完成")
mock_video.summary.assert_called()
out = buf.getvalue() out = buf.getvalue()
self.assertIn("模板", out) payload = json.loads(out.strip().splitlines()[-1])
self.assertTrue(payload.get("ok"))
from db import task_logs_repository as tlr from db import task_logs_repository as tlr
rows = tlr.list_task_logs(1) rows = tlr.list_task_logs(1)
self.assertTrue(rows) self.assertTrue(rows)
summary = json.loads(rows[0][7]) self.assertEqual(rows[0][1], "download_settlement")
self.assertIn("video", summary)
self.assertIn("video_path", summary)
if __name__ == "__main__": if __name__ == "__main__":