v1.1.5: platform_collector 修复登录等待太短导致采集不到数据
All checks were successful
技能自动化发布 / release (push) Successful in 8s

问题:sandbox RPA 采集时,给用户登录态的等待时间太短(settle_ms=0 /
input timeout=10s / wait_for_url 20s / click 后 0ms 路由稳定时间),SPA
异步跳转被打断,session 没保住,最终 40s 内找不到表格、fetched_rows=0。

修复(核心 5 处):
- _fill_spa_login:settle_ms 默认 4000ms、input timeout 20s、wait_for_url 30s、
  URL 判断覆盖 /auth/ 中间态
- _goto(SPA_LOGIN_URL) 后 wait 3000ms(SPA hydration)
- 登录按钮 click 后 wait 2000ms(等 token 写入 cookie 再 goto home)
- 步骤 4 改为 retry 循环(最多 2 次)
- 步骤 5.6:刷新后若 URL 在 /login 重新登录一次

健壮性修复(5 处):
- 侧边栏 link 等待 15s → 30s
- reload timeout 20s → 30s
- 「续聘材料包」click 后 wait 1500ms → 3000ms
- 抽出 _dump_page helper,自动 mkdir(parents=True) 修 5 处 dump 父目录缺失
- 移除残留 dump_path / html 引用

版本对齐:
- SKILL.md version 1.1.4 → 1.1.5
- constants.py SKILL_VERSION 1.1.4 → 1.1.5
- CHANGELOG.md 加 1.1.5 条目

测试:21/21 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 17:06:28 +08:00
parent 51cfccc400
commit f38f662316
4 changed files with 102 additions and 64 deletions

View File

@@ -2,6 +2,20 @@
本文件对应技能市场 **「更新日志」** Tab。
## 1.1.5
- **platform_collector RPA 采集修复(登录等待时间太短 → 拿不到数据)**
- `_fill_spa_login`:settle_ms 默认 0 → **4000ms**,等 username input timeout 10s → **20s**,wait_for_url(URL 真的跳离 login)20s → **30s**;URL 判断增加 `/auth/` 路径(覆盖 SPA 中间态)
- `_goto(SPA_LOGIN_URL)` 后:wait 1500ms → **3000ms**(SPA hydration)
- 登录按钮 click 后:新增 `wait_for_timeout(2000)`,等 SPA token 写入 cookie + 路由稳定再 goto home(之前直接打断 pending 跳转)
- 步骤 4 改为 retry 循环(最多 2 次),检查 `/app/` in url 且不含 `/login` 才退出
- **采集流程健壮性修复**
- 步骤 5 侧边栏 link 等待:15s → **30s**(sandbox mock 慢启动不 fallback 到 pushState)
- 步骤 5.5 `page.reload()` timeout:20s → **30s**
- **新增 5.6**:刷新后检查 URL,若被踢回 `/login` 则重新登录一次(之前刷新丢 session 后还继续往后走,导致 40s 找不到表格)
- 「续聘材料包」按钮 click 后:wait 1500ms → **3000ms**(卡片动画慢导致文本抓不全)
- 抽出 `_dump_page(page, path, label)` helper,**自动 `mkdir(parents=True)`**,修复 5 处 dump 路径父目录可能不存在的静默失败
## 1.1.4
- **多平台发布声明**:SKILL.md `metadata.openclaw.brands` 新增 `jiangchang` + `huashang`,发布后同步挂匠厂主站与华商 OEM 市场。CI 会幂等补写 `skill_market_relations` 两行(**只补不删**)。

View File

@@ -1,7 +1,7 @@
---
name: 合同到期智能预警与续聘辅助
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。"
version: 1.1.4
version: 1.1.5
author: 深圳匠厂科技有限公司
metadata:
openclaw:

View File

