Compare commits

...

3 Commits

Author SHA1 Message Date
bf3bb9cd10 chore: auto release commit (2026-09-27 16:42:38)
All checks were successful
技能自动化发布 / release (push) Successful in 12s
2026-09-27 16:42:39 +08:00
166007b460 release: v1.0.117 沉淀多数据源契约(SQLite 默认 + 可选 MySQL 云库)
All checks were successful
技能自动化发布 / release (push) Successful in 9s
新增 DATA_SOURCES.md、data-sources 示例与双后端 connection 骨架;CLI 与宿主 UI 可读写同一库。要求匠厂 Host ≥ 6.9.28。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 16:39:13 +08:00
78c24a515d docs: document year and year_month display_type host contract
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-09-26 10:23:06 +08:00
32 changed files with 665 additions and 29 deletions

View File

@@ -53,3 +53,6 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
# 临时目录:运行中临时文件的位置(不上侧栏)
# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp
# MySQL 云库连接:技能走云库时在用户数据目录配置 data-sources.json,并在此填写 DSN
# MYSQL_DSN=mysql://user:password@192.168.0.20:3306/your_database # 留空或不写=本地 SQLite;须 mysql:// 开头且含库名;匠厂 Host 须 ≥ 6.9.28;见 references/DATA_SOURCES.md

View File

@@ -9,6 +9,24 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.118
- 沉淀浏览器 RPA 窗口/viewport 最佳实践(`RPA.md` §1.2.1):`no_viewport=True` + `--start-maximized`,禁止 `viewport=None` 与 CDP/JS 中途改窗
- `RPA_BROWSER.md` 补充 Shadow DOM 双 input 陷阱与 Cookie 可选 dismiss 模式
- 新增 `POLICY-RPA-007` 文档标记与 launch 禁止模式门禁;CHECKLIST A5-4 对齐
## 1.0.117
- 沉淀匠厂 Host ≥ 6.9.28 **多数据源**契约:可选 MySQL 云库 + `data-sources.json` 声明;默认仍为本地 SQLite
- 新增 `references/DATA_SOURCES.md`、`assets/data-sources.example.json`(SQLite 默认)、`assets/examples/data-sources.mysql.example.json`
- `scripts/db/backend.py`、`cursor_compat.py` 与双后端 `connection.py`:CLI / Action 与宿主 UI 读写同一库
- `.env.example` 增加 `MYSQL_DSN` 说明;CHECKLIST / CONFIG / DATA_PATHS / SCHEMA / ACTIONS 口径同步
## 1.0.116
- 沉淀宿主 **年 / 年月** 表单字段契约:`display_type: year`(`YYYY`)、`year_month` / `month`(`YYYY-MM`);与 `date` / `datetime_unix_seconds` 对称,Action 与 CRUD 均适用
- 更新 `ACTIONS.md`、`SCHEMA.md`、`skill-actions.schema.json`;新增 `assets/examples/year-month-action.example.json`;`display_metadata.py` 增加 `YEAR` / `YEAR_MONTH` 常量
## 1.0.115
- 沉淀匠厂配置管理必填项契约:`.env.example` 在行头上方用独立一行 `# @required` 声明;宿主显示红色 `*` 并在保存时校验非空

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.115
version: 1.0.118
author: 深圳匠厂科技有限公司
metadata:
openclaw:
@@ -155,7 +155,7 @@ python {baseDir}/scripts/main.py init-db
4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制;开发前先读 `development/PRODUCT_SURFACES.md` 并填写入口定位表。
5. 用户市场四 Tab 材料写入根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`、`references/ACTIONS.md`。
6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。
7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。
7. 本地 SQLite 或可选 MySQL 云库:数据源声明见 `references/DATA_SOURCES.md`;中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。
## 技能命名与 slug

View File

@@ -4,7 +4,8 @@
- **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。
- `html/example-report.html`:HTML 快照页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`。不要放进 `pages/`。
- `html/pages/*.html`:装完就出现在侧栏的壳子页(宿主直接读安装包,不经生成)。页面用 `jiangchang.call` 调本技能已声明的命令。见 `references/ACTIONS.md`「HTML 页面报告」。模板仓库不要在这里放示例页。
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。
- `data-sources.example.json`:声明式数据源默认示例(SQLite `{skillSlug}.db`);宿主安装时可复制到用户目录。MySQL 参考见 `examples/data-sources.mysql.example.json`。
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `data-sources.mysql.example.json`、`path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping` / `columnMapping` / **`labelColumn`**(选择摘要标签列)/ **`selectionMode`**(`required` \| `none` \| `optional`:表勾选如何驱动 Action;省略则宿主兼容推断)。placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层在会应用勾选时展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。弹窗 `description` / 确认黄条 / 参数说明须**用户可见中文**(勿写 `pickCount`/`async`/`pending`/CLI 子命令;见 ACTIONS.md「用户可见文案」)。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"sources": [
{
"id": "primary",
"kind": "sqlite",
"displayName": "本地库",
"path": "{skillSlug}.db"
}
]
}

View File

@@ -1,11 +1,13 @@
# 示例 JSON
- `data-sources.mysql.example.json`:MySQL 云库 `data-sources.json` 形状参考(复制到用户数据目录并配 `.env` 中 `MYSQL_DSN`;见 `references/DATA_SOURCES.md`)。
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
- `skill-grid-presentation.example.json`:数据管理整行 / 整列 / 区域呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。
- `lookup-action.example.json`:Action 内联 Lookup 形状参考(单选 + `multiple: true` 多选并列;跨表引用勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。
- `year-month-action.example.json`:Action / CRUD 账期月份字段 `display_type: year_month` 形状参考(见 `references/ACTIONS.md`「表单字段类型对照」)。
- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ Policy L3)。
- `selection-label-column.action.example.json`:选择摘要 `bind.labelColumn` 形状参考(见 `references/ACTIONS.md`「选择摘要」)。
- `selection-mode-optional.action.example.json`:`bind.selectionMode: "optional"` 智能勾选/按数量合并形状参考;用户可见文案为口语中文(见 `references/ACTIONS.md`「勾选驱动模式」「用户可见文案」)。

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"sources": [
{
"id": "primary",
"kind": "mysql",
"displayName": "业务云库",
"connectionEnv": "MYSQL_DSN"
}
]
}

