diff --git a/CHANGELOG.md b/CHANGELOG.md index c289259..3b2821a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 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 diff --git a/SKILL.md b/SKILL.md index f107dd0..dc67ce7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.117 +version: 1.0.118 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/development/CHECKLIST.md b/development/CHECKLIST.md index ff4fede..6420319 100644 --- a/development/CHECKLIST.md +++ b/development/CHECKLIST.md @@ -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` 在当前用户数据目录真实存在 diff --git a/development/POLICY_MATRIX.md b/development/POLICY_MATRIX.md index f8b4a59..7434a07 100644 --- a/development/POLICY_MATRIX.md +++ b/development/POLICY_MATRIX.md @@ -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` | diff --git a/development/RPA.md b/development/RPA.md index 261b58e..15807ab 100644 --- a/development/RPA.md +++ b/development/RPA.md @@ -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 / 验证码 diff --git a/development/RPA_BROWSER.md b/development/RPA_BROWSER.md index 15ede81..0d48ea9 100644 --- a/development/RPA_BROWSER.md +++ b/development/RPA_BROWSER.md @@ -90,6 +90,19 @@ button = row.get_by_role("button", name="添加") Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。closed Shadow DOM 无法正常读取时,评估目标应用是否提供可访问性属性,不能注入破解。 +**Web Component 双 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` 就判定结果成功。 diff --git a/examples/real_browser_rpa/README.md b/examples/real_browser_rpa/README.md index e7debee..9f5aafe 100644 --- a/examples/real_browser_rpa/README.md +++ b/examples/real_browser_rpa/README.md @@ -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`) ## 复制边界:哪些能复制,哪些必须替换 diff --git a/examples/real_browser_rpa/scripts/service/browser_session.py b/examples/real_browser_rpa/scripts/service/browser_session.py index 4b89336..8911d79 100644 --- a/examples/real_browser_rpa/scripts/service/browser_session.py +++ b/examples/real_browser_rpa/scripts/service/browser_session.py @@ -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 diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 24b28d7..ffe74c0 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.117" +SKILL_VERSION = "1.0.118" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.2.11" diff --git a/tests/test_development_policy_guard.py b/tests/test_development_policy_guard.py index 9bcb932..90e1d2b 100644 --- a/tests/test_development_policy_guard.py +++ b/tests/test_development_policy_guard.py @@ -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()