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

101
README.md
View File

@@ -1,99 +1,76 @@
---
description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。"
description: "自动从 Amazon 卖家中心下载结算报表,省去重复登录与手工导出。"
---
> **模板说明(复制后删除本段)**
> 本文是技能市场详情与普通用户说明,**不是** Agent 参考索引、CLI 契约或开发文档。
> frontmatter 的 `description` 必须是用户能看懂的一句话能力描述;**不得**写成 CLI、schema、references、development、契约、数据结构、开发规范或 Agent 参考索引。
> 正文只回答用户关心的问题,**不要**把 Playwright、DOM、Python、RPA、references、development、schema 等作为面向用户的主说明内容;若业务上必须出现技术词,须用用户能理解的方式解释,且不能喧宾夺主。
# Amazon 结算报表自动下载
# 【技能名称】
> 用一两句话说明这个技能帮助用户完成什么工作。
> 这里不要解释技术实现,要从用户的业务目标出发描述价值。
帮你从 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
用自然语言示例展示用户可以怎么发起任务,例如:
> 「帮我下载 2025 年 12 月的 Amazon 结算报表。」
> “帮我查询今天的【业务对象】,整理成表格。”
>
> “帮我核对这些【数据/单据】是否有异常,并告诉我问题在哪里。”
>
> “帮我把【时间范围】内的【业务数据】导出并生成处理报告。”
>
> “帮我根据这个文件里的内容,逐条完成【业务动作】。”
> 「从 Amazon 卖家中心导出 12 月 1 日到 12 月 31 日的结算单。」
> 注意:示例应是自然语言,不要写命令行命令。
> 「用 AMA1OF6T 店铺下载上周的 settlement 报表。」
---
## 执行后你会得到什么
说明用户会看到什么结果,例如:
- 任务是否成功完成
- 处理了多少条数据
- 哪些内容成功、哪些失败
- 失败原因和建议处理方式
- 生成的文件、表格、报告或记录
- 成功时:本地 xlsx 文件路径、任务编号、日期范围JSON 摘要)
- 失败时:错误说明;必要时可在数据目录查看失败截图
- 任务日志中可查询历史下载记录
---
## 注意事项
用用户能理解的方式说明边界和风险,例如:
- 请确认输入范围准确,避免处理错数据
- 涉及提交、付款、删除、审批等高风险动作时,技能应在执行前请求确认
- 如果外部系统登录失效、权限不足或页面变化,任务可能需要用户介入
- 不建议在未核对数据的情况下直接执行批量操作
---
## 常见问题
### 为什么任务没有查到数据?
> 可能是时间范围、关键词、账号权限或系统筛选条件不正确。请先确认输入信息是否准确。
### 为什么任务执行到一半需要我操作?
> 可能遇到了登录、验证码、权限确认、二次审批或系统异常。请根据 Agent 的提示完成必要操作。
### 结果文件在哪里查看?
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。
- 请确认日期范围正确,避免下载错期间的数据
- 仿真环境与真实 Amazon 卖家中心界面类似,但数据为实训用途
- 本技能仅下载报表文件,**不会**自动解析 Excel 内容或入账

101
SKILL.md
View File

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

View File

