Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11739478d2 | |||
| 7d9aad1672 | |||
| 8ccae17111 | |||
| 04d562745f | |||
| 8ad3186057 | |||
| 6eb304bca2 |
55
CHANGELOG.md
55
CHANGELOG.md
@@ -2,6 +2,61 @@
|
|||||||
|
|
||||||
本文件对应技能市场 **「更新日志」** 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
|
## 1.1.7
|
||||||
|
|
||||||
- **RPA 手工登录 fallback 实跑验证(2026-09-14)**
|
- **RPA 手工登录 fallback 实跑验证(2026-09-14)**
|
||||||
|
|||||||
8
SKILL.md
8
SKILL.md
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: 合同到期智能预警与续聘辅助
|
name: 合同到期智能预警与续聘辅助
|
||||||
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。"
|
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。"
|
||||||
version: 1.1.7
|
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`。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -232,33 +232,66 @@ def _fill_spa_login(page, settle_ms: int = SPA_LOGIN_SETTLE_MS) -> bool:
|
|||||||
返回是否执行了登录(True = 已点登录按钮且 URL 已跳离 login;调用方可继续后续流程)。
|
返回是否执行了登录(True = 已点登录按钮且 URL 已跳离 login;调用方可继续后续流程)。
|
||||||
失败时**不抛异常**,由调用方决定是否 fallback 到 _wait_for_manual_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 登录时间太短导致采集不到数据):
|
修复要点(v1.1.5/v1.1.6 登录时间太短导致采集不到数据):
|
||||||
- 默认 settle_ms = SPA_LOGIN_SETTLE_MS(4000ms),给 SPA 异步渲染 + token 写入 cookie 时间
|
- 默认 settle_ms = SPA_LOGIN_SETTLE_MS(4000ms),给 SPA 异步渲染 + token 写入 cookie 时间
|
||||||
- 等 username input 的 timeout = SPA_LOGIN_INPUT_TIMEOUT_MS(20s)
|
- 等 username input 的 timeout = SPA_LOGIN_INPUT_TIMEOUT_MS(20s)
|
||||||
- click 按钮后 wait_for_url timeout = SPA_LOGIN_URL_TIMEOUT_MS(30s)
|
- click 按钮后 wait_for_url timeout = SPA_LOGIN_URL_TIMEOUT_MS(30s)
|
||||||
- 多处过渡路径(/login, /signin, /auth/)一并判断
|
- 多处过渡路径(/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()
|
url_lower = page.url.lower()
|
||||||
if "/login" not in url_lower and "/signin" not in url_lower and "/auth/" not in 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(
|
page.wait_for_selector(
|
||||||
'input[autocomplete="username"]',
|
'#jc-user, input[type="tel"], input[autocomplete="username"]',
|
||||||
timeout=SPA_LOGIN_INPUT_TIMEOUT_MS,
|
timeout=SPA_LOGIN_INPUT_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("SPA login input 未出现: %s;当前 URL=%s", e, page.url)
|
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)
|
page.wait_for_timeout(settle_ms)
|
||||||
try:
|
try:
|
||||||
page.wait_for_url(
|
page.wait_for_url(
|
||||||
@@ -270,6 +303,13 @@ def _fill_spa_login(page, settle_ms: int = SPA_LOGIN_SETTLE_MS) -> bool:
|
|||||||
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
|
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
|
||||||
|
|
||||||
@@ -306,46 +346,98 @@ def collect_all(
|
|||||||
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,导致
|
# v1.1.6:覆盖 Playwright 默认操作超时(之前无 set_default_timeout,导致
|
||||||
# 所有 wait_for_selector 默认 30s,调试时不直观)
|
# 所有 wait_for_selector 默认 30s,调试时不直观)
|
||||||
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
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(
|
||||||
# 2. 直接 goto sandbox SPA login(绕过 /select 和 root 跳转)
|
"platform_collector: 检测登录态,goto SPA_HOME 先探 (当前 URL=%s)",
|
||||||
logger.info("platform_collector: 强制 goto sandbox SPA login, 当前 URL=%s", page.url)
|
page.url,
|
||||||
_goto(page, SPA_LOGIN_URL)
|
)
|
||||||
# SPA hydration + framework 初始化时间(修复登录时间太短的核心点)
|
_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)
|
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)
|
||||||
|
|
||||||
# 3. sandbox SPA 自动登录(demo/123456 已知可用)
|
if _is_logged_in(page):
|
||||||
# 失败 fallback 手工登录等待(轮询 _is_logged_in + 180s 兜底)
|
logger.info(
|
||||||
if not _fill_spa_login(page):
|
"platform_collector: ✅ 已登录态,**完全跳过登录流程**(不弹窗、不自动 fill、不打扰)"
|
||||||
logger.warning(
|
",URL=%s", page.url,
|
||||||
"platform_collector: 自动 fill 失败(URL=%s),fallback 手工登录等待",
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"platform_collector: 未登录态(SPA_HOME 跳回 login, URL=%s),走登录分支",
|
||||||
page.url,
|
page.url,
|
||||||
)
|
)
|
||||||
if not headless:
|
# 2. 未登录:直接 goto sandbox SPA login(绕过 /select 和 root 跳转)
|
||||||
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
|
_goto(page, SPA_LOGIN_URL)
|
||||||
else:
|
# SPA hydration + framework 初始化时间(修复登录时间太短的核心点)
|
||||||
# headless 模式无法手工登录:抛 LoginTimeoutError 让 CLI 决定
|
page.wait_for_timeout(SPA_HYDRATION_MS)
|
||||||
raise LoginTimeoutError(
|
|
||||||
f"headless 模式下自动 fill 失败,且无法手工登录"
|
# 3. sandbox SPA 自动登录(demo/123456 已知可用)
|
||||||
f"(请用 --show-browser 重试)"
|
# 失败 fallback 手工登录等待(轮询 _is_logged_in + 180s 兜底)
|
||||||
|
if not _fill_spa_login(page):
|
||||||
|
logger.warning(
|
||||||
|
"platform_collector: 自动 fill 失败(URL=%s),fallback 手工登录等待",
|
||||||
|
page.url,
|
||||||
)
|
)
|
||||||
else:
|
if not headless:
|
||||||
# 等 SPA 路由稳定(之前这里没有 wait,直接 goto home 会打断 pending 跳转)
|
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
|
||||||
page.wait_for_timeout(POST_LOGIN_SETTLE_MS)
|
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,重试登录一次)
|
# 4. 强制 goto SPA home(如果还停在 login,重试登录一次)
|
||||||
for retry in range(SPA_HOME_RETRY_MAX):
|
for retry in range(SPA_HOME_RETRY_MAX):
|
||||||
@@ -420,48 +512,59 @@ def collect_all(
|
|||||||
|
|
||||||
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(
|
logger.info(
|
||||||
wait_until="networkidle",
|
"platform_collector: 进入 hr/contracts 后**不刷新**,等待表格出现"
|
||||||
timeout=RELOAD_NETWORKIDLE_TIMEOUT_MS,
|
"(首次登录态不要打断)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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:
|
try:
|
||||||
page.reload(
|
page.reload(
|
||||||
wait_until="domcontentloaded",
|
wait_until="networkidle",
|
||||||
timeout=RELOAD_DOMCONTENTLOADED_TIMEOUT_MS,
|
timeout=RELOAD_NETWORKIDLE_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
except Exception as e2:
|
except Exception as e:
|
||||||
logger.warning("刷新 domcontentloaded 也失败: %s", e2)
|
logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e)
|
||||||
|
try:
|
||||||
# 5.6. 刷新后可能被踢回 login(session 丢失),重新登录
|
page.reload(
|
||||||
# 修复:之前没检查 URL,刷新后停在 login 页继续往后走,找不到表格就 dump 报错
|
wait_until="domcontentloaded",
|
||||||
if "/login" in page.url.lower() or "/signin" in page.url.lower():
|
timeout=RELOAD_DOMCONTENTLOADED_TIMEOUT_MS,
|
||||||
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:
|
except Exception as e2:
|
||||||
page.wait_for_timeout(POST_LOGIN_SETTLE_MS)
|
logger.warning("刷新 domcontentloaded 也失败: %s", e2)
|
||||||
_goto(page, PLATFORM_URL)
|
if "/login" in page.url.lower() or "/signin" in page.url.lower():
|
||||||
page.wait_for_timeout(POST_RELOGIN_GOTO_WAIT_MS)
|
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 页面(用于调试)
|
||||||
_dump_page(page, download_dir.parent / "hr_contracts_dump.html", "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 数据填充时间
|
page.wait_for_timeout(TABLE_INITIAL_WAIT_MS) # 给 React 初始化 + mock 数据填充时间
|
||||||
found_table = False
|
found_table = False
|
||||||
for attempt in range(TABLE_POLL_MAX_ATTEMPTS):
|
for attempt in range(TABLE_POLL_MAX_ATTEMPTS):
|
||||||
@@ -480,12 +583,38 @@ def collect_all(
|
|||||||
cur_url, attempt + 1, TABLE_POLL_MAX_ATTEMPTS,
|
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:
|
if not found_table:
|
||||||
_dump_page(page, download_dir.parent / "sandbox_dump.html", "sandbox")
|
_dump_page(page, download_dir.parent / "sandbox_dump.html", "sandbox")
|
||||||
logger.warning("platform_collector: 表格未找到")
|
logger.warning("platform_collector: 表格未找到(兜底刷新后仍未出现)")
|
||||||
raise TableNotFoundError(
|
raise TableNotFoundError(
|
||||||
f"{TABLE_POLL_MAX_ATTEMPTS * TABLE_POLL_INTERVAL_MS // 1000}s "
|
f"{TABLE_POLL_MAX_ATTEMPTS * TABLE_POLL_INTERVAL_MS // 1000}s "
|
||||||
f"内未找到表格 (URL={page.url})"
|
f"内未找到表格(兜底刷新后仍未出现) (URL={page.url})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector)
|
# 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector)
|
||||||
@@ -527,27 +656,53 @@ def collect_all(
|
|||||||
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,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SKILL_SLUG = "alert-contract-expiring-hr"
|
SKILL_SLUG = "alert-contract-expiring-hr"
|
||||||
SKILL_VERSION = "1.1.7"
|
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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user