Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a8fa3cb0c | |||
| de127d7e71 | |||
| 1e2f94dd10 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -9,6 +9,22 @@
|
||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||
|
||||
## 1.0.126
|
||||
|
||||
- 沉淀 `replaces: import` 宿主契约:表头必填红星在前(`*显示名`),技能须同时兼容前/后缀 `*`;lookup 单元格可为 `{value} · {label}`
|
||||
- 新增可复用模块 `scripts/service/table_file_import.py` 与单测;更新 ACTIONS / SCHEMA / CHECKLIST A4-14 / replaces-import 示例
|
||||
|
||||
## 1.0.125
|
||||
|
||||
- 固化免费 PyArmor Trial 发布约定:行数门槛是预防线;实际失败时按日志定位模块并按职责拆分,保留兼容门面,以 Gitea Trial 加密结果验收
|
||||
- 记录 field_locator 拆分后同一免费 Trial 链路成功的复盘案例;不使用未经验证的统一字节数规则
|
||||
|
||||
## 1.0.124
|
||||
|
||||
- 沉淀宿主能力:Excel 导入模板按列元数据自动加下拉(有可选集合的列展示 label;CSV 无下拉);技能只需声明 `_jiangchang_columns` 选项即可受益
|
||||
- 更新 `references/SCHEMA.md`「宿主负责」与 `options_json` 说明
|
||||
|
||||
|
||||
## 1.0.123
|
||||
|
||||
- 沉淀 `selectionMode: optional` 在**定时任务等无表勾选入口**的宿主约定:配置页按无勾选分支,隐藏空「编号列表」、只保留数量字段;勿为 cron 再拆 Action
|
||||
|
||||
2
SKILL.md
2
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 技能开发模板(通用业务版)
|
||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||
version: 1.0.123
|
||||
version: 1.0.126
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
- `bind-visible-when-row.action.example.json`:行菜单显隐 `bind.visibleWhen` 形状参考(按当前行状态显示/隐藏行按钮;见 `references/ACTIONS.md`「行菜单显隐」)。
|
||||
- `selection-label-column.action.example.json`:选择摘要 `bind.labelColumn` 形状参考(见 `references/ACTIONS.md`「选择摘要」)。
|
||||
- `selection-mode-optional.action.example.json`:`bind.selectionMode: "optional"` 智能勾选/按数量合并形状参考(含 `cron`:配置页按无勾选分支,只见数量字段);用户可见文案为口语中文(见 `references/ACTIONS.md`「勾选驱动模式」「用户可见文案」)。
|
||||
- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。
|
||||
- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。实现 import 解析时复用 `scripts/service/table_file_import.py`(兼容宿主前缀 `*` 表头与 `{value} · {label}` lookup 单元格)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "item-import",
|
||||
"label": "导入记录",
|
||||
"description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。",
|
||||
"description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。表头可带前/后缀*;lookup 列可为「编号 · 名称」。",
|
||||
"replaces": "import",
|
||||
"placements": [],
|
||||
"executionProfile": "sync",
|
||||
@@ -19,7 +19,7 @@
|
||||
"filePath": {
|
||||
"type": "string",
|
||||
"title": "导入文件",
|
||||
"description": "由宿主导入面板传入的 Excel/CSV 路径。",
|
||||
"description": "由宿主导入面板传入的 Excel/CSV 路径。表头:显示名(可带前/后缀*);关联列可填编号或「编号 · 名称」。",
|
||||
"format": "file"
|
||||
},
|
||||
"onError": {
|
||||
|
||||
@@ -116,6 +116,7 @@ checklist_version: 1.0
|
||||
- [ ] A4-11 **P1** 若行操作应按业务状态显隐(如仅待确认可「采用」、仅已采用可「加入待写」):用 **`bind.visibleWhen`**(对当前行列值求值;存库原始值如 `approved`);勿指望 Policy L3;CLI 侧仍须校验状态(见 `references/ACTIONS.md`「行菜单显隐」)
|
||||
- [ ] A4-12 **P1** 若要在表单里翻页勾选一批业务行(文章、平台等):用 Lookup **`presentation: "paged_table"`**(仍是 `display_type: lookup`,勿新造类型);`skill_cli` 的 stdout 为一行 `{"items","total"}`,0 条也成功退出;该列表命令不要在入口加载整包 platform-kit 或 Playwright(见 `references/ACTIONS.md`「分页勾选小表」)
|
||||
- [ ] A4-13 **P1** 若 Action 有固定小枚举(状态/船司/优先级等)需中文展示:用 `enum` + **`enumNames`**,或绑列复用列 `options_json` 数组;宿主优先序为绑列 options → enumNames → 纯 enum;**禁止**为中文展示造假 Lookup(见 `references/ACTIONS.md`「静态枚举 label」)
|
||||
- [ ] A4-14 **P0** 若声明 `replaces: import`:表头清洗须兼容宿主**前缀**必填星(`*显示名`)与旧后缀(`显示名*`);lookup 列须能解析 `{value} · {label}`;推荐复用 `scripts/service/table_file_import.py`;**禁止**只 `removesuffix("*")` 导致「字段不能为空」(见 `references/ACTIONS.md`「导入表头与 lookup 单元格」)
|
||||
---
|
||||
|
||||
## 5. 账号与浏览器会话(网页 RPA 适用;其他形态标 N/A 或改测等价项)
|
||||
|
||||
@@ -213,6 +213,14 @@ release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前
|
||||
- 不要把完整 RPA 主流程、DOM 选择器、数据库、CLI、日志全部堆进一个大文件。
|
||||
- 正确做法是拆分到 `scripts/cli/`、`scripts/service/`、`scripts/db/`、`scripts/util/`。
|
||||
|
||||
以上行数是预防性硬门槛,**不是 Trial 加密成功的充分条件**。PyArmor Trial 对具体源码模块的限制不能用一个未经验证的字节数阈值代替;即使文件低于 1000 行,实际加密仍可能因单个模块过大或过于复杂而失败。免费模式下的处理约定:
|
||||
|
||||
1. 以 Gitea release workflow 使用的 Trial 加密步骤和目标平台为准做实际验证;查看日志确认失败发生在哪个 `.py` 文件,不以本地行数检查通过推断加密一定成功。
|
||||
2. 若日志指出某个模块加密失败(例如 Trial license / big script 一类限制),按职责把该模块拆成更小的实现模块;原文件保留为兼容门面,重导出原有公开函数、类或常量,避免改坏已有 import 路径。
|
||||
3. 拆分后先跑完整测试,再升补丁版本并 release;检查 Gitea job 中所有 Python 模块均已加密、加密包成功生成/上传且 job 最终成功。失败时继续针对日志定位的模块拆分并重新验证,不放宽免费模式、不猜统一字节上限。
|
||||
|
||||
模板案例:得一智慧审单的 `field_locator.py` 在免费 Trial 加密阶段失败;按职责拆分为多个实现模块并保留原文件兼容门面后,同一免费 Trial 发布链路成功。这个案例用于说明应对**具体失败模块做职责拆分并复验**,不作为其他技能的固定文件字节数阈值。
|
||||
|
||||
`examples/` 里的 Python 示例也建议保持模块化,但**行数限制不作为 examples 的发布硬约束**;不要对 README、HTML、测试数据等文档/资源套用 PyArmor 行数规则。
|
||||
|
||||
**文本编码(UTF-8 without BOM)**
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| POLICY-RPA-005 | 必须保留 AI + RPA 总纲,明确 Agent / RPA 职责、授权范围、禁止破解/逆向/私有协议/安全验证绕过、人工介入、状态等待;真实业务默认动作节奏为 1~3 秒 | development/AI_RPA.md §4–§6;development/RPA.md §0.0/§0.3 | hard(文档与默认配置) / soft(真实行为) | 检查总纲关键边界;解析 `.env.example` 的 `STEP_DELAY_MIN=1.0` / `STEP_DELAY_MAX=3.0` | `tests/test_development_policy_guard.py::TestPolicyRpa005` |
|
||||
| POLICY-RPA-006 | 浏览器 RPA 必须保留 account-manager 生命周期标准;所有样例账号选择路径都须返回非空 `profile_dir + lease_token`,指定数字账号 ID 不得退化为无租约 `account get` | development/ACCOUNT_MANAGER.md;development/ADAPTER.md §兄弟依赖;development/RPA.md §0.2 | hard | 检查标准文档关键标记;扫描真实/仿真 `account_client.py` 的指定 ID 带租约实现并禁止空租约赋值 | `tests/test_development_policy_guard.py::TestPolicyRpa006` |
|
||||
| POLICY-RPA-007 | 网页 RPA 启动须 `no_viewport=True` + `--start-maximized`;禁止 `viewport=None`、CDP/JS 中途改窗;`RPA.md` §1.2.1 须保留窗口标准 | development/RPA.md §1.2.1;development/RPA_BROWSER.md §4;examples/real_browser_rpa | hard | 文档标记 + 扫描含 `launch_persistent_*` 的 Python 禁止模式;校验 real_browser 示例 | `tests/test_development_policy_guard.py::TestPolicyRpa007` |
|
||||
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
|
||||
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行;该门槛不保证 Trial 加密成功。免费 Trial 实际失败时按报错模块拆分职责、保留兼容门面,并以 Gitea 实际加密 job 成功作为发布验收 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数测试 + Trial 发布 job 结果 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit`、`test_trial_packaging_guidance_is_documented`;实际加密由 Gitea release workflow 验证 |
|
||||
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
|
||||
| POLICY-RELEASE-001 | 发布 tag(去掉前缀)必须等于 `SKILL.md` 的 `version:` 且等于 `scripts/util/constants.py` 的 `SKILL_VERSION`;`release.ps1` 打 tag 前硬失败,不自动改写字段 | development/DEVELOPMENT.md §15 发布;宿主市场「最新版本」来自 tag、「当前版本」来自包内 `SKILL.md` | hard | `release.ps1` 门禁 + 日常版本字段对齐测试 | `release.ps1`(`Assert-SkillReleaseVersionAlignment`);`tests/test_development_policy_guard.py::TestPolicyRelease001`;`tests/test_skill_metadata.py::test_skill_version_alignment` |
|
||||
| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` |
|
||||
|
||||
@@ -140,6 +140,8 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均
|
||||
|
||||
release workflow 加密 `scripts/` 源码时,可能运行在 PyArmor 免费/试用模式:
|
||||
|
||||
- `scripts/**/*.py` 单文件必须 **< 1000 行**(`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。
|
||||
- 接近上限时拆分到 `cli/`、`service/`、`db/`、`util/`,不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。
|
||||
- `scripts/**/*.py` 单文件必须 **< 1000 行**(`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。这是预防性硬门槛,不代表低于该行数就一定能被 Trial 加密。
|
||||
- 模块应按职责拆分到 `cli/`、`service/`、`db/`、`util/`;不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。发现某个文件在免费 Trial 加密时报错时,按报错定位该文件并继续拆分其职责,重新走实际加密验证;不要通过猜测统一的源码字节数阈值代替验证。
|
||||
- 拆分已有模块时,尽量保留原模块作为兼容门面(re-export / 委托到新模块),避免破坏现有 import 路径;先跑测试,再发布验证。
|
||||
- 发布验收以 Gitea release workflow 中实际的 PyArmor Trial 加密结果为准:确认全部 `scripts/**/*.py` 均成功加密、加密包成功生成/上传且 job 成功。若 PyArmor 步骤失败,不得把版本视为已发布成功;按日志中的失败文件拆分、升补丁版本并重新 release。
|
||||
- 此行数限制**仅适用于** `scripts/**/*.py`;不限制 README 等文档行数,也不把 examples 里的 HTML/README 纳入 PyArmor 行数约束。
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
- Skill Action manifest:`assets/actions.json` 结构、CLI 映射、placements 与 inputSchema 守护(见 `test_actions_manifest.py`);
|
||||
- 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`);
|
||||
- **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
|
||||
- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
|
||||
- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、PyArmor Trial 拆分/实际发布验证约定、文本文件 UTF-8 without BOM。行数通过只表示通过预防性门槛;免费 Trial 是否能加密成功仍以 Gitea 实际加密 job 为准。
|
||||
- **开发规范守护(hard policy)**:[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) ——规则索引见 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md),只执行可机器检测的 hard 规则。
|
||||
- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——动词-名词命名;平台可选,`default` 不再作为占位。
|
||||
- **HTML 报告壳**:[`tests/test_html_page_service.py`](../tests/test_html_page_service.py) 守护 `assets/html/example-report.html` 的数据管理 iframe 预览布局基线,并守护 ACTIONS.md 里的壳子页目录 `assets/html/pages` 与 `jiangchang.call`。业务技能若有自定义 `assets/html/*.html` 或 `pages/*.html`,建议在自有仓库加同类静态断言(见 `references/ACTIONS.md`「宿主预览布局」)。不要把 `example-report.html` 放进 `pages/`。
|
||||
|
||||
@@ -291,6 +291,20 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
|
||||
可抄文件见 [`assets/examples/`](../assets/examples/):`replaces-*.action.example.json`。
|
||||
|
||||
### 导入表头与 lookup 单元格(宿主契约,`replaces: import` 必读)
|
||||
|
||||
宿主「下载导入模板」与导入预览已统一:
|
||||
|
||||
| 现象 | 宿主实际写出 | 技能解析须兼容 |
|
||||
|------|--------------|----------------|
|
||||
| 必填红星 | 写在**显示名前**:`*长尾词`、`* 名称`(表单标签同此) | **前缀 / 后缀** `*` / `*` 都要剥掉后再对 `display_name`;**禁止**只 `removesuffix("*")` |
|
||||
| lookup / 关联列 | 单元格常为 `{value} · {label}`,如 `157 · 波兰小包专线` | 取前导整数写库;勿要求用户只填纯数字 |
|
||||
| 示例行 | 单元格以 `【示例】` 开头 | 整行跳过,勿当业务数据 |
|
||||
|
||||
写库链路:宿主面板只把 **`filePath` + `onError`** 交给技能;**字段映射下拉结果不传入 CLI**。技能必须自己读文件、洗表头、映射列。预览「看起来有字」不等于技能已认到必填列——只剥后缀 `*` 时,前缀模板会导致「xxx不能为空」。
|
||||
|
||||
**推荐实现:** 直接复用模板内 [`scripts/service/table_file_import.py`](../scripts/service/table_file_import.py)(`normalize_header` / `optional_int` / `load_table_rows`)。Action 文案里写「表头:名称(可带前/后缀*)」即可,**不要**再写死「表头:名称*」误导后人。
|
||||
|
||||
### 示例:create
|
||||
|
||||
```json
|
||||
@@ -437,7 +451,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
- 点默认「新增/编辑/删除」时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。
|
||||
- 点默认「导入」时**固定**打开宿主导入面板;确认后若有 `replaces:import` 则调用该 Action(`interaction` 不决定是否再弹技能表单)。
|
||||
- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。
|
||||
- `import` 约定:`filePath`(或 `file`)+ 可选 `onError`(`skip`/`abort` 或上例中文枚举);技能自行解析文件与表头。
|
||||
- `import` 约定:`filePath`(或 `file`)+ 可选 `onError`(`skip`/`abort` 或上例中文枚举);技能自行解析文件与表头(须兼容宿主前缀 `*` 与 lookup `编号 · 名称`,见上文「导入表头与 lookup 单元格」)。
|
||||
- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏宿主默认新增/编辑/导入与直写删除;**若声明了 `replaces: delete`,宿主仍显示「删除」并走技能 CLI**(不直写 SQLite)。
|
||||
- **列元数据复用**:Action 表单(含 toolbar/batch 弹窗与 create/update 抽屉)可将 `inputSchema` 字段对齐业务列,复用中文名与列上 **options**(静态枚举 `options_json` **数组**,或已解析的 lookup):
|
||||
- 优先让属性名 = 物理列名(如 `carrier_code` / `platform_key`);
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
- 不根据具体技能名称写特殊逻辑
|
||||
- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果
|
||||
- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为格子 / 行 / 列 / 区域映射语义样式(`tone`);不猜测业务规则;整行色只涂表格行,不铺到详情字段
|
||||
- **导入模板下拉(宿主能力)**:下载「Excel 模板」时,宿主按本表可导入列的元数据自动加 Excel 数据有效性下拉——凡列已有可选集合(`options_json` 静态枚举、schema 已解析的 lookup 选项、未写 options 的 `boolean` 默认「是/否」等)即加下拉;无选项的列仍为自由文本。下拉展示 **label**;导入写库时宿主(或 `replaces: import` 技能)再 **label → value**。CSV 模板**不能**带下拉,可选值仍写在导入弹窗「可导入字段说明」与 Excel「填写说明」页。技能**无需**自备带下拉的模板文件;把字段选项声明在 `_jiangchang_columns` 即可让所有技能受益。宿主不按技能名写死「只支持某几种业务字段」。
|
||||
- **导入模板表头必填标记(宿主能力)**:Excel/CSV 表头对必填列统一加**前缀**红星(`*显示名` / `* 显示名`),与新增表单标签一致;**不是**后缀 `显示名*`。走 `replaces: import` 的技能必须按 [`ACTIONS.md`](ACTIONS.md)「导入表头与 lookup 单元格」剥前/后缀 `*` 后再对 `display_name`;推荐复用 [`scripts/service/table_file_import.py`](../scripts/service/table_file_import.py)。
|
||||
- **导入模板 lookup 单元格**:用户从下拉选中的值常以 `{value} · {label}` 写入表格(与列表展示一致)。`replaces: import` 技能解析关联列时须能从该展示串取出编号(`optional_int`),不能只接受纯数字。
|
||||
|
||||
## 初始化入口
|
||||
|
||||
@@ -671,7 +674,7 @@ CREATE TABLE demo_items (
|
||||
|
||||
## 枚举字段 options_json
|
||||
|
||||
`options_json` 支持两种形态:
|
||||
`options_json` 支持两种形态。正确声明后,数据管理「新增/编辑」下拉、列筛选、**以及 Excel 导入模板下拉**共用同一套选项(见上文「宿主负责」中的导入模板下拉)。
|
||||
|
||||
### 1)静态枚举(数组)
|
||||
|
||||
|
||||
216
scripts/service/table_file_import.py
Normal file
216
scripts/service/table_file_import.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""解析数据管理导入用的 Excel / CSV(中文表头、【示例】行跳过)。
|
||||
|
||||
实现 ``replaces: import`` 时请复用本模块,勿只认「后缀 *」表头。
|
||||
|
||||
宿主契约(须兼容):
|
||||
|
||||
- 必填红星写在显示名前:``*长尾词`` / ``* 名称``(旧技能文档可能写 ``名称*``)
|
||||
- lookup 单元格可能是 ``157 · 波兰小包专线``(``{value} · {label}``),不只是纯数字
|
||||
- 以 ``【示例】`` 开头的单元格所在行应跳过
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
EXAMPLE_MARKER = "【示例】"
|
||||
DATA_SHEET_NAMES = ("数据", "Sheet1", "sheet1")
|
||||
|
||||
|
||||
def normalize_on_error(raw: Optional[str]) -> str:
|
||||
text = str(raw or "").strip().lower()
|
||||
if text in {
|
||||
"abort",
|
||||
"stop",
|
||||
"立即停止,不再导入后续行",
|
||||
"立即停止",
|
||||
"停止",
|
||||
}:
|
||||
return "abort"
|
||||
# Chinese default / skip variants
|
||||
return "skip"
|
||||
|
||||
|
||||
def normalize_header(header: str) -> str:
|
||||
"""归一化导入表头。
|
||||
|
||||
宿主模板把必填红星写在名称前(``*长尾词`` / ``* 长尾词``);
|
||||
旧技能文档常用后缀(``长尾词*``)。两侧都要认。
|
||||
"""
|
||||
text = str(header or "").strip().replace("\ufeff", "")
|
||||
for _ in range(4):
|
||||
prev = text
|
||||
text = text.strip()
|
||||
for star in ("*", "*"):
|
||||
text = text.removeprefix(star).removesuffix(star).strip()
|
||||
for marker in ("(必填)", "(required)"):
|
||||
text = text.removeprefix(marker).removesuffix(marker).strip()
|
||||
if text == prev:
|
||||
break
|
||||
return text
|
||||
|
||||
|
||||
def _is_example_row(cells: Iterable[Any]) -> bool:
|
||||
for cell in cells:
|
||||
text = str(cell or "").strip()
|
||||
if text.startswith(EXAMPLE_MARKER):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _cell_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def map_headers(
|
||||
headers: list[str],
|
||||
aliases: dict[str, tuple[str, ...]],
|
||||
) -> dict[str, int]:
|
||||
"""aliases: field_name -> accepted Chinese/English header names."""
|
||||
normalized = [normalize_header(h) for h in headers]
|
||||
mapping: dict[str, int] = {}
|
||||
for field, names in aliases.items():
|
||||
accepted = {normalize_header(n) for n in names}
|
||||
accepted.add(normalize_header(field))
|
||||
for index, header in enumerate(normalized):
|
||||
if header in accepted:
|
||||
mapping[field] = index
|
||||
break
|
||||
return mapping
|
||||
|
||||
|
||||
def rows_as_dicts(
|
||||
headers: list[str],
|
||||
data_rows: list[list[Any]],
|
||||
*,
|
||||
aliases: dict[str, tuple[str, ...]],
|
||||
) -> list[dict[str, str]]:
|
||||
mapping = map_headers(headers, aliases)
|
||||
if not mapping:
|
||||
raise ValueError("未识别到有效表头,请使用技能模板或中文列名")
|
||||
out: list[dict[str, str]] = []
|
||||
for row_index, raw in enumerate(data_rows, start=2):
|
||||
if _is_example_row(raw):
|
||||
continue
|
||||
if not any(_cell_str(c) for c in raw):
|
||||
continue
|
||||
item: dict[str, str] = {"_row": str(row_index)}
|
||||
for field, col in mapping.items():
|
||||
item[field] = _cell_str(raw[col] if col < len(raw) else "")
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def read_csv_matrix(path: Path) -> tuple[list[str], list[list[str]]]:
|
||||
raw = path.read_bytes()
|
||||
text = raw.decode("utf-8-sig")
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(text[:4096], delimiters=",\t;")
|
||||
except csv.Error:
|
||||
dialect = csv.excel
|
||||
reader = csv.reader(text.splitlines(), dialect)
|
||||
rows = [list(r) for r in reader]
|
||||
if not rows:
|
||||
raise ValueError("CSV 为空")
|
||||
return [str(c) for c in rows[0]], rows[1:]
|
||||
|
||||
|
||||
def read_xlsx_matrix(path: Path) -> tuple[list[str], list[list[Any]]]:
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError as exc:
|
||||
raise ValueError("读取 Excel 需要 openpyxl,请安装依赖或另存为 CSV 后再导入") from exc
|
||||
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
sheet = None
|
||||
for name in DATA_SHEET_NAMES:
|
||||
if name in wb.sheetnames:
|
||||
sheet = wb[name]
|
||||
break
|
||||
if sheet is None:
|
||||
# Prefer first non-guide sheet
|
||||
for name in wb.sheetnames:
|
||||
if name not in ("填写说明",):
|
||||
sheet = wb[name]
|
||||
break
|
||||
if sheet is None:
|
||||
raise ValueError("Excel 中没有可用工作表")
|
||||
matrix: list[list[Any]] = []
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
matrix.append(list(row))
|
||||
if not matrix:
|
||||
raise ValueError("Excel 数据表为空")
|
||||
headers = [_cell_str(c) for c in matrix[0]]
|
||||
return headers, matrix[1:]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def load_table_rows(
|
||||
file_path: str,
|
||||
*,
|
||||
aliases: dict[str, tuple[str, ...]],
|
||||
) -> list[dict[str, str]]:
|
||||
path = Path(file_path)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"文件不存在:{path}")
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in {".xlsx", ".xlsm"}:
|
||||
headers, data = read_xlsx_matrix(path)
|
||||
elif suffix in {".csv", ".txt"}:
|
||||
headers, data = read_csv_matrix(path)
|
||||
else:
|
||||
raise ValueError("仅支持 .xlsx / .csv 文件")
|
||||
return rows_as_dicts(headers, data, aliases=aliases)
|
||||
|
||||
|
||||
_STATUS_ALIASES = {
|
||||
"active": "active",
|
||||
"启用": "active",
|
||||
"archived": "archived",
|
||||
"停用": "archived",
|
||||
"pending_review": "pending_review",
|
||||
"待确认": "pending_review",
|
||||
"approved": "approved",
|
||||
"已采用": "approved",
|
||||
"skipped": "skipped",
|
||||
"已跳过": "skipped",
|
||||
"pending": "pending",
|
||||
"待生成": "pending",
|
||||
"generated": "generated",
|
||||
"已生成": "generated",
|
||||
"failed": "failed",
|
||||
"失败": "failed",
|
||||
}
|
||||
|
||||
|
||||
def map_status(raw: str, *, allowed: set[str], default: str) -> str:
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return default
|
||||
key = _STATUS_ALIASES.get(text) or _STATUS_ALIASES.get(text.lower()) or text
|
||||
return key if key in allowed else default
|
||||
|
||||
|
||||
def optional_int(raw: str) -> Optional[int]:
|
||||
"""解析编号;兼容宿主 lookup 展示值 ``157 · 波兰小包专线``。"""
|
||||
text = str(raw or "").strip()
|
||||
if not text or text.lower() in {"null", "none", "undefined"}:
|
||||
return None
|
||||
if re.fullmatch(r"-?\d+", text):
|
||||
value = int(text)
|
||||
return None if value <= 0 else value
|
||||
# Host lookup labels: "157 · 名称" / "157·名称" / "157 - 名称"
|
||||
matched = re.match(r"^(-?\d+)\s*[·•\-—||]\s*\S", text)
|
||||
if matched:
|
||||
value = int(matched.group(1))
|
||||
return None if value <= 0 else value
|
||||
raise ValueError(f"编号必须是整数:{text}")
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.123"
|
||||
SKILL_VERSION = "1.0.126"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
@@ -241,6 +241,8 @@ LOGGING_006_EXEMPT_BASENAMES = frozenset(
|
||||
"task_run_support.py",
|
||||
"entitlement_service.py",
|
||||
"sibling_bridge.py",
|
||||
# Pure Excel/CSV header helpers for replaces:import; no domain side effects.
|
||||
"table_file_import.py",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -382,6 +382,10 @@ class TestDocsStandards(unittest.TestCase):
|
||||
"drop_deprecated_user_table",
|
||||
"孤儿物理表",
|
||||
"_jiangchang_grid_presentation",
|
||||
"导入模板表头必填标记",
|
||||
"*显示名",
|
||||
"table_file_import.py",
|
||||
"导入模板 lookup 单元格",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
|
||||
|
||||
@@ -432,8 +436,21 @@ class TestDocsStandards(unittest.TestCase):
|
||||
"visibleWhen",
|
||||
"grid_help",
|
||||
"列筛选",
|
||||
"导入表头与 lookup 单元格",
|
||||
"table_file_import.py",
|
||||
"*长尾词",
|
||||
"{value} · {label}",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||||
checklist = self._read("development/CHECKLIST.md")
|
||||
self.assertIn("A4-14", checklist)
|
||||
self.assertIn("table_file_import.py", checklist)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(get_skill_root(), "scripts/service/table_file_import.py")
|
||||
),
|
||||
msg="scripts/service/table_file_import.py missing",
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(get_skill_root(), "assets/examples/lookup-skill-cli.action.example.json")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""发布打包守护:PyArmor 单文件行数与 UTF-8 without BOM。"""
|
||||
"""发布打包守护:PyArmor Trial 约定、单文件行数与 UTF-8 without BOM。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -89,6 +89,26 @@ def _iter_scripts_py_files(skill_root: str) -> list[str]:
|
||||
|
||||
|
||||
class TestReleasePackagingConstraints(unittest.TestCase):
|
||||
def test_trial_packaging_guidance_is_documented(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
runtime_path = os.path.join(skill_root, "development", "RUNTIME.md")
|
||||
development_path = os.path.join(skill_root, "development", "DEVELOPMENT.md")
|
||||
with open(runtime_path, encoding="utf-8") as f:
|
||||
runtime = f.read()
|
||||
with open(development_path, encoding="utf-8") as f:
|
||||
development = f.read()
|
||||
|
||||
required_guidance = {
|
||||
"free/trial mode": (runtime + development, "Trial"),
|
||||
"line limit is not sufficient": (runtime + development, "不是 Trial 加密成功的充分条件"),
|
||||
"split the failing module by responsibility": (runtime + development, "按职责"),
|
||||
"preserve compatibility imports": (runtime + development, "兼容门面"),
|
||||
"verify real release encryption": (runtime + development, "实际加密"),
|
||||
"inspect Gitea release result": (runtime + development, "Gitea"),
|
||||
}
|
||||
missing = [label for label, (text, needle) in required_guidance.items() if needle not in text]
|
||||
self.assertEqual(missing, [], msg=f"Missing PyArmor Trial guidance: {missing}")
|
||||
|
||||
def test_scripts_py_files_under_pyarmor_line_limit(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
offenders: list[str] = []
|
||||
@@ -104,8 +124,9 @@ class TestReleasePackagingConstraints(unittest.TestCase):
|
||||
offenders,
|
||||
[],
|
||||
msg=(
|
||||
"PyArmor trial/free mode may fail when a single Python file reaches "
|
||||
f"{SCRIPTS_MAX_LINES} lines; split this file into smaller modules. "
|
||||
"PyArmor trial/free mode has a preventive single-file line guard; "
|
||||
"passing it does not guarantee Trial encryption succeeds. Split any "
|
||||
"module identified by the actual Trial release error. "
|
||||
f"Recommended keep each file < {SCRIPTS_RECOMMENDED_MAX_LINES} lines. "
|
||||
f"Offenders: {offenders}"
|
||||
),
|
||||
|
||||
67
tests/test_table_file_import.py
Normal file
67
tests/test_table_file_import.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""宿主导入模板表头 / lookup 展示值契约(replaces:import)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TableFileImportTests(unittest.TestCase):
|
||||
def test_normalize_header_host_leading_and_legacy_trailing_star(self) -> None:
|
||||
from service.table_file_import import map_headers, normalize_header
|
||||
|
||||
self.assertEqual(normalize_header("*长尾词"), "长尾词")
|
||||
self.assertEqual(normalize_header("* 长尾词"), "长尾词")
|
||||
self.assertEqual(normalize_header("长尾词*"), "长尾词")
|
||||
self.assertEqual(normalize_header("*关键词*"), "关键词")
|
||||
self.assertEqual(normalize_header("*关键词(必填)"), "关键词")
|
||||
self.assertEqual(normalize_header("名称"), "名称")
|
||||
|
||||
aliases = {
|
||||
"phrase": ("长尾词", "phrase", "问题"),
|
||||
"keyword_id": ("所属关键词", "keyword_id", "关键词编号"),
|
||||
}
|
||||
mapping = map_headers(["*长尾词", "所属关键词"], aliases)
|
||||
self.assertEqual(mapping.get("phrase"), 0)
|
||||
self.assertEqual(mapping.get("keyword_id"), 1)
|
||||
|
||||
mapping_legacy = map_headers(["长尾词*", "所属关键词"], aliases)
|
||||
self.assertEqual(mapping_legacy.get("phrase"), 0)
|
||||
|
||||
def test_optional_int_accepts_lookup_display_label(self) -> None:
|
||||
from service.table_file_import import optional_int
|
||||
|
||||
self.assertEqual(optional_int("157"), 157)
|
||||
self.assertEqual(optional_int("157 · 波兰小包专线"), 157)
|
||||
self.assertEqual(optional_int("157·波兰小包专线"), 157)
|
||||
self.assertEqual(optional_int("2 · 通用私信-留资"), 2)
|
||||
self.assertIsNone(optional_int(""))
|
||||
with self.assertRaises(ValueError):
|
||||
optional_int("波兰小包专线")
|
||||
|
||||
def test_load_table_rows_skips_example_and_maps_leading_star(self) -> None:
|
||||
from service.table_file_import import load_table_rows
|
||||
|
||||
aliases = {
|
||||
"phrase": ("长尾词",),
|
||||
"keyword_id": ("所属关键词",),
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "rows.csv"
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(["*长尾词", "所属关键词"])
|
||||
writer.writerow(["【示例】示例长尾词", "152 · 示例"])
|
||||
writer.writerow(["波兰小包专线怎么选", "157 · 波兰小包专线"])
|
||||
|
||||
rows = load_table_rows(str(path), aliases=aliases)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["phrase"], "波兰小包专线怎么选")
|
||||
self.assertEqual(rows[0]["keyword_id"], "157 · 波兰小包专线")
|
||||
self.assertEqual(rows[0]["_row"], "3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user