Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f38f662316 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
本文件对应技能市场 **「更新日志」** Tab。
|
本文件对应技能市场 **「更新日志」** 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
|
## 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` 两行(**只补不删**)。
|
||||||
|
|||||||
2
SKILL.md
2
SKILL.md
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: 合同到期智能预警与续聘辅助
|
name: 合同到期智能预警与续聘辅助
|
||||||
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。"
|
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。"
|
||||||
version: 1.1.4
|
version: 1.1.5
|
||||||
author: 深圳匠厂科技有限公司
|
author: 深圳匠厂科技有限公司
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
|
|||||||
@@ -68,6 +68,20 @@ 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 _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
|
||||||
@@ -103,14 +117,24 @@ def _click_campus_link(page) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _fill_spa_login(page) -> bool:
|
def _fill_spa_login(page, settle_ms: int = 4000) -> 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 跳走)。
|
||||||
|
|
||||||
|
修复要点(登录时间太短导致采集不到数据):
|
||||||
|
- 默认 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
|
return False
|
||||||
try:
|
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:
|
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
|
spa_user = page.locator('input[autocomplete="username"]').first
|
||||||
if spa_user.count() == 0:
|
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")'
|
'button:has-text("登录"), button[type="submit"]:has-text("Sign in")'
|
||||||
).first.click()
|
).first.click()
|
||||||
logger.info("platform_collector: sandbox SPA 自动登录...")
|
logger.info("platform_collector: sandbox SPA 自动登录...")
|
||||||
|
# click 后给 SPA 一点时间把 token 写入 cookie / 触发跳转
|
||||||
|
page.wait_for_timeout(settle_ms)
|
||||||
|
# 再等到 URL 真的跳离 login 页(最长 30s;之前 20s 太短)
|
||||||
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=30_000,
|
||||||
)
|
)
|
||||||
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
|
||||||
logger.info("platform_collector: SPA 登录后 URL=%s", page.url)
|
logger.info("platform_collector: SPA 登录后 URL=%s", page.url)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -172,39 +202,39 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
|||||||
# 2. 直接 goto sandbox SPA login(绕过 /select 和 root 跳转)
|
# 2. 直接 goto sandbox SPA login(绕过 /select 和 root 跳转)
|
||||||
logger.info("platform_collector: 强制 goto sandbox SPA login, 当前 URL=%s", page.url)
|
logger.info("platform_collector: 强制 goto sandbox SPA login, 当前 URL=%s", page.url)
|
||||||
_goto(page, SPA_LOGIN_URL)
|
_goto(page, SPA_LOGIN_URL)
|
||||||
|
# SPA hydration + framework 初始化时间(修复登录时间太短的核心点)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
# 3. sandbox SPA 登录(demo/123456 已知可用)
|
# 3. sandbox SPA 登录(demo/123456 已知可用)
|
||||||
page.wait_for_timeout(1500)
|
# 等待 URL 真的跳离 login(settle_ms 已给到 4s,wait_for_url 又给 30s)
|
||||||
_fill_spa_login(page)
|
if _fill_spa_login(page):
|
||||||
|
# 等 SPA 路由稳定(之前这里没有 wait,直接 goto home 会打断 pending 跳转)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
# 4. 强制 goto SPA home
|
# 4. 强制 goto SPA home(如果还停在 login,重试登录一次)
|
||||||
if "/app/" not in page.url:
|
for retry in range(2):
|
||||||
logger.info("platform_collector: 强制 goto SPA_HOME, 当前 URL=%s", page.url)
|
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)
|
_goto(page, SPA_HOME_URL)
|
||||||
# SPA home 也可能再次 redirect 到 /login(session 没保住)
|
page.wait_for_timeout(2500)
|
||||||
page.wait_for_timeout(1500)
|
|
||||||
if _fill_spa_login(page):
|
if _fill_spa_login(page):
|
||||||
_goto(page, SPA_HOME_URL)
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
# 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(2000)
|
||||||
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 出现,最长 30s)
|
||||||
|
# 修复:之前 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=30_000, state="attached"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("等侧边栏 hr/contracts link 超时: %s", 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 数据可能未加载,需刷新触发)
|
# 5.5. 进入页面后刷新(SPA 进入新路由时 mock 数据可能未加载,需刷新触发)
|
||||||
try:
|
try:
|
||||||
logger.info("platform_collector: 进入 hr/contracts 后刷新页面")
|
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:
|
except Exception as e:
|
||||||
logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e)
|
logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e)
|
||||||
try:
|
try:
|
||||||
page.reload(wait_until="domcontentloaded", timeout=20_000)
|
page.reload(wait_until="domcontentloaded", timeout=30_000)
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.warning("刷新 domcontentloaded 也失败: %s", e2)
|
logger.warning("刷新 domcontentloaded 也失败: %s", e2)
|
||||||
|
|
||||||
# 7. dump 当前 hr/contracts 页面(用于调试)
|
# 5.6. 刷新后可能被踢回 login(session 丢失),重新登录一次
|
||||||
try:
|
# 修复:之前没检查 URL,刷新后停在 login 页继续往后走,找不到表格就 dump 报错
|
||||||
html = page.content()
|
if "/login" in page.url.lower() or "/signin" in page.url.lower():
|
||||||
dump_path = download_dir.parent / "hr_contracts_dump.html"
|
logger.warning(
|
||||||
dump_path.write_text(html, encoding="utf-8")
|
"platform_collector: 刷新后回到登录页(%s),重新登录",
|
||||||
logger.info(
|
page.url,
|
||||||
"platform_collector: hr/contracts dump → %s (%d 字节)",
|
|
||||||
dump_path, len(html),
|
|
||||||
)
|
)
|
||||||
except Exception:
|
_goto(page, SPA_LOGIN_URL)
|
||||||
pass
|
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 数据,多等几秒)
|
# 8. 等待表格加载(SPA 异步填充 mock 数据,多等几秒)
|
||||||
page.wait_for_timeout(3000) # 给 React 初始化 + 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:
|
if not found_table:
|
||||||
try:
|
_dump_page(page, download_dir.parent / "sandbox_dump.html", "sandbox")
|
||||||
html = page.content()
|
logger.warning("platform_collector: 40s 后未找到表格")
|
||||||
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)
|
|
||||||
return _error_result("table_not_found_after_40s", 0)
|
return _error_result("table_not_found_after_40s", 0)
|
||||||
|
|
||||||
# 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector)
|
# 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector)
|
||||||
@@ -302,12 +329,10 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
|||||||
timeout=10_000,
|
timeout=10_000,
|
||||||
)
|
)
|
||||||
page.wait_for_timeout(1500)
|
page.wait_for_timeout(1500)
|
||||||
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)
|
||||||
@@ -376,7 +401,8 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
|||||||
)
|
)
|
||||||
btn.click()
|
btn.click()
|
||||||
# 等卡片出现(页面下方追加 h2「续聘准备任务书」/「单位清单」)
|
# 等卡片出现(页面下方追加 h2「续聘准备任务书」/「单位清单」)
|
||||||
page.wait_for_timeout(1500)
|
# 修复:1500ms 太短,sandbox mock 渲染卡片动画慢,导致抓到的文本不全
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
# 抓表格下方所有 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 +424,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)
|
||||||
|
|||||||
@@ -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.5"
|
||||||
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