@@ -1,4 +1,4 @@
{
"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
## 0. 技能标识(复制 REQUIREMENTS 时必填)
- 拟定 slug英文符合 `development/NAMING.md`
- 中文 name
- 中文 description一句话
- account-manager platform_key如有
- 命名形态:标准型 / scope 型
| 项 | 值 |
|----|-----|
| slug | `download-settlement-report-amazon` |
| 中文 name | Amazon 结算报表自动下载 |
| 中文 description | 在 Amazon 卖家中心自动请求并下载结算报表Settlement |
| account-manager platform_key | `amazon_sim`(若尚未注册,见 README / 本文 §7 |
| 命名形态 | 标准型verb-noun-platform |
## 1. 文档目标
说明这份需求文档的作用,以及它解决什么问题
模板写法:
- 本文档用于明确 `your-skill-slug` 的研发范围、交付标准、依赖关系和验收要求。
- 本文档用于明确 `download-settlement-report-amazon` 的研发范围、交付标准、依赖关系和验收要求
- 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。
- 本文档同时作为**老师/实训样板**:学员可复制本技能结构开发同类 Amazon / 跨境报表技能。
## 2. 业务背景
说明为什么要做这个 skill它服务什么业务场景解决什么实际问题
模板写法:
- 当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
- 现有流程主要依赖人工处理,存在 `<效率低 / 容易出错 / 不可批量 / 不可追踪>` 等问题。
- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 `task_logs` 便于追踪。
填写提示:
- `<业务场景>`例如“每日批量导入外部系统结果”“Agent 触发的单次查询”
- `<输入数据>`:例如“关键词列表”“批次 ID”“账号标识”
- `<核心动作>`:例如“调用 adapter 提交”“采集并入库”“对账摘要生成”
- `<输出结果>`:例如“结构化 JSON”“任务摘要”“本地 SQLite 记录”
- 跨境财务实训需定期从 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 下载;必须走浏览器页面操作。
## 3. 开发目标
说明本次开发希望最终交付什么结果。
模板写法(按 skill 类型勾选/改写):
- 解析并校验 `<输入数据>` 的关键字段
- 通过 adapter 完成 mock / simulator / real 档位下的 `<核心动作>`
- 输出结构化结果stdout JSON 或固定文本 + 机读字段)
- 写入 `task_logs`(及必要的业务表,见 `references/SCHEMA.md`
- 支持失败可诊断(稳定错误码、`ERROR:` 前缀、必要截图/日志)
- 完成 CLI 入口与 `health` / `version` 最小可运行验证
- 完成正式环境发布与安装验证(如适用)
- 提供标准 CLI`health``version``run``logs``log-get`
- 实现四档 adapter 中的 **mock**CI 默认)与 **simulator_rpa**(联调)
- 集成 **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}/`
- 可选 `RpaVideoSession` 录屏(中文步骤标题)
## 4. 功能范围
说明这次开发明确要实现哪些功能。
模板写法:
- 支持 `<命令1>`(如 `run`
- 支持 `<命令2>`(如 `logs` / `log-get`
- 支持读取 `<数据来源>`(兄弟 skill / 本地 DB / 外部 API
- 支持执行 `<核心业务动作>`
- 支持记录 `<日志 / 状态 / 结果>`
建议将功能拆成“必须实现”和“可后续扩展”。
### 必须实现
- 支持 `python scripts/main.py health`
- 支持 `python scripts/main.py version`
- 支持 `python scripts/main.py <your-main-command>`
- 支持核心业务编排HTTP / 批处理 / 可选 RPA按四档 adapter 选型
- 支持写入任务日志(`task_logs`
- `python scripts/main.py health`
- `python scripts/main.py version`
- `python scripts/main.py run --date-from YYYY-MM-DD --date-to YYYY-MM-DD [--seller-code CODE]`
- Playwright RPA`OPENCLAW_TEST_TARGET=simulator_rpa`
- 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. 非功能要求
说明除功能外,对稳定性、可维护性、目录规范、日志、编码等方面的要求。
- 目录结构必须符合当前 `skill-template` 规范
- 入口必须统一为 `scripts/main.py`
- 用户说明写在根 `README.md`LLM/运行契约写在 `SKILL.md`;开发规范在 `development/`
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:`
- Windows 环境下需保证 UTF-8 输出兼容
- 必须具备基本日志能力;敏感字段脱敏
- RPA 类 skillPlaywright 由宿主共享 runtime 提供;技能侧不要 `playwright install`URL 不要放进 `launch_persistent_context``args`
- 目录结构符合 `skill-template` 规范;入口 `scripts/main.py`
- Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`
- URL **不要**放进 `launch_persistent_context(args=[...])`Reports 页通过 `page.goto(REPORTS_URL)` 打开
- 元素定位优先 `data-testid`F12 原则)
- 步骤间 `random_delay`;禁止 `el.value=`、JS 跳转
- Windows UTF-8 输出;错误前缀 `ERROR:`
## 6. 输入输出要求
### 输入
填写本 skill 实际接受的参数与数据来源:
| 参数 | 必填 | 默认 | 说明 |
|------|------|------|------|
| `--date-from` | 是* | `2025-12-01` | 报表起始日期 |
| `--date-to` | 是* | `2025-12-31` | 报表结束日期 |
| `--seller-code` | 否 | 页面第一个 seller | 店铺代码,如 `AMA1OF6T` |
- `<参数名>`:含义、是否必填、默认值
- `<依赖系统>` 返回的结构化数据(如有)
- 环境变量 / `.env` 中的关键配置(引用 `CONFIG.md`
\* CLI 提供默认值,业务上仍须为合法 `YYYY-MM-DD`
### 输出
环境变量见 `.env.example``AMAZON_SIM_BASE_URL``AMAZON_SIM_REPORTS_URL``OPENCLAW_TEST_TARGET` 等。
- 成功结果stdout / JSON 字段说明
- 错误输出(`ERROR:` 稳定码 + 中文 message
- 数据库记录(表名、关键字段,见 `references/SCHEMA.md`
- 可选RPA 截图/视频路径artifacts
### 成功输出stdout JSON
## 7. 依赖的兄弟技能或外部系统
### 兄弟技能依赖
- `<sibling-skill-slug>``<用途>`(例如 account-manager 提供 profile_dir / 租约)
### 外部系统依赖
- `<目标系统>``<用途>`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 | 张三 | 初版 |
```json
{
"ok": true,
"job_id": "...",
"file_path": "...",
"filename": "settlement_....xlsx",
"date_from": "2025-12-01",
"date_to": "2025-12-31"
}
```
### 失败输出
- 退出码非 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
```
## 标准行为
## run — 下载结算报表
- **任意 CLI 启动前**执行 `bootstrap_skill_config()``main.py``cli.app.main()` 均触发)。
- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets不执行业务动作**;不输出敏感值。
- **`config-path`**:输出 JSON包含 `skill``env_path``example_path`
- **`version`**:输出 JSON`version``skill`)。
- **`run`**:长时间无 stdout **不代表卡死**;应通过 `logs` / `log-get` 排查。
- **任务完成后**若有 video artifactCLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`)。
```bash
python {baseDir}/scripts/main.py run \
--date-from 2025-12-01 \
--date-to 2025-12-31 \
[--seller-code AMA1OF6T]
```
## config-path配置路径
| 参数 | 必填 | 默认 | 说明 |
|------|------|------|------|
| `--date-from` | 否 | `2025-12-01` | 起始日期 YYYY-MM-DD |
| `--date-to` | 否 | `2025-12-31` | 结束日期 YYYY-MM-DD |
| `--seller-code` | 否 | (页面首个 seller | Amazon 店铺代码 |
### 成功 stdoutJSON最后一行
```json
{
"skill": "your-skill-slug",
"env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env",
"ok": true,
"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"
}
```
@@ -35,36 +74,10 @@ python {baseDir}/scripts/main.py version
- `python_executable` — 当前 Python 解释器路径
- `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok`
- `jiangchang_skill_core_file` — 公共库加载来源(若来自技能目录副本会报 warning
- `media_assets_root``ffmpeg_path``ffmpeg_available``background_music_mp3_count`
- `runtime_issue[warning|error]` — 非致命/致命问题列表
- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径
- `jiangchang_skill_core_file` — 公共库加载来源
- `env_path` / `env_exists` / `example_path`
## 通用业务命令骨架
```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
**建议使用宿主共享 python-runtime**,避免技能目录内临时 venv 加载不到公共库:
@@ -79,9 +92,3 @@ Windows:
`<shared-python>` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`
**不建议**在生产/测试机使用技能目录内 `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 |
| 对账 | reconcile | 银行/平台 | 对账批次 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 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。