View File

@@ -0,0 +1,40 @@
{
"_comment": "形状参考:账期/月份字段用 year_month,宿主渲染年月选择器。完整契约见 references/ACTIONS.md「表单字段类型对照」与 references/SCHEMA.md。",
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [
{
"id": "create-period-job",
"label": "新建账期作业(示例)",
"description": "演示 year_month 字段:提交值为 YYYY-MM 字符串,不要用 date。",
"placements": ["toolbar"],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["close_jobs"]
},
"entrypoint": {
"type": "cli",
"command": "create-job",
"args": ["--period", "{{period}}", "--title", "{{title}}"]
},
"inputSchema": {
"type": "object",
"required": ["period"],
"properties": {
"period": {
"type": "string",
"title": "月份",
"description": "格式 YYYY-MM,例如 2026-05",
"display_type": "year_month"
},
"title": {
"type": "string",
"title": "标题",
"description": "可留空,默认按月份生成"
}
}
}
}
]
}

View File

@@ -85,7 +85,7 @@
"mutationKind": {
"type": "string",
"enum": ["create", "update", "delete", "import"],
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写已注册数据源(默认 SQLite)。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
},
"scalarArg": {
"type": ["string", "number", "boolean"]
@@ -124,12 +124,12 @@
},
"display_type": {
"type": "string",
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
"description": "可选。表单控件提示;与 format 二选一即可。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。"
"enum": ["textarea", "year", "year_month", "month", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
"description": "可选。表单控件提示;与 format 二选一即可。year/year_month=年或年月选择器(YYYY / YYYY-MM;month 为 year_month 别名)。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。"
},
"format": {
"type": "string",
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
"enum": ["textarea", "year", "year_month", "month", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
"description": "可选。display_type 别名,语义相同。"
},
"pathRoot": {

View File

@@ -120,7 +120,7 @@ checklist_version: 1.0
- [ ] A5-1 **P0** 未准备好账号/浏览器档案时,主路径失败并提示,不会默默用本机默认浏览器身份跑起来
- [ ] A5-2 **P0** 登录策略为 required 时:未登录不能把做不成的事当成成功
- [ ] A5-3 **P0** 交付联调默认能看见浏览器窗口(有头);不能把无头当作唯一可验收形态
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入)
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入);`no_viewport=True` + `--start-maximized`,无「先小后大」或右侧留白/scrollbar 后遗症(见 `RPA.md` §1.2.1)
- [ ] A5-5 **P1** 验证码或人工确认:有等待与超时;超时后任务失败可理解,不无限挂死或假成功
- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活
- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在
@@ -147,9 +147,11 @@ checklist_version: 1.0
- [ ] A6-10 **P1** 有 HTML 侧栏预览时(S0-12):预览区横向铺满、无横向滚动条;重新生成后内容与页头时间更新;布局符合 `references/ACTIONS.md`「宿主预览布局」。无 HTML 报告 → **N/A**
- [ ] A6-11 **P1** 业务**状态 / 启用类**列若用户需要筛表或顶栏搜中文:`searchable: 1` 且 `options_json`(或 `boolean` + `options_json`)的 **value 与存库一致**;仅 `searchable: 0` 则宿主不提供列筛与全局搜索。见 `references/SCHEMA.md`「列筛选与全局搜索」
- [ ] A6-12 **P2** `display_type: boolean` 的展示列推荐同时配置 `options_json` 中文 label(如 停用/启用),避免仅靠宿主默认「是/否」;无 boolean 列 → **N/A**
- [ ] A6-13 **P2** 账期/月份/生效月等 `YYYY-MM` 字段用 `display_type: year_month`(或 Action 同义 `format`),**不要**只用 `description` 写格式却留纯文本框;完整日才用 `date`
- [ ] A6-13 **P0** 新技能**不得**创建 `task_logs`;过程/进度/业务结果走运行日志、任务中心、业务表(见 `LOGGING.md`、`SCHEMA.md`)
- [ ] A6-14 **P0** 废弃用户可见表时:`drop_deprecated_user_table` 物理删表 + 清元数据;**禁止**只删元数据留物理表;有迁移时须提供旧库回归测试(见 `SCHEMA.md`、`tests/samples/test_table_deprecation_migration.py.sample`)
- [ ] A6-15 **P1** 若启用 `cellPresentation`:旁路表不得出现在数据管理侧栏;整行着色不得铺到详情每个字段;禁止 `all`×`all`;tone 闭集。未启用 → **N/A**(见 `references/CELL_PRESENTATION.md`)
- [ ] A6-16 **P0** 若走 **MySQL 云库**:用户目录 `data-sources.json` + `.env` DSN 已配置;Host ≥ 6.9.28;CLI / Action 与数据管理 UI 读写**同一库**(抽测 list/create);业务 DDL 支持所选后端。纯 SQLite → **N/A**(见 `references/DATA_SOURCES.md`)
---

View File

@@ -230,8 +230,25 @@ config.get_float("STEP_DELAY_MIN", 1.0)
- `python scripts/main.py doctor`:检查浏览器、Profile/账号、`.env` 落盘、设备等。
- `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`。
## MySQL 云库(可选)
技能若走 MySQL 云库(匠厂 Host **≥ 6.9.28**),须同时满足:
1. 用户数据目录有 [`data-sources.json`](../references/DATA_SOURCES.md)(声明 `kind: mysql` 与 `connectionEnv`)
2. `.env` 配置对应 DSN,模板默认为 `MYSQL_DSN`
```env
# MySQL 云库连接(留空=本地 SQLite)
MYSQL_DSN=mysql://user:password@host:3306/database_name
```
**不要**把 DSN 写进 `data-sources.json`。技能 CLI 通过 `scripts/db/backend.py` 读取同一 key,与宿主 UI 保持一致。
完整契约见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md)。
## 相关文档
- `DATA_SOURCES.md` — 多数据源声明(SQLite 默认 / MySQL opt-in)
- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释)
- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收
- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释)

