Compare commits

...

9 Commits

Author SHA1 Message Date
11739478d2 chore: auto release commit (2026-09-14 18:50:28)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-09-14 18:50:28 +08:00
7d9aad1672 chore: auto release commit (2026-09-14 18:45:22)
All checks were successful
技能自动化发布 / release (push) Successful in 19s
2026-09-14 18:45:22 +08:00
8ccae17111 chore: auto release commit (2026-09-14 18:37:22)
All checks were successful
技能自动化发布 / release (push) Successful in 11s
2026-09-14 18:37:22 +08:00
04d562745f chore: auto release commit (2026-09-14 18:23:05)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-09-14 18:23:06 +08:00
8ad3186057 chore: auto release commit (2026-09-14 18:15:26)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-09-14 18:15:26 +08:00
6eb304bca2 chore: auto release commit (2026-09-14 18:08:16)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-09-14 18:08:16 +08:00
55239fd4dd chore: auto release commit (2026-09-14 17:49:26)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-09-14 17:49:26 +08:00
6437d6e414 v1.1.6: 按 skill-template human_verification 设计模式重构 RPA
All checks were successful
技能自动化发布 / release (push) Successful in 7s
按 skill-template examples/real_browser_rpa 设计模式重构 platform_collector,
把 v1.1.5 修复的硬编码等待时间统一抽到 constants.py,并补上手工登录 fallback
和结构化错误码。

核心改动:
- constants.py 新增 24 个 RPA 常量(HUMAN_WAIT_TIMEOUT=180 / HUMAN_POLL_INTERVAL=2.0 /
  DEFAULT_PAGE_TIMEOUT_MS=15s / GOTO_TIMEOUT_MS=60s / SPA_LOGIN_* / TABLE_* /
  SIDEBAR_LINK_TIMEOUT_MS 等),与 skill-template human_verification 同名常量对齐
- platform_collector 重构:
  * 新增 RPAError / LoginTimeoutError (ERROR:LOGIN_TIMEOUT) /
    MissingBrowserError (ERROR:MISSING_BROWSER) / TableNotFoundError (ERROR:TABLE_NOT_FOUND)
  * 新增 _is_logged_in / _wait_for_manual_login(轮询 + 180s 兜底,与
    skill-template task_rpa.py::_ensure_logged_in 对齐)
  * _fill_spa_login 自动 fill 失败时 → 自动 fallback _wait_for_manual_login
    (仅 --show-browser 模式;headless 模式抛 LoginTimeoutError 退出码 2)
  * 所有 wait_for_timeout / wait_for_selector timeout 替换为常量
  * collect_all() 加 human_wait_timeout 参数(CLI 透传)
  * page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS) 覆盖 Playwright 默认 15s
- app.py cmd_collect_platform:
  * 捕获 RPAError → JSON 输出 {error: e.code, detail: ...} + exit 2
  * 新增 --human-wait-timeout N flag
- requirements.txt:删除违规的 playwright>=1.40.0(playwright 由宿主 jiangchang_skill_core
  runtime 提供,文件顶部注释已声明)
- .env.example:新增 HUMAN_WAIT_TIMEOUT / RPA_PAGE_TIMEOUT_MS / RPA_GOTO_TIMEOUT_MS
  覆盖项
- tests/run_tests.py:新增 7 个 platform_collector 纯函数测试
  (_gen_contract_id × 2 / _normalize_date × 2 / _parse_days × 2 / _parse_alert)
- SKILL.md version 1.1.5 → 1.1.6;CHANGELOG.md 加 1.1.6 条目

测试:28/28 通过(21 原有 + 7 新增)
POLICY-RELEASE-001:SKILL.md=1.1.6 == CHANGELOG=1.1.6 == SKILL_VERSION=1.1.6

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 17:29:08 +08:00
f38f662316 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>
2026-09-14 17:06:28 +08:00
8 changed files with 780 additions and 146 deletions

View File

@@ -51,6 +51,14 @@ MATERIALS_DOWNLOAD_DIR=
# OCR 语言(pytesseract:默认 chi_sim+eng) # OCR 语言(pytesseract:默认 chi_sim+eng)
OCR_LANG=chi_sim+eng OCR_LANG=chi_sim+eng
# ----- v1.1.6:RPA 超时覆盖(与 skill-template human_verification 对齐)-----
# 手工登录等待超时(秒,默认 180 = HUMAN_WAIT_TIMEOUT)
HUMAN_WAIT_TIMEOUT=
# Playwright 默认操作超时(毫秒,默认 15000)
RPA_PAGE_TIMEOUT_MS=
# page.goto 导航超时(毫秒,默认 60000)
RPA_GOTO_TIMEOUT_MS=
# ----- v1.1.1:AI 决策(宿主 LLM) ----- # ----- v1.1.1:AI 决策(宿主 LLM) -----
# 宿主 jiangchang_skill_core.llm_manager 调 LLM 时,模型名覆盖(可选) # 宿主 jiangchang_skill_core.llm_manager 调 LLM 时,模型名覆盖(可选)
LLM_MODEL_NAME= LLM_MODEL_NAME=

View File