View File

@@ -1,4 +1,4 @@
"""CLIargparse 与分发模板"""
"""CLIargparse 与分发。"""
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))

View File

@@ -0,0 +1,236 @@
"""account-manager CLI 集成(仅 subprocess不 import 兄弟 skill 内部模块)。"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
from typing import Any, Dict, List, Optional
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
from util.logging import mask_text
logger = logging.getLogger(__name__)
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
ACCOUNT_SETUP_MESSAGE = (
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。"
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}"
"status=active、带 profile_dir 的账号后重新运行。"
)
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
class AccountManagerError(Exception):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
def mask_login_id(login_id: str) -> str:
return mask_text(login_id)
def _resolve_account_manager_main() -> str:
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
skills_root = get_sibling_skills_root(scripts_dir)
candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py")
if os.path.isfile(candidate):
return candidate
dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py"
if os.path.isfile(dev):
return dev
raise AccountManagerError(
"ACCOUNT_NOT_FOUND",
"未找到 account-manager请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。",
)
def inject_account_manager_scripts_path() -> str:
"""将 account-manager/scripts 注入 sys.path供 rpa_helpers 同进程 import。"""
main_py = _resolve_account_manager_main()
scripts_dir = os.path.dirname(os.path.abspath(main_py))
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
return scripts_dir
def _parse_last_json(stdout: str) -> Any:
lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()]
for raw in reversed(lines):
if raw.startswith("ERROR:"):
continue
try:
return json.loads(raw)
except json.JSONDecodeError:
continue
raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。")
def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]:
main_py = _resolve_account_manager_main()
env = os.environ.copy()
return subprocess.run(
[sys.executable, main_py, *argv_suffix],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
def _normalize_error_code(raw: str) -> str:
code = (raw or "").strip()
if code.startswith("ERROR:"):
code = code[6:]
return code
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or ""))
message = str(err.get("message") or "")
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE)
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。")
return payload
def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
proc = _run_argv(
[
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
]
)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False:
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
return payload
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
raw = _parse_last_json(out)
if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict):
data = raw["data"]
elif isinstance(raw, dict):
data = raw
else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower():
raise AccountManagerError(
"ACCOUNT_NOT_FOUND",
f"账号 {account_id} 不是 {platform} 平台账号。",
)
profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir
data["lease_token"] = ""
return data
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
"""获取网页账号profile_dir + lease_token"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
if account_id:
try:
aid = int(account_id)
except (TypeError, ValueError):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
return _pick_by_id(platform_key, aid)
try:
return _pick_web_with_lease(platform_key)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
raise
def get_account_credential(account_id: int, lease_token: str) -> dict[str, Any]:
"""通过租约 reveal 账号凭据 plaintext。"""
tok = (lease_token or "").strip()
if not tok:
raise AccountManagerError("ERR_LEASE_INVALID", "reveal 需要有效 lease_token。")
proc = _run_argv(
[
"account",
"get-credential",
str(account_id),
"--reveal",
"--lease-token",
tok,
]
)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"CREDENTIAL_FAILED",
(proc.stderr or "").strip() or "get-credential 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or "CREDENTIAL_FAILED"))
message = str(err.get("message") or "获取凭据失败")
raise AccountManagerError(code, message)
if not isinstance(payload, dict):
raise AccountManagerError("CREDENTIAL_FAILED", "get-credential 返回格式异常。")
return payload
def release_lease(lease_token: Optional[str]) -> None:
token = (lease_token or "").strip()
if not token:
return
try:
proc = _run_argv(["lease", "release", token])
if proc.returncode != 0:
logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip())
except Exception:
logger.warning("lease_release_exception", exc_info=True)