View File

@@ -52,8 +52,9 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` |
| `rpa-artifacts/` | RPA 失败截图、录屏中间产物 | `get_rpa_artifacts_dir(batch_id)` |
| `videos/` | RPA 录屏成片 MP4 | `get_videos_dir()` |
| `{skill_slug}.db` | SQLite | `get_db_path()` |
| `.env` | 用户配置 | `config.get_env_file_path()` |
| `{skill_slug}.db` | SQLite(默认本地库) | `get_db_path()` |
| `data-sources.json` | 声明式数据源(可选;不配则宿主自动扫描 `.db`) | 用户目录根;格式见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md) |
| `.env` | 用户配置(含可选 `MYSQL_DSN`) | `config.get_env_file_path()` |
复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。

View File

@@ -118,8 +118,8 @@ scripts/
不写具体业务逻辑
- `db/`
作用:SQLite 连接、建表、repository
不写页面操作和业务编排
作用:数据库连接(默认 SQLite;配 `MYSQL_DSN` 时为 MySQL)、建表、repository
不写页面操作和业务编排;多数据源契约见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md)
- `service/`
作用:核心业务逻辑

View File

@@ -23,6 +23,7 @@
| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器;实现上应走 ensure-web);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` |
| 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-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` |

View File

@@ -189,18 +189,35 @@ BrowserType.launch_persistent_context: Arguments can not specify page to be open
**正确做法(生产路径):**
1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, ...)`
1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, locale="zh-CN", no_viewport=True, ...)`
2. `page = await context.new_page()`
3. `await page.goto(start_url, wait_until="domcontentloaded", timeout=60000)`
对强风控站点,如果需要更拟人,可以启动后通过**地址栏输入 / 真实点击进入**目标页,但**仍然不能把 URL 塞进 launch args**。
#### 1.2.1 窗口与 viewport(`POLICY-RPA-007`)
persistent profile 会记住上次窗口大小;**最大化与 layout 同步**须按下列标准,避免「先小后大、右侧留白、scrollbar 后遗症」:
| 必须 | 禁止 |
|------|------|
| `no_viewport=True`(页面 layout 跟随真实窗口) | `viewport=None` 或固定 `viewport={width,height}` 冒充全屏 |
| launch args 含 `--start-maximized` | CDP `Browser.setWindowBounds` / JS `resizeTo` **中途改窗** |
| 真实站:`browser_session` 内 **启动后尽快 `goto` 业务页** | 长时间停空白页再导航、goto 后再 CDP 最大化 |
| 优先复制 `examples/real_browser_rpa/scripts/service/browser_session.py` | 自造 maximize / CDP 窗口 hack |
说明:
- **`no_viewport=True` ≠ `viewport=None`**。后者在 Playwright 中不等于「跟窗口走」,易导致 OS 窗口已最大化但页面仍按窄 viewport 排版。
- **仿真站**(`examples/simulator_browser_rpa`)可在 `browser_session` 只做 launch、由上层 `goto`;仍须 **`no_viewport=True`**。
- 共享库 `jiangchang_skill_core.rpa.launch_persistent_browser` 已内置 `no_viewport=True`;自写 `browser_session` 时勿漏。
完整参考实现见:
- `examples/real_browser_rpa/README.md`(**开发真实浏览器 RPA 类 skill 时必须先读**)
- `examples/real_browser_rpa/scripts/service/browser_session.py`
**生产路径不要依赖 CDP 接管现有页面**;CDP 仅用于诊断或桌面宿主已打开浏览器的场景。
**生产路径不要依赖 CDP 接管现有页面**;CDP 仅用于诊断或桌面宿主已打开浏览器的场景(**含禁止用 CDP 最大化窗口**)。
### 1.3 HITL / 验证码

View File

@@ -90,6 +90,19 @@ button = row.get_by_role("button", name="添加")
Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。closed Shadow DOM 无法正常读取时,评估目标应用是否提供可访问性属性,不能注入破解。
**Web Component 双 input 陷阱**(常见于 `<mc-input>` 等宿主):
- 轻 DOM 内可能有 **hidden 同步用 input**(`aria-hidden="true"`、`tabindex="-1"`)。
- 裸写 `host >> input` 时 Playwright 常默认匹配**第一个** hidden input,等 `visible` 会超时。
- 做法:等 **host 可见**作 page ready;输入用 shadow 内真实字段(如 `>> #mc-input-track-input` 或 `>> input.input:not([aria-hidden="true"])`);或 click host + keyboard。
### Cookie / 同意条(可选出现)
Profile 已点过「允许全部」后,下次可能**不再出现** Cookie 条。不要无限等待:
- 短超时(约 2~3s)探测可见 → 点击;
- 超时或无元素 → **skip 继续**,不要阻塞主流程。
### 动态列表
等待列表完成加载后再读取;`locator.all()` 不会等待动态列表稳定。滚动或刷新后重新获取 locator。
@@ -109,6 +122,7 @@ Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。close
## 6. 禁止模式
- 用全页所有 `input` 的尺寸和位置猜搜索框。
- 对 Web Component 裸用 `host >> input` 且不等 shadow 内真实可编辑 input。
- 用 `.first` 绕过严格模式。
- 从 DevTools 复制深层 `nth-child` 或绝对 XPath。
- 只等 URL 含 `search` 就判定结果成功。

View File

