diff --git a/.env.example b/.env.example index f30adbf..56c454e 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,10 @@ -# ── 运行模式 / adapter 档位(见 references/ADAPTER.md)── -OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_api | real_rpa +# ── 运行模式 / adapter 档位(见 development/ADAPTER.md)── +OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_rpa -# ── 目标系统 ── -TARGET_BASE_URL=https://sandbox.jc2009.com # 仿真/生产地址,可被进程环境变量覆盖 - -# ── 默认账号(仅非敏感标识;密码走 account-manager)── -DEFAULT_LOGIN_ID=04110001 +# ── Amazon 仿真(线上 sandbox,禁止 localhost:5173)── +AMAZON_SIM_BASE_URL=https://sandbox.jc2009.com/industries/ecommerce/amazon +AMAZON_SIM_REPORTS_URL=https://sandbox.jc2009.com/industries/ecommerce/amazon/seller/reports +AMAZON_SIM_LOGIN_URL=https://sandbox.jc2009.com/industries/ecommerce/amazon/login # ── 浏览器 / RPA ── OPENCLAW_BROWSER_HEADLESS=0 # 0=有头(默认) 1=无头(CI) diff --git a/README.md b/README.md index 08b42c5..ff145a6 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,76 @@ --- -description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。" +description: "自动从 Amazon 卖家中心下载结算报表,省去重复登录与手工导出。" --- -> **模板说明(复制后删除本段)** -> 本文是技能市场详情与普通用户说明,**不是** Agent 参考索引、CLI 契约或开发文档。 -> frontmatter 的 `description` 必须是用户能看懂的一句话能力描述;**不得**写成 CLI、schema、references、development、契约、数据结构、开发规范或 Agent 参考索引。 -> 正文只回答用户关心的问题,**不要**把 Playwright、DOM、Python、RPA、references、development、schema 等作为面向用户的主说明内容;若业务上必须出现技术词,须用用户能理解的方式解释,且不能喧宾夺主。 +# Amazon 结算报表自动下载 -# 【技能名称】 - -> 用一两句话说明这个技能帮助用户完成什么工作。 -> 这里不要解释技术实现,要从用户的业务目标出发描述价值。 +帮你从 Amazon 卖家中心自动申请并下载结算报表(Settlement),保存为 Excel 文件,并记录每次下载结果。 --- ## 这个技能能帮你做什么 -用 3 到 6 条业务能力说明技能价值,例如: - -- 【自动整理/查询/生成/同步/核对/提交】某类业务数据 -- 帮你减少重复操作,降低人工录入或核对成本 -- 在任务完成后给出清晰的处理结果、失败原因或后续建议 +- 按你指定的日期范围,自动申请结算报表 +- 等待报表生成完成后自动下载,保存到本地 +- 记录每次下载是否成功、文件路径与失败原因 +- 适合跨境财务实训中定期拉取 Amazon 结算数据 --- ## 适合什么场景 -列出典型使用场景。请使用用户熟悉的业务语言,例如: - -- 每天需要重复处理同一类订单、单据、报表或客户资料 -- 需要从多个系统中查询信息并汇总结果 -- 需要按固定规则检查数据是否完整、是否异常 -- 需要把处理结果保存为文件、记录或任务报告 +- 月末或周度需要从 Amazon 导出结算报表做对账 +- 实训/教学中反复练习「登录 → 选日期 → 下载报表」流程 +- 希望 Agent 代为完成重复性的报表下载操作 --- ## 使用前需要准备什么 -说明用户在使用前要准备的东西,例如: +- 已在账号管理中添加 **Amazon 仿真卖家** 账号(平台标识 `amazon_sim`),且状态为可用 +- 账号已完成登录(或具备自动登录凭据) +- 确认要下载的**起止日期**(例如 2025-12-01 至 2025-12-31) +- 可选:指定店铺代码;不指定时使用页面默认第一个店铺 -- 相关账号已登录,且具备查询、导出或提交权限 -- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息 -- 如果涉及外部系统,请确认网络、账号、验证码或审批流程可正常使用 +### 添加 Amazon 仿真账号(若尚未配置) + +若 `amazon_sim` 平台尚未在 account-manager 中注册,需由运维先注册平台;然后执行: + +```text +python D:\OpenClaw\client-commons\account-manager\scripts\main.py account add-web \ + --platform amazon_sim \ + --login-id <邮箱> \ + --url https://sandbox.jc2009.com/industries/ecommerce/amazon/login \ + --environment simulator \ + --auth-strategy password_auto +``` + +建议 `extra_json`:`{"logged_in_url_pattern": "/seller"}` + +联调前须存在 **active** 账号且带 **profile_dir**(浏览器登录态目录)。 --- ## 你可以这样告诉 Agent -用自然语言示例展示用户可以怎么发起任务,例如: +> 「帮我下载 2025 年 12 月的 Amazon 结算报表。」 -> “帮我查询今天的【业务对象】,整理成表格。” -> -> “帮我核对这些【数据/单据】是否有异常,并告诉我问题在哪里。” -> -> “帮我把【时间范围】内的【业务数据】导出并生成处理报告。” -> -> “帮我根据这个文件里的内容,逐条完成【业务动作】。” +> 「从 Amazon 卖家中心导出 12 月 1 日到 12 月 31 日的结算单。」 -> 注意:示例应是自然语言,不要写命令行命令。 +> 「用 AMA1OF6T 店铺下载上周的 settlement 报表。」 --- ## 执行后你会得到什么 -说明用户会看到什么结果,例如: - -- 任务是否成功完成 -- 处理了多少条数据 -- 哪些内容成功、哪些失败 -- 失败原因和建议处理方式 -- 生成的文件、表格、报告或记录 +- 成功时:本地 xlsx 文件路径、任务编号、日期范围(JSON 摘要) +- 失败时:错误说明;必要时可在数据目录查看失败截图 +- 任务日志中可查询历史下载记录 --- ## 注意事项 -用用户能理解的方式说明边界和风险,例如: - -- 请确认输入范围准确,避免处理错数据 -- 涉及提交、付款、删除、审批等高风险动作时,技能应在执行前请求确认 -- 如果外部系统登录失效、权限不足或页面变化,任务可能需要用户介入 -- 不建议在未核对数据的情况下直接执行批量操作 - ---- - -## 常见问题 - -### 为什么任务没有查到数据? - -> 可能是时间范围、关键词、账号权限或系统筛选条件不正确。请先确认输入信息是否准确。 - -### 为什么任务执行到一半需要我操作? - -> 可能遇到了登录、验证码、权限确认、二次审批或系统异常。请根据 Agent 的提示完成必要操作。 - -### 结果文件在哪里查看? - -> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。 +- 请确认日期范围正确,避免下载错期间的数据 +- 仿真环境与真实 Amazon 卖家中心界面类似,但数据为实训用途 +- 本技能仅下载报表文件,**不会**自动解析 Excel 内容或入账 diff --git a/SKILL.md b/SKILL.md index 7783100..4294a9c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,101 +1,54 @@ --- -name: 技能开发模板(通用业务版) -description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.32 +name: Amazon 结算报表自动下载 +description: "在 Amazon 卖家中心自动请求并下载结算报表(Settlement)" +version: 1.0.0 author: 深圳匠厂科技有限公司 metadata: openclaw: - slug: your-skill-slug + slug: download-settlement-report-amazon platform_kit_min_version: "1.0.17" - emoji: "📦" - category: "通用" + emoji: "📊" + category: "跨境电商" developer_ids: - 10032 - - 12428 + - 12500 allowed-tools: - bash +dependencies: + required: + - account-manager --- -# 技能开发模板(skill-template) +# Amazon 结算报表自动下载 -这是一个**用于复制的新技能模板**,不是业务 skill 本身。复制后请替换占位内容,实现你的真实业务逻辑。 +在 Amazon 卖家仿真中心自动请求并下载结算报表(Settlement),产出 xlsx 文件并写入任务日志。 ## 面向用户问答(LLM 规则) - 本文(`SKILL.md`)是 LLM / OpenClaw 平台读取的技能入口,**不是**用户市场说明。 -- 根目录 `README.md` 是面向普通用户的说明。 +- 根目录 `README.md` 是面向普通用户的说明(用户市场详情页主来源)。 -当用户询问以下问题时,**必须优先读取**根目录 `README.md`,并用用户能理解的业务语言回答: - -- 这个技能是做什么的 -- 这个技能怎么用 -- 适合什么场景 -- 使用前要准备什么 -- 执行后会得到什么 -- 能不能帮我完成某个业务任务 +当用户询问技能用途、场景、准备事项、结果形态等业务问题时,**必须优先读取**根目录 `README.md`,并用用户能理解的业务语言回答。 - **不要**把 `SKILL.md`、`references/`、`development/` 中的技术细节直接作为用户回答。 -- **只有当**用户明确询问命令、参数、输出结构、开发、调试、集成或排错时,才读取 `references/` 或 `development/`。 -- 若 `README.md` 与 `SKILL.md` / `references` 表述冲突:对用户展示与市场说明以 `README.md` 为准;对执行契约、CLI、schema、运行约束以 `SKILL.md` / `references` 为准。 -- 回答用户时**不要**暴露 Playwright、DOM、Python、RPA 等实现细节,除非用户明确询问技术实现。 -- 用户未问技术实现时,**不要**讨论 slug 命名规则;开发者问命名或 slug 时,读取 `development/NAMING.md`。 - -## 文档分工 - -| 文档 | 读者 | 用途 | -|------|------|------| -| 根目录 `README.md` | 普通用户 | 技能市场详情页说明(`metadata.readme_md` 主来源) | -| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 | -| `references/` | Agent 编排/调用 | 渐进式加载:CLI 契约、字段 schema 等 | -| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、技术规范 | - -## 目录约定 - -复制后建议保留: - -- `assets/`、`development/`、`evals/`、`references/`、`scripts/`、`tests/` -- CLI 入口固定为 `scripts/main.py` -- 业务逻辑按 `cli / db / service / util` 分层 - -## 最小命令 - -```bash -python {baseDir}/scripts/main.py health -python {baseDir}/scripts/main.py config-path -python {baseDir}/scripts/main.py version -``` - -配置:仓库 `.env.example` 为模板;用户 `.env` 在 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`,启动时自动 bootstrap。优先级:进程环境变量 > 用户 `.env` > `.env.example`。 +- 用户未问技术实现时,**不要**讨论 Playwright、RPA 等细节。 ## 运行依赖 - Python 运行环境由匠厂宿主注入**共享 runtime**:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。 - 公共能力来自共享 runtime 安装的 `jiangchang-platform-kit>=1.0.17`(`jiangchang_skill_core` 包);**不要 vendor** `scripts/jiangchang_skill_core/`,新技能不得在仓库内保留该目录副本。 -- config、logging、runtime_env、rpa、media-assets、video_session、runtime_diagnostics 等均从共享 venv 的 `jiangchang_skill_core` import,而非技能目录副本。 -- 根目录 `requirements.txt` **只声明技能特有** Python 三方依赖;`jiangchang-platform-kit`、`playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。 -- `metadata.openclaw.platform_kit_min_version`(当前 `1.0.17`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是**技能 pip 依赖声明。 -- Skill 代码**不要**自行 `pip install`;系统级依赖(如 VC++ Runtime)仅在 `health` / preflight 中提示用户安装。 -- `health` 使用 `collect_runtime_diagnostics` 输出统一 runtime 诊断(只读,不下载/修复 media-assets)。 +- config、logging、runtime_env、rpa、video_session 等均从共享 venv 的 `jiangchang_skill_core` import。 +- 根目录 `requirements.txt` **只声明技能特有** Python 三方依赖;`playwright` 等公共能力由宿主共享 runtime 提供。 +- Skill 代码**不要**自行 `pip install playwright`;**不要**在技能内 `playwright install`。 +- 浏览器 RPA 档位:`OPENCLAW_TEST_TARGET=simulator_rpa`;CI 默认 `mock`。 +- 账号由 `account-manager` 提供(platform_key=`amazon_sim`)。 -## 定制入口 +## 最小命令 -1. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词。 -2. 按 `development/DEVELOPMENT.md` 完成开发与文档定制。 -3. 用户市场说明写入根 `README.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`。 -4. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。 -5. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`(元数据表 `_jiangchang_*` + `CREATE TABLE` 物理顺序)。 +```bash +python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py version +python {baseDir}/scripts/main.py run --date-from 2025-12-01 --date-to 2025-12-31 +``` -## 技能命名与 slug - -- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。 -- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case;`name`、`description`、根 `README.md`:**中文**。 -- 复制为新业务技能前**必须**阅读 `development/NAMING.md`。 -- `name`(frontmatter):**用户可见的中文业务名称**;`description` 是说明,不是名称。 -- `metadata.openclaw.slug`:**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug,不使用中文。 -- 复制后全局替换 `your-skill-slug`;正式技能不得保留英文占位 `name` 或 slug。 - -## 平台元数据 - -- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。 -- 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。 -- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`。 +执行契约见 `references/CLI.md`。 diff --git a/assets/examples/version-response.json b/assets/examples/version-response.json index 8b5992a..9cc75d1 100644 --- a/assets/examples/version-response.json +++ b/assets/examples/version-response.json @@ -1,4 +1,4 @@ { "version": "1.0.0", - "skill": "your-skill-slug" + "skill": "download-settlement-report-amazon" } diff --git a/development/REQUIREMENTS.md b/development/REQUIREMENTS.md index 5898945..a2ceb8d 100644 --- a/development/REQUIREMENTS.md +++ b/development/REQUIREMENTS.md @@ -1,259 +1,200 @@ -# 技能需求文档模板 - -这份文档用于给技术人员、产品人员和实施人员统一描述某个 skill 的研发需求。 - -建议原则: - -- 一个 skill 对应一份主需求文档 -- 需求先写清楚,再进入开发 -- 文档描述“要做什么”和“做到什么程度”,不要在这里堆实现细节 -- 实现方式、代码结构、发布流程,分别放到 `DEVELOPMENT.md`、`references/`、`development/` 下的技术规范 - -如果你是从 `skill-template` 复制新技能,请复制本文件后,把下面所有占位内容替换成你的真实项目内容。 - ---- - # REQUIREMENTS ## 0. 技能标识(复制 REQUIREMENTS 时必填) -- 拟定 slug(英文,符合 `development/NAMING.md`): -- 中文 name: -- 中文 description(一句话): -- account-manager platform_key(如有): -- 命名形态:标准型 / scope 型 +| 项 | 值 | +|----|-----| +| slug | `download-settlement-report-amazon` | +| 中文 name | Amazon 结算报表自动下载 | +| 中文 description | 在 Amazon 卖家中心自动请求并下载结算报表(Settlement) | +| account-manager platform_key | `amazon_sim`(若尚未注册,见 README / 本文 §7) | +| 命名形态 | 标准型(verb-noun-platform) | ## 1. 文档目标 -说明这份需求文档的作用,以及它解决什么问题。 - -模板写法: - -- 本文档用于明确 `your-skill-slug` 的研发范围、交付标准、依赖关系和验收要求。 +- 本文档用于明确 `download-settlement-report-amazon` 的研发范围、交付标准、依赖关系和验收要求。 - 技术人员应以本文件作为开发范围依据,以避免实现偏差、范围蔓延或理解不一致。 +- 本文档同时作为**老师/实训样板**:学员可复制本技能结构开发同类 Amazon / 跨境报表技能。 ## 2. 业务背景 -说明为什么要做这个 skill,它服务什么业务场景,解决什么实际问题。 - -模板写法: - -- 当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。 -- 现有流程主要依赖人工处理,存在 `<效率低 / 容易出错 / 不可批量 / 不可追踪>` 等问题。 -- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 `task_logs` 便于追踪。 - -填写提示: - -- `<业务场景>`:例如“每日批量导入外部系统结果”“Agent 触发的单次查询” -- `<输入数据>`:例如“关键词列表”“批次 ID”“账号标识” -- `<核心动作>`:例如“调用 adapter 提交”“采集并入库”“对账摘要生成” -- `<输出结果>`:例如“结构化 JSON”“任务摘要”“本地 SQLite 记录” +- 跨境财务实训需定期从 Amazon 下载**结算报表(Settlement)**,用于对账、利润分析与凭证附件。 +- 人工操作路径:登录卖家中心 → Reports → **Payments** 分类 → 选择 Settlement → 填写日期 → 请求 → 等待生成 → 下载 xlsx。 +- 本技能用 **Playwright RPA** 在仿真环境 `https://sandbox.jc2009.com/industries/ecommerce/amazon` 自动完成上述流程,产出 xlsx 并写入 `task_logs`(`task_type=download_settlement`)。 +- **禁止**调用 `GET/POST /api/ecommerce/amazon/reports` 等 REST API 下载;必须走浏览器页面操作。 ## 3. 开发目标 -说明本次开发希望最终交付什么结果。 - -模板写法(按 skill 类型勾选/改写): - -- 解析并校验 `<输入数据>` 的关键字段 -- 通过 adapter 完成 mock / simulator / real 档位下的 `<核心动作>` -- 输出结构化结果(stdout JSON 或固定文本 + 机读字段) -- 写入 `task_logs`(及必要的业务表,见 `references/SCHEMA.md`) -- 支持失败可诊断(稳定错误码、`ERROR:` 前缀、必要截图/日志) -- 完成 CLI 入口与 `health` / `version` 最小可运行验证 -- 完成正式环境发布与安装验证(如适用) +- 提供标准 CLI:`health`、`version`、`run`、`logs`、`log-get` +- 实现四档 adapter 中的 **mock**(CI 默认)与 **simulator_rpa**(联调) +- 集成 **account-manager**:`pick-web --platform amazon_sim --lease` 获取 `profile_dir` 与租约 +- 登录:`launch_browser_with_profile` + `ensure_logged_in`(account-manager `rpa_helpers`) +- 下载文件保存至 `{skill_data_dir}/downloads/`;失败截图至 `{skill_data_dir}/rpa-artifacts/{batch_id}/` +- 可选 `RpaVideoSession` 录屏(中文步骤标题) ## 4. 功能范围 -说明这次开发明确要实现哪些功能。 - -模板写法: - -- 支持 `<命令1>`(如 `run`) -- 支持 `<命令2>`(如 `logs` / `log-get`) -- 支持读取 `<数据来源>`(兄弟 skill / 本地 DB / 外部 API) -- 支持执行 `<核心业务动作>` -- 支持记录 `<日志 / 状态 / 结果>` - -建议将功能拆成“必须实现”和“可后续扩展”。 - ### 必须实现 -- 支持 `python scripts/main.py health` -- 支持 `python scripts/main.py version` -- 支持 `python scripts/main.py ` -- 支持核心业务编排(HTTP / 批处理 / 可选 RPA,按四档 adapter 选型) -- 支持写入任务日志(`task_logs`) +- `python scripts/main.py health` +- `python scripts/main.py version` +- `python scripts/main.py run --date-from YYYY-MM-DD --date-to YYYY-MM-DD [--seller-code CODE]` +- Playwright RPA(`OPENCLAW_TEST_TARGET=simulator_rpa`) +- mock 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. 非功能要求 -说明除功能外,对稳定性、可维护性、目录规范、日志、编码等方面的要求。 - -- 目录结构必须符合当前 `skill-template` 规范 -- 入口必须统一为 `scripts/main.py` -- 用户说明写在根 `README.md`;LLM/运行契约写在 `SKILL.md`;开发规范在 `development/` -- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:` -- Windows 环境下需保证 UTF-8 输出兼容 -- 必须具备基本日志能力;敏感字段脱敏 -- RPA 类 skill:Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install`;URL 不要放进 `launch_persistent_context` 的 `args` +- 目录结构符合 `skill-template` 规范;入口 `scripts/main.py` +- Playwright 由宿主共享 runtime 提供;技能侧不要 `playwright install` +- URL **不要**放进 `launch_persistent_context(args=[...])`;Reports 页通过 `page.goto(REPORTS_URL)` 打开 +- 元素定位优先 `data-testid`(F12 原则) +- 步骤间 `random_delay`;禁止 `el.value=`、JS 跳转 +- Windows UTF-8 输出;错误前缀 `ERROR:` ## 6. 输入输出要求 ### 输入 -填写本 skill 实际接受的参数与数据来源: +| 参数 | 必填 | 默认 | 说明 | +|------|------|------|------| +| `--date-from` | 是* | `2025-12-01` | 报表起始日期 | +| `--date-to` | 是* | `2025-12-31` | 报表结束日期 | +| `--seller-code` | 否 | 页面第一个 seller | 店铺代码,如 `AMA1OF6T` | -- `<参数名>`:含义、是否必填、默认值 -- `<依赖系统>` 返回的结构化数据(如有) -- 环境变量 / `.env` 中的关键配置(引用 `CONFIG.md`) +\* CLI 提供默认值,业务上仍须为合法 `YYYY-MM-DD`。 -### 输出 +环境变量见 `.env.example`:`AMAZON_SIM_BASE_URL`、`AMAZON_SIM_REPORTS_URL`、`OPENCLAW_TEST_TARGET` 等。 -- 成功结果(stdout / JSON 字段说明) -- 错误输出(`ERROR:` 稳定码 + 中文 message) -- 数据库记录(表名、关键字段,见 `references/SCHEMA.md`) -- 可选:RPA 截图/视频路径(artifacts) +### 成功输出(stdout JSON) -## 7. 依赖的兄弟技能或外部系统 - -### 兄弟技能依赖 - -- ``:`<用途>`(例如 account-manager 提供 profile_dir / 租约) - -### 外部系统依赖 - -- `<目标系统>`:`<用途>`(HTTP API / 仿真 sandbox / 真实网页) -- 浏览器(如适用):系统 Chrome/Edge,非 Playwright 内置 Chromium -- Gitea / 匠厂客户端:发布与正式环境验证 - -### Python 包依赖(requirements.txt) - -- 本技能若需**特有** Python 三方包,写入根目录 `requirements.txt`。 -- `jiangchang-platform-kit`、`playwright` 等公共能力由宿主共享 runtime 提供,**不要**写入技能 requirements。 -- `SKILL.md` 的 `platform_kit_min_version` 是运行契约/兼容性声明,**不是** pip 依赖声明。 -- 版本须尽量收窄(如 `requests>=2.31.0,<3`),避免共享 venv 冲突。 -- **不要**把系统运行时组件写进 requirements.txt;在 `health` / preflight 中检测并提示。 - -## 8. 不在本次范围内的内容 - -明确本次“不做什么”,避免范围蔓延: - -- 本次不实现 `<功能A>` -- 本次不处理 `<平台B>` / `<档位>` -- 本次不做 `<高级能力>` -- 本次不兼容旧模板历史结构(`docs/`、`optional/`、`skill_main.py` 等) - -## 9. 验收标准 - -什么情况下才算开发完成: - -- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致 -- `health`、`version` 命令执行正常 -- 主命令(如 `run`)在 mock / simulator 档位可重复验证 -- `python tests/run_tests.py -v` 必跑测试全部通过 -- `task_logs` 写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范) -- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name` -- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order` -- `tests/test_display_metadata.py` 通过 -- 真实联调(如有)放在 `tests/integration/`,且默认套件不包含真实外联 -- 发布后 Gitea 工作流成功;匠厂技能市场可见最新版本;安装后可在“新建任务”中调用 - -## 10. 开发注意事项 - -- 只修改当前 skill 仓库,不要改动无关兄弟项目 -- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md` -- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`) -- 发布前完成本地验证、工作流验证和正式环境安装验证 - -## 11. 变更记录 - -| 日期 | 版本 | 变更人 | 变更内容 | -|------|------|--------|----------| -| YYYY-MM-DD | v1.0 | `<姓名>` | 初版需求文档 | - ---- - -## 建议使用方式 - -1. 先写 `REQUIREMENTS.md` -2. 再按 `DEVELOPMENT.md` 进入开发 -3. 开发过程中补充 `references/CLI.md`、`references/SCHEMA.md` 及 `development/` 技术规范 -4. 发布前对照第 9 节验收标准逐项检查 - -## 最小模板示例 - -下面给出一个可直接参考的简化版(复制后替换占位符): - -```md -# REQUIREMENTS - -## 1. 文档目标 - -本文档用于明确 `your-skill-slug` 的研发需求、范围和验收标准,作为开发与测试的统一依据。 - -## 2. 业务背景 - -当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。 -因此需要开发 `your-skill-slug`,自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 task_logs。 - -## 3. 开发目标 - -- 解析并校验输入 -- 通过 adapter 完成 mock / simulator / real 档位动作 -- 输出结构化结果并写入 task_logs -- 支持失败可诊断 - -## 4. 功能范围 - -- 支持 health / version / run -- 支持 logs / log-get - -## 5. 非功能要求 - -- 入口统一为 scripts/main.py -- 保持 UTF-8 输出;结构符合 skill-template - -## 6. 输入输出要求 - -### 输入 - -- target_id、input_id(按业务定义) - -### 输出 - -- 成功 JSON / 摘要 -- ERROR: 稳定错误码 -- task_logs 记录 - -## 7. 依赖的兄弟技能或外部系统 - -- ``(按需) -- `<目标系统>` API 或 sandbox - -## 8. 不在本次范围内的内容 - -- 不实现自动重试 -- 不实现 `<本次不做>` - -## 9. 验收标准 - -- 命令可运行;tests/run_tests.py -v 通过 -- task_logs 符合 SCHEMA -- 正式环境安装验证通过 - -## 10. 开发注意事项 - -- 不修改无关项目;不引入旧模板结构 - -## 11. 变更记录 - -| 日期 | 版本 | 变更人 | 变更内容 | -|------|------|--------|----------| -| YYYY-MM-DD | v1.0 | 张三 | 初版 | +```json +{ + "ok": true, + "job_id": "...", + "file_path": "...", + "filename": "settlement_....xlsx", + "date_from": "2025-12-01", + "date_to": "2025-12-31" +} ``` + +### 失败输出 + +- 退出码非 0 +- stdout JSON 中 `ok: false` 或前置 `ERROR:` 行(如 `ERROR:INVALID_ARGS`) +- `task_logs.status=failed`,`error_msg` 含原因 +- 可选:`rpa-artifacts/` 下 PNG 截图 + +## 7. 依赖的兄弟技能或外部系统 + +### 兄弟技能 + +- **account-manager**:`platform_key=amazon_sim`;提供 `profile_dir`、租约、`get-credential --reveal` +- 若 `amazon_sim` 平台不存在,需运维在 account-manager 注册后再添加账号 + +添加账号示例: + +```bash +python D:\OpenClaw\client-commons\account-manager\scripts\main.py account add-web \ + --platform amazon_sim \ + --login-id <邮箱> \ + --url https://sandbox.jc2009.com/industries/ecommerce/amazon/login \ + --environment simulator \ + --auth-strategy password_auto +``` + +`extra_json` 建议:`{"logged_in_url_pattern": "/seller"}` + +Amazon 仿真登录:登录页为**邮箱 + Continue**,无复杂验证码。 + +### 外部系统 + +| 页面 | URL | +|------|-----| +| 卖家首页 | `https://sandbox.jc2009.com/industries/ecommerce/amazon/seller` | +| 登录页 | `https://sandbox.jc2009.com/industries/ecommerce/amazon/login` | +| Reports | `https://sandbox.jc2009.com/industries/ecommerce/amazon/seller/reports` | + +**禁止**使用 `localhost:5173`。 + +## 8. 不在本次范围内的内容 + +- 不解析 xlsx、不入账 Kingdee / 星航 +- 不实现多店铺批跑 +- 不修改 `client-sandbox` 源码 +- 不修改 `skill-template` 源仓库 +- 不实现 `real_api` / 生产 Amazon `real_rpa` + +## 9. 验收标准与 RPA 步骤 + +### RPA 步骤(与 sandbox 联调文档一致) + +参考:`client-sandbox/sandbox-docs/migration/from-monolith-docs/skill-integration-guide.md` + +1. `account-manager pick-web --platform amazon_sim --lease` → `profile_dir`、`url`、`lease_token` +2. `launch_browser_with_profile` + `ensure_logged_in` +3. `page.goto(REPORTS_URL)`(URL 不进 launch args) +4. 等待 `[data-testid="reports-page-root"]` +5. 切换到 **Payments** 分类(Settlement 在该 Tab;默认 Tab 为 Fulfillment) + - 优先 `[data-testid="reports-tab-payments"]` + - 若无 testid,用 `get_by_role("button", name="Payments")` +6. 点击 `[data-testid="reports-type-item-settlement"]` +7. 填写 `[data-testid="reports-form-date-from"]` / `reports-form-date-to` +8. 可选:`[data-testid="reports-form-seller-select"]` +9. 点击 `[data-testid="reports-form-submit"]` +10. 轮询 `[data-testid^="reports-jobs-status-"][data-status="DONE"]`(超时 60s,间隔 2s) +11. 点击 `[data-testid^="reports-jobs-download-"]`,`expect_download` 保存 xlsx +12. `lease release` +13. 写 `task_logs` + 返回 JSON + +### 仿真环境已知依赖(Payments Tab) + +截至开发时,`sim-ecommerce-amazon/frontend/pages/ReportsPage.jsx` 中: + +- 已定义 `REPORT_TABS`(含 Payments),且 `settlement` 类型挂载在 `payments` Tab 下 +- **但页面未渲染 Tab 切换按钮**(无 `reports-tab-payments` DOM),默认 `activeTab=fulfillment`,页面上仅显示 Orders 类型 + +因此 **线上仿真** 联调时,RPA 可能在步骤 5 失败(Payments Tab 不可见)。处理方式: + +- **mock 测试**必须全部通过(CI 不依赖 Tab UI) +- **simulator_rpa** 代码已实现 Tab 切换逻辑,待 sandbox 前端补充 Tab UI 后即可联调 +- 联调前可请 sandbox 团队在 Reports 页渲染 Tab 并添加 `data-testid="reports-tab-payments"` + +### 命令验收 + +- `python tests/run_tests.py -v` 全部通过 +- `python scripts/main.py health` 退出码 0 +- `OPENCLAW_TEST_TARGET=mock python scripts/main.py run --date-from 2025-12-01 --date-to 2025-12-31` 退出码 0 且产出 mock xlsx + +### 联调验收(有账号时) + +```powershell +$env:OPENCLAW_TEST_TARGET="simulator_rpa" +$env:JIANGCHANG_DATA_ROOT="D:\claw-data" +$env:JIANGCHANG_USER_ID="10032" +python scripts/main.py run --date-from 2025-12-01 --date-to 2025-12-31 +``` + +成功:产出 xlsx + `task_logs` 有 `download_settlement` 记录 + 退出码 0。 + +## 10. 开发注意事项 + +- 只修改本 skill 仓库;不改动其他 61 个空目录 +- adapter 目录:`scripts/service/amazon_report_adapter/` +- 真实浏览器联调样例:`tests/integration/test_simulator_rpa_contract.py.sample`(不进默认套件) + +## 11. 变更记录 + +| 日期 | 版本 | 变更人 | 变更内容 | +|------|------|--------|----------| +| 2026-07-03 | v1.0.0 | 样板实现 | 初版:mock + simulator_rpa + REQUIREMENTS 填实 | diff --git a/evals/rpa-flow.md b/evals/rpa-flow.md new file mode 100644 index 0000000..07bd51d --- /dev/null +++ b/evals/rpa-flow.md @@ -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。 diff --git a/references/CLI.md b/references/CLI.md index 25a3102..ea50c59 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -1,6 +1,6 @@ -# 通用业务技能模板 CLI 标准 +# Amazon 结算报表自动下载 CLI -将 `{baseDir}` 替换为技能根目录(含 `SKILL.md`、`scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。 +将 `{baseDir}` 替换为技能根目录。入口:`python {baseDir}/scripts/main.py` ## 最小命令 @@ -10,21 +10,60 @@ python {baseDir}/scripts/main.py config-path python {baseDir}/scripts/main.py version ``` -## 标准行为 +## run — 下载结算报表 -- **任意 CLI 启动前**执行 `bootstrap_skill_config()`(`main.py` 与 `cli.app.main()` 均触发)。 -- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。 -- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。 -- **`version`**:输出 JSON(`version`、`skill`)。 -- **`run`**:长时间无 stdout **不代表卡死**;应通过 `logs` / `log-get` 排查。 -- **任务完成后**若有 video artifact,CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`)。 +```bash +python {baseDir}/scripts/main.py run \ + --date-from 2025-12-01 \ + --date-to 2025-12-31 \ + [--seller-code AMA1OF6T] +``` -## config-path(配置路径) +| 参数 | 必填 | 默认 | 说明 | +|------|------|------|------| +| `--date-from` | 否 | `2025-12-01` | 起始日期 YYYY-MM-DD | +| `--date-to` | 否 | `2025-12-31` | 结束日期 YYYY-MM-DD | +| `--seller-code` | 否 | (页面首个 seller) | Amazon 店铺代码 | + +### 成功 stdout(JSON,最后一行) ```json { - "skill": "your-skill-slug", - "env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env", + "ok": true, + "job_id": "abc123", + "file_path": "{DATA_ROOT}/{USER_ID}/download-settlement-report-amazon/downloads/settlement_....xlsx", + "filename": "settlement_....xlsx", + "date_from": "2025-12-01", + "date_to": "2025-12-31" +} +``` + +### 失败 + +- 退出码 `1` +- `ERROR:INVALID_ARGS` / `ERROR:ENTITLEMENT` 等前缀,或 JSON 中 `"ok": false` + +## logs / log-get + +```bash +python {baseDir}/scripts/main.py logs [--limit 10] [--task-type download_settlement] [--status success] +python {baseDir}/scripts/main.py log-get +``` + +## 运行档位 + +| OPENCLAW_TEST_TARGET | 行为 | +|----------------------|------| +| `mock`(默认) | 离线 mock,不写真实浏览器 | +| `simulator_rpa` | Playwright 操作线上 sandbox Reports 页 | +| `real_rpa` | 未实现,回退 mock | + +## config-path 示例 + +```json +{ + "skill": "download-settlement-report-amazon", + "env_path": "{DATA_ROOT}/{USER_ID}/download-settlement-report-amazon/.env", "example_path": "{skill_root}/.env.example" } ``` @@ -35,36 +74,10 @@ python {baseDir}/scripts/main.py version - `python_executable` — 当前 Python 解释器路径 - `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok` -- `jiangchang_skill_core_file` — 公共库加载来源(若来自技能目录副本会报 warning) -- `media_assets_root`、`ffmpeg_path`、`ffmpeg_available`、`background_music_mp3_count` -- `runtime_issue[warning|error]` — 非致命/致命问题列表 -- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径 +- `jiangchang_skill_core_file` — 公共库加载来源 +- `env_path` / `env_exists` / `example_path` -## 通用业务命令骨架 - -```bash -python {baseDir}/scripts/main.py run -python {baseDir}/scripts/main.py run -t -i -python {baseDir}/scripts/main.py logs -python {baseDir}/scripts/main.py logs --task-type demo --status failed -python {baseDir}/scripts/main.py log-get -``` - -| 命令 | 用途 | -|------|------| -| `run` | 执行一次任务 | -| `logs` | 列出最近 N 条任务日志 | -| `log-get` | 按 log_id 查看单条任务日志(JSON) | - -## 查询/导出类(可选) - -复制后按需增加,例如:`contacts`、`contact-get`、`export` 或业务等价命令。 - -## 若你的技能依赖兄弟技能 - -使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)`,不要硬编码兄弟技能名到 `sibling_bridge.py` 自身;并在 `SKILL.md` 声明 `dependencies.required`。 - -## 手工排查命令(推荐) +## 手工排查(共享 python-runtime) **建议使用宿主共享 python-runtime**,避免技能目录内临时 venv 加载不到公共库: @@ -79,9 +92,3 @@ Windows: `` 通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。 **不建议**在生产/测试机使用技能目录内 `uv run python`,以免加载不到共享 runtime 中的 `jiangchang-platform-kit` / `playwright`。 - -## 模板约定 - -- 最小模板至少保留 `health` / `config-path` / `version` -- 通用业务技能建议使用 `run` / `logs` / `log-get` -- 不要再用旧模板的 `publish` / `--account-id` / `--article-id` 命名 diff --git a/references/SCHEMA.md b/references/SCHEMA.md index d654264..5f75dbc 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -190,6 +190,21 @@ CREATE TABLE IF NOT EXISTS task_logs ( | 工资代发 | disburse | 付款账户 | 批次 ID | | 对账 | reconcile | 银行/平台 | 对账批次 ID | | 发票验真 | verify | 税务地区 | 发票批次 ID | +| **Amazon 结算报表下载** | **download_settlement** | **seller_code 或 default** | **`{date_from}_{date_to}`** | + +### download_settlement 的 result_summary 字段(JSON) + +| 字段 | 说明 | +|------|------| +| `ok` | 是否成功 | +| `job_id` | 仿真/报表任务 ID | +| `file_path` | 本地 xlsx 绝对路径 | +| `filename` | 文件名 | +| `date_from` / `date_to` | 请求日期范围 | +| `seller_code` | 店铺代码(可选) | +| `adapter` | `mock` / `simulator_rpa` | +| `artifacts` | 截图路径等 | +| `video` / `video_path` | 可选录屏信息 | 业务特有字段优先放入 `result_summary`(JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 77f8668..deb0a37 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -1,4 +1,4 @@ -"""CLI:argparse 与分发模板。""" +"""CLI:argparse 与分发。""" from __future__ import annotations @@ -16,7 +16,7 @@ from service.task_service import ( ) from util.config_bootstrap import bootstrap_skill_config from util.argparse_zh import ZhArgumentParser -from util.constants import LOG_LOGGER_NAME, SKILL_SLUG +from util.constants import DEFAULT_DATE_FROM, DEFAULT_DATE_TO, LOG_LOGGER_NAME, SKILL_SLUG from util.logging_config import get_skill_logger, setup_skill_logging @@ -28,32 +28,16 @@ def _cli_str_or_none(raw: Optional[str]) -> Optional[str]: def _handle_run(args: argparse.Namespace) -> int: - tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()] - if len(tail) > 2: - print("❌ 参数过多。") - print("用法:python main.py run [target [input_id]] | run [-t target] [-i input_id]") - return 1 - - t_target: Optional[str] = None - t_input: Optional[str] = None - if len(tail) == 2: - t_target, t_input = tail[0], tail[1] - elif len(tail) == 1: - if tail[0].isdigit(): - t_input = tail[0] - else: - t_target = tail[0] - - pick_t = _cli_str_or_none(getattr(args, "target", None)) - pick_i = _cli_str_or_none(getattr(args, "input_id", None)) - target = pick_t or t_target - input_id = pick_i or t_input - return cmd_run(target=target, input_id=input_id) + return cmd_run( + date_from=_cli_str_or_none(getattr(args, "date_from", None)), + date_to=_cli_str_or_none(getattr(args, "date_to", None)), + seller_code=_cli_str_or_none(getattr(args, "seller_code", None)), + ) def _print_full_usage() -> None: - print("通用业务技能模板(main.py)可用命令:") - print(" python main.py run [target [input_id]] [-t target] [-i input_id]") + print("Amazon 结算报表自动下载(main.py)可用命令:") + print(" python main.py run --date-from YYYY-MM-DD --date-to YYYY-MM-DD [--seller-code CODE]") print(" python main.py logs [--limit N] [--status s] [--task-type t] [--target-id tid]") print(" python main.py log-get ") print(" python main.py health") @@ -64,15 +48,31 @@ def _print_full_usage() -> None: def build_parser() -> ZhArgumentParser: p = ZhArgumentParser( prog="main.py", - description="通用业务技能模板:任务执行命令骨架、日志查询、健康检查、版本输出。", + description="Amazon 结算报表自动下载:请求并下载 Settlement xlsx,写入任务日志。", formatter_class=argparse.RawDescriptionHelpFormatter, ) sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser) - sp = sub.add_parser("run", help="任务执行命令骨架") - sp.add_argument("--target", "-t", default=None, metavar="目标") - sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id") - sp.add_argument("run_tail", nargs="*", metavar="位置参数") + sp = sub.add_parser("run", help="下载指定日期范围的结算报表") + sp.add_argument( + "--date-from", + default=DEFAULT_DATE_FROM, + metavar="起始日期", + help=f"起始日期 YYYY-MM-DD(默认 {DEFAULT_DATE_FROM})", + ) + sp.add_argument( + "--date-to", + default=DEFAULT_DATE_TO, + metavar="结束日期", + help=f"结束日期 YYYY-MM-DD(默认 {DEFAULT_DATE_TO})", + ) + sp.add_argument( + "--seller-code", + default=None, + metavar="店铺代码", + dest="seller_code", + help="可选;默认使用页面第一个 seller", + ) sp.set_defaults(handler=_handle_run) sp = sub.add_parser("logs", help="查看任务日志") @@ -107,10 +107,6 @@ def main(argv: Optional[List[str]] = None) -> int: if not argv: _print_full_usage() return 1 - if len(argv) == 2 and argv[0] not in { - "run", "logs", "log-get", "health", "config-path", "version", "-h", "--help" - }: - return cmd_run(target=argv[0], input_id=argv[1]) parser = build_parser() args = parser.parse_args(argv) return int(args.handler(args)) diff --git a/scripts/service/account_client.py b/scripts/service/account_client.py new file mode 100644 index 0000000..d29eb25 --- /dev/null +++ b/scripts/service/account_client.py @@ -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) diff --git a/scripts/service/amazon_report_adapter/__init__.py b/scripts/service/amazon_report_adapter/__init__.py new file mode 100644 index 0000000..41e69df --- /dev/null +++ b/scripts/service/amazon_report_adapter/__init__.py @@ -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) diff --git a/scripts/service/amazon_report_adapter/base.py b/scripts/service/amazon_report_adapter/base.py new file mode 100644 index 0000000..3946940 --- /dev/null +++ b/scripts/service/amazon_report_adapter/base.py @@ -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 diff --git a/scripts/service/amazon_report_adapter/mock.py b/scripts/service/amazon_report_adapter/mock.py new file mode 100644 index 0000000..8631f89 --- /dev/null +++ b/scripts/service/amazon_report_adapter/mock.py @@ -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}, + ) diff --git a/scripts/service/amazon_report_adapter/simulator_rpa.py b/scripts/service/amazon_report_adapter/simulator_rpa.py new file mode 100644 index 0000000..fc9d20e --- /dev/null +++ b/scripts/service/amazon_report_adapter/simulator_rpa.py @@ -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 diff --git a/scripts/service/browser_session.py b/scripts/service/browser_session.py new file mode 100644 index 0000000..4544058 --- /dev/null +++ b/scripts/service/browser_session.py @@ -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() diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py index ce12b9c..6854491 100644 --- a/scripts/service/task_service.py +++ b/scripts/service/task_service.py @@ -1,14 +1,11 @@ -"""任务编排、日志查询模板。 - -通用业务编排层:负责参数校验、鉴权、调用具体业务实现、写入任务日志。 -复制后在 cmd_run 中实现真正的业务逻辑。 -""" +"""任务编排:下载 Amazon 结算报表、写入 task_logs。""" from __future__ import annotations import asyncio import json import os +import re import uuid from typing import Any, Dict, Optional @@ -16,71 +13,186 @@ from jiangchang_skill_core import collect_runtime_diagnostics, config, format_ru from jiangchang_skill_core.rpa.video_session import RpaVideoSession from db import task_logs_repository as tlr +from service.amazon_report_adapter import get_adapter from service.entitlement_service import check_entitlement from service.task_run_support import ( _print_video_summary, build_video_info, merge_video_into_result_summary, ) -from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION +from util.constants import ( + DEFAULT_DATE_FROM, + DEFAULT_DATE_TO, + PLATFORM_KIT_MIN_VERSION, + SKILL_SLUG, + SKILL_VERSION, +) from util.runtime_paths import get_skill_data_dir, get_skill_root from util.timeutil import unix_to_iso -async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]: - """最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。""" - batch_id = uuid.uuid4().hex[:12] +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _validate_date(label: str, value: str) -> Optional[str]: + v = (value or "").strip() + if not v: + return f"{label} 不能为空" + if not _DATE_RE.match(v): + return f"{label} 格式须为 YYYY-MM-DD" + return None + + +def _artifacts_dir(batch_id: str) -> str: + return os.path.join(get_skill_data_dir(), "rpa-artifacts", batch_id) + + +def _downloads_dir() -> str: + path = os.path.join(get_skill_data_dir(), "downloads") + os.makedirs(path, exist_ok=True) + return path + + +async def _run_with_video( + *, + date_from: str, + date_to: str, + seller_code: Optional[str], + batch_id: str, +) -> tuple[dict[str, Any], dict[str, Any]]: + """可选 RpaVideoSession 包裹 adapter 执行。""" data_dir = get_skill_data_dir() + art_dir = _artifacts_dir(batch_id) + os.makedirs(art_dir, exist_ok=True) async with RpaVideoSession( skill_slug=SKILL_SLUG, skill_data_dir=data_dir, batch_id=batch_id, - title="开始执行示例任务", - closing_title="示例任务执行完成", + title="开始下载 Amazon 结算报表", + closing_title="结算报表下载完成", ) as video: - video.add_step("准备执行示例任务") - if target: - video.add_step(f"接收目标参数:{target}") - if input_id: - video.add_step(f"接收输入标识:{input_id}") - video.add_step("示例任务执行完成") + video.add_step("准备下载结算报表") + video.add_step(f"日期范围:{date_from} 至 {date_to}") + if seller_code: + video.add_step(f"指定店铺:{seller_code}") + + adapter = get_adapter(artifacts_dir=art_dir) + video.add_step(f"使用 adapter:{adapter.name}") + + result = adapter.download_settlement( + date_from=date_from, + date_to=date_to, + seller_code=seller_code, + downloads_dir=_downloads_dir(), + artifacts_dir=art_dir, + ) + + if result.ok: + video.add_step(f"下载成功:{result.filename or ''}") + else: + video.add_step(f"下载失败:{result.error_msg or '未知错误'}") video_summary = video.summary() video_info = build_video_info(video_summary, {}, {}) - return 1, video_info + payload: dict[str, Any] = { + "ok": result.ok, + "job_id": result.job_id, + "file_path": result.file_path, + "filename": result.filename, + "date_from": result.date_from, + "date_to": result.date_to, + "seller_code": result.seller_code or seller_code, + "adapter": result.artifacts.get("adapter") or adapter.name, + "artifacts": result.artifacts, + } + if not result.ok: + payload["error"] = result.error_msg + return payload, video_info -def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int: - """通用任务执行入口模板。复制后请实现真实业务逻辑。""" +def cmd_run( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + seller_code: Optional[str] = None, +) -> int: ok, reason = check_entitlement(SKILL_SLUG) if not ok: - print(f"❌ {reason}") + print(f"ERROR:ENTITLEMENT {reason}") return 1 - rc, video_info = asyncio.run(_run_template_demo(target, input_id)) - _print_video_summary(video_info) + d_from = (date_from or DEFAULT_DATE_FROM).strip() + d_to = (date_to or DEFAULT_DATE_TO).strip() + seller = (seller_code or "").strip() or None - summary_payload = merge_video_into_result_summary( - { - "template_demo": True, - "target": target, - "input_id": input_id, - }, - video_info, - ) + for label, val in (("date_from", d_from), ("date_to", d_to)): + err = _validate_date(label, val) + if err: + print(f"ERROR:INVALID_ARGS {err}") + return 1 + + batch_id = uuid.uuid4().hex[:12] + record_video = config.get_bool("OPENCLAW_RECORD_VIDEO", default=True) + + if record_video: + payload, video_info = asyncio.run( + _run_with_video( + date_from=d_from, + date_to=d_to, + seller_code=seller, + batch_id=batch_id, + ) + ) + else: + art_dir = _artifacts_dir(batch_id) + os.makedirs(art_dir, exist_ok=True) + adapter = get_adapter(artifacts_dir=art_dir) + result = adapter.download_settlement( + date_from=d_from, + date_to=d_to, + seller_code=seller, + downloads_dir=_downloads_dir(), + artifacts_dir=art_dir, + ) + payload = { + "ok": result.ok, + "job_id": result.job_id, + "file_path": result.file_path, + "filename": result.filename, + "date_from": result.date_from, + "date_to": result.date_to, + "seller_code": result.seller_code or seller, + "adapter": result.artifacts.get("adapter") or adapter.name, + "artifacts": result.artifacts, + } + if not result.ok: + payload["error"] = result.error_msg + video_info = {} + + _print_video_summary(video_info if record_video else None) + + summary_payload = merge_video_into_result_summary(payload, video_info if record_video else None) + status = "success" if payload.get("ok") else "failed" tlr.save_task_log( - task_type="demo", - target_id=target, - input_id=input_id, - input_title="模板示例任务", - status="failed", - error_msg="模板仓库未实现真实业务", + task_type="download_settlement", + target_id=seller or "default", + input_id=f"{d_from}_{d_to}", + input_title=f"结算报表 {d_from} ~ {d_to}", + status=status, + error_msg=None if payload.get("ok") else str(payload.get("error") or ""), result_summary=json.dumps(summary_payload, ensure_ascii=False), ) - print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。") - return rc + stdout_payload = { + "ok": payload.get("ok"), + "job_id": payload.get("job_id"), + "file_path": payload.get("file_path"), + "filename": payload.get("filename"), + "date_from": payload.get("date_from"), + "date_to": payload.get("date_to"), + } + print(json.dumps(stdout_payload, ensure_ascii=False)) + return 0 if payload.get("ok") else 1 def cmd_logs( diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 0c18b10..3d0dc92 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,53 @@ -"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" +"""技能标识、版本与 Amazon 仿真平台 URL 常量。""" -SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.32" -LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" +from __future__ import annotations + +import os + +SKILL_SLUG = "download-settlement-report-amazon" +SKILL_VERSION = "1.0.0" +LOG_LOGGER_NAME = "openclaw.skill.download_settlement_report_amazon" PLATFORM_KIT_MIN_VERSION = "1.0.17" + +TARGET_PLATFORM = "amazon_sim" +LEASE_HOLDER = "download-settlement-report-amazon" +LEASE_TTL_SEC = "600" + +DEFAULT_DATE_FROM = "2025-12-01" +DEFAULT_DATE_TO = "2025-12-31" + +DEFAULT_TIMEOUT_MS = 15_000 +LOGIN_NAVIGATE_TIMEOUT_MS = 30_000 +REPORTS_NAVIGATE_TIMEOUT_MS = 60_000 +JOB_POLL_TIMEOUT_SEC = 60 +JOB_POLL_INTERVAL_SEC = 2 + +ENV_AMAZON_SIM_BASE_URL = "AMAZON_SIM_BASE_URL" +ENV_AMAZON_SIM_REPORTS_URL = "AMAZON_SIM_REPORTS_URL" +ENV_AMAZON_SIM_LOGIN_URL = "AMAZON_SIM_LOGIN_URL" + +DEFAULT_AMAZON_SIM_BASE_URL = "https://sandbox.jc2009.com/industries/ecommerce/amazon" +DEFAULT_AMAZON_SIM_REPORTS_URL = ( + "https://sandbox.jc2009.com/industries/ecommerce/amazon/seller/reports" +) +DEFAULT_AMAZON_SIM_LOGIN_URL = ( + "https://sandbox.jc2009.com/industries/ecommerce/amazon/login" +) + + +def resolve_amazon_sim_base_url() -> str: + return ( + (os.getenv(ENV_AMAZON_SIM_BASE_URL) or DEFAULT_AMAZON_SIM_BASE_URL).strip().rstrip("/") + ) + + +def resolve_amazon_sim_reports_url() -> str: + return ( + (os.getenv(ENV_AMAZON_SIM_REPORTS_URL) or DEFAULT_AMAZON_SIM_REPORTS_URL).strip() + ) + + +def resolve_amazon_sim_login_url() -> str: + return ( + (os.getenv(ENV_AMAZON_SIM_LOGIN_URL) or DEFAULT_AMAZON_SIM_LOGIN_URL).strip() + ) diff --git a/scripts/util/logging.py b/scripts/util/logging.py new file mode 100644 index 0000000..9febf2c --- /dev/null +++ b/scripts/util/logging.py @@ -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 diff --git a/tests/integration/test_simulator_rpa_contract.py.sample b/tests/integration/test_simulator_rpa_contract.py.sample index 1815483..fef0bf1 100644 --- a/tests/integration/test_simulator_rpa_contract.py.sample +++ b/tests/integration/test_simulator_rpa_contract.py.sample @@ -1,20 +1,40 @@ # -*- coding: utf-8 -*- -# 样例:复制为 test_simulator_rpa_contract.py 后按需运行;默认跳过。 -"""仿真页面 / RPA 契约(不默认启动浏览器)。""" +""" +Amazon 仿真 Reports RPA 联调契约样例(不进入默认 unittest 套件)。 + +用法(需账号 + 浏览器 + OPENCLAW_TEST_TARGET=simulator_rpa): + copy tests\\integration\\test_simulator_rpa_contract.py.sample \\ + tests\\integration\\test_simulator_rpa_contract.py + set OPENCLAW_TEST_TARGET=simulator_rpa + python tests\\integration\\test_simulator_rpa_contract.py +""" from __future__ import annotations +import os +import sys import unittest -from adapter_test_utils import should_skip_profile +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, "..", "..")) +_SCRIPTS = os.path.join(_SKILL_ROOT, "scripts") +for _p in (_SCRIPTS, _TESTS_DIR): + if _p not in sys.path: + sys.path.insert(0, _p) + +from jiangchang_skill_core import config # noqa: E402 + +from service.task_service import cmd_run # noqa: E402 -class TestSimulatorRpaContractSample(unittest.TestCase): - def test_skip_unless_simulator_rpa_target(self) -> None: - skip, reason = should_skip_profile("simulator_rpa") - if skip: - raise unittest.SkipTest(reason) - # 真实技能中:可替换为 Playwright 连接 http://localhost:5180/select 等仿真入口。 - self.assertFalse(skip) +@unittest.skipUnless( + os.environ.get("OPENCLAW_TEST_TARGET") == "simulator_rpa", + "requires OPENCLAW_TEST_TARGET=simulator_rpa", +) +class TestSimulatorRpaContract(unittest.TestCase): + def test_run_downloads_settlement_xlsx(self) -> None: + config.reset_cache() + rc = cmd_run(date_from="2025-12-01", date_to="2025-12-31") + self.assertEqual(rc, 0) if __name__ == "__main__": diff --git a/tests/test_adapter_dispatch.py b/tests/test_adapter_dispatch.py new file mode 100644 index 0000000..264bdc1 --- /dev/null +++ b/tests/test_adapter_dispatch.py @@ -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() diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 165c61d..e443aa2 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -25,7 +25,7 @@ class TestCliSmoke(unittest.TestCase): rc = main([]) self.assertEqual(rc, 1) out = buf.getvalue() - self.assertIn("通用业务技能模板", out) + self.assertIn("Amazon 结算报表", out) self.assertIn("health", out) def test_health_zero(self) -> None: @@ -72,19 +72,32 @@ class TestCliSmoke(unittest.TestCase): self.assertNotEqual(rc, 0) self.assertIn("数字", buf.getvalue()) - def test_run_placeholder_returns_nonzero_without_network(self) -> None: - """占位 run:RpaVideoSession 示范 + 模板提示;OPENCLAW_RECORD_VIDEO=0 时不启 ffmpeg。""" + def test_run_mock_returns_ok_json(self) -> None: + """mock 档位 run:返回 ok JSON,退出码 0。""" old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None) old_record = os.environ.get("OPENCLAW_RECORD_VIDEO") + old_target = os.environ.get("OPENCLAW_TEST_TARGET") os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + os.environ["OPENCLAW_TEST_TARGET"] = "mock" try: with IsolatedDataRoot(user_id="_cli_run"): config.reset_cache() buf = io.StringIO() with redirect_stdout(buf), redirect_stderr(io.StringIO()): - rc = main(["run"]) - self.assertNotEqual(rc, 0) - self.assertIn("模板", buf.getvalue()) + rc = main( + [ + "run", + "--date-from", + "2025-12-01", + "--date-to", + "2025-12-31", + ] + ) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue().strip().splitlines()[-1]) + self.assertTrue(payload.get("ok")) + self.assertIn("job_id", payload) + self.assertIn("file_path", payload) finally: if old_auth is not None: os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth @@ -92,6 +105,11 @@ class TestCliSmoke(unittest.TestCase): os.environ.pop("OPENCLAW_RECORD_VIDEO", None) else: os.environ["OPENCLAW_RECORD_VIDEO"] = old_record + if old_target is None: + os.environ.pop("OPENCLAW_TEST_TARGET", None) + else: + os.environ["OPENCLAW_TEST_TARGET"] = old_target + config.reset_cache() if __name__ == "__main__": diff --git a/tests/test_video_service.py b/tests/test_video_service.py index 1667a3f..8e0116a 100644 --- a/tests/test_video_service.py +++ b/tests/test_video_service.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""RpaVideoSession 模板示范与 result_summary / CLI 输出测试。""" +"""RpaVideoSession 与 cmd_run 测试。""" from __future__ import annotations import io @@ -26,6 +26,7 @@ class TestEnvExampleVideoDefaults(unittest.TestCase): "OPENCLAW_ARTIFACTS_ON_FAILURE=1", "OPENCLAW_BROWSER_HEADLESS=0", "OPENCLAW_PLAYWRIGHT_STEALTH=1", + "AMAZON_SIM_REPORTS_URL=https://sandbox.jc2009.com", ): self.assertIn(key, text, msg=f"missing {key!r} in .env.example") @@ -39,70 +40,35 @@ class TestPrintVideoSummary(unittest.TestCase): _print_video_summary( { "enabled": True, - "path": r"D:\data\videos\your-skill-slug_demo.mp4", + "path": r"D:\data\videos\download-settlement-report-amazon_demo.mp4", "record_log_path": r"D:\data\logs\ffmpeg-record.log", "warnings": [], } ) out = buf.getvalue() self.assertIn("录屏路径", out) - self.assertIn("your-skill-slug_demo.mp4", out) + self.assertIn("download-settlement-report-amazon_demo.mp4", out) self.assertIn("录屏日志", out) - def test_disabled_video_prints_nothing(self) -> None: - from service.task_run_support import _print_video_summary - - buf = io.StringIO() - with redirect_stdout(buf), redirect_stderr(io.StringIO()): - _print_video_summary({"enabled": False, "path": r"D:\tmp\x.mp4"}) - self.assertEqual(buf.getvalue(), "") - - -class TestMergeVideoIntoResultSummary(unittest.TestCase): - def test_result_summary_includes_video_fields(self) -> None: - from service.task_run_support import merge_video_into_result_summary - - video_info = { - "enabled": True, - "path": r"D:\data\videos\demo.mp4", - "capture_path": r"D:\data\capture.mp4", - "record_log_path": r"D:\data\logs\ffmpeg-record.log", - "warnings": ["test_warning"], - "voiceover_path": r"D:\data\voiceover.wav", - "music_path": r"D:\data\music.mp3", - "audio_warnings": ["tts_skipped"], - } - payload = merge_video_into_result_summary({"demo": True}, video_info) - self.assertIn("video", payload) - self.assertEqual(payload["video_path"], video_info["path"]) - self.assertEqual(payload["raw_video"], video_info["capture_path"]) - self.assertEqual(payload["video_log"], video_info["record_log_path"]) - self.assertEqual(payload["video_warnings"], ["test_warning"]) - self.assertEqual(payload["voiceover_path"], video_info["voiceover_path"]) - self.assertEqual(payload["music_path"], video_info["music_path"]) - self.assertEqual(payload["audio_warnings"], ["tts_skipped"]) - - -class TestTemplateRunVideoSession(unittest.TestCase): - def _fake_video_session(self, mock_video: MagicMock): - class _FakeVideoSession: - def __init__(self, **kwargs): - self.kwargs = kwargs - - async def __aenter__(self): - return mock_video - - async def __aexit__(self, *args): - return None - - return _FakeVideoSession +class TestCmdRunVideoSession(unittest.TestCase): def test_cmd_run_creates_video_session_with_chinese_titles(self) -> None: with IsolatedDataRoot(user_id="_video_run"): - os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + os.environ["OPENCLAW_RECORD_VIDEO"] = "1" + os.environ["OPENCLAW_TEST_TARGET"] = "mock" config.reset_cache() from service import task_service + from service.amazon_report_adapter.base import DownloadSettlementResult + + mock_result = DownloadSettlementResult( + ok=True, + job_id="MOCK-1", + file_path="/tmp/settlement.xlsx", + filename="settlement.xlsx", + date_from="2025-12-01", + date_to="2025-12-31", + ) mock_video = MagicMock() mock_video.add_step = MagicMock() @@ -129,32 +95,35 @@ class TestTemplateRunVideoSession(unittest.TestCase): async def __aexit__(self, *args): return None - with patch.object(task_service, "RpaVideoSession", _CapturingFakeSession): - buf = io.StringIO() - with redirect_stdout(buf), redirect_stderr(io.StringIO()): - rc = task_service.cmd_run(target="demo-target", input_id="42") + mock_adapter = MagicMock() + mock_adapter.name = "mock" + mock_adapter.download_settlement.return_value = mock_result - 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") kwargs = created[0] - self.assertEqual(kwargs.get("title"), "开始执行示例任务") - self.assertEqual(kwargs.get("closing_title"), "示例任务执行完成") + self.assertEqual(kwargs.get("title"), "开始下载 Amazon 结算报表") + self.assertEqual(kwargs.get("closing_title"), "结算报表下载完成") self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("title", ""))) - self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("closing_title", ""))) - mock_video.add_step.assert_any_call("准备执行示例任务") - mock_video.add_step.assert_any_call("示例任务执行完成") - mock_video.summary.assert_called() out = buf.getvalue() - self.assertIn("模板", out) + payload = json.loads(out.strip().splitlines()[-1]) + self.assertTrue(payload.get("ok")) from db import task_logs_repository as tlr rows = tlr.list_task_logs(1) self.assertTrue(rows) - summary = json.loads(rows[0][7]) - self.assertIn("video", summary) - self.assertIn("video_path", summary) + self.assertEqual(rows[0][1], "download_settlement") if __name__ == "__main__":