From f38f6623163a5a4a240d12a74ea8992e57e27f06 Mon Sep 17 00:00:00 2001 From: taojianmin Date: Mon, 14 Sep 2026 17:06:28 +0800 Subject: [PATCH] =?UTF-8?q?v1.1.5:=20platform=5Fcollector=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=99=BB=E5=BD=95=E7=AD=89=E5=BE=85=E5=A4=AA=E7=9F=AD?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E9=87=87=E9=9B=86=E4=B8=8D=E5=88=B0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: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 --- CHANGELOG.md | 14 +++ SKILL.md | 2 +- scripts/service/platform_collector.py | 148 +++++++++++++++----------- scripts/util/constants.py | 2 +- 4 files changed, 102 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8aae4..2944b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 两行(**只补不删**)。 diff --git a/SKILL.md b/SKILL.md index e3398fa..6239b01 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 合同到期智能预警与续聘辅助 description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。" -version: 1.1.4 +version: 1.1.5 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/scripts/service/platform_collector.py b/scripts/service/platform_collector.py index 826436a..8b461b6 100644 --- a/scripts/service/platform_collector.py +++ b/scripts/service/platform_collector.py @@ -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) diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 513ab79..5243cf9 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -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"