@@ -91,6 +91,7 @@ real_browser_rpa/
- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供
- `launch_persistent_context` 的 `args` **只放 Chrome 参数**;**不要把 URL 放进 `args`**
- 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入
- **必须** `no_viewport=True` + `--start-maximized`;**禁止** `viewport=None` 与 CDP/JS 中途改窗(见 `development/RPA.md` §1.2.1、`POLICY-RPA-007`)
## 复制边界:哪些能复制,哪些必须替换

View File

@@ -59,6 +59,7 @@ async def start_browser_session(
"""
启动持久化 Chrome profile,new_page + goto 入口页,返回 (playwright, context, page)。
窗口标准(POLICY-RPA-007):no_viewport=True + --start-maximized;勿用固定 viewport 或 CDP 改窗。
真实 skill 需要安装 playwright 与 jiangchang_skill_core。
"""
from playwright.async_api import async_playwright

View File

@@ -84,7 +84,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
| 字段 / 层 | 只决定什么 | 不决定什么 |
|-----------|------------|------------|
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 |
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写 SQLite;**import 时 UI 仍用宿主「导入数据」面板** |
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写**已注册数据源**(默认本地 SQLite);**import 时 UI 仍用宿主「导入数据」面板** |
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
@@ -231,7 +231,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
## `replaces`(可选:接管默认新增 / 编辑 / 删除 / 导入)
有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。
有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写默认 CRUD。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。
### 怎么选
@@ -250,7 +250,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
2. **写库**按表能力分流:
- 有 `replaces: import` → 把选中文件路径 + 出错规则交给该 Action(技能解析并写入)
- 仅有 `replaces: create` → 宿主按行调用 create(兼容)
- 都没有 → 宿主直写 SQLite
- 都没有 → 宿主直写已注册数据源(默认 SQLite)
**不要**再做一张技能自己的「开始执行」导入表单来冒充导入体验。`placements` 对 import 保持 `[]`;`description` 写明「由宿主导入面板调用」。
@@ -992,6 +992,8 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
| 枚举下拉 | `enum: [...]` | `options_json` 数组 |
| 关联下拉(Lookup) | 内联 `display_type: lookup` + `lookup`(可选 `multiple`),或绑列 | 同左;多选存 `TEXT` 逗号分隔 id |
| 密码 | `sensitive: true` | 列 `sensitive` |
| 年份 | `display_type` 或 `format`: `year` | `display_type: year` → 存 `YYYY` |
| 年月 | `display_type` 或 `format`: `year_month`(别名 `month`) | `display_type: year_month` → 存 `YYYY-MM` |
| 日期(日) | `display_type` 或 `format`: `date` | `display_type: date` → 存 `YYYY-MM-DD` |
| 日期时间 | `display_type` 或 `format`: `datetime_unix_seconds` | 同左;**存/传 Unix 秒** |
| URL(薄层) | `display_type` 或 `format`: `url` | `display_type: url` |
@@ -1012,8 +1014,10 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
日期时间约定:
- Action 提交与 CRUD 写库统一用 Unix **秒**整数(与 SCHEMA 标准时间字段一致)
- `year` 只表示公历年份,值为 `YYYY`(如 `2026`)
- `year_month` 只表示年月,值为 `YYYY-MM`(如 `2026-05`);**不要**用 `date` 表示账期/月份
- `date` 只表示日历日,值为 `YYYY-MM-DD`
- 脏/无法归一的旧预设:日期控件可回退为普通文本,避免卡死表单
- 脏/无法归一的旧预设:日期/年月控件可回退为普通文本,避免卡死表单
### 本地路径选择与 `pathRoot`
@@ -1074,11 +1078,13 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
形状参考:[`assets/examples/path-root-file-action.example.json`](../assets/examples/path-root-file-action.example.json)。
账期/月份(`YYYY-MM`)字段形状参考:[`assets/examples/year-month-action.example.json`](../assets/examples/year-month-action.example.json)。
### 当前宿主稳定支持(摘要)
- 根:`object`;属性:`string` / `number` / `boolean`(`object`/`array` 仅作 JSON 多行编辑兼容,新技能慎用)
- `enum` / `default` / `required` / `sensitive` / `readOnly` / 可选 `revealInForm`
- `display_type` / `format`:`textarea` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`** | **`file`** | **`directory`**
- `display_type` / `format`:`textarea` | **`year`** | **`year_month`** / `month` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`** | **`file`** | **`directory`**
- 可选 **`pathRoot`**(仅 `file` / `directory`):相对技能数据根的起始目录;省略默认 `{skill_data}/input`
- 字段内联 `lookup`(与 CRUD `options_json` lookup 对象同构;可选 `extra_column`、`multiple`)
- 属性级 `description`(表单标签下方灰字说明)
@@ -1156,6 +1162,12 @@ CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展
"description": "最多返回多少条;填 0 表示不限制(若业务支持)",
"default": 50
},
"period": {
"type": "string",
"title": "账期月份",
"display_type": "year_month",
"description": "格式 YYYY-MM,例如 2026-05"
},
"dueDate": {
"type": "string",
"title": "截止日期",
@@ -1201,6 +1213,7 @@ CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展
- `boolean` 在宿主中渲染为开关
- `enum` 在宿主中渲染为选择框
- `type: number` 渲染为数字输入
- `display_type`/`format: year` 渲染为年份选择;`year_month` / `month` 渲染为年月选择
- `display_type`/`format: date` 渲染为日期(日);`datetime_unix_seconds` 渲染为日期时间并按 **Unix 秒**提交
- `display_type`/`format: url` / `email` 为薄层输入类型(校验从宽,勿当强校验网关)
- `required` 字段在提交前校验

View File

@@ -44,7 +44,7 @@ python {baseDir}/scripts/main.py init-db
- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。
- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。
- **`version`**:输出 JSON(`version`、`skill`)。
- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
- **`init-db`**:幂等创建/迁移当前 active 后端(默认本地 SQLite;配 `MYSQL_DSN` 时为 MySQL 云库,见 `references/DATA_SOURCES.md`),含 `_jiangchang_*` 展示元数据。成功输出一行 JSON(`ok` / `skill` / `db_path` 或等价字段),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。
- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。
- **排查顺序**(由快到慢):

139
references/DATA_SOURCES.md Normal file
View File

@@ -0,0 +1,139 @@
# 多数据源声明(SQLite / MySQL 云库)
本文说明技能如何向匠厂宿主**声明**业务数据库,以及技能 CLI / Action 如何与宿主读写**同一物理库**。
**产品定位:** 默认仍是本地 SQLite(`{skill_slug}.db`)。需要多用户共享、集中运维或云库时,可显式声明 **MySQL**。UI / API 契约不变;`_jiangchang_*` 展示元数据仍写在同一逻辑库中。
**宿主版本:** 匠厂 Host **≥ 6.9.28** 才支持 `data-sources.json` 与 MySQL 连接器。
**完整参考实现:** [`process-work-order`](https://git.jc2009.com/client-commons/process-work-order) @ v1.0.2(工单管理 + MySQL 云库试点)。
---
## 何时用哪种后端
| 场景 | 推荐 |
|------|------|
| 单机、离线、小数据量、便携部署 | **SQLite**(默认,不配声明文件亦可) |
| 多用户共享、运维集中管库、与外部系统共库 | **MySQL 云库**(显式 opt-in) |
**硬规则:一个 `sourceId` 对应一个物理库。** 禁止双写、禁止 SQLite 与 MySQL 同步镜像。
---
## 文件位置
均在**用户数据目录**(不是安装包目录):
```
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/
├── data-sources.json # 声明式数据源(可选;见下)
├── .env # MYSQL_DSN 等连接配置
└── {skill_slug}.db # SQLite 模式下的本地库文件
```
安装/升级时,若用户目录尚无 `data-sources.json` 且技能包内有 `assets/data-sources.example.json`,宿主会**复制一次**示例(不覆盖已有文件)。
模板默认示例为 **SQLite 显式声明**(与自动扫描 `{skill_slug}.db` 等价)。改走 MySQL 时,请参照 `assets/examples/data-sources.mysql.example.json` 手工替换用户目录中的 `data-sources.json`,并在 `.env` 配置 DSN。
若不需要声明式接入,可删除用户目录中的 `data-sources.json`,宿主恢复自动扫描 `.db` 文件。
---
## `data-sources.json` 格式
根对象:
| 字段 | 类型 | 说明 |
|------|------|------|
| `version` | `1` | 固定为 1 |
| `sources` | 数组 | 至少一条数据源 |
每条 `sources[]`:
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | 是 | 英文标识,如 `primary` |
| `kind` | 是 | `sqlite` 或 `mysql` |
| `displayName` | 否 | 侧栏展示名;多库时建议填写 |
| `path` | sqlite 必填 | 相对技能数据目录的路径;可用 `{skillSlug}` 占位 |
| `connectionEnv` | mysql 必填 | `.env` 中 DSN 的环境变量名,如 `MYSQL_DSN` |
### SQLite 示例(模板默认)
见 [`../assets/data-sources.example.json`](../assets/data-sources.example.json)。
### MySQL 示例
见 [`../assets/examples/data-sources.mysql.example.json`](../assets/examples/data-sources.mysql.example.json)。
---
## `.env` 与 DSN
MySQL 连接串写在用户 `.env`(或进程环境变量),**不要**写进 `data-sources.json`:
```env
# MySQL 云库连接(不配则走本地 SQLite)
MYSQL_DSN=mysql://user:password@192.168.0.20:3306/your_database
```
格式:`mysql://` 或 `mysql2://`,须含库名。注释写法须符合 [`../development/CONFIG.md`](../development/CONFIG.md)(配置管理会展示给用户)。
技能 Python 侧:`scripts/db/backend.py` 读取同一 `MYSQL_DSN`;有值则 `resolve_backend()` 为 `mysql`,否则 `sqlite`。
---
## 技能代码职责
### 宿主 UI(数据管理)
宿主读取用户目录 `data-sources.json` + `.env`,经 Host 内置连接器访问库。**不依赖**技能 `connection.py`。
### CLI / Action / `init-db`
**必须**与宿主使用同一后端策略,否则会出现「UI 看云库、CLI 写本地 `.db`」的分裂。
模板已提供:
| 模块 | 作用 |
|------|------|
| `scripts/db/backend.py` | `resolve_backend()`、`parse_mysql_dsn()` |
| `scripts/db/cursor_compat.py` | 统一 `?` 占位符与 `executescript` |
| `scripts/db/connection.py` | `get_conn()` 双分支;`init_db()` 幂等建 `_jiangchang_*` |
业务表 DDL / 迁移仍在技能自己的 `schema` / `init_db()` 中按 `conn.backend` 分支处理(见工单参考实现)。
可选:`describe_active_store()` 用于 health / 排障,输出当前连的是 sqlite 还是 mysql。
---
## MySQL 当前限制(Host)
- 不支持 `tableAdmin`(建表/改表 UI)
- 不支持 `cellPresentation`
- 元数据与 CRUD 与 SQLite 路径一致
表结构变更仍由技能 `init-db` / 迁移脚本负责。
---
## 开发 Checklist(云库)
复制新业务技能并选择 MySQL 时:
1. Host ≥ 6.9.28
2. 用户目录放置 `data-sources.json`(mysql 源 + `connectionEnv`)
3. `.env` 配置 `MYSQL_DSN`(或你在 json 里声明的 env 名)
4. `requirements.txt` 含 `PyMySQL>=1.1.0`(模板已声明)
5. 业务 DDL 同时支持 sqlite / mysql 分支(或仅部署 mysql)
6. **抽测**:数据管理 UI 与 CLI(如 list / create)读写同一库
7. 见 [`../development/CHECKLIST.md`](../development/CHECKLIST.md) A6-16
---
## 与 SCHEMA / ACTIONS 的关系
- [`SCHEMA.md`](SCHEMA.md):`_jiangchang_*` 契约适用于**已注册数据源**上的表,不限于本地文件。
- [`ACTIONS.md`](ACTIONS.md):无 `replaces` 时宿主直写**已注册数据源**(默认本地 SQLite)。
- [`CLI.md`](CLI.md):`init-db` 对当前 active backend 幂等初始化元数据。

View File

@@ -18,10 +18,11 @@
| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口与触发摘要 |
| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 |
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口、按钮中文名 `label` 参考(按入口定位表挂载;先读 `development/PRODUCT_SURFACES.md`) |
| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
| [`SCHEMA.md`](SCHEMA.md) | 业务表与数据管理展示契约(SQLite / MySQL) |
| [`DATA_SOURCES.md`](DATA_SOURCES.md) | 多数据源声明:SQLite 默认、可选 MySQL 云库(Host ≥ 6.9.28) |
| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) |
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理呈现:格子 / 整行 / 整列 / 区域语义 tone(用途不限;多数技能 P0 不配) |
| [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md) | 业务行生命周期:处理队列的可操作/进行中/终态角色与重做规则(有处理行 Action 时适用) |
| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) |
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;云库 / 声明式数据源读 `DATA_SOURCES.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。

View File

@@ -1,6 +1,6 @@
# 数据存储与数据管理展示规范
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
本文是 skill **已注册数据源**(默认本地 SQLite 文件;可选 MySQL 云库,见 [`DATA_SOURCES.md`](DATA_SOURCES.md))与匠厂宿主「数据管理」页面展示契约的**权威说明**。
**产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。
@@ -10,7 +10,7 @@
### 技能负责
- 定义真实 SQLite 表结构
- 定义真实业务表结构(SQLite 或 MySQL,按技能所选后端)
- 使用英文 `snake_case` 表名和字段名
- 使用 `_jiangchang_tables` 提供表级展示元数据
- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明)
@@ -339,6 +339,8 @@ upsert_column_metadata(
| (枚举) | 任意 + `options_json` **数组** | 标签映射 | 下拉选择 | 选项 `value` |
| `lookup` | 跨表引用 id;`options_json` **对象**;可选 `multiple` | 单选:标签映射;多选:多标签(如「豆包、元宝」) | 单选下拉;多选见下文 | 单选:业务 id 标量;多选:`TEXT` 逗号分隔 id(非 SQLite 外键) |
| (敏感) | 任意;列 `sensitive=1` | 掩码/隐藏策略由宿主定 | 新建可用密码框;编辑策略与既有宿主一致 | 原文(勿日志明文) |
| `year` | `TEXT` 推荐 `YYYY` | 原文 | **年份选择** | `YYYY` 字符串 |
| `year_month` | `TEXT` 推荐 `YYYY-MM` | 原文 | **年月选择** | `YYYY-MM` 字符串;别名 `month` |
| `date` | `TEXT` 推荐 `YYYY-MM-DD`,或可安全归一的值 | 日期 | **日期选择**(日) | `YYYY-MM-DD` |
| `datetime_unix_seconds` | `INTEGER` | `YYYY-MM-DD HH:mm:ss` | **日期时间选择**;提交写回 **Unix 秒** | Unix **秒**整数;禁止改成 ISO 字符串 |
| `url` | `TEXT` | 原文(可作链接) | 单行输入 `type=url`(薄层;校验从宽) | 原文 |
@@ -438,7 +440,7 @@ Action 参数表单的 `title` / `description` / `default` / `display_type` 见
| `boolean` | 下拉(有 `options_json` 用其 label;否则宿主默认可选「是/否」) | 等于 / 不等于 / 为空 | `display_type: boolean`;**推荐同时写 `options_json`** 指定中文 label |
| `lookup` 单选 | 下拉 | 等于 / 不等于 / 为空 | `display_type: lookup` + lookup 对象 |
| `lookup` + `multiple: true` | 列筛选 UI 演进中;**全局搜索**已支持 | 等于时按逗号分隔 id 的 token 匹配 | 多选存 `TEXT` 逗号分隔 id |
| `date` / `datetime_unix_seconds` | 日期 / 日期时间 | 比较类 | 时间列通常 `searchable: 0` |
| `year` / `year_month` / `date` / `datetime_unix_seconds` | 年 / 年月 / 日期 / 日期时间 | 比较类 | 账期/月份列推荐 `year_month`;时间列通常 `searchable: 0` |
| 敏感列 | **不可筛** | — | 宿主强制排除 |
**全局搜索扩展(用户可搜中文)**

View File

@@ -1 +1,2 @@
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。
PyMySQL>=1.1.0

35
scripts/db/backend.py Normal file
View File

@@ -0,0 +1,35 @@
"""数据库后端选择:配置了 MYSQL_DSN 时走 MySQL,否则回退本地 SQLite。"""
from __future__ import annotations
from typing import Literal
from urllib.parse import unquote, urlparse
from jiangchang_skill_core import config
DbBackend = Literal["sqlite", "mysql"]
def get_mysql_dsn() -> str:
return (config.get("MYSQL_DSN") or "").strip()
def resolve_backend() -> DbBackend:
return "mysql" if get_mysql_dsn() else "sqlite"
def parse_mysql_dsn(dsn: str) -> dict[str, object]:
text = dsn.strip()
if not text.startswith(("mysql://", "mysql2://")):
raise ValueError("MYSQL_DSN must be a mysql:// URL.")
parsed = urlparse(text)
database = (parsed.path or "").lstrip("/")
if not database:
raise ValueError("MYSQL_DSN must include a database name.")
return {
"host": parsed.hostname or "127.0.0.1",
"port": parsed.port or 3306,
"user": unquote(parsed.username or ""),
"password": unquote(parsed.password or ""),
"database": database,
}

View File

@@ -1,18 +1,38 @@
"""SQLite 连接与幂等初始化模板。
"""SQLite / MySQL 连接与幂等初始化模板。
模板默认只建数据管理元数据表。业务表由复制后的技能自行 CREATE,并同步展示元数据。
配置了 MYSQL_DSN 时走 MySQL 云库,否则使用本地 {skill_slug}.db。见 references/DATA_SOURCES.md。
"""
from __future__ import annotations
import sqlite3
from db.backend import get_mysql_dsn, parse_mysql_dsn, resolve_backend
from db.cursor_compat import AdaptedConnection
from db.display_metadata import init_display_metadata
from util.runtime_paths import get_db_path
def get_conn() -> sqlite3.Connection:
return sqlite3.connect(get_db_path())
def get_conn() -> AdaptedConnection:
backend = resolve_backend()
if backend == "mysql":
import pymysql
params = parse_mysql_dsn(get_mysql_dsn())
raw = pymysql.connect(
host=str(params["host"]),
port=int(params["port"]),
user=str(params["user"]),
password=str(params["password"]),
database=str(params["database"]),
charset="utf8mb4",
autocommit=False,
)
return AdaptedConnection(raw, "mysql")
raw = sqlite3.connect(get_db_path())
return AdaptedConnection(raw, "sqlite")
def init_db() -> None:
@@ -23,3 +43,18 @@ def init_db() -> None:
conn.commit()
finally:
conn.close()
def describe_active_store() -> dict[str, str]:
backend = resolve_backend()
if backend == "mysql":
params = parse_mysql_dsn(get_mysql_dsn())
return {
"backend": "mysql",
"host": str(params["host"]),
"database": str(params["database"]),
}
return {
"backend": "sqlite",
"db_path": get_db_path(),
}

View File

@@ -0,0 +1,96 @@
"""统一 SQLite / MySQL 游标:占位符 ? 与 executescript。"""
from __future__ import annotations
import re
from typing import Any
from db.backend import DbBackend
_PLACEHOLDER_RE = re.compile(r"\?")
def adapt_sql(sql: str, backend: DbBackend) -> str:
if backend != "mysql":
return sql
return _PLACEHOLDER_RE.sub("%s", sql)
def _split_sql_script(script: str) -> list[str]:
parts: list[str] = []
for chunk in script.split(";"):
stmt = chunk.strip()
if stmt:
parts.append(stmt)
return parts
class AdaptedCursor:
backend: DbBackend
def __init__(self, cursor: Any, backend: DbBackend) -> None:
self._cursor = cursor
self.backend = backend
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any:
adapted = adapt_sql(sql, self.backend)
if params is None:
return self._cursor.execute(adapted)
return self._cursor.execute(adapted, params)
def executemany(self, sql: str, seq_of_params: list[tuple[Any, ...]]) -> Any:
return self._cursor.executemany(adapt_sql(sql, self.backend), seq_of_params)
def fetchone(self) -> Any:
return self._cursor.fetchone()
def fetchall(self) -> list[Any]:
return self._cursor.fetchall()
@property
def lastrowid(self) -> int:
return int(self._cursor.lastrowid)
@property
def rowcount(self) -> int:
return int(self._cursor.rowcount)
def executescript(self, script: str) -> None:
if self.backend == "sqlite":
self._cursor.connection.executescript(script)
return
for stmt in _split_sql_script(script):
self.execute(stmt)
class AdaptedConnection:
backend: DbBackend
def __init__(self, conn: Any, backend: DbBackend) -> None:
self._conn = conn
self.backend = backend
def cursor(self) -> AdaptedCursor:
return AdaptedCursor(self._conn.cursor(), self.backend)
def commit(self) -> None:
self._conn.commit()
def close(self) -> None:
self._conn.close()
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any:
cur = self.cursor()
return cur.execute(sql, params)
def executemany(self, sql: str, seq_of_params: list[tuple[Any, ...]]) -> Any:
cur = self.cursor()
return cur.executemany(sql, seq_of_params)
def executescript(self, script: str) -> None:
if self.backend == "sqlite":
self._conn.executescript(script)
return
cur = self.cursor()
for stmt in _split_sql_script(script):
cur.execute(stmt)

View File

@@ -22,6 +22,8 @@ LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
YEAR_MONTH = "year_month"
YEAR = "year"
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
_JIANGCHANG_TABLES_DDL = """

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.115"
SKILL_VERSION = "1.0.118"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.11"

49
tests/test_db_backend.py Normal file
View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""数据库后端选择:默认 SQLite,DSN 解析。"""
from __future__ import annotations
import os
import unittest
from _support import IsolatedDataRoot
class TestDbBackend(unittest.TestCase):
def test_resolve_backend_defaults_to_sqlite(self) -> None:
with IsolatedDataRoot():
from db.backend import resolve_backend
self.assertEqual(resolve_backend(), "sqlite")
def test_parse_mysql_dsn(self) -> None:
with IsolatedDataRoot():
from db.backend import parse_mysql_dsn
params = parse_mysql_dsn("mysql://root:secret@192.168.0.20:3306/demo_db")
self.assertEqual(params["host"], "192.168.0.20")
self.assertEqual(params["port"], 3306)
self.assertEqual(params["user"], "root")
self.assertEqual(params["password"], "secret")
self.assertEqual(params["database"], "demo_db")
def test_resolve_backend_uses_mysql_dsn_from_env(self) -> None:
with IsolatedDataRoot():
from db.backend import resolve_backend
os.environ["MYSQL_DSN"] = "mysql://u:p@127.0.0.1:3306/t"
try:
self.assertEqual(resolve_backend(), "mysql")
finally:
os.environ.pop("MYSQL_DSN", None)
def test_describe_active_store_sqlite(self) -> None:
with IsolatedDataRoot():
from db.connection import describe_active_store
info = describe_active_store()
self.assertEqual(info["backend"], "sqlite")
self.assertIn("db_path", info)
if __name__ == "__main__":
unittest.main()

View File

@@ -39,6 +39,7 @@ POLICY_IDS = (
"POLICY-RPA-004",
"POLICY-RPA-005",
"POLICY-RPA-006",
"POLICY-RPA-007",
"POLICY-PACKAGING-001",
"POLICY-PACKAGING-002",
"POLICY-RELEASE-001",
@@ -205,6 +206,26 @@ RPA_006_EXAMPLE_CLIENTS = (
"examples/simulator_browser_rpa/scripts/service/account_client.py",
)
RPA_007_SOURCE = (
"development/RPA.md §1.2.1; development/RPA_BROWSER.md §4; "
"examples/real_browser_rpa; development/POLICY_MATRIX.md"
)
RPA_007_DOC_MARKERS = (
"no_viewport=True",
"viewport=None",
"setWindowBounds",
"POLICY-RPA-007",
)
RPA_007_LAUNCH_MARKERS = RPA_004_LAUNCH_MARKERS
RPA_007_FORBIDDEN_PATTERNS = (
"viewport=None",
"setWindowBounds",
"maximize_browser_window",
)
RPA_007_REAL_BROWSER_SESSION = (
"examples/real_browser_rpa/scripts/service/browser_session.py"
)
LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md"
LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
LOGGING_003_SOURCE = "development/LOGGING.md"
@@ -837,6 +858,84 @@ class TestPolicyRpa005(unittest.TestCase):
)
class TestPolicyRpa007(unittest.TestCase):
def test_rpa_md_documents_viewport_window_standard(self) -> None:
skill_root = get_skill_root()
path = os.path.join(skill_root, "development", "RPA.md")
text = _read_text(path)
missing = [marker for marker in RPA_007_DOC_MARKERS if marker not in text]
self.assertEqual(
missing,
[],
msg=_policy_msg(
"POLICY-RPA-007",
RPA_007_SOURCE,
"RPA.md missing viewport/window markers: " + ", ".join(missing),
),
)
def test_real_browser_example_uses_no_viewport(self) -> None:
skill_root = get_skill_root()
path = os.path.join(skill_root, *RPA_007_REAL_BROWSER_SESSION.split("/"))
text = _read_text(path)
self.assertIn(
"no_viewport=True",
text,
msg=_policy_msg(
"POLICY-RPA-007",
RPA_007_SOURCE,
f"{RPA_007_REAL_BROWSER_SESSION} must use no_viewport=True",
),
)
self.assertNotIn(
"viewport=None",
text,
msg=_policy_msg(
"POLICY-RPA-007",
RPA_007_SOURCE,
f"{RPA_007_REAL_BROWSER_SESSION} must not use viewport=None",
),
)
def test_launch_persistent_forbids_bad_viewport_and_cdp_maximize(self) -> None:
skill_root = get_skill_root()
rel_paths: list[str] = []
for root_name in ("scripts", "examples"):
root = os.path.join(skill_root, root_name)
if not os.path.isdir(root):
continue
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
rel_dir = os.path.relpath(dirpath, skill_root).replace("\\", "/")
if any(rel_dir == p or rel_dir.startswith(p + "/") for p in SKIP_REL_PREFIXES):
continue
for name in filenames:
if not name.endswith(".py"):
continue
rel = os.path.relpath(os.path.join(dirpath, name), skill_root).replace(
"\\", "/"
)
rel_paths.append(rel)
violations: list[str] = []
for rel in rel_paths:
text = _read_text(os.path.join(skill_root, *rel.split("/")))
if not any(marker in text for marker in RPA_007_LAUNCH_MARKERS):
continue
for pattern in RPA_007_FORBIDDEN_PATTERNS:
if pattern in text:
violations.append(f"{rel}: forbidden {pattern!r}")
self.assertEqual(
violations,
[],
msg=_policy_msg(
"POLICY-RPA-007",
RPA_007_SOURCE,
"launch_persistent files with forbidden viewport/window hacks: "
+ "; ".join(violations),
),
)
class TestPolicyRpa006(unittest.TestCase):
def test_account_manager_lifecycle_doc_has_required_markers(self) -> None:
skill_root = get_skill_root()

View File

@@ -358,6 +358,7 @@ class TestDocsStandards(unittest.TestCase):
"新增/编辑表单:字段说明与默认值(宿主支持)",
"表单与展示:稳定字段类型(宿主支持,须与 Action 对称)",
"datetime_unix_seconds",
"year_month",
"display_type: \"number\"",
"extra_column",
"{value} · {label}",
@@ -384,6 +385,28 @@ class TestDocsStandards(unittest.TestCase):
):
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
def test_data_sources_md_documents_mysql_and_declaration(self) -> None:
text = self._read("references/DATA_SOURCES.md")
for marker in (
"data-sources.json",
"MYSQL_DSN",
"6.9.28",
"resolve_backend",
"一个 `sourceId`",
"process-work-order",
"tableAdmin",
):
self.assertIn(marker, text, msg=f"DATA_SOURCES.md missing {marker!r}")
self.assertTrue(os.path.isfile(os.path.join(get_skill_root(), "assets/data-sources.example.json")))
self.assertTrue(
os.path.isfile(os.path.join(get_skill_root(), "assets/examples/data-sources.mysql.example.json"))
)
env_example = self._read(".env.example")
self.assertIn("MYSQL_DSN", env_example)
checklist = self._read("development/CHECKLIST.md")
self.assertIn("A6-16", checklist)
self.assertIn("DATA_SOURCES.md", checklist)
def test_actions_md_documents_form_schema_split(self) -> None:
text = self._read("references/ACTIONS.md")
for marker in (
@@ -396,6 +419,7 @@ class TestDocsStandards(unittest.TestCase):
"数据管理新增/编辑",
"_jiangchang_columns.description",
"datetime_unix_seconds",
"year_month",
"SCHEMA.md",
"字段条件显隐",
"visibleWhen",