View File

@@ -0,0 +1,40 @@
"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。"""
from __future__ import annotations
import logging
from typing import Optional
from jiangchang_skill_core import config
from service.amazon_report_adapter.base import DownloadSettlementAdapterBase, DownloadSettlementResult
from service.amazon_report_adapter.mock import MockDownloadSettlementAdapter
from service.amazon_report_adapter.simulator_rpa import SimulatorRpaDownloadSettlementAdapter
logger = logging.getLogger(__name__)
__all__ = [
"get_adapter",
"DownloadSettlementAdapterBase",
"DownloadSettlementResult",
"MockDownloadSettlementAdapter",
"SimulatorRpaDownloadSettlementAdapter",
]
def get_adapter(*, artifacts_dir: Optional[str] = None) -> DownloadSettlementAdapterBase:
target = (config.get("OPENCLAW_TEST_TARGET") or "").strip().lower()
if target in ("mock", "unit"):
logger.info("target '%s': MockDownloadSettlementAdapter", target)
return MockDownloadSettlementAdapter()
if target == "real_rpa":
logger.warning("target real_rpa 未实现,回退 MockDownloadSettlementAdapter")
return MockDownloadSettlementAdapter()
if target and target != "simulator_rpa":
logger.warning("未知 target '%s',回退 simulator_rpa", target)
logger.info("target '%s': SimulatorRpaDownloadSettlementAdapter", target or "(unset=default)")
return SimulatorRpaDownloadSettlementAdapter(artifacts_dir=artifacts_dir)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,136 @@
"""浏览器会话启动(系统 Chrome/EdgeURL 仅通过 page.goto 打开)。"""
from __future__ import annotations
import logging
import os
from contextlib import contextmanager
from typing import Any, Generator, List, Optional
logger = logging.getLogger(__name__)
DEFAULT_PAGE_TIMEOUT_MS = 15_000
GOTO_TIMEOUT_MS = 60_000
CHROME_LAUNCH_ARGS: List[str] = [
"--start-maximized",
"--disable-blink-features=AutomationControlled",
]
STEALTH_INIT_SCRIPT = """
(() => {
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
})();
"""
def chrome_launch_args() -> List[str]:
return list(CHROME_LAUNCH_ARGS)
def _headless_from_env() -> bool:
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
return v in ("1", "true", "yes", "on")
def _find_chrome_executable() -> str | None:
try:
from jiangchang_skill_core.runtime_env import find_chrome_executable
chrome = find_chrome_executable()
if chrome and os.path.isfile(chrome):
return chrome
except ImportError:
pass
candidates = [
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
]
for path in candidates:
if path and os.path.isfile(path):
return path
return None
def _clear_node_options() -> None:
os.environ.pop("NODE_OPTIONS", None)
@contextmanager
def browser_session(
start_url: str,
*,
profile_dir: Optional[str] = None,
headless: Optional[bool] = None,
) -> Generator[Any, None, None]:
"""
同步 Playwright 浏览器会话。
- Playwright Python 包由宿主共享 runtime 提供;技能侧不要 playwright install。
- launch args 仅放 Chrome 参数,不含 URL。
- 流程launch → new_page → goto(start_url)
"""
from playwright.sync_api import sync_playwright
_clear_node_options()
hl = headless if headless is not None else _headless_from_env()
chrome = _find_chrome_executable()
if not chrome:
raise RuntimeError("未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。")
args = chrome_launch_args()
logger.info(
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
chrome,
profile_dir or "",
start_url,
args,
hl,
)
browser_holder = None
context = None
with sync_playwright() as pw:
try:
if profile_dir:
context = pw.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=hl,
executable_path=chrome,
locale="zh-CN",
no_viewport=True,
args=args,
ignore_default_args=["--enable-automation"],
)
else:
browser_holder = pw.chromium.launch(
headless=hl,
executable_path=chrome,
args=args,
ignore_default_args=["--enable-automation"],
)
context = browser_holder.new_context(no_viewport=True, locale="zh-CN")
context.add_init_script(STEALTH_INIT_SCRIPT)
page = context.new_page()
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
yield page
finally:
try:
if context is not None:
context.close()
except Exception as exc:
logger.warning("browser_context_close_failed: %s", exc)
if browser_holder is not None:
try:
browser_holder.close()
except Exception as exc:
logger.warning("browser_close_failed: %s", exc)
def find_chrome_executable() -> str | None:
return _find_chrome_executable()

