feat: add three-surface RPA locator standards and examples
All checks were successful
技能自动化发布 / release (push) Successful in 5s
All checks were successful
技能自动化发布 / release (push) Successful in 5s
This commit is contained in:
@@ -33,6 +33,8 @@ real_browser_rpa/
|
||||
│ └── util/
|
||||
│ ├── constants.py # 平台常量、selector 占位
|
||||
│ └── logging.py # 结构化日志与脱敏
|
||||
├── assets/
|
||||
│ └── element-map.sample.yaml # 元素清单模板;真实拾取后填写
|
||||
└── tests/
|
||||
├── test_human_verification.py # 验证码误判防护
|
||||
└── test_stop_reason.py # 停止原因文案
|
||||
@@ -55,12 +57,13 @@ real_browser_rpa/
|
||||
## 开发新 skill 时如何复制
|
||||
|
||||
1. **先阅读**本 README 及下方「Copy map」「复制边界」章节
|
||||
2. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
|
||||
3. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
|
||||
4. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
|
||||
5. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
|
||||
6. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
|
||||
7. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
|
||||
2. **先完成元素工程**:阅读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`,在真实站用 Pick Locator/F12 拾取并填写 element map
|
||||
3. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
|
||||
4. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
|
||||
5. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
|
||||
6. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
|
||||
7. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
|
||||
8. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
|
||||
|
||||
## Copy map(复制到新技能 `scripts/`)
|
||||
|
||||
@@ -73,6 +76,7 @@ real_browser_rpa/
|
||||
| `examples/real_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 参考并合并编排层 |
|
||||
| `examples/real_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 平台常量与 selector(须替换) |
|
||||
| `examples/real_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选,按需合并 |
|
||||
| `examples/real_browser_rpa/assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实 Pick Locator/F12 拾取后填写 |
|
||||
| `examples/real_browser_rpa/tests/` | `tests/` | 可按需复制测试思路 |
|
||||
| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
|
||||
|
||||
@@ -121,6 +125,8 @@ real_browser_rpa/
|
||||
- 不要照抄平台名、slug、logger 名
|
||||
- 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖
|
||||
- 不要把示例里的 result selector 当成真实站点 selector
|
||||
- 不要复制“扫描所有 input 再按宽度/位置打分”的诊断式猜测作为生产主定位器
|
||||
- 不要用 `.first` / `.nth` 绕过多匹配;关键动作必须先解决唯一性
|
||||
- 不要把示例代码原样发布为业务 skill
|
||||
- 不要把真实账号、手机号、cookie、token 写入代码或 README
|
||||
|
||||
|
||||
45
examples/real_browser_rpa/assets/element-map.sample.yaml
Normal file
45
examples/real_browser_rpa/assets/element-map.sample.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
surface: browser
|
||||
status: placeholder_requires_real_site_inspection
|
||||
elements:
|
||||
- logical_name: search_input
|
||||
purpose: 输入搜索关键词
|
||||
scope:
|
||||
kind: main
|
||||
business_container: 待真实拾取
|
||||
primary_locator:
|
||||
strategy: role_or_label
|
||||
value: 待真实拾取
|
||||
fallback_locators: []
|
||||
expected_count: 1
|
||||
ready_condition:
|
||||
visible: true
|
||||
enabled: true
|
||||
postcondition:
|
||||
description: 搜索结果容器出现
|
||||
verified_target_version: 待填写
|
||||
|
||||
- logical_name: search_button
|
||||
purpose: 提交搜索
|
||||
scope:
|
||||
logical_parent: search_form
|
||||
primary_locator:
|
||||
strategy: role
|
||||
value: button,name=搜索
|
||||
fallback_locators: []
|
||||
expected_count: 1
|
||||
postcondition:
|
||||
description: 结果页或业务错误状态出现
|
||||
verified_target_version: 待填写
|
||||
|
||||
- logical_name: result_container
|
||||
purpose: 承载搜索结果
|
||||
scope:
|
||||
kind: main
|
||||
primary_locator:
|
||||
strategy: stable_business_container
|
||||
value: 待真实拾取
|
||||
fallback_locators: []
|
||||
expected_count: 1
|
||||
postcondition:
|
||||
description: 列表项、空结果或结束标记可区分
|
||||
verified_target_version: 待填写
|
||||
@@ -188,42 +188,22 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None:
|
||||
async def _find_search_input(page) -> Tuple[Optional[Any], str]:
|
||||
for sel in SEARCH_INPUT_SELECTORS:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
if await _visible(loc):
|
||||
loc = page.locator(sel)
|
||||
count = await loc.count()
|
||||
if count == 1 and await _visible(loc):
|
||||
return loc, sel
|
||||
if count > 1:
|
||||
logger.warning("search_input_ambiguous selector=%s count=%s", sel, count)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
candidates: List[Tuple[int, Any, str]] = []
|
||||
try:
|
||||
inputs = await page.locator("input").all()
|
||||
except Exception:
|
||||
inputs = []
|
||||
for inp in inputs:
|
||||
try:
|
||||
if not await inp.is_visible():
|
||||
continue
|
||||
box = await inp.bounding_box()
|
||||
if not box or box.get("width", 0) < 120:
|
||||
continue
|
||||
placeholder = await inp.get_attribute("placeholder") or ""
|
||||
score = box.get("width", 0)
|
||||
if "搜索" in placeholder:
|
||||
score += 500
|
||||
if box.get("y", 9999) < 200:
|
||||
score += 200
|
||||
candidates.append((score, inp, f"input placeholder={placeholder!r}"))
|
||||
except Exception:
|
||||
continue
|
||||
if candidates:
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
_, inp, sel = candidates[0]
|
||||
return inp, sel
|
||||
|
||||
try:
|
||||
ph = page.get_by_placeholder(re.compile("搜索"))
|
||||
if await ph.count() > 0 and await ph.first.is_visible():
|
||||
return ph.first, "placeholder=搜索"
|
||||
count = await ph.count()
|
||||
if count == 1 and await ph.is_visible():
|
||||
return ph, "placeholder=搜索"
|
||||
if count > 1:
|
||||
logger.warning("search_input_ambiguous selector=placeholder_search count=%s", count)
|
||||
except Exception:
|
||||
pass
|
||||
return None, ""
|
||||
@@ -232,14 +212,24 @@ async def _find_search_input(page) -> Tuple[Optional[Any], str]:
|
||||
async def _find_search_button(page):
|
||||
try:
|
||||
btn = page.get_by_role("button", name="搜索")
|
||||
if await btn.count() > 0 and await btn.first.is_visible():
|
||||
return btn.first
|
||||
count = await btn.count()
|
||||
if count == 1 and await btn.is_visible():
|
||||
return btn
|
||||
if count > 1:
|
||||
logger.warning("search_button_ambiguous selector=role_button_search count=%s", count)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
btn = page.locator(SEARCH_BUTTON_SELECTOR).first
|
||||
if await btn.count() > 0 and await btn.is_visible():
|
||||
btn = page.locator(SEARCH_BUTTON_SELECTOR)
|
||||
count = await btn.count()
|
||||
if count == 1 and await btn.is_visible():
|
||||
return btn
|
||||
if count > 1:
|
||||
logger.warning(
|
||||
"search_button_ambiguous selector=%s count=%s",
|
||||
SEARCH_BUTTON_SELECTOR,
|
||||
count,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user