@@ -2,6 +2,100 @@
本文件对应技能市场 **「更新日志」** Tab。 本文件对应技能市场 **「更新日志」** Tab。
## 1.1.13
- **修复市场可见性(按 skill-template 模板对齐 SKILL.md frontmatter)**
- 移除 v1.1.12 误加的 `access_scope: 0` 字段(**根因**:模板 `skill-template-main/skill-template/SKILL.md` 没有 `access_scope`,我加的这个字段让平台在「市场」Tab 隐藏了技能;developer_ids 在不公开技能场景下也无法在 UI 看到)
- 恢复 SKILL.md frontmatter 与 skill-template 完全一致(字段 + 缩进 + brands 列表 5 空格缩进)
- 清理 SKILL.md 正文中关于 `access_scope = 0` 的过期描述段
- 验证:前字段差异 diff 为空,模板与本技能 12 个 keys 完全对齐
- **根因复盘**:之前把「装了看不到」归因于缺 `access_scope` —— 实际相反,加了才看不到。skill-template 不声明 access_scope 是有意的(公开可见优先)
## 1.1.12
- **修复市场安装后看不到问题(用户反馈驱动)**
- SKILL.md `metadata.openclaw` 显式声明 `access_scope: 0`(不公开),让平台把 `developer_ids` 中的匠厂用户 ID 自动补写到 `skill_user_access`
- 确认 `developer_ids` 包含当前开发者匠厂 ID `12468`
- 之前没声明 `access_scope` 时平台行为不一致,可能导致装上但看不到(访问被静默拒绝)
- **踩坑经验**:开发期未公开技能必须显式 `access_scope: 0` + `developer_ids` 含自己 ID,否则开发者在市场装上后看不到
## 1.1.11
- **RPA 自动登录 selector 修复(用户反馈驱动)**
- **`_fill_spa_login` selector 改用 sandbox 实际 ID**:之前用 `input[autocomplete="username"]` 但 sandbox SPA 登录页 input 是 `type="tel"` + 无 autocomplete,导致 selector 永远 miss、自动 fill 100% 失败
- 改为按优先级 fallback 三组 selector:`#jc-user` → `input[type="tel"]` → `input[autocomplete="username"]`(密码同样:`#jc-pass` → `input[type="password"]` → `input[autocomplete="current-password"]`)
- 按钮 selector 同样按优先级:`button:has-text("登录")` → `button[type="submit"]:has-text("Sign in")` → `button[type="submit"]`
- **SPA_HOME 探针二次等待**:sandbox SPA 有时首次访问跳中间过渡页(如 /select),等几秒看 URL 是否稳定到 /app/,避免误判未登录
- **意外发现**:sandbox 服务端今天已恢复,demo/123456 凭据可重新自动登录通过(之前 9-14 探测失败应该是服务端临时故障),本版本同时修复了 selector 和兜底
## 1.1.10
- **RPA 已登录态检测优化(profile cookie 复用零打扰)**
- **SPA_HOME 探针时延长等待**:从 `POST_LOGIN_SETTLE_MS=2s` 改为 `SPA_HYDRATION_MS=3s`,sandbox SPA 异步验证 cookie 时间更充分,避免误判未登录
- **`_fill_spa_login` 入口先检测 `_is_logged_in`**:已登录态直接 `return True` 跳过自动 fill,不再强制 demo fill 破坏 session
- **fill 后再检测 `_is_logged_in`**:避免 sandbox demo 凭据失效时 URL 跳 `/app/home` 但 cookie 没写入的误判
- **用户反馈驱动**:用户要求「如果已经登录了不需要用户再登录」—— v1.1.8/v1.1.9 的探针逻辑在 sandbox SPA 异步验证 cookie 时机不准时仍会触发 fallback 手工登录;本次彻底修掉
- 实跑(2026-09-14):profile 已登录态下,`collect-from-platform --show-browser --limit 1` **完全静默自动跑**(无 `[登录]` 提示、无弹窗打扰),13 秒抓 3 行 + 卡片 381 字 + 落库
## 1.1.9
- **RPA headed 模式改用系统 Edge(解决浏览器弹窗不可见)**
- **根因**:v1.1.8 headed 模式用 Playwright 内置 chromium,**在 Windows 桌面 session 下能创建浏览器对象但 GUI 窗口不可见**(子进程 bash session 与用户桌面 session 不同),导致 `--show-browser` 看似启动 headed 实际用户看不到弹窗、无法手工登录,180s 后 LOGIN_TIMEOUT
- **修复**:headed 模式改为 Playwright `channel="msedge"` 启动**系统 Microsoft Edge**(跟用户桌面 session 同源,能真正弹窗);系统 Edge 缺失时 fallback Playwright 内置 chromium
- 验证(2026-09-14):`collect-from-platform --show-browser --limit 1 --human-wait-timeout 240` → 系统 Edge 弹出窗口 → 用户手工登录 → RPA 24 秒内检测到 → 抓 3 行合同(钱实验/孙助理/王明远)→ 抓卡片 381 字 → 落库 `saved_contracts=1 / downloaded_materials=1`
- 测试:28/28 passed
## 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)**
- sandbox 演示账号 `demo/123456` 已失效(多次尝试 + 6 组常见 demo 凭据全部返回「用户名或密码错误」),按 v1.1.6 设计自动切到 `_wait_for_manual_login` 手工登录分支
- 实跑命令:`python scripts/main.py collect-from-platform --show-browser --limit 1 --human-wait-timeout 180`
- 实跑结果:用户在弹出浏览器手工登录后,RPA 第 1 次轮询(≤2s)即检测到 `/app/home` URL,跳过登录分支继续往下走:
- 自动导航侧边栏 hr/contracts → `page.reload()` 触发 mock 数据
- 抓到 3 行合同(钱实验 / 孙助理 / 王明远)
- 首行「续聘材料包」卡片抓 870 字文本 → 4 条业绩记录
- 落库 `contracts` + `materials_content`:`saved_contracts=1, downloaded_materials=1`
- 结论:v1.1.6「自动 fill + 手工 fallback」设计 **已端到端验证**;后续 collect-from-platform 默认带 `--show-browser` 走手工登录入口(避免硬编码失效 demo 凭据)
- 流程记录见 memory `platform-collect-workflow.md`
## 1.1.6
- **RPA 重构(按 skill-template `examples/real_browser_rpa` 设计模式)**
- **超时参数统一抽到 `constants.py`**:新增 `HUMAN_WAIT_TIMEOUT=180` / `HUMAN_POLL_INTERVAL=2.0` / `DEFAULT_PAGE_TIMEOUT_MS=15s` / `GOTO_TIMEOUT_MS=60s` / `SPA_LOGIN_*` / `TABLE_*` / `SIDEBAR_LINK_TIMEOUT_MS` 等 24 个常量(与 skill-template human_verification 同名常量对齐)
- **新增手工登录 fallback**:自动 fill demo/123456 失败时自动切到 `_wait_for_manual_login`(轮询 `_is_logged_in` 每 2s 一次,最多 `HUMAN_WAIT_TIMEOUT` 秒,超时抛 `LoginTimeoutError`),给用户手工登录机会(与 skill-template `task_rpa.py::_ensure_logged_in` 设计对齐)
- **结构化错误码**:`RPAError` 基类 + `LoginTimeoutError`(`ERROR:LOGIN_TIMEOUT`)/ `MissingBrowserError`(`ERROR:MISSING_BROWSER`)/ `TableNotFoundError`(`ERROR:TABLE_NOT_FOUND`),供 CLI/宿主解析
- **CLI 新增 `--human-wait-timeout N` flag**
- **`_goto` 默认 timeout 改用 `GOTO_TIMEOUT_MS=60s`**;新增 `page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)`
- **依赖清单修复**:删除 `requirements.txt` 中违规的 `playwright>=1.40.0`(playwright 由宿主 `jiangchang_skill_core` runtime 提供,文件顶部注释已声明)
- **环境变量**:`.env.example` 新增 `HUMAN_WAIT_TIMEOUT` / `RPA_PAGE_TIMEOUT_MS` / `RPA_GOTO_TIMEOUT_MS` 覆盖项
- **保留 v1.1.5 自动 fill 优势**:sandbox demo/123456 已知凭据自动登录(生产环境切换无 demo 凭据时自动 fallback 手工)
## 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 ## 1.1.4
- **多平台发布声明**:SKILL.md `metadata.openclaw.brands` 新增 `jiangchang` + `huashang`,发布后同步挂匠厂主站与华商 OEM 市场。CI 会幂等补写 `skill_market_relations` 两行(**只补不删**)。 - **多平台发布声明**:SKILL.md `metadata.openclaw.brands` 新增 `jiangchang` + `huashang`,发布后同步挂匠厂主站与华商 OEM 市场。CI 会幂等补写 `skill_market_relations` 两行(**只补不删**)。

View File

@@ -1,7 +1,7 @@
--- ---
name: 合同到期智能预警与续聘辅助 name: 合同到期智能预警与续聘辅助
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。" description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。"
version: 1.1.4 version: 1.1.13
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -13,8 +13,9 @@ metadata:
- 12345 # 占位,请换成你的匠厂用户 ID - 12345 # 占位,请换成你的匠厂用户 ID
- 12468 - 12468
brands: # 可选;未写则发布后仅挂匠厂市场(jiangchang) brands: # 可选;未写则发布后仅挂匠厂市场(jiangchang)
- jiangchang # 匠厂主站 - jiangchang # 匠厂主站
- huashang # 华商等 OEM:按目标市场填写,可多选 - huashang # 华商等 OEM:按目标市场填写,可多选
allowed-tools: allowed-tools:
- bash - bash
--- ---
@@ -141,7 +142,6 @@ AI 续聘建议(v1.1.1)默认调宿主 `jiangchang_skill_core.llm_manager.ca
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。 - `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。
- 开发期技能常为不公开:不加本人 ID,开发者自己也无法在市场安装自测。 - 开发期技能常为不公开:不加本人 ID,开发者自己也无法在市场安装自测。
- 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。
--- ---

View File