View File

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

View File

@@ -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
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 -*-
# 样例:复制为 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
import os
import sys
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):
def test_skip_unless_simulator_rpa_target(self) -> None:
skip, reason = should_skip_profile("simulator_rpa")
if skip:
raise unittest.SkipTest(reason)
# 真实技能中:可替换为 Playwright 连接 http://localhost:5180/select 等仿真入口。
self.assertFalse(skip)
@unittest.skipUnless(
os.environ.get("OPENCLAW_TEST_TARGET") == "simulator_rpa",
"requires OPENCLAW_TEST_TARGET=simulator_rpa",
)
class TestSimulatorRpaContract(unittest.TestCase):
def test_run_downloads_settlement_xlsx(self) -> None:
config.reset_cache()
rc = cmd_run(date_from="2025-12-01", date_to="2025-12-31")
self.assertEqual(rc, 0)
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([])
self.assertEqual(rc, 1)
out = buf.getvalue()
self.assertIn("通用业务技能模板", out)
self.assertIn("Amazon 结算报表", out)
self.assertIn("health", out)
def test_health_zero(self) -> None:
@@ -72,19 +72,32 @@ class TestCliSmoke(unittest.TestCase):
self.assertNotEqual(rc, 0)
self.assertIn("数字", buf.getvalue())
def test_run_placeholder_returns_nonzero_without_network(self) -> None:
"""占位 runRpaVideoSession 示范 + 模板提示OPENCLAW_RECORD_VIDEO=0 时不启 ffmpeg"""
def test_run_mock_returns_ok_json(self) -> None:
"""mock 档位 run返回 ok JSON退出码 0"""
old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None)
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_TEST_TARGET"] = "mock"
try:
with IsolatedDataRoot(user_id="_cli_run"):
config.reset_cache()
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["run"])
self.assertNotEqual(rc, 0)
self.assertIn("模板", buf.getvalue())
rc = main(
[
"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:
if old_auth is not None:
os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth
@@ -92,6 +105,11 @@ class TestCliSmoke(unittest.TestCase):
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
else:
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__":

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""RpaVideoSession 模板示范与 result_summary / CLI 输出测试。"""
"""RpaVideoSession 与 cmd_run 测试。"""
from __future__ import annotations
import io
@@ -26,6 +26,7 @@ class TestEnvExampleVideoDefaults(unittest.TestCase):
"OPENCLAW_ARTIFACTS_ON_FAILURE=1",
"OPENCLAW_BROWSER_HEADLESS=0",
"OPENCLAW_PLAYWRIGHT_STEALTH=1",
"AMAZON_SIM_REPORTS_URL=https://sandbox.jc2009.com",
):
self.assertIn(key, text, msg=f"missing {key!r} in .env.example")
@@ -39,70 +40,35 @@ class TestPrintVideoSummary(unittest.TestCase):
_print_video_summary(
{
"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",
"warnings": [],
}
)
out = buf.getvalue()
self.assertIn("录屏路径", out)
self.assertIn("your-skill-slug_demo.mp4", out)
self.assertIn("download-settlement-report-amazon_demo.mp4", 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:
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()
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.add_step = MagicMock()
@@ -129,32 +95,35 @@ class TestTemplateRunVideoSession(unittest.TestCase):
async def __aexit__(self, *args):
return None
mock_adapter = MagicMock()
mock_adapter.name = "mock"
mock_adapter.download_settlement.return_value = mock_result
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(target="demo-target", input_id="42")
rc = task_service.cmd_run(
date_from="2025-12-01",
date_to="2025-12-31",
)
self.assertEqual(rc, 1)
self.assertEqual(rc, 0)
self.assertTrue(created, "RpaVideoSession should be constructed")
kwargs = created[0]
self.assertEqual(kwargs.get("title"), "开始执行示例任务")
self.assertEqual(kwargs.get("closing_title"), "示例任务执行完成")
self.assertEqual(kwargs.get("title"), "开始下载 Amazon 结算报表")
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("closing_title", "")))
mock_video.add_step.assert_any_call("准备执行示例任务")
mock_video.add_step.assert_any_call("示例任务执行完成")
mock_video.summary.assert_called()
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
rows = tlr.list_task_logs(1)
self.assertTrue(rows)
summary = json.loads(rows[0][7])
self.assertIn("video", summary)
self.assertIn("video_path", summary)
self.assertEqual(rows[0][1], "download_settlement")
if __name__ == "__main__":