chore: auto release commit (2026-07-03 12:09:55)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
All checks were successful
技能自动化发布 / release (push) Successful in 5s
This commit is contained in:
13
.env.example
13
.env.example
@@ -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
101
README.md
@@ -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
101
SKILL.md
@@ -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`。
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"skill": "your-skill-slug"
|
"skill": "download-settlement-report-amazon"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 adapter(CI 默认,`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 类 skill:Playwright 由宿主共享 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
22
evals/rpa-flow.md
Normal 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/Edge(URL 不写入 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`(约 1–3 秒,最长 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。
|
||||||
@@ -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 artifact,CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `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 店铺代码 |
|
||||||
|
|
||||||
|
### 成功 stdout(JSON,最后一行)
|
||||||
|
|
||||||
```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` 命名
|
|
||||||
|
|||||||
@@ -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 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""CLI:argparse 与分发模板。"""
|
"""CLI:argparse 与分发。"""
|
||||||
|
|
||||||
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))
|
||||||
|
|||||||
236
scripts/service/account_client.py
Normal file
236
scripts/service/account_client.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
|
||||||
|
|
||||||
|
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
|
||||||
|
from util.logging import mask_text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
|
||||||
|
|
||||||
|
ACCOUNT_SETUP_MESSAGE = (
|
||||||
|
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。"
|
||||||
|
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}、"
|
||||||
|
"status=active、带 profile_dir 的账号后重新运行。"
|
||||||
|
)
|
||||||
|
|
||||||
|
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountManagerError(Exception):
|
||||||
|
def __init__(self, code: str, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
def mask_login_id(login_id: str) -> str:
|
||||||
|
return mask_text(login_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_account_manager_main() -> str:
|
||||||
|
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
|
||||||
|
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
|
||||||
|
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
|
||||||
|
|
||||||
|
scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
skills_root = get_sibling_skills_root(scripts_dir)
|
||||||
|
candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py")
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py"
|
||||||
|
if os.path.isfile(dev):
|
||||||
|
return dev
|
||||||
|
|
||||||
|
raise AccountManagerError(
|
||||||
|
"ACCOUNT_NOT_FOUND",
|
||||||
|
"未找到 account-manager,请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_account_manager_scripts_path() -> str:
|
||||||
|
"""将 account-manager/scripts 注入 sys.path,供 rpa_helpers 同进程 import。"""
|
||||||
|
main_py = _resolve_account_manager_main()
|
||||||
|
scripts_dir = os.path.dirname(os.path.abspath(main_py))
|
||||||
|
if scripts_dir not in sys.path:
|
||||||
|
sys.path.insert(0, scripts_dir)
|
||||||
|
return scripts_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_last_json(stdout: str) -> Any:
|
||||||
|
lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()]
|
||||||
|
for raw in reversed(lines):
|
||||||
|
if raw.startswith("ERROR:"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
main_py = _resolve_account_manager_main()
|
||||||
|
env = os.environ.copy()
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, main_py, *argv_suffix],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_error_code(raw: str) -> str:
|
||||||
|
code = (raw or "").strip()
|
||||||
|
if code.startswith("ERROR:"):
|
||||||
|
code = code[6:]
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if payload.get("success") is False:
|
||||||
|
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||||
|
code = _normalize_error_code(str(err.get("code") or ""))
|
||||||
|
message = str(err.get("message") or "")
|
||||||
|
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
|
||||||
|
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE)
|
||||||
|
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
|
||||||
|
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
|
||||||
|
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。")
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
|
||||||
|
if not payload.get("profile_dir"):
|
||||||
|
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
|
||||||
|
proc = _run_argv(
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"pick-web",
|
||||||
|
"--platform",
|
||||||
|
platform,
|
||||||
|
"--lease",
|
||||||
|
"--holder",
|
||||||
|
LEASE_HOLDER,
|
||||||
|
"--ttl-sec",
|
||||||
|
LEASE_TTL_SEC,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
out = proc.stdout or ""
|
||||||
|
if proc.returncode != 0 and not out.strip():
|
||||||
|
raise AccountManagerError(
|
||||||
|
"PICK_WEB_FAILED",
|
||||||
|
(proc.stderr or "").strip() or "pick-web 子进程失败",
|
||||||
|
)
|
||||||
|
payload = _parse_last_json(out)
|
||||||
|
if isinstance(payload, dict) and payload.get("success") is False:
|
||||||
|
return _validate_pick_payload(payload)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||||
|
proc = _run_argv(["account", "get", str(account_id)])
|
||||||
|
out = proc.stdout or ""
|
||||||
|
if proc.returncode != 0 or out.startswith("ERROR:"):
|
||||||
|
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
|
||||||
|
raw = _parse_last_json(out)
|
||||||
|
if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict):
|
||||||
|
data = raw["data"]
|
||||||
|
elif isinstance(raw, dict):
|
||||||
|
data = raw
|
||||||
|
else:
|
||||||
|
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
|
||||||
|
|
||||||
|
platform_key = str(data.get("platform_key") or "").lower()
|
||||||
|
if platform_key != platform.lower():
|
||||||
|
raise AccountManagerError(
|
||||||
|
"ACCOUNT_NOT_FOUND",
|
||||||
|
f"账号 {account_id} 不是 {platform} 平台账号。",
|
||||||
|
)
|
||||||
|
profile_dir = str(data.get("profile_dir") or "").strip()
|
||||||
|
if not profile_dir:
|
||||||
|
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
|
||||||
|
data["profile_dir"] = profile_dir
|
||||||
|
data["lease_token"] = ""
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
|
||||||
|
"""获取网页账号(profile_dir + lease_token)。"""
|
||||||
|
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
try:
|
||||||
|
aid = int(account_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
|
||||||
|
return _pick_by_id(platform_key, aid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _pick_web_with_lease(platform_key)
|
||||||
|
except AccountManagerError as exc:
|
||||||
|
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
|
||||||
|
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_credential(account_id: int, lease_token: str) -> dict[str, Any]:
|
||||||
|
"""通过租约 reveal 账号凭据 plaintext。"""
|
||||||
|
tok = (lease_token or "").strip()
|
||||||
|
if not tok:
|
||||||
|
raise AccountManagerError("ERR_LEASE_INVALID", "reveal 需要有效 lease_token。")
|
||||||
|
proc = _run_argv(
|
||||||
|
[
|
||||||
|
"account",
|
||||||
|
"get-credential",
|
||||||
|
str(account_id),
|
||||||
|
"--reveal",
|
||||||
|
"--lease-token",
|
||||||
|
tok,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
out = proc.stdout or ""
|
||||||
|
if proc.returncode != 0 and not out.strip():
|
||||||
|
raise AccountManagerError(
|
||||||
|
"CREDENTIAL_FAILED",
|
||||||
|
(proc.stderr or "").strip() or "get-credential 子进程失败",
|
||||||
|
)
|
||||||
|
payload = _parse_last_json(out)
|
||||||
|
if isinstance(payload, dict) and payload.get("success") is False:
|
||||||
|
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||||
|
code = _normalize_error_code(str(err.get("code") or "CREDENTIAL_FAILED"))
|
||||||
|
message = str(err.get("message") or "获取凭据失败")
|
||||||
|
raise AccountManagerError(code, message)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise AccountManagerError("CREDENTIAL_FAILED", "get-credential 返回格式异常。")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def release_lease(lease_token: Optional[str]) -> None:
|
||||||
|
token = (lease_token or "").strip()
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc = _run_argv(["lease", "release", token])
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip())
|
||||||
|
except Exception:
|
||||||
|
logger.warning("lease_release_exception", exc_info=True)
|
||||||
40
scripts/service/amazon_report_adapter/__init__.py
Normal file
40
scripts/service/amazon_report_adapter/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from jiangchang_skill_core import config
|
||||||
|
|
||||||
|
from service.amazon_report_adapter.base import DownloadSettlementAdapterBase, DownloadSettlementResult
|
||||||
|
from service.amazon_report_adapter.mock import MockDownloadSettlementAdapter
|
||||||
|
from service.amazon_report_adapter.simulator_rpa import SimulatorRpaDownloadSettlementAdapter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_adapter",
|
||||||
|
"DownloadSettlementAdapterBase",
|
||||||
|
"DownloadSettlementResult",
|
||||||
|
"MockDownloadSettlementAdapter",
|
||||||
|
"SimulatorRpaDownloadSettlementAdapter",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_adapter(*, artifacts_dir: Optional[str] = None) -> DownloadSettlementAdapterBase:
|
||||||
|
target = (config.get("OPENCLAW_TEST_TARGET") or "").strip().lower()
|
||||||
|
|
||||||
|
if target in ("mock", "unit"):
|
||||||
|
logger.info("target '%s': MockDownloadSettlementAdapter", target)
|
||||||
|
return MockDownloadSettlementAdapter()
|
||||||
|
|
||||||
|
if target == "real_rpa":
|
||||||
|
logger.warning("target real_rpa 未实现,回退 MockDownloadSettlementAdapter")
|
||||||
|
return MockDownloadSettlementAdapter()
|
||||||
|
|
||||||
|
if target and target != "simulator_rpa":
|
||||||
|
logger.warning("未知 target '%s',回退 simulator_rpa", target)
|
||||||
|
|
||||||
|
logger.info("target '%s': SimulatorRpaDownloadSettlementAdapter", target or "(unset=default)")
|
||||||
|
return SimulatorRpaDownloadSettlementAdapter(artifacts_dir=artifacts_dir)
|
||||||
34
scripts/service/amazon_report_adapter/base.py
Normal file
34
scripts/service/amazon_report_adapter/base.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Amazon 结算报表 adapter 基类与数据契约。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadSettlementResult:
|
||||||
|
ok: bool
|
||||||
|
job_id: Optional[str]
|
||||||
|
file_path: Optional[str]
|
||||||
|
filename: Optional[str]
|
||||||
|
date_from: str
|
||||||
|
date_to: str
|
||||||
|
seller_code: Optional[str] = None
|
||||||
|
error_msg: Optional[str] = None
|
||||||
|
artifacts: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadSettlementAdapterBase:
|
||||||
|
name: str = "base"
|
||||||
|
|
||||||
|
def download_settlement(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
date_from: str,
|
||||||
|
date_to: str,
|
||||||
|
seller_code: Optional[str] = None,
|
||||||
|
downloads_dir: str = "",
|
||||||
|
artifacts_dir: str = "",
|
||||||
|
) -> DownloadSettlementResult:
|
||||||
|
raise NotImplementedError
|
||||||
44
scripts/service/amazon_report_adapter/mock.py
Normal file
44
scripts/service/amazon_report_adapter/mock.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""mock / unit 档位:不启动浏览器,返回假 job_id 与假 xlsx 路径。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from service.amazon_report_adapter.base import DownloadSettlementAdapterBase, DownloadSettlementResult
|
||||||
|
|
||||||
|
|
||||||
|
class MockDownloadSettlementAdapter(DownloadSettlementAdapterBase):
|
||||||
|
name = "mock"
|
||||||
|
|
||||||
|
def download_settlement(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
date_from: str,
|
||||||
|
date_to: str,
|
||||||
|
seller_code: Optional[str] = None,
|
||||||
|
downloads_dir: str = "",
|
||||||
|
artifacts_dir: str = "",
|
||||||
|
) -> DownloadSettlementResult:
|
||||||
|
del artifacts_dir
|
||||||
|
os.makedirs(downloads_dir, exist_ok=True)
|
||||||
|
time.sleep(0.05)
|
||||||
|
ts = int(time.time())
|
||||||
|
job_id = f"MOCK-{ts}"
|
||||||
|
seller = seller_code or "MOCK-SELLER"
|
||||||
|
filename = f"settlement_{seller}_{date_from}_{date_to}_{ts}.xlsx"
|
||||||
|
file_path = os.path.join(downloads_dir, filename)
|
||||||
|
with open(file_path, "wb") as fh:
|
||||||
|
fh.write(b"PK\x03\x04mock-settlement-xlsx")
|
||||||
|
return DownloadSettlementResult(
|
||||||
|
ok=True,
|
||||||
|
job_id=job_id,
|
||||||
|
file_path=file_path,
|
||||||
|
filename=filename,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
seller_code=seller,
|
||||||
|
error_msg=None,
|
||||||
|
artifacts={"adapter": self.name},
|
||||||
|
)
|
||||||
356
scripts/service/amazon_report_adapter/simulator_rpa.py
Normal file
356
scripts/service/amazon_report_adapter/simulator_rpa.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"""Amazon 仿真 Reports 页 Playwright RPA:请求并下载结算报表 xlsx。
|
||||||
|
|
||||||
|
定位原则:优先 data-testid(F12 实测),禁止 el.value= / JS 跳转 / REST API 下载。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from jiangchang_skill_core import config
|
||||||
|
|
||||||
|
from service.account_client import (
|
||||||
|
AccountManagerError,
|
||||||
|
get_account_credential,
|
||||||
|
inject_account_manager_scripts_path,
|
||||||
|
pick_web_account,
|
||||||
|
release_lease,
|
||||||
|
)
|
||||||
|
from service.amazon_report_adapter.base import DownloadSettlementAdapterBase, DownloadSettlementResult
|
||||||
|
from service.browser_session import find_chrome_executable
|
||||||
|
from util.constants import (
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
JOB_POLL_INTERVAL_SEC,
|
||||||
|
JOB_POLL_TIMEOUT_SEC,
|
||||||
|
REPORTS_NAVIGATE_TIMEOUT_MS,
|
||||||
|
TARGET_PLATFORM,
|
||||||
|
resolve_amazon_sim_login_url,
|
||||||
|
resolve_amazon_sim_reports_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PAUSE_MIN_MS = 500
|
||||||
|
_PAUSE_MAX_MS = 2000
|
||||||
|
|
||||||
|
|
||||||
|
class RpaError(Exception):
|
||||||
|
def __init__(self, message: str, screenshot_path: Optional[str] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.screenshot_path = screenshot_path
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatorRpaDownloadSettlementAdapter(DownloadSettlementAdapterBase):
|
||||||
|
name = "simulator_rpa"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
reports_url: Optional[str] = None,
|
||||||
|
login_url: Optional[str] = None,
|
||||||
|
headless: Optional[bool] = None,
|
||||||
|
artifacts_dir: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.reports_url = (reports_url or resolve_amazon_sim_reports_url()).strip()
|
||||||
|
self.login_url = (login_url or resolve_amazon_sim_login_url()).strip()
|
||||||
|
if headless is None:
|
||||||
|
self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
||||||
|
else:
|
||||||
|
self.headless = headless
|
||||||
|
self.artifacts_dir = artifacts_dir
|
||||||
|
|
||||||
|
def download_settlement(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
date_from: str,
|
||||||
|
date_to: str,
|
||||||
|
seller_code: Optional[str] = None,
|
||||||
|
downloads_dir: str = "",
|
||||||
|
artifacts_dir: str = "",
|
||||||
|
) -> DownloadSettlementResult:
|
||||||
|
art_dir = artifacts_dir or self.artifacts_dir or ""
|
||||||
|
os.makedirs(downloads_dir, exist_ok=True)
|
||||||
|
if art_dir:
|
||||||
|
os.makedirs(art_dir, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
return DownloadSettlementResult(
|
||||||
|
ok=False,
|
||||||
|
job_id=None,
|
||||||
|
file_path=None,
|
||||||
|
filename=None,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
seller_code=seller_code,
|
||||||
|
error_msg=(
|
||||||
|
"playwright Python 包不可用。"
|
||||||
|
"生产/宿主运行由共享 runtime 提供;技能侧不要 pip install playwright。"
|
||||||
|
),
|
||||||
|
artifacts={"adapter": self.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not find_chrome_executable():
|
||||||
|
return DownloadSettlementResult(
|
||||||
|
ok=False,
|
||||||
|
job_id=None,
|
||||||
|
file_path=None,
|
||||||
|
filename=None,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
seller_code=seller_code,
|
||||||
|
error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。",
|
||||||
|
artifacts={"adapter": self.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
lease_token: Optional[str] = None
|
||||||
|
ctx = None
|
||||||
|
try:
|
||||||
|
account = pick_web_account(TARGET_PLATFORM)
|
||||||
|
lease_token = str(account.get("lease_token") or "").strip() or None
|
||||||
|
profile_dir = str(account.get("profile_dir") or "").strip()
|
||||||
|
account_id = account.get("id") or account.get("account_id")
|
||||||
|
|
||||||
|
credentials: dict = {}
|
||||||
|
if lease_token and account_id is not None:
|
||||||
|
try:
|
||||||
|
cred = get_account_credential(int(account_id), lease_token)
|
||||||
|
plaintext = cred.get("plaintext")
|
||||||
|
if plaintext:
|
||||||
|
credentials = {"plaintext": plaintext}
|
||||||
|
except AccountManagerError as exc:
|
||||||
|
logger.warning("get_credential_failed: %s", exc.message)
|
||||||
|
|
||||||
|
inject_account_manager_scripts_path()
|
||||||
|
from service.rpa_helpers import close_browser, ensure_logged_in, launch_browser_with_profile
|
||||||
|
|
||||||
|
login_start = str(account.get("url") or self.login_url).strip()
|
||||||
|
ctx, page = launch_browser_with_profile(profile_dir, headless=self.headless)
|
||||||
|
try:
|
||||||
|
if login_start:
|
||||||
|
page.goto(login_start, wait_until="domcontentloaded", timeout=REPORTS_NAVIGATE_TIMEOUT_MS)
|
||||||
|
login_result = ensure_logged_in(page, account, credentials=credentials or None)
|
||||||
|
if not login_result.ok:
|
||||||
|
sp = self._safe_screenshot(page, "login_failed", art_dir)
|
||||||
|
raise RpaError(login_result.message or "登录失败", screenshot_path=sp)
|
||||||
|
|
||||||
|
self._random_delay()
|
||||||
|
page.goto(self.reports_url, wait_until="domcontentloaded", timeout=REPORTS_NAVIGATE_TIMEOUT_MS)
|
||||||
|
job_id, file_path, filename = self._run_reports_flow(
|
||||||
|
page, date_from, date_to, seller_code, downloads_dir, art_dir
|
||||||
|
)
|
||||||
|
return DownloadSettlementResult(
|
||||||
|
ok=True,
|
||||||
|
job_id=job_id,
|
||||||
|
file_path=file_path,
|
||||||
|
filename=filename,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
seller_code=seller_code,
|
||||||
|
error_msg=None,
|
||||||
|
artifacts={"adapter": self.name},
|
||||||
|
)
|
||||||
|
except RpaError as exc:
|
||||||
|
arts = {"adapter": self.name}
|
||||||
|
if exc.screenshot_path:
|
||||||
|
arts["screenshot"] = exc.screenshot_path
|
||||||
|
return DownloadSettlementResult(
|
||||||
|
ok=False,
|
||||||
|
job_id=None,
|
||||||
|
file_path=None,
|
||||||
|
filename=None,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
seller_code=seller_code,
|
||||||
|
error_msg=str(exc),
|
||||||
|
artifacts=arts,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
sp = self._safe_screenshot(page, "unexpected_error", art_dir)
|
||||||
|
logger.exception("rpa_unexpected: %s", exc)
|
||||||
|
arts = {"adapter": self.name}
|
||||||
|
if sp:
|
||||||
|
arts["screenshot"] = sp
|
||||||
|
return DownloadSettlementResult(
|
||||||
|
ok=False,
|
||||||
|
job_id=None,
|
||||||
|
file_path=None,
|
||||||
|
filename=None,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
seller_code=seller_code,
|
||||||
|
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
|
||||||
|
artifacts=arts,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
close_browser(ctx)
|
||||||
|
except AccountManagerError as exc:
|
||||||
|
return DownloadSettlementResult(
|
||||||
|
ok=False,
|
||||||
|
job_id=None,
|
||||||
|
file_path=None,
|
||||||
|
filename=None,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
seller_code=seller_code,
|
||||||
|
error_msg=f"ERROR:{exc.code} {exc.message}",
|
||||||
|
artifacts={"adapter": self.name},
|
||||||
|
)
|
||||||
|
except Exception as outer:
|
||||||
|
return DownloadSettlementResult(
|
||||||
|
ok=False,
|
||||||
|
job_id=None,
|
||||||
|
file_path=None,
|
||||||
|
filename=None,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
seller_code=seller_code,
|
||||||
|
error_msg=f"启动浏览器失败:{outer}",
|
||||||
|
artifacts={"adapter": self.name},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
release_lease(lease_token)
|
||||||
|
|
||||||
|
def _random_delay(self) -> None:
|
||||||
|
lo = float(config.get("STEP_DELAY_MIN") or "1.0")
|
||||||
|
hi = float(config.get("STEP_DELAY_MAX") or "5.0")
|
||||||
|
if hi < lo:
|
||||||
|
hi = lo
|
||||||
|
time.sleep(random.uniform(lo, hi))
|
||||||
|
|
||||||
|
def _switch_to_payments_tab(self, page) -> None:
|
||||||
|
"""Settlement 在 Payments 分类下;默认 Tab 为 Fulfillment。"""
|
||||||
|
tab = page.locator('[data-testid="reports-tab-payments"]')
|
||||||
|
if tab.count() > 0:
|
||||||
|
self._random_delay()
|
||||||
|
tab.first.click()
|
||||||
|
return
|
||||||
|
btn = page.get_by_role("button", name="Payments")
|
||||||
|
if btn.count() > 0:
|
||||||
|
self._random_delay()
|
||||||
|
btn.first.click()
|
||||||
|
return
|
||||||
|
raise RpaError(
|
||||||
|
"未找到 Payments 分类 Tab(reports-tab-payments 或 Payments 按钮不可见)。"
|
||||||
|
"线上仿真 Reports 页可能尚未渲染 Tab 切换 UI,见 development/REQUIREMENTS.md §9。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_reports_flow(
|
||||||
|
self,
|
||||||
|
page,
|
||||||
|
date_from: str,
|
||||||
|
date_to: str,
|
||||||
|
seller_code: Optional[str],
|
||||||
|
downloads_dir: str,
|
||||||
|
art_dir: str,
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
try:
|
||||||
|
page.wait_for_selector('[data-testid="reports-page-root"]', timeout=DEFAULT_TIMEOUT_MS)
|
||||||
|
except Exception as exc:
|
||||||
|
sp = self._safe_screenshot(page, "reports_page_not_ready", art_dir)
|
||||||
|
raise RpaError(f"Reports 页未就绪:{exc}", screenshot_path=sp) from exc
|
||||||
|
|
||||||
|
self._random_delay()
|
||||||
|
self._switch_to_payments_tab(page)
|
||||||
|
|
||||||
|
try:
|
||||||
|
settlement = page.locator('[data-testid="reports-type-item-settlement"]')
|
||||||
|
settlement.wait_for(timeout=DEFAULT_TIMEOUT_MS)
|
||||||
|
self._random_delay()
|
||||||
|
settlement.click()
|
||||||
|
except Exception as exc:
|
||||||
|
sp = self._safe_screenshot(page, "settlement_type_not_visible", art_dir)
|
||||||
|
raise RpaError(f"未找到结算报表类型按钮:{exc}", screenshot_path=sp) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.locator('[data-testid="reports-form-date-from"]').fill(date_from)
|
||||||
|
self._random_delay()
|
||||||
|
page.locator('[data-testid="reports-form-date-to"]').fill(date_to)
|
||||||
|
except Exception as exc:
|
||||||
|
sp = self._safe_screenshot(page, "fill_dates_failed", art_dir)
|
||||||
|
raise RpaError(f"填写日期失败:{exc}", screenshot_path=sp) from exc
|
||||||
|
|
||||||
|
if seller_code:
|
||||||
|
try:
|
||||||
|
page.locator('[data-testid="reports-form-seller-select"]').select_option(value=seller_code)
|
||||||
|
self._random_delay()
|
||||||
|
except Exception as exc:
|
||||||
|
sp = self._safe_screenshot(page, "select_seller_failed", art_dir)
|
||||||
|
raise RpaError(f"选择店铺失败:{exc}", screenshot_path=sp) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._random_delay()
|
||||||
|
page.locator('[data-testid="reports-form-submit"]').click()
|
||||||
|
except Exception as exc:
|
||||||
|
sp = self._safe_screenshot(page, "submit_failed", art_dir)
|
||||||
|
raise RpaError(f"点击请求报表失败:{exc}", screenshot_path=sp) from exc
|
||||||
|
|
||||||
|
job_id = self._wait_for_done_job(page, art_dir)
|
||||||
|
file_path, filename = self._download_xlsx(page, job_id, downloads_dir, art_dir)
|
||||||
|
return job_id, file_path, filename
|
||||||
|
|
||||||
|
def _wait_for_done_job(self, page, art_dir: str) -> str:
|
||||||
|
deadline = time.time() + JOB_POLL_TIMEOUT_SEC
|
||||||
|
last_err: Optional[Exception] = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
done = page.locator('[data-testid^="reports-jobs-status-"][data-status="DONE"]')
|
||||||
|
if done.count() > 0:
|
||||||
|
testid = done.first.get_attribute("data-testid") or ""
|
||||||
|
prefix = "reports-jobs-status-"
|
||||||
|
if testid.startswith(prefix):
|
||||||
|
return testid[len(prefix) :]
|
||||||
|
return testid
|
||||||
|
except Exception as exc:
|
||||||
|
last_err = exc
|
||||||
|
time.sleep(JOB_POLL_INTERVAL_SEC)
|
||||||
|
sp = self._safe_screenshot(page, "job_poll_timeout", art_dir)
|
||||||
|
msg = f"等待报表生成超时({JOB_POLL_TIMEOUT_SEC}s)"
|
||||||
|
if last_err:
|
||||||
|
msg = f"{msg}:{last_err}"
|
||||||
|
raise RpaError(msg, screenshot_path=sp)
|
||||||
|
|
||||||
|
def _download_xlsx(
|
||||||
|
self,
|
||||||
|
page,
|
||||||
|
job_id: str,
|
||||||
|
downloads_dir: str,
|
||||||
|
art_dir: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
download_sel = f'[data-testid="reports-jobs-download-{job_id}"]'
|
||||||
|
try:
|
||||||
|
link = page.locator(download_sel)
|
||||||
|
if link.count() == 0:
|
||||||
|
link = page.locator('[data-testid^="reports-jobs-download-"]').first
|
||||||
|
link.wait_for(timeout=DEFAULT_TIMEOUT_MS)
|
||||||
|
suggested = link.get_attribute("data-filename") or f"settlement_{job_id}.xlsx"
|
||||||
|
self._random_delay()
|
||||||
|
with page.expect_download(timeout=REPORTS_NAVIGATE_TIMEOUT_MS) as dl_info:
|
||||||
|
link.click()
|
||||||
|
download = dl_info.value
|
||||||
|
filename = download.suggested_filename or suggested
|
||||||
|
file_path = os.path.join(downloads_dir, filename)
|
||||||
|
download.save_as(file_path)
|
||||||
|
logger.info("download_saved path=%s job_id=%s", file_path, job_id)
|
||||||
|
return file_path, filename
|
||||||
|
except Exception as exc:
|
||||||
|
sp = self._safe_screenshot(page, "download_failed", art_dir)
|
||||||
|
raise RpaError(f"下载 xlsx 失败:{exc}", screenshot_path=sp) from exc
|
||||||
|
|
||||||
|
def _safe_screenshot(self, page, tag: str, artifacts_dir: str) -> Optional[str]:
|
||||||
|
if not artifacts_dir:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.makedirs(artifacts_dir, exist_ok=True)
|
||||||
|
path = os.path.join(artifacts_dir, f"{tag}_{int(time.time())}.png")
|
||||||
|
page.screenshot(path=path, full_page=True)
|
||||||
|
logger.info("screenshot_saved path=%s", path)
|
||||||
|
return path
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("screenshot_failed tag=%s err=%s", tag, exc)
|
||||||
|
return None
|
||||||
136
scripts/service/browser_session.py
Normal file
136
scripts/service/browser_session.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""浏览器会话启动(系统 Chrome/Edge;URL 仅通过 page.goto 打开)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Generator, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_PAGE_TIMEOUT_MS = 15_000
|
||||||
|
GOTO_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
CHROME_LAUNCH_ARGS: List[str] = [
|
||||||
|
"--start-maximized",
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
]
|
||||||
|
|
||||||
|
STEALTH_INIT_SCRIPT = """
|
||||||
|
(() => {
|
||||||
|
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def chrome_launch_args() -> List[str]:
|
||||||
|
return list(CHROME_LAUNCH_ARGS)
|
||||||
|
|
||||||
|
|
||||||
|
def _headless_from_env() -> bool:
|
||||||
|
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||||
|
return v in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_chrome_executable() -> str | None:
|
||||||
|
try:
|
||||||
|
from jiangchang_skill_core.runtime_env import find_chrome_executable
|
||||||
|
|
||||||
|
chrome = find_chrome_executable()
|
||||||
|
if chrome and os.path.isfile(chrome):
|
||||||
|
return chrome
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
|
||||||
|
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
|
||||||
|
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
|
||||||
|
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if path and os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_node_options() -> None:
|
||||||
|
os.environ.pop("NODE_OPTIONS", None)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def browser_session(
|
||||||
|
start_url: str,
|
||||||
|
*,
|
||||||
|
profile_dir: Optional[str] = None,
|
||||||
|
headless: Optional[bool] = None,
|
||||||
|
) -> Generator[Any, None, None]:
|
||||||
|
"""
|
||||||
|
同步 Playwright 浏览器会话。
|
||||||
|
|
||||||
|
- Playwright Python 包由宿主共享 runtime 提供;技能侧不要 playwright install。
|
||||||
|
- launch args 仅放 Chrome 参数,不含 URL。
|
||||||
|
- 流程:launch → new_page → goto(start_url)
|
||||||
|
"""
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
_clear_node_options()
|
||||||
|
hl = headless if headless is not None else _headless_from_env()
|
||||||
|
chrome = _find_chrome_executable()
|
||||||
|
if not chrome:
|
||||||
|
raise RuntimeError("未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。")
|
||||||
|
|
||||||
|
args = chrome_launch_args()
|
||||||
|
logger.info(
|
||||||
|
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
|
||||||
|
chrome,
|
||||||
|
profile_dir or "",
|
||||||
|
start_url,
|
||||||
|
args,
|
||||||
|
hl,
|
||||||
|
)
|
||||||
|
|
||||||
|
browser_holder = None
|
||||||
|
context = None
|
||||||
|
with sync_playwright() as pw:
|
||||||
|
try:
|
||||||
|
if profile_dir:
|
||||||
|
context = pw.chromium.launch_persistent_context(
|
||||||
|
user_data_dir=profile_dir,
|
||||||
|
headless=hl,
|
||||||
|
executable_path=chrome,
|
||||||
|
locale="zh-CN",
|
||||||
|
no_viewport=True,
|
||||||
|
args=args,
|
||||||
|
ignore_default_args=["--enable-automation"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
browser_holder = pw.chromium.launch(
|
||||||
|
headless=hl,
|
||||||
|
executable_path=chrome,
|
||||||
|
args=args,
|
||||||
|
ignore_default_args=["--enable-automation"],
|
||||||
|
)
|
||||||
|
context = browser_holder.new_context(no_viewport=True, locale="zh-CN")
|
||||||
|
|
||||||
|
context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||||
|
page = context.new_page()
|
||||||
|
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
||||||
|
page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
|
||||||
|
yield page
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if context is not None:
|
||||||
|
context.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("browser_context_close_failed: %s", exc)
|
||||||
|
if browser_holder is not None:
|
||||||
|
try:
|
||||||
|
browser_holder.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("browser_close_failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def find_chrome_executable() -> str | None:
|
||||||
|
return _find_chrome_executable()
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
"""任务编排、日志查询模板。
|
"""任务编排:下载 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(
|
||||||
|
|||||||
@@ -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
34
scripts/util/logging.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""结构化日志与敏感字段脱敏。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from util.constants import LOG_LOGGER_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str | None = None) -> logging.Logger:
|
||||||
|
return logging.getLogger(name or LOG_LOGGER_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str:
|
||||||
|
s = (value or "").strip()
|
||||||
|
if len(s) >= 11 and s.isdigit():
|
||||||
|
return s[:3] + "****" + s[-4:]
|
||||||
|
if len(s) > keep_start + keep_end:
|
||||||
|
return s[:keep_start] + "****" + s[-keep_end:]
|
||||||
|
return "****"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_log_value(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
text = str(value)
|
||||||
|
if re.fullmatch(r"\d{7,}", text):
|
||||||
|
return mask_text(text)
|
||||||
|
if "@" in text and len(text) > 6:
|
||||||
|
local, _, domain = text.partition("@")
|
||||||
|
return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}"
|
||||||
|
return text
|
||||||
@@ -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__":
|
||||||
|
|||||||
82
tests/test_adapter_dispatch.py
Normal file
82
tests/test_adapter_dispatch.py
Normal 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()
|
||||||
@@ -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:
|
||||||
"""占位 run:RpaVideoSession 示范 + 模板提示;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__":
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user