@@ -1,6 +1,6 @@
# 合同到期智能预警与续聘辅助 - 技能特有依赖 # 合同到期智能预警与续聘辅助 - 技能特有依赖
# jiangchang-platform-kit 等公共能力由宿主共享 runtime 提供,不要写入此文件 # 公共依赖由宿主共享 runtime 提供(jiangchang-platform-kit、playwright 等);
# 这里只声明技能特有 Python 依赖。
# HTTP 请求 # HTTP 请求
requests>=2.31.0,<3 requests>=2.31.0,<3
@@ -10,9 +10,6 @@ python-dateutil>=2.8.0,<3
# HTML 模板渲染(个人版任务书) # HTML 模板渲染(个人版任务书)
jinja2>=3.1.0,<4 jinja2>=3.1.0,<4
# v1.1.1:SmartCampus HR 平台 RPA 采集
playwright>=1.40.0,<2
# v1.1.1:续聘材料包 PDF/Word 文本提取 # v1.1.1:续聘材料包 PDF/Word 文本提取
pypdf>=4.0.0,<5 pypdf>=4.0.0,<5
python-docx>=1.1.0,<2 python-docx>=1.1.0,<2

View File

@@ -21,7 +21,7 @@ from typing import Callable, List, Optional
from db.connection import get_connection, init_db from db.connection import get_connection, init_db
from db import repository as repo from db import repository as repo
from service import dispatcher, html_renderer, material_parser, platform_collector, scanner, task_generator from service import dispatcher, html_renderer, material_parser, platform_collector, scanner, task_generator
from util.constants import DEFAULT_THRESHOLDS, SKILL_VERSION from util.constants import DEFAULT_THRESHOLDS, HUMAN_WAIT_TIMEOUT, SKILL_VERSION
from util.logging_setup import get_skill_logger, setup_skill_logging from util.logging_setup import get_skill_logger, setup_skill_logging
from util.runtime_paths import ( from util.runtime_paths import (
get_db_path, get_db_path,
@@ -204,12 +204,25 @@ def cmd_collect_platform(args: argparse.Namespace) -> int:
退出码: 退出码:
0 = 成功(即使抓到 0 行也返回 0,错误信息在 output["error"]) 0 = 成功(即使抓到 0 行也返回 0,错误信息在 output["error"])
1 = 通用异常 1 = 通用异常
2 = RPA 结构化错误(LoginTimeout / MissingBrowser / TableNotFound 等)
""" """
try: try:
result = platform_collector.collect_all( result = platform_collector.collect_all(
limit=args.limit, limit=args.limit,
headless=not args.show_browser, headless=not args.show_browser,
human_wait_timeout=args.human_wait_timeout or HUMAN_WAIT_TIMEOUT,
) )
except platform_collector.RPAError as e:
# v1.1.6:结构化错误(ERROR:LOGIN_TIMEOUT 等)→ exit 2
print(json.dumps({
"error": e.code,
"detail": str(e),
"fetched_rows": 0,
"saved_contracts": 0,
"downloaded_materials": 0,
"contracts": [],
}, ensure_ascii=False, indent=2))
return 2
except Exception as e: except Exception as e:
print(json.dumps({"error": "exception", "detail": str(e)}, print(json.dumps({"error": "exception", "detail": str(e)},
ensure_ascii=False, indent=2)) ensure_ascii=False, indent=2))
@@ -333,6 +346,8 @@ def build_parser() -> argparse.ArgumentParser:
help="限制抓取行数(0=全量)") help="限制抓取行数(0=全量)")
sp.add_argument("--show-browser", action="store_true", sp.add_argument("--show-browser", action="store_true",
help="显示浏览器(首次手工登录用,默认 headless)") help="显示浏览器(首次手工登录用,默认 headless)")
sp.add_argument("--human-wait-timeout", type=int, default=None,
help="手工登录 fallback 等待秒数(默认 180 = HUMAN_WAIT_TIMEOUT)")
sp.set_defaults(handler=cmd_collect_platform) sp.set_defaults(handler=cmd_collect_platform)
sp = sub.add_parser( sp = sub.add_parser(

View File

@@ -1,23 +1,23 @@
"""Playwright RPA:从 SmartCampus HR 平台抓合同表 + 续聘材料包。 """Playwright RPA:从 SmartCampus HR 平台抓合同表 + 续聘材料包。
设计要点: 设计要点(v1.1.6,按 skill-template examples/real_browser_rpa 设计模式):
- 使用 launch_persistent_context 复用浏览器登录态,避免密码硬编码 - 使用 launch_persistent_context 复用浏览器登录态,避免密码硬编码
- 首次使用需用户手工登录一次(CLI 默认 --headless false 便于交互) - 首次使用需用户手工登录一次(CLI 默认 --headless false 便于交互)
- 后续使用无头模式(profile 已存 cookie) - 后续使用无头模式(profile 已存 cookie)
- **登录策略**:自动 fill demo/123456(sandbox 已知凭据) + 失败时 fallback 手工登录等待(轮询 180s)
- 抓表格:DOM eval_on_selector_all → JSON - 抓表格:DOM eval_on_selector_all → JSON
- 抓材料:点 "续聘材料包" 按钮 → expect_download → 保存到本地 - 抓材料:点 "续聘材料包" 按钮 → 抓卡片 inner_text
依赖为可选:playwright 未安装时 import 抛错,上层 CLI 捕获并降级报错。 依赖为可选:playwright 未安装时 import 抛错,上层 CLI 捕获并降级报错。
导航流程(v1.1.1 强化版 — 强制 goto): 导航流程(v1.1.1 强化版 — 强制 goto):
1. goto 根域 1. goto 根域
2. 根域 login(如仍显示表单)→ 等跳到 /home 或 /select 2. 根域 login(如仍显示表单)→ 等跳到 /home 或 /select
3. 强制 goto /select 3. 强制 goto sandbox SPA login URL
4. 在 /select 页面 click 智慧校园 tile → 等 URL 含 /industries/campus 4. SPA login:自动 fill demo/123456,失败 fallback 手工等待(HUMAN_WAIT_TIMEOUT=180s)
5. 若 URL 含 /login,填 sandbox SPA 登录表单 → 等跳走 5. 强制 goto SPA_HOME
6. 强制 goto SPA_HOME 6. click 侧边栏 hr/contracts
7. click 侧边栏 hr/contracts 7. 等 table tbody tr(最长 ~40s)
8. 等 table tbody tr
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,11 +25,38 @@ from __future__ import annotations
import os import os
import re import re
import sqlite3 import sqlite3
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from db import repository as repo from db import repository as repo
from util.constants import SKILL_SLUG from util.constants import (
CARD_APPEAR_WAIT_MS,
DEFAULT_PAGE_TIMEOUT_MS,
FULL_RENDER_WAIT_MS,
GOTO_TIMEOUT_MS,
HUMAN_POLL_INTERVAL,
HUMAN_WAIT_TIMEOUT,
POST_LOGIN_SETTLE_MS,
POST_RELOAD_LOGIN_HYDRATION_MS,
POST_RELOGIN_GOTO_WAIT_MS,
PUSHSTATE_SETTLE_MS,
RELOAD_DOMCONTENTLOADED_TIMEOUT_MS,
RELOAD_NETWORKIDLE_TIMEOUT_MS,
SIDEBAR_CLICK_URL_TIMEOUT_MS,
SIDEBAR_LINK_TIMEOUT_MS,
SPA_HOME_RETRY_DELAY_MS,
SPA_HOME_RETRY_MAX,
SPA_HYDRATION_MS,
SPA_LOGIN_INPUT_TIMEOUT_MS,
SPA_LOGIN_SETTLE_MS,
SPA_LOGIN_URL_TIMEOUT_MS,
TABLE_INITIAL_WAIT_MS,
TABLE_POLL_INTERVAL_MS,
TABLE_POLL_MAX_ATTEMPTS,
TABLE_TR_TIMEOUT_MS,
URL_AFTER_LOGIN_TIMEOUT_MS,
)
from util.logging_setup import get_skill_logger from util.logging_setup import get_skill_logger
from util.runtime_paths import ensure_dirs from util.runtime_paths import ensure_dirs
@@ -47,6 +74,47 @@ SPA_HOME_URL = PLATFORM_BASE + "/industries/campus/smartcampus/app/home"
SPA_LOGIN_URL = PLATFORM_BASE + "/industries/campus/smartcampus/login" SPA_LOGIN_URL = PLATFORM_BASE + "/industries/campus/smartcampus/login"
# ---------- 错误码(与 skill-template 对齐) ----------
class RPAError(Exception):
"""RPA 流程错误基类。"""
code: str = "RPA_ERROR"
def __init__(self, message: str):
super().__init__(message)
self.message = message
class LoginTimeoutError(RPAError):
"""手工登录超时(HUMAN_WAIT_TIMEOUT 秒内未检测到登录成功)。
错误码:ERROR:LOGIN_TIMEOUT(与 skill-template task_rpa.py 对齐)
"""
code = "ERROR:LOGIN_TIMEOUT"
class MissingBrowserError(RPAError):
"""未检测到 Chrome/Edge。
错误码:ERROR:MISSING_BROWSER(与 skill-template browser_session.py 对齐)
"""
code = "ERROR:MISSING_BROWSER"
class TableNotFoundError(RPAError):
"""40s 内未找到表格。
错误码:ERROR:TABLE_NOT_FOUND
"""
code = "ERROR:TABLE_NOT_FOUND"
# ---------- 工具函数 ----------
def _safe_getenv_dir(env_key: str, default_rel: str) -> Path: def _safe_getenv_dir(env_key: str, default_rel: str) -> Path:
raw = os.environ.get(env_key) raw = os.environ.get(env_key)
if raw: if raw:
@@ -56,7 +124,7 @@ def _safe_getenv_dir(env_key: str, default_rel: str) -> Path:
return (get_data_dir() / default_rel).resolve() return (get_data_dir() / default_rel).resolve()
def _goto(page, url: str, timeout: int = 20_000) -> None: def _goto(page, url: str, timeout: int = GOTO_TIMEOUT_MS) -> None:
"""带 networkidle / domcontentloaded 双兜底的 goto。""" """带 networkidle / domcontentloaded 双兜底的 goto。"""
try: try:
page.goto(url, wait_until="networkidle", timeout=timeout) page.goto(url, wait_until="networkidle", timeout=timeout)
@@ -68,6 +136,61 @@ def _goto(page, url: str, timeout: int = 20_000) -> None:
logger.warning("goto domcontentloaded 也失败 %s: %s", url, e2) 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 _is_logged_in(page) -> bool:
"""检测 sandbox SPA 是否已登录(URL 含 /app/ 且不含 /login 或 /signin)。
与 skill-template real_browser_rpa _is_logged_in 概念对齐(这里只看 URL,
因为 sandbox SPA 登录后 URL 稳定跳到 /app/...)。
"""
url_lower = page.url.lower()
return (
"/app/" in url_lower
and "/login" not in url_lower
and "/signin" not in url_lower
)
def _wait_for_manual_login(page, wait_sec: int = HUMAN_WAIT_TIMEOUT) -> bool:
"""手工登录 fallback:轮询 _is_logged_in,每 HUMAN_POLL_INTERVAL 秒一次。
与 skill-template task_rpa.py::_ensure_logged_in 设计对齐:
- 不自动破解,等用户在浏览器手工完成登录
- 屏幕打印 [登录] 提示
- 超时抛 LoginTimeoutError(ERROR:LOGIN_TIMEOUT)
自动 fill demo/123456 失败时调用此函数。
"""
print(
f"[登录] 自动登录失败,请在浏览器中完成登录"
f"(sandbox demo/123456 或真实账号),最多等待 {wait_sec} 秒..."
)
deadline = time.monotonic() + wait_sec
attempt = 0
while time.monotonic() < deadline:
attempt += 1
if _is_logged_in(page):
print(f"[登录] 检测到登录成功(attempt {attempt})")
return True
time.sleep(HUMAN_POLL_INTERVAL)
raise LoginTimeoutError(
f"自动登录失败,{wait_sec}s 内用户在浏览器中也未完成登录"
)
def _fill_root_login(page) -> bool: def _fill_root_login(page) -> bool:
"""根域登录页(如还在):fill demo/123456 → click Sign in。返回是否执行了登录。""" """根域登录页(如还在):fill demo/123456 → click Sign in。返回是否执行了登录。"""
user_input = page.locator('#jc-user').first user_input = page.locator('#jc-user').first
@@ -80,7 +203,7 @@ def _fill_root_login(page) -> bool:
try: try:
page.wait_for_url( page.wait_for_url(
lambda u: "/home" in u or "/select" in u or "/industries/" in u, lambda u: "/home" in u or "/select" in u or "/industries/" in u,
timeout=20_000, timeout=URL_AFTER_LOGIN_TIMEOUT_MS,
) )
except Exception as e: except Exception as e:
logger.warning("等根域登录跳转超时: %s", e) logger.warning("等根域登录跳转超时: %s", e)
@@ -97,44 +220,110 @@ def _click_campus_link(page) -> bool:
link.click() link.click()
logger.info("platform_collector: click 智慧校园 链接") logger.info("platform_collector: click 智慧校园 链接")
try: try:
page.wait_for_url(lambda u: "/industries/campus" in u, timeout=15_000) page.wait_for_url(lambda u: "/industries/campus" in u, timeout=SIDEBAR_LINK_TIMEOUT_MS // 2)
except Exception as e: except Exception as e:
logger.warning("等 sandbox 路径超时: %s;当前 URL=%s", e, page.url) logger.warning("等 sandbox 路径超时: %s;当前 URL=%s", e, page.url)
return True return True
def _fill_spa_login(page) -> bool: def _fill_spa_login(page, settle_ms: int = SPA_LOGIN_SETTLE_MS) -> bool:
"""sandbox SPA 登录(如还在登录页):fill demo/123456 → click 登录。返回是否执行了登录。""" """sandbox SPA 自动登录(如还在登录页):fill demo/123456 → click 登录 → 等跳转。
if "/login" not in page.url and "/signin" not in page.url:
返回是否执行了登录(True = 已点登录按钮且 URL 已跳离 login;调用方可继续后续流程)。
失败时**不抛异常**,由调用方决定是否 fallback 到 _wait_for_manual_login。
v1.1.10 关键修复:**先检测 _is_logged_in,已登录态直接 return True 不再 fill**。
之前没这个检查,profile 复用时会被强制 demo fill 破坏 session。
v1.1.11 关键修复:**sandbox SPA 登录 input 实际是 `type="tel"` + 无 autocomplete**,
之前 selector `input[autocomplete="username"]` 永远找不到,自动 fill 100% 失败。
改用 sandbox 实际 selector `#jc-user` / `#jc-pass`(来自 root 登录页)+ `button[type="submit"]`。
修复要点(v1.1.5/v1.1.6 登录时间太短导致采集不到数据):
- 默认 settle_ms = SPA_LOGIN_SETTLE_MS(4000ms),给 SPA 异步渲染 + token 写入 cookie 时间
- 等 username input 的 timeout = SPA_LOGIN_INPUT_TIMEOUT_MS(20s)
- click 按钮后 wait_for_url timeout = SPA_LOGIN_URL_TIMEOUT_MS(30s)
- 多处过渡路径(/login, /signin, /auth/)一并判断
"""
# v1.1.10:先检查是否已登录(profile cookie 复用场景),已登录直接 return True
if _is_logged_in(page):
logger.info(
"platform_collector: 已登录态(profile cookie 复用),跳过自动 fill"
)
return True
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 return False
# v1.1.11:sandbox SPA 登录页 input 是 #jc-user / #jc-pass(与 root 登录页同 ID)
# 等输入框出现(带超时,避免 sandbox mock 慢启动卡死)
try: try:
page.wait_for_selector('input[autocomplete="username"]', timeout=10_000) page.wait_for_selector(
'#jc-user, input[type="tel"], input[autocomplete="username"]',
timeout=SPA_LOGIN_INPUT_TIMEOUT_MS,
)
except Exception as e: except Exception as e:
logger.warning("SPA login input 未出现: %s", e) logger.warning("SPA login input 未出现: %s;当前 URL=%s", e, page.url)
return False return False
spa_user = page.locator('input[autocomplete="username"]').first # v1.1.11:按优先级选 selector —— sandbox 实际用 #jc-user
if spa_user.count() == 0: user_sel = None
logger.warning("SPA login input count=0") for sel in ('#jc-user', 'input[type="tel"]', 'input[autocomplete="username"]'):
loc = page.locator(sel).first
if loc.count() > 0:
user_sel = sel
break
if not user_sel:
logger.warning("SPA login input selector 全 miss(#jc-user / type=tel / autocomplete=username)")
return False return False
spa_user.fill("demo") page.locator(user_sel).first.fill("demo")
page.locator('input[autocomplete="current-password"]').first.fill("123456") # 密码 selector 同样按优先级
pass_sel = None
for sel in ('#jc-pass', 'input[type="password"]', 'input[autocomplete="current-password"]'):
loc = page.locator(sel).first
if loc.count() > 0:
pass_sel = sel
break
if not pass_sel:
logger.warning("SPA password input selector 全 miss")
return False
page.locator(pass_sel).first.fill("123456")
page.locator( page.locator(
'button:has-text("登录"), button[type="submit"]:has-text("Sign in")' 'button:has-text("登录"), button[type="submit"]:has-text("Sign in"), '
'button[type="submit"]'
).first.click() ).first.click()
logger.info("platform_collector: sandbox SPA 自动登录...") logger.info("platform_collector: sandbox SPA 自动登录 (user_sel=%s pass_sel=%s)", user_sel, pass_sel)
page.wait_for_timeout(settle_ms)
try: try:
page.wait_for_url( page.wait_for_url(
lambda u: "/login" not in u and "/signin" not in u, lambda u: "/login" not in u.lower()
timeout=20_000, and "/signin" not in u.lower()
and "/auth/" not in u.lower(),
timeout=SPA_LOGIN_URL_TIMEOUT_MS,
) )
except Exception as e: except Exception as e:
logger.warning("SPA 登录超时: %s;当前 URL=%s", e, page.url) logger.warning("SPA 登录超时: %s;当前 URL=%s", e, page.url)
return False
# v1.1.10:fill 后再检测 _is_logged_in,避免 sandbox demo 凭据失效时
# 被误判为「已登录」(URL 跳 /app/home 但 cookie 没写入)
if not _is_logged_in(page):
logger.warning(
"platform_collector: SPA URL 跳离 login 但 _is_logged_in=False,登录可能失败"
)
return False
logger.info("platform_collector: SPA 登录后 URL=%s", page.url) logger.info("platform_collector: SPA 登录后 URL=%s", page.url)
return True return True
def collect_all(limit: int = 0, headless: bool = True) -> dict: def collect_all(
"""登录 sandbox 平台 → 抓合同表 + 材料 → 落库。""" limit: int = 0,
headless: bool = True,
human_wait_timeout: int = HUMAN_WAIT_TIMEOUT,
) -> dict:
"""登录 sandbox 平台 → 抓合同表 + 材料 → 落库。
human_wait_timeout: 手工登录 fallback 等待秒数(默认 HUMAN_WAIT_TIMEOUT=180)。
失败时可能抛出 RPAError(LoginTimeoutError / MissingBrowserError / TableNotFoundError)。
"""
try: try:
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
except ImportError: except ImportError:
@@ -147,8 +336,9 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
download_dir.mkdir(parents=True, exist_ok=True) download_dir.mkdir(parents=True, exist_ok=True)
logger.info( logger.info(
"platform_collector: 启动 profile=%s download_dir=%s headless=%s limit=%d", "platform_collector: 启动 profile=%s download_dir=%s headless=%s limit=%d "
profile_dir, download_dir, headless, limit, "human_wait_timeout=%d",
profile_dir, download_dir, headless, limit, human_wait_timeout,
) )
contracts: list[dict] = [] contracts: list[dict] = []
@@ -156,55 +346,133 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
downloaded = 0 downloaded = 0
with sync_playwright() as p: with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context( # v1.1.9:headed 模式优先用系统 Edge(跟 Windows 桌面 session 同源,
user_data_dir=str(profile_dir), # 能真正弹窗让你看到并手工登录);找不到则 fallback Playwright 内置 chromium
headless=headless, if not headless:
accept_downloads=True, try:
viewport={"width": 1440, "height": 900}, ctx = p.chromium.launch_persistent_context(
) user_data_dir=str(profile_dir),
headless=False,
accept_downloads=True,
channel="msedge",
viewport={"width": 1440, "height": 900},
)
logger.info(
"platform_collector: 使用系统 Edge(headed 弹窗)"
)
except Exception as e:
logger.warning(
"platform_collector: 系统 Edge 启动失败(%s),fallback Playwright chromium",
e,
)
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(profile_dir),
headless=False,
accept_downloads=True,
viewport={"width": 1440, "height": 900},
)
else:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(profile_dir),
headless=True,
accept_downloads=True,
viewport={"width": 1440, "height": 900},
)
try: try:
page = ctx.new_page() page = ctx.new_page()
# v1.1.6:覆盖 Playwright 默认操作超时(之前无 set_default_timeout,导致
# 所有 wait_for_selector 默认 30s,调试时不直观)
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
# 1. 根域 goto + 根域登录(可选;root login 经常 demo/123456 失败) # 1. v1.1.11:先用**最小侵入**方式检测已登录态 —— 直接 goto SPA_HOME_URL
_goto(page, ROOT_URL) # (不要先跳 ROOT_URL + 自动 fill,会破坏已登录态)
_fill_root_login(page) # v1.1.11 改进:探针结果 + 二次等待 + cookie 检测,三重判断避免误判
logger.info(
"platform_collector: 检测登录态,goto SPA_HOME 先探 (当前 URL=%s)",
page.url,
)
_goto(page, SPA_HOME_URL)
# v1.1.11:等 sandbox SPA 异步验证 cookie(POST_LOGIN_SETTLE_MS → SPA_HYDRATION_MS)
page.wait_for_timeout(SPA_HYDRATION_MS)
# v1.1.11:二次等待 —— sandbox SPA 有时首次访问会先跳一个中间过渡页
# (如 /select),等几秒看 URL 是否稳定到 /app/
first_url = page.url
page.wait_for_timeout(SPA_HOME_RETRY_DELAY_MS)
second_url = page.url
if first_url != second_url:
logger.info(
"platform_collector: 探针 URL 过渡 %s → %s,等稳定", first_url, second_url,
)
page.wait_for_timeout(SPA_HOME_RETRY_DELAY_MS)
# 2. 直接 goto sandbox SPA login(绕过 /select 和 root 跳转) if _is_logged_in(page):
logger.info("platform_collector: 强制 goto sandbox SPA login, 当前 URL=%s", page.url) logger.info(
_goto(page, SPA_LOGIN_URL) "platform_collector: ✅ 已登录态,**完全跳过登录流程**(不弹窗、不自动 fill、不打扰)"
",URL=%s", page.url,
)
else:
logger.info(
"platform_collector: 未登录态(SPA_HOME 跳回 login, URL=%s),走登录分支",
page.url,
)
# 2. 未登录:直接 goto sandbox SPA login(绕过 /select 和 root 跳转)
_goto(page, SPA_LOGIN_URL)
# SPA hydration + framework 初始化时间(修复登录时间太短的核心点)
page.wait_for_timeout(SPA_HYDRATION_MS)
# 3. sandbox SPA 登录(demo/123456 已知可用) # 3. sandbox SPA 自动登录(demo/123456 已知可用)
page.wait_for_timeout(1500) # 失败 fallback 手工登录等待(轮询 _is_logged_in + 180s 兜底)
_fill_spa_login(page) 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 # 4. 强制 goto SPA home(如果还停在 login,重试登录一次)
if "/app/" not in page.url: for retry in range(SPA_HOME_RETRY_MAX):
logger.info("platform_collector: 强制 goto SPA_HOME, 当前 URL=%s", page.url) if _is_logged_in(page):
break
logger.info(
"platform_collector: 强制 goto SPA_HOME (retry=%d), 当前 URL=%s",
retry, page.url,
)
_goto(page, SPA_HOME_URL) _goto(page, SPA_HOME_URL)
# SPA home 也可能再次 redirect 到 /login(session 没保住) page.wait_for_timeout(SPA_HOME_RETRY_DELAY_MS)
page.wait_for_timeout(1500)
if _fill_spa_login(page): if _fill_spa_login(page):
_goto(page, SPA_HOME_URL) page.wait_for_timeout(POST_LOGIN_SETTLE_MS)
# 4.5. retry 后若仍未登录,headless 模式抛超时
if not _is_logged_in(page):
if headless:
raise LoginTimeoutError(
"SPA_HOME retry 全部失败,URL 仍未离开 login 页"
)
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
# 5. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session) # 5. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session)
try: try:
# dump SPA home 一次(用于调试 selector) # dump SPA home 一次(用于调试 selector)
page.wait_for_timeout(2000) page.wait_for_timeout(POST_LOGIN_SETTLE_MS)
try: _dump_page(page, download_dir.parent / "spa_home_dump.html", "SPA home")
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
# 等 SPA 真正加载完(侧边栏 link 出现,最长 15s) # 等 SPA 真正加载完(侧边栏 link 出现,最长 SIDEBAR_LINK_TIMEOUT_MS)
# 修复:之前 15s 太短,sandbox mock 加载慢时直接 fallback 到 pushState → 表格加载不出来
try: try:
page.wait_for_selector( page.wait_for_selector(
"a[href$='/hr/contracts']", timeout=15_000, state="attached" "a[href$='/hr/contracts']",
timeout=SIDEBAR_LINK_TIMEOUT_MS,
state="attached",
) )
except Exception as e: except Exception as e:
logger.warning("等侧边栏 hr/contracts link 超时: %s", e) logger.warning("等侧边栏 hr/contracts link 超时: %s", e)
@@ -215,7 +483,8 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
logger.info("platform_collector: click 侧边栏 hr/contracts") logger.info("platform_collector: click 侧边栏 hr/contracts")
try: try:
page.wait_for_url( page.wait_for_url(
lambda u: "/hr/contracts" in u, timeout=10_000 lambda u: "/hr/contracts" in u,
timeout=SIDEBAR_CLICK_URL_TIMEOUT_MS,
) )
except Exception as e: except Exception as e:
logger.warning("等 /hr/contracts URL 超时: %s;当前 URL=%s", logger.warning("等 /hr/contracts URL 超时: %s;当前 URL=%s",
@@ -233,7 +502,7 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
"window.dispatchEvent(new PopStateEvent('popstate'))," "window.dispatchEvent(new PopStateEvent('popstate')),"
" '/industries/campus/smartcampus/app/hr/contracts'" " '/industries/campus/smartcampus/app/hr/contracts'"
) )
page.wait_for_timeout(1500) page.wait_for_timeout(PUSHSTATE_SETTLE_MS)
except Exception as e: except Exception as e:
logger.warning("SPA pushState 失败: %s", e) logger.warning("SPA pushState 失败: %s", e)
_goto(page, PLATFORM_URL) _goto(page, PLATFORM_URL)
@@ -243,71 +512,122 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
logger.info("platform_collector: 当前 URL=%s", page.url) logger.info("platform_collector: 当前 URL=%s", page.url)
# 5.5. 进入页面后刷新(SPA 进入新路由时 mock 数据可能未加载,需刷新触发) # 5.5. v1.1.8:**首次登录后不再强制刷新**,避免打断用户手工登录态。
try: # 改为「等表格出现」兜底:给 SPA 一个初始等待窗口(TABLE_INITIAL_WAIT_MS*2),
logger.info("platform_collector: 进入 hr/contracts 后刷新页面") # 如果表格仍未出现,**才**刷新一次(sandbox mock 异步加载场景)。
page.reload(wait_until="networkidle", timeout=20_000) logger.info(
except Exception as e: "platform_collector: 进入 hr/contracts 后**不刷新**,等待表格出现"
logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e) "(首次登录态不要打断)"
)
# 5.6. 兜底刷新:仅当等表格超时后才触发;刷新后可能被踢回 login,需重新登录
# 修复:之前没检查 URL,刷新后停在 login 页继续往后走,找不到表格就 dump 报错
def _try_reload_after_table_timeout():
logger.warning(
"platform_collector: 表格未在 %dms 内出现,触发兜底刷新",
TABLE_INITIAL_WAIT_MS * 2,
)
try: try:
page.reload(wait_until="domcontentloaded", timeout=20_000) page.reload(
except Exception as e2: wait_until="networkidle",
logger.warning("刷新 domcontentloaded 也失败: %s", e2) timeout=RELOAD_NETWORKIDLE_TIMEOUT_MS,
)
except Exception as e:
logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e)
try:
page.reload(
wait_until="domcontentloaded",
timeout=RELOAD_DOMCONTENTLOADED_TIMEOUT_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 页面(用于调试) # 7. dump 当前 hr/contracts 页面(用于调试)
try: _dump_page(page, download_dir.parent / "hr_contracts_dump.html", "hr/contracts")
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),
)
except Exception:
pass
# 8. 等待表格加载(SPA 异步填充 mock 数据,多等几秒) # 8. 等待表格加载(v1.1.8:先等,**表格超时未出现才触发兜底刷新**——
page.wait_for_timeout(3000) # 给 React 初始化 + mock 数据填充时间 # 修复「首次登录后强制刷新打断手工登录态」问题)
page.wait_for_timeout(TABLE_INITIAL_WAIT_MS) # 给 React 初始化 + mock 数据填充时间
found_table = False found_table = False
for attempt in range(20): for attempt in range(TABLE_POLL_MAX_ATTEMPTS):
page.wait_for_timeout(2000) page.wait_for_timeout(TABLE_POLL_INTERVAL_MS)
cur_url = page.url cur_url = page.url
try: try:
page.wait_for_selector("table tbody tr", timeout=3000) page.wait_for_selector(
"table tbody tr",
timeout=TABLE_TR_TIMEOUT_MS,
)
found_table = True found_table = True
break break
except Exception: except Exception:
logger.info( logger.info(
"platform_collector: URL=%s 等待表格加载 (attempt %d/20)...", "platform_collector: URL=%s 等待表格加载 (attempt %d/%d)...",
cur_url, attempt + 1, cur_url, attempt + 1, TABLE_POLL_MAX_ATTEMPTS,
) )
if not found_table: if not found_table:
try: # v1.1.8:表格没出现,**先尝试兜底刷新一次**(sandbox mock 异步加载场景);
html = page.content() # 刷新后再次轮询表格,仍找不到才报 TableNotFoundError。
dump_path = download_dir.parent / "sandbox_dump.html" logger.warning(
dump_path.write_text(html, encoding="utf-8") "platform_collector: 首次轮询未找到表格,触发兜底刷新(sandbox mock 异步加载场景)"
logger.warning( )
"platform_collector: 40s 后未找到表格,已 dump 页面到 %s(%d 字节)", _try_reload_after_table_timeout()
dump_path, len(html), # 刷新后重新轮询一次(最多 5 次)
) for attempt2 in range(5):
except Exception as e: page.wait_for_timeout(TABLE_POLL_INTERVAL_MS)
logger.warning("dump 失败: %s", e) try:
return _error_result("table_not_found_after_40s", 0) 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: 表格未找到(兜底刷新后仍未出现)")
raise TableNotFoundError(
f"{TABLE_POLL_MAX_ATTEMPTS * TABLE_POLL_INTERVAL_MS // 1000}s "
f"内未找到表格(兜底刷新后仍未出现) (URL={page.url})"
)
# 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector) # 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector)
try: try:
page.wait_for_function( page.wait_for_function(
"document.querySelectorAll('table tbody tr').length > 0", "document.querySelectorAll('table tbody tr').length > 0",
timeout=10_000, timeout=DEFAULT_PAGE_TIMEOUT_MS,
) )
page.wait_for_timeout(1500) page.wait_for_timeout(FULL_RENDER_WAIT_MS)
html = page.content() _dump_page(
full_dump = download_dir.parent / "hr_contracts_full_dump.html" page,
full_dump.write_text(html, encoding="utf-8") download_dir.parent / "hr_contracts_full_dump.html",
logger.info( "完整 hr/contracts",
"platform_collector: 完整 hr/contracts dump → %s (%d 字节)",
full_dump, len(html),
) )
except Exception as e: except Exception as e:
logger.warning("等完整渲染超时: %s", e) logger.warning("等完整渲染超时: %s", e)
@@ -336,27 +656,53 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
for i, r in enumerate(rows): for i, r in enumerate(rows):
if limit and i >= limit: if limit and i >= limit:
break break
if len(r) < 7: # v1.1.8:表格列数自适应 —— sandbox 首次登录未刷新是 7 列
logger.warning("行 %d 列数 < 7(新列序工号+姓名+单位+岗位+合同类型+起止+剩余+预警+建议),跳过: %s", i, r) # (姓名/单位/到期日/剩余天数/预警/建议/按钮),刷新后才是 10 列
# (多 工号/岗位/合同类型/起止日期)。两种都支持。
ncols = len(r)
if ncols < 6:
logger.warning(
"行 %d 列数=%d < 6,跳过: %s", i, ncols, r
)
continue continue
# 新列序(sandbox 加了工号 / 岗位 / 合同类型 / 起止日期): # 列序探测:判断首列是不是工号(纯数字 + 字母,纯姓名是中文)
# 0: 工号 1: 姓名 2: 单位 3: 岗位 4: 合同类型 first_col = r[0].strip()
# 5: 起止日期 (含 ~ ) 6: 剩余天数 7: 预警 8: 建议(含原因) import re as _re
emp_id = r[0].strip() is_emp_id = bool(_re.match(r"^[A-Za-z]?\d+$", first_col))
teacher = r[1].strip()
unit = r[2].strip() if is_emp_id and ncols >= 9:
position = r[3].strip() # 10 列布局(含工号):0 工号 1 姓名 2 单位 3 岗位 4 合同类型
contract_type = r[4].strip() # 5 起止(含~) 6 剩余天数 7 预警 8 建议 9 按钮
period = r[5].strip() emp_id = first_col
# 起止日期形如 "2024-03-01 ~ 2026-04-15" → 取 ~ 后 teacher = r[1].strip()
contract_end = period.split("~")[-1].strip() if "~" in period else period unit = r[2].strip()
days_raw = r[6].strip() period = r[5].strip()
alert_label = r[7].strip() contract_end = period.split("~")[-1].strip() if "~" in period else period
suggestion_hint = r[8].strip() if len(r) > 8 else "" 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(稳定,不会随姓名变化)
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 = {
"contract_id": contract_id, "contract_id": contract_id,
"teacher_name": teacher, "teacher_name": teacher,
@@ -376,7 +722,7 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
) )
btn.click() btn.click()
# 等卡片出现(页面下方追加 h2「续聘准备任务书」/「单位清单」) # 等卡片出现(页面下方追加 h2「续聘准备任务书」/「单位清单」)
page.wait_for_timeout(1500) page.wait_for_timeout(CARD_APPEAR_WAIT_MS)
# 抓表格下方所有 card 文本 # 抓表格下方所有 card 文本
cards_text = page.locator( cards_text = page.locator(
"main .grid > div, main > div > div > div" "main .grid > div, main > div > div > div"
@@ -398,12 +744,10 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
) )
# dump 一次(i==0)方便调试 # dump 一次(i==0)方便调试
if i == 0: if i == 0:
html_after = page.content() _dump_page(
click_dump = download_dir.parent / "after_click_dump.html" page,
click_dump.write_text(html_after, encoding="utf-8") download_dir.parent / "after_click_dump.html",
logger.info( "首行 click 后",
"platform_collector: 首行 click 后 dump → %s (%d 字节)",
click_dump, len(html_after),
) )
except Exception as e: except Exception as e:
logger.warning("抓卡片失败 %s: %s", contract_id, e) logger.warning("抓卡片失败 %s: %s", contract_id, e)

