From 6eb304bca26d8028c13d48a2c487321c8846ae4c Mon Sep 17 00:00:00 2001 From: taojianmin Date: Mon, 14 Sep 2026 18:08:16 +0800 Subject: [PATCH] chore: auto release commit (2026-09-14 18:08:16) --- CHANGELOG.md | 12 ++ SKILL.md | 2 +- scripts/service/platform_collector.py | 221 +++++++++++++++++--------- scripts/util/constants.py | 2 +- 4 files changed, 162 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7cfd7..4b1b829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 本文件对应技能市场 **「更新日志」** Tab。 +## 1.1.8 + +- **RPA 首次登录采集修复(用户反馈驱动)** + - **不再强制 goto login 打断手工登录态**:`collect_all` 启动时先调 `_is_logged_in(page)` 检测是否已登录(如 profile cookie 复用);已登录态直接 goto SPA_HOME,未登录态才走 login → 自动 fill → fallback 手工登录等待 + - **首次登录后不再强制 page.reload**:原 5.5 步骤「刷新触发 mock 数据」会打断刚登录的 SPA session,导致 sandbox 把已登录态踢回 login;改为**默认不刷新**,表格等 10s 未出现才触发兜底刷新(5.5/5.6 逻辑下沉到 `_try_reload_after_table_timeout`,仅在表格超时调用) + - **表格列数自适应**:sandbox 首次登录未刷新时表格是 **7 列**(姓名/单位/到期日/剩余天数/预警/建议/按钮),刷新后才是 **10 列**(多 工号/岗位/合同类型/起止日期);原代码硬编码 10 列解析 → 7 列时 `r[7]/r[8]` IndexError。改为「首列匹配工号正则 `^[A-Za-z]?\d+$` 走 10 列,否则走 7 列」双布局自适应 + - **contract_id 派生兼容 7 列**:7 列布局无工号 → 用 `teacher_name` 派生 contract_id(如 `RPA-钱实验-20260415`),避免与 10 列工号布局冲突 +- **实跑验证(2026-09-14)** + - 命令:`python scripts/main.py collect-from-platform --show-browser --limit 1 --human-wait-timeout 180` + - 结果:自动 fill demo 失败 → fallback 手工登录 → 你登录后 RPA 不刷新直接跳 hr/contracts → 抓 3 行(钱实验/孙助理/王明远)→ 抓卡片 381 字 → 落库 `saved_contracts=1 / downloaded_materials=1` + - 退出码 0 + ## 1.1.7 - **RPA 手工登录 fallback 实跑验证(2026-09-14)** diff --git a/SKILL.md b/SKILL.md index b2212b5..568c5e2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 合同到期智能预警与续聘辅助 description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。" -version: 1.1.7 +version: 1.1.8 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/scripts/service/platform_collector.py b/scripts/service/platform_collector.py index ddcf79c..7384ff6 100644 --- a/scripts/service/platform_collector.py +++ b/scripts/service/platform_collector.py @@ -322,30 +322,42 @@ def collect_all( _goto(page, ROOT_URL) _fill_root_login(page) - # 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(SPA_HYDRATION_MS) - - # 3. sandbox SPA 自动登录(demo/123456 已知可用) - # 失败 fallback 手工登录等待(轮询 _is_logged_in + 180s 兜底) - if not _fill_spa_login(page): - logger.warning( - "platform_collector: 自动 fill 失败(URL=%s),fallback 手工登录等待", + # 2. v1.1.8:已登录态优先检测 —— 复用 profile cookie 时(手工登录成功后 + # cookie 已持久化),root 直接跳 SPA home,**不再强制 goto login**, + # 避免打断用户手工登录态导致「已登录却被踢回 login 页」。 + if _is_logged_in(page): + logger.info( + "platform_collector: 已登录态,跳过 login,直接 goto SPA_HOME, URL=%s", page.url, ) - if not headless: - _wait_for_manual_login(page, wait_sec=human_wait_timeout) - else: - # headless 模式无法手工登录:抛 LoginTimeoutError 让 CLI 决定 - raise LoginTimeoutError( - f"headless 模式下自动 fill 失败,且无法手工登录" - f"(请用 --show-browser 重试)" - ) else: - # 等 SPA 路由稳定(之前这里没有 wait,直接 goto home 会打断 pending 跳转) - page.wait_for_timeout(POST_LOGIN_SETTLE_MS) + # 未登录:直接 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(SPA_HYDRATION_MS) + + # 3. sandbox SPA 自动登录(demo/123456 已知可用) + # 失败 fallback 手工登录等待(轮询 _is_logged_in + 180s 兜底) + if not _fill_spa_login(page): + logger.warning( + "platform_collector: 自动 fill 失败(URL=%s),fallback 手工登录等待", + page.url, + ) + if not headless: + _wait_for_manual_login(page, wait_sec=human_wait_timeout) + else: + # headless 模式无法手工登录:抛 LoginTimeoutError 让 CLI 决定 + raise LoginTimeoutError( + f"headless 模式下自动 fill 失败,且无法手工登录" + f"(请用 --show-browser 重试)" + ) + else: + # 等 SPA 路由稳定(之前这里没有 wait,直接 goto home 会打断 pending 跳转) + page.wait_for_timeout(POST_LOGIN_SETTLE_MS) # 4. 强制 goto SPA home(如果还停在 login,重试登录一次) for retry in range(SPA_HOME_RETRY_MAX): @@ -420,48 +432,59 @@ def collect_all( logger.info("platform_collector: 当前 URL=%s", page.url) - # 5.5. 进入页面后刷新(SPA 进入新路由时 mock 数据可能未加载,需刷新触发) - try: - logger.info("platform_collector: 进入 hr/contracts 后刷新页面") - page.reload( - wait_until="networkidle", - timeout=RELOAD_NETWORKIDLE_TIMEOUT_MS, + # 5.5. v1.1.8:**首次登录后不再强制刷新**,避免打断用户手工登录态。 + # 改为「等表格出现」兜底:给 SPA 一个初始等待窗口(TABLE_INITIAL_WAIT_MS*2), + # 如果表格仍未出现,**才**刷新一次(sandbox mock 异步加载场景)。 + logger.info( + "platform_collector: 进入 hr/contracts 后**不刷新**,等待表格出现" + "(首次登录态不要打断)" + ) + + # 5.6. 兜底刷新:仅当等表格超时后才触发;刷新后可能被踢回 login,需重新登录 + # 修复:之前没检查 URL,刷新后停在 login 页继续往后走,找不到表格就 dump 报错 + def _try_reload_after_table_timeout(): + logger.warning( + "platform_collector: 表格未在 %dms 内出现,触发兜底刷新", + TABLE_INITIAL_WAIT_MS * 2, ) - except Exception as e: - logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e) try: page.reload( - wait_until="domcontentloaded", - timeout=RELOAD_DOMCONTENTLOADED_TIMEOUT_MS, + wait_until="networkidle", + timeout=RELOAD_NETWORKIDLE_TIMEOUT_MS, ) - except Exception as e2: - logger.warning("刷新 domcontentloaded 也失败: %s", e2) - - # 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, - ) - _goto(page, SPA_LOGIN_URL) - page.wait_for_timeout(POST_RELOAD_LOGIN_HYDRATION_MS) - if not _fill_spa_login(page): - if not headless: - _wait_for_manual_login(page, wait_sec=human_wait_timeout) - else: - raise LoginTimeoutError( - "刷新后回到 login 页且 headless 无法手工登录" + except Exception as e: + logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e) + try: + page.reload( + wait_until="domcontentloaded", + timeout=RELOAD_DOMCONTENTLOADED_TIMEOUT_MS, ) - else: - page.wait_for_timeout(POST_LOGIN_SETTLE_MS) - _goto(page, PLATFORM_URL) - page.wait_for_timeout(POST_RELOGIN_GOTO_WAIT_MS) + except Exception as e2: + logger.warning("刷新 domcontentloaded 也失败: %s", e2) + if "/login" in page.url.lower() or "/signin" in page.url.lower(): + logger.warning( + "platform_collector: 刷新后回到登录页(%s),重新登录", + page.url, + ) + _goto(page, SPA_LOGIN_URL) + page.wait_for_timeout(POST_RELOAD_LOGIN_HYDRATION_MS) + if not _fill_spa_login(page): + if not headless: + _wait_for_manual_login(page, wait_sec=human_wait_timeout) + else: + raise LoginTimeoutError( + "刷新后回到 login 页且 headless 无法手工登录" + ) + else: + page.wait_for_timeout(POST_LOGIN_SETTLE_MS) + _goto(page, PLATFORM_URL) + page.wait_for_timeout(POST_RELOGIN_GOTO_WAIT_MS) # 7. dump 当前 hr/contracts 页面(用于调试) _dump_page(page, download_dir.parent / "hr_contracts_dump.html", "hr/contracts") - # 8. 等待表格加载(SPA 异步填充 mock 数据,多等几秒) + # 8. 等待表格加载(v1.1.8:先等,**表格超时未出现才触发兜底刷新**—— + # 修复「首次登录后强制刷新打断手工登录态」问题) page.wait_for_timeout(TABLE_INITIAL_WAIT_MS) # 给 React 初始化 + mock 数据填充时间 found_table = False for attempt in range(TABLE_POLL_MAX_ATTEMPTS): @@ -480,12 +503,38 @@ def collect_all( cur_url, attempt + 1, TABLE_POLL_MAX_ATTEMPTS, ) + if not found_table: + # v1.1.8:表格没出现,**先尝试兜底刷新一次**(sandbox mock 异步加载场景); + # 刷新后再次轮询表格,仍找不到才报 TableNotFoundError。 + logger.warning( + "platform_collector: 首次轮询未找到表格,触发兜底刷新(sandbox mock 异步加载场景)" + ) + _try_reload_after_table_timeout() + # 刷新后重新轮询一次(最多 5 次) + for attempt2 in range(5): + page.wait_for_timeout(TABLE_POLL_INTERVAL_MS) + try: + page.wait_for_selector( + "table tbody tr", + timeout=TABLE_TR_TIMEOUT_MS, + ) + found_table = True + logger.info( + "platform_collector: 兜底刷新后找到表格 (attempt %d)", attempt2 + 1 + ) + break + except Exception: + logger.info( + "platform_collector: 兜底刷新后仍等待表格 (attempt %d/5)...", + attempt2 + 1, + ) + if not found_table: _dump_page(page, download_dir.parent / "sandbox_dump.html", "sandbox") - logger.warning("platform_collector: 表格未找到") + logger.warning("platform_collector: 表格未找到(兜底刷新后仍未出现)") raise TableNotFoundError( f"{TABLE_POLL_MAX_ATTEMPTS * TABLE_POLL_INTERVAL_MS // 1000}s " - f"内未找到表格 (URL={page.url})" + f"内未找到表格(兜底刷新后仍未出现) (URL={page.url})" ) # 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector) @@ -527,27 +576,53 @@ def collect_all( for i, r in enumerate(rows): if limit and i >= limit: break - if len(r) < 7: - logger.warning("行 %d 列数 < 7(新列序工号+姓名+单位+岗位+合同类型+起止+剩余+预警+建议),跳过: %s", i, r) + # v1.1.8:表格列数自适应 —— sandbox 首次登录未刷新是 7 列 + # (姓名/单位/到期日/剩余天数/预警/建议/按钮),刷新后才是 10 列 + # (多 工号/岗位/合同类型/起止日期)。两种都支持。 + ncols = len(r) + if ncols < 6: + logger.warning( + "行 %d 列数=%d < 6,跳过: %s", i, ncols, r + ) continue - # 新列序(sandbox 加了工号 / 岗位 / 合同类型 / 起止日期): - # 0: 工号 1: 姓名 2: 单位 3: 岗位 4: 合同类型 - # 5: 起止日期 (含 ~ ) 6: 剩余天数 7: 预警 8: 建议(含原因) - emp_id = r[0].strip() - teacher = r[1].strip() - unit = r[2].strip() - position = r[3].strip() - contract_type = r[4].strip() - period = r[5].strip() - # 起止日期形如 "2024-03-01 ~ 2026-04-15" → 取 ~ 后 - contract_end = period.split("~")[-1].strip() if "~" in period else period - days_raw = r[6].strip() - alert_label = r[7].strip() - suggestion_hint = r[8].strip() if len(r) > 8 else "" + # 列序探测:判断首列是不是工号(纯数字 + 字母,纯姓名是中文) + first_col = r[0].strip() + import re as _re + is_emp_id = bool(_re.match(r"^[A-Za-z]?\d+$", first_col)) + + if is_emp_id and ncols >= 9: + # 10 列布局(含工号):0 工号 1 姓名 2 单位 3 岗位 4 合同类型 + # 5 起止(含~) 6 剩余天数 7 预警 8 建议 9 按钮 + emp_id = first_col + teacher = r[1].strip() + unit = r[2].strip() + period = r[5].strip() + contract_end = period.split("~")[-1].strip() if "~" in period else period + days_raw = r[6].strip() + alert_label = r[7].strip() + suggestion_hint = r[8].strip() + elif ncols >= 6: + # 7 列布局(无工号):0 姓名 1 单位 2 到期日 3 剩余天数 4 预警 5 建议 6 按钮 + teacher = first_col + unit = r[1].strip() + contract_end = r[2].strip() + days_raw = r[3].strip() + alert_label = r[4].strip() + suggestion_hint = r[5].strip() + emp_id = "" # 工号缺失,留空让 _gen_contract_id 用 teacher_name 兜底 + else: + logger.warning( + "行 %d 列数=%d 无法识别布局,跳过: %s", i, ncols, r + ) + continue # 用工号生成 contract_id(稳定,不会随姓名变化) - contract_id = _gen_contract_id(emp_id, contract_end) + # v1.1.8:7 列布局无工号 → 用 teacher_name 派生(避免与 10 列布局冲突) + if emp_id: + contract_id = _gen_contract_id(emp_id, contract_end) + else: + contract_id = _gen_contract_id(teacher, contract_end) contract = { "contract_id": contract_id, "teacher_name": teacher, diff --git a/scripts/util/constants.py b/scripts/util/constants.py index e2865d5..10ec7bf 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.7" +SKILL_VERSION = "1.1.8" LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr" PLATFORM_KIT_MIN_VERSION = "1.2.11"