@@ -68,6 +68,20 @@ def _goto(page, url: str, timeout: int = 20_000) -> None:
logger.warning("goto domcontentloaded 也失败 %s: %s", url, e2)
def _dump_page(page, path: Path, label: str) -> None:
"""Dump 当前页面 HTML 到指定路径,自动创建父目录。修复:父目录可能不存在导致静默失败。"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
html = page.content()
path.write_text(html, encoding="utf-8")
logger.info(
"platform_collector: %s dump → %s (%d 字节)",
label, path, len(html),
)
except Exception as e:
logger.warning("dump %s 失败: %s", label, e)
def _fill_root_login(page) -> bool:
"""根域登录页(如还在):fill demo/123456 → click Sign in。返回是否执行了登录。"""
user_input = page.locator('#jc-user').first
@@ -103,14 +117,24 @@ def _click_campus_link(page) -> bool:
return True
def _fill_spa_login(page) -> bool:
"""sandbox SPA 登录(如还在登录页):fill demo/123456 → click 登录。返回是否执行了登录。"""
if "/login" not in page.url and "/signin" not in page.url:
def _fill_spa_login(page, settle_ms: int = 4000) -> bool:
"""sandbox SPA 登录(如还在登录页):fill demo/123456 → click 登录 → 等跳转。
返回是否执行了登录(True = 已点登录按钮;调用方应继续等 URL 跳走)。
修复要点(登录时间太短导致采集不到数据):
- 默认 settle_ms 从 0 提到 4000ms,给 SPA 异步渲染 + token 写入 cookie 时间
- 等 username input 的 timeout 从 10s 提到 20s(mock 慢启动)
- click 按钮后显式 wait_for_url 含更长 timeout(30s),URL 真的不含 /login 才返回 True
- 多处过渡路径(/login, /signin, /auth/)一并判断,避免 SPA 中间态被误判为「已离开登录页」
"""
url_lower = page.url.lower()
if "/login" not in url_lower and "/signin" not in url_lower and "/auth/" not in url_lower:
return False
try:
page.wait_for_selector('input[autocomplete="username"]', timeout=10_000)
page.wait_for_selector('input[autocomplete="username"]', timeout=20_000)
except Exception as e:
logger.warning("SPA login input 未出现: %s", e)
logger.warning("SPA login input 未出现: %s;当前 URL=%s", e, page.url)
return False
spa_user = page.locator('input[autocomplete="username"]').first
if spa_user.count() == 0:
@@ -122,13 +146,19 @@ def _fill_spa_login(page) -> bool:
'button:has-text("登录"), button[type="submit"]:has-text("Sign in")'
).first.click()
logger.info("platform_collector: sandbox SPA 自动登录...")
# click 后给 SPA 一点时间把 token 写入 cookie / 触发跳转
page.wait_for_timeout(settle_ms)
# 再等到 URL 真的跳离 login 页(最长 30s;之前 20s 太短)
try:
page.wait_for_url(
lambda u: "/login" not in u and "/signin" not in u,
timeout=20_000,
lambda u: "/login" not in u.lower()
and "/signin" not in u.lower()
and "/auth/" not in u.lower(),
timeout=30_000,
)
except Exception as e:
logger.warning("SPA 登录超时: %s;当前 URL=%s", e, page.url)
return False
logger.info("platform_collector: SPA 登录后 URL=%s", page.url)
return True
@@ -172,39 +202,39 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
# 2. 直接 goto sandbox SPA login(绕过 /select 和 root 跳转)
logger.info("platform_collector: 强制 goto sandbox SPA login, 当前 URL=%s", page.url)
_goto(page, SPA_LOGIN_URL)
# SPA hydration + framework 初始化时间(修复登录时间太短的核心点)
page.wait_for_timeout(3000)
# 3. sandbox SPA 登录(demo/123456 已知可用)
page.wait_for_timeout(1500)
_fill_spa_login(page)
# 等待 URL 真的跳离 login(settle_ms 已给到 4s,wait_for_url 又给 30s)
if _fill_spa_login(page):
# 等 SPA 路由稳定(之前这里没有 wait,直接 goto home 会打断 pending 跳转)
page.wait_for_timeout(2000)
# 4. 强制 goto SPA home
if "/app/" not in page.url:
logger.info("platform_collector: 强制 goto SPA_HOME, 当前 URL=%s", page.url)
# 4. 强制 goto SPA home(如果还停在 login,重试登录一次)
for retry in range(2):
if "/app/" in page.url and "/login" not in page.url.lower():
break
logger.info(
"platform_collector: 强制 goto SPA_HOME (retry=%d), 当前 URL=%s",
retry, page.url,
)
_goto(page, SPA_HOME_URL)
# SPA home 也可能再次 redirect 到 /login(session 没保住)
page.wait_for_timeout(1500)
page.wait_for_timeout(2500)
if _fill_spa_login(page):
_goto(page, SPA_HOME_URL)
page.wait_for_timeout(2000)
# 5. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session)
try:
# dump SPA home 一次(用于调试 selector)
page.wait_for_timeout(2000)
try:
html = page.content()
dump_path = download_dir.parent / "spa_home_dump.html"
dump_path.write_text(html, encoding="utf-8")
logger.info(
"platform_collector: SPA home dump → %s (%d 字节)",
dump_path, len(html),
)
except Exception:
pass
_dump_page(page, download_dir.parent / "spa_home_dump.html", "SPA home")
# 等 SPA 真正加载完(侧边栏 link 出现,最长 15s)
# 等 SPA 真正加载完(侧边栏 link 出现,最长 30s)
# 修复:之前 15s 太短,sandbox mock 加载慢时直接 fallback 到 pushState → 表格加载不出来
try:
page.wait_for_selector(
"a[href$='/hr/contracts']", timeout=15_000, state="attached"
"a[href$='/hr/contracts']", timeout=30_000, state="attached"
)
except Exception as e:
logger.warning("等侧边栏 hr/contracts link 超时: %s", e)
@@ -246,25 +276,30 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
# 5.5. 进入页面后刷新(SPA 进入新路由时 mock 数据可能未加载,需刷新触发)
try:
logger.info("platform_collector: 进入 hr/contracts 后刷新页面")
page.reload(wait_until="networkidle", timeout=20_000)
page.reload(wait_until="networkidle", timeout=30_000)
except Exception as e:
logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e)
try:
page.reload(wait_until="domcontentloaded", timeout=20_000)
page.reload(wait_until="domcontentloaded", timeout=30_000)
except Exception as e2:
logger.warning("刷新 domcontentloaded 也失败: %s", e2)
# 7. dump 当前 hr/contracts 页面(用于调试)
try:
html = page.content()
dump_path = download_dir.parent / "hr_contracts_dump.html"
dump_path.write_text(html, encoding="utf-8")
logger.info(
"platform_collector: hr/contracts dump → %s (%d 字节)",
dump_path, len(html),
# 5.6. 刷新后可能被踢回 login(session 丢失),重新登录一次
# 修复:之前没检查 URL,刷新后停在 login 页继续往后走,找不到表格就 dump 报错
if "/login" in page.url.lower() or "/signin" in page.url.lower():
logger.warning(
"platform_collector: 刷新后回到登录页(%s),重新登录",
page.url,
)
except Exception:
pass
_goto(page, SPA_LOGIN_URL)
page.wait_for_timeout(3000)
if _fill_spa_login(page):
page.wait_for_timeout(2000)
_goto(page, PLATFORM_URL)
page.wait_for_timeout(2000)
# 7. dump 当前 hr/contracts 页面(用于调试)
_dump_page(page, download_dir.parent / "hr_contracts_dump.html", "hr/contracts")
# 8. 等待表格加载(SPA 异步填充 mock 数据,多等几秒)
page.wait_for_timeout(3000) # 给 React 初始化 + mock 数据填充时间
@@ -283,16 +318,8 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
)
if not found_table:
try:
html = page.content()
dump_path = download_dir.parent / "sandbox_dump.html"
dump_path.write_text(html, encoding="utf-8")
logger.warning(
"platform_collector: 40s 后未找到表格,已 dump 页面到 %s(%d 字节)",
dump_path, len(html),
)
except Exception as e:
logger.warning("dump 失败: %s", e)
_dump_page(page, download_dir.parent / "sandbox_dump.html", "sandbox")
logger.warning("platform_collector: 40s 后未找到表格")
return _error_result("table_not_found_after_40s", 0)
# 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector)
@@ -302,12 +329,10 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
timeout=10_000,
)
page.wait_for_timeout(1500)
html = page.content()
full_dump = download_dir.parent / "hr_contracts_full_dump.html"
full_dump.write_text(html, encoding="utf-8")
logger.info(
"platform_collector: 完整 hr/contracts dump → %s (%d 字节)",
full_dump, len(html),
_dump_page(
page,
download_dir.parent / "hr_contracts_full_dump.html",
"完整 hr/contracts",
)
except Exception as e:
logger.warning("等完整渲染超时: %s", e)
@@ -376,7 +401,8 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
)
btn.click()
# 等卡片出现(页面下方追加 h2「续聘准备任务书」/「单位清单」)
page.wait_for_timeout(1500)
# 修复:1500ms 太短,sandbox mock 渲染卡片动画慢,导致抓到的文本不全
page.wait_for_timeout(3000)
# 抓表格下方所有 card 文本
cards_text = page.locator(
"main .grid > div, main > div > div > div"
@@ -398,12 +424,10 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
)
# dump 一次(i==0)方便调试
if i == 0:
html_after = page.content()
click_dump = download_dir.parent / "after_click_dump.html"
click_dump.write_text(html_after, encoding="utf-8")
logger.info(
"platform_collector: 首行 click 后 dump → %s (%d 字节)",
click_dump, len(html_after),
_dump_page(
page,
download_dir.parent / "after_click_dump.html",
"首行 click 后",
)
except Exception as e:
logger.warning("抓卡片失败 %s: %s", contract_id, e)

View File

@@ -6,7 +6,7 @@
"""
SKILL_SLUG = "alert-contract-expiring-hr"
SKILL_VERSION = "1.1.4"
SKILL_VERSION = "1.1.5"
LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr"
PLATFORM_KIT_MIN_VERSION = "1.2.11"