View File

@@ -6,7 +6,7 @@
""" """
SKILL_SLUG = "alert-contract-expiring-hr" SKILL_SLUG = "alert-contract-expiring-hr"
SKILL_VERSION = "1.1.4" SKILL_VERSION = "1.1.13"
LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr" LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr"
PLATFORM_KIT_MIN_VERSION = "1.2.11" PLATFORM_KIT_MIN_VERSION = "1.2.11"
@@ -43,3 +43,47 @@ DEFAULT_MATERIALS = [
MORAL_FAIL_THRESHOLD = 60 # 师德 < 60 直接 non_renew MORAL_FAIL_THRESHOLD = 60 # 师德 < 60 直接 non_renew
PERFORMANCE_FAIL_THRESHOLD = 60 # 单年考核 < 60 算不合格 PERFORMANCE_FAIL_THRESHOLD = 60 # 单年考核 < 60 算不合格
PERFORMANCE_RENEW_MIN = 80 # 全部 >= 80 且逐年上升 → renew PERFORMANCE_RENEW_MIN = 80 # 全部 >= 80 且逐年上升 → renew
# ----- v1.1.6:RPA 超时常量(与 skill-template human_verification.py 对齐)-----
# 手工登录等待(HITL fallback)
HUMAN_WAIT_TIMEOUT = 180 # 手工登录等待超时(秒)—— skill-template 同名常量
HUMAN_POLL_INTERVAL = 2.0 # 轮询间隔(秒)—— skill-template 同名常量
# Playwright 操作超时(毫秒)
DEFAULT_PAGE_TIMEOUT_MS = 15_000 # Playwright 默认操作超时 —— skill-template 同名常量
GOTO_TIMEOUT_MS = 60_000 # page.goto 导航超时 —— skill-template 同名常量
# SPA 登录等待(毫秒)
SPA_LOGIN_SETTLE_MS = 4_000 # login click 后 settle 时间(SPA 写 cookie)
SPA_LOGIN_INPUT_TIMEOUT_MS = 20_000 # 等 username input 出现
SPA_LOGIN_URL_TIMEOUT_MS = 30_000 # 等 URL 跳离 login 页
SPA_HYDRATION_MS = 3_000 # goto login URL 后 SPA 框架初始化
POST_LOGIN_SETTLE_MS = 2_000 # 登录后等 SPA 路由稳定
SPA_HOME_RETRY_DELAY_MS = 2_500 # SPA_HOME retry 间隔
SPA_HOME_RETRY_MAX = 2 # SPA_HOME retry 最大次数
# 表格等待(毫秒)
TABLE_INITIAL_WAIT_MS = 3_000 # 表格初始等待(React + mock 填充)
TABLE_POLL_INTERVAL_MS = 2_000 # 表格轮询间隔
TABLE_POLL_MAX_ATTEMPTS = 20 # 表格轮询最大次数(合计 ~40s)
TABLE_TR_TIMEOUT_MS = 3_000 # 单次 wait_for_selector 超时
FULL_RENDER_WAIT_MS = 1_500 # 完整 DOM 渲染后等渲染结束
# 侧边栏/卡片(毫秒)
SIDEBAR_LINK_TIMEOUT_MS = 30_000 # 等侧边栏 hr/contracts link
PUSHSTATE_SETTLE_MS = 1_500 # SPA pushState 后等
SIDEBAR_CLICK_URL_TIMEOUT_MS = 10_000 # click 后等 URL 变 /hr/contracts
URL_AFTER_LOGIN_TIMEOUT_MS = 20_000 # 根域 login 后等跳转
# reload 后备用
RELOAD_NETWORKIDLE_TIMEOUT_MS = 30_000 # page.reload networkidle 超时
RELOAD_DOMCONTENTLOADED_TIMEOUT_MS = 30_000 # page.reload domcontentloaded 超时
POST_RELOAD_LOGIN_HYDRATION_MS = 3_000 # reload 后回到 login 页的 hydration
POST_RELOGIN_GOTO_WAIT_MS = 2_000 # 重新登录后 goto PLATFORM_URL 等
# 卡片
CARD_APPEAR_WAIT_MS = 3_000 # 「续聘材料包」click 后等卡片出现
# dump
DUMP_PATH_PARENT_MKDIR = True # _dump_page 是否自动创建父目录

View File

@@ -558,6 +558,131 @@ def test_html_render_unknown_task_raises() -> tuple[bool, str]:
return False, "应抛 ValueError,实际未抛" return False, "应抛 ValueError,实际未抛"
# ---------- v1.1.6:service.platform_collector 纯函数 ----------
def test_gen_contract_id_emp_id() -> tuple[bool, str]:
"""_gen_contract_id 工号派生(v1.1.1 起用工号作主键)"""
from service import platform_collector as pc
cases = [
(("T20190033", "2026-04-15"), "RPA-T20190033-20260415"),
(("T20200055", "2026-06-30"), "RPA-T20200055-20260630"),
(("T20180021", "2026-08-31"), "RPA-T20180021-20260831"),
]
for (emp_id, end_date), expected in cases:
got = pc._gen_contract_id(emp_id, end_date)
if got != expected:
return False, f"({emp_id!r}, {end_date!r}) → {got!r},want {expected!r}"
return True, f"3 工号派生正确"
def test_gen_contract_id_special_chars() -> tuple[bool, str]:
"""_gen_contract_id 工号含特殊字符时正确清洗(保留前 10 字符)"""
from service import platform_collector as pc
# 工号含非 \w 字符 → 清洗;超过 10 字符截断
got = pc._gen_contract_id("T-2019.0033", "2026-04-15")
# 清洗后保留 \w,"T-2019.0033" → "T20190033"(8 字符,未截断)
if "RPA-" not in got or "20260415" not in got:
return False, f"清洗后格式错:{got!r}"
# 长工号 → 截断到 10 字符
got2 = pc._gen_contract_id("ABCDEFGHIJKLMNO", "2026-01-01")
# "ABCDEFGHIJ"(10 字符)
if not got2.startswith("RPA-ABCDEFGHIJ-"):
return False, f"长工号未截断到 10 字符:{got2!r}"
return True, "清洗 + 截断 OK"
def test_normalize_date_iso() -> tuple[bool, str]:
"""_normalize_date ISO 日期直通"""
from service import platform_collector as pc
cases = [
("2026-04-15", "2026-04-15"),
("2024-01-01", "2024-01-01"),
]
for inp, want in cases:
got = pc._normalize_date(inp)
if got != want:
return False, f"{inp!r} → {got!r},want {want!r}"
return True, "ISO 日期直通"
def test_normalize_date_fallback() -> tuple[bool, str]:
"""_normalize_date 非标准格式截前 10 字符"""
from service import platform_collector as pc
# 非标准但带 ISO 前缀
got = pc._normalize_date("2026-04-15T00:00:00")
if got != "2026-04-15":
return False, f"ISO 前缀应保留前 10 字符 → {got!r}"
# 完全非日期 → 截前 10 字符
got2 = pc._normalize_date("20260415X")
if got2 != "20260415X": # 10 字符
return False, f"非日期应截前 10 字符 → {got2!r}"
# 带空白
got3 = pc._normalize_date(" 2026-04-15 ")
if got3 != "2026-04-15":
return False, f"带空白应去除 → {got3!r}"
return True, "非标准格式 fallback OK"
def test_parse_days_positive() -> tuple[bool, str]:
"""_parse_days 正天数"""
from service import platform_collector as pc
cases = [
("30", 30),
("90", 90),
("还剩 45 天", 45),
("0", 0),
]
for inp, want in cases:
got = pc._parse_days(inp)
if got != want:
return False, f"{inp!r} → {got!r},want {want!r}"
return True, "正天数解析 OK"
def test_parse_days_negative() -> tuple[bool, str]:
"""_parse_days 负数 / 兜底"""
from service import platform_collector as pc
# 含负号
if pc._parse_days("-15") != -15:
return False, "负天数未解析"
# 已过期(带 "已过期" 文本)
if pc._parse_days("已过期 7 天") != 7:
return False, "已过期文本未解析数字部分"
# 无法解析
if pc._parse_days("") is not None:
return False, "空字符串应返回 None"
if pc._parse_days("无数字") is not None:
return False, "纯文本应返回 None"
return True, "负数 + 兜底 OK"
def test_parse_alert_red_orange_yellow_unknown() -> tuple[bool, str]:
"""_parse_alert 红/橙/黄/未知四级映射"""
from service import platform_collector as pc
cases = [
("红色预警", "red"),
("红牌", "red"),
("橙色预警", "orange"),
("黄灯", "yellow"),
("黄色预警", "yellow"),
("无", "unknown"),
("", "unknown"),
("blue", "unknown"),
]
for inp, want in cases:
got = pc._parse_alert(inp)
if got != want:
return False, f"{inp!r} → {got!r},want {want!r}"
return True, "四级映射 OK"
# ---------- 入口 ---------- # ---------- 入口 ----------
def main() -> int: def main() -> int:
@@ -592,6 +717,13 @@ def main() -> int:
_run("scanner/skips_non_active", test_scanner_skips_non_active_contracts) _run("scanner/skips_non_active", test_scanner_skips_non_active_contracts)
_run("html_renderer/basic", test_html_render_basic) _run("html_renderer/basic", test_html_render_basic)
_run("html_renderer/unknown_task_raises", test_html_render_unknown_task_raises) _run("html_renderer/unknown_task_raises", test_html_render_unknown_task_raises)
_run("platform_collector/gen_contract_id_emp_id", test_gen_contract_id_emp_id)
_run("platform_collector/gen_contract_id_special", test_gen_contract_id_special_chars)
_run("platform_collector/normalize_date_iso", test_normalize_date_iso)
_run("platform_collector/normalize_date_fallback", test_normalize_date_fallback)
_run("platform_collector/parse_days_positive", test_parse_days_positive)
_run("platform_collector/parse_days_negative", test_parse_days_negative)
_run("platform_collector/parse_alert_levels", test_parse_alert_red_orange_yellow_unknown)
total = len(_RESULTS) total = len(_RESULTS)
passed = sum(1 for _, ok, _ in _RESULTS if ok) passed = sum(1 for _, ok, _ in _RESULTS if ok)