v1.1.5: platform_collector 修复登录等待太短导致采集不到数据
All checks were successful
技能自动化发布 / release (push) Successful in 8s
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>
This commit is contained in:
@@ -68,6 +68,20 @@ def _goto(page, url: str, timeout: int = 20_000) -> None:
|
||||
logger.warning("goto domcontentloaded 也失败 %s: %s", url, e2)
|
||||
|
||||
|
||||
def _dump_page(page, path: Path, label: str) -> None:
|
||||
"""Dump 当前页面 HTML 到指定路径,自动创建父目录。修复:父目录可能不存在导致静默失败。"""
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
html = page.content()
|
||||
path.write_text(html, encoding="utf-8")
|
||||
logger.info(
|
||||
"platform_collector: %s dump → %s (%d 字节)",
|
||||
label, path, len(html),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("dump %s 失败: %s", label, e)
|
||||
|
||||
|
||||
def _fill_root_login(page) -> bool:
|
||||
"""根域登录页(如还在):fill demo/123456 → click Sign in。返回是否执行了登录。"""
|
||||
user_input = page.locator('#jc-user').first
|
||||
@@ -103,14 +117,24 @@ def _click_campus_link(page) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _fill_spa_login(page) -> bool:
|
||||
"""sandbox SPA 登录(如还在登录页):fill demo/123456 → click 登录。返回是否执行了登录。"""
|
||||
if "/login" not in page.url and "/signin" not in page.url:
|
||||
def _fill_spa_login(page, settle_ms: int = 4000) -> bool:
|
||||
"""sandbox SPA 登录(如还在登录页):fill demo/123456 → click 登录 → 等跳转。
|
||||
|
||||
返回是否执行了登录(True = 已点登录按钮;调用方应继续等 URL 跳走)。
|
||||
|
||||
修复要点(登录时间太短导致采集不到数据):
|
||||
- 默认 settle_ms 从 0 提到 4000ms,给 SPA 异步渲染 + token 写入 cookie 时间
|
||||
- 等 username input 的 timeout 从 10s 提到 20s(mock 慢启动)
|
||||
- click 按钮后显式 wait_for_url 含更长 timeout(30s),URL 真的不含 /login 才返回 True
|
||||
- 多处过渡路径(/login, /signin, /auth/)一并判断,避免 SPA 中间态被误判为「已离开登录页」
|
||||
"""
|
||||
url_lower = page.url.lower()
|
||||
if "/login" not in url_lower and "/signin" not in url_lower and "/auth/" not in url_lower:
|
||||
return False
|
||||
try:
|
||||
page.wait_for_selector('input[autocomplete="username"]', timeout=10_000)
|
||||
page.wait_for_selector('input[autocomplete="username"]', timeout=20_000)
|
||||
except Exception as e:
|
||||
logger.warning("SPA login input 未出现: %s", e)
|
||||
logger.warning("SPA login input 未出现: %s;当前 URL=%s", e, page.url)
|
||||
return False
|
||||
spa_user = page.locator('input[autocomplete="username"]').first
|
||||
if spa_user.count() == 0:
|
||||
@@ -122,13 +146,19 @@ def _fill_spa_login(page) -> bool:
|
||||
'button:has-text("登录"), button[type="submit"]:has-text("Sign in")'
|
||||
).first.click()
|
||||
logger.info("platform_collector: sandbox SPA 自动登录...")
|
||||
# click 后给 SPA 一点时间把 token 写入 cookie / 触发跳转
|
||||
page.wait_for_timeout(settle_ms)
|
||||
# 再等到 URL 真的跳离 login 页(最长 30s;之前 20s 太短)
|
||||
try:
|
||||
page.wait_for_url(
|
||||
lambda u: "/login" not in u and "/signin" not in u,
|
||||
timeout=20_000,
|
||||
lambda u: "/login" not in u.lower()
|
||||
and "/signin" not in u.lower()
|
||||
and "/auth/" not in u.lower(),
|
||||
timeout=30_000,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("SPA 登录超时: %s;当前 URL=%s", e, page.url)
|
||||
return False
|
||||
logger.info("platform_collector: SPA 登录后 URL=%s", page.url)
|
||||
return True
|
||||
|
||||
@@ -172,39 +202,39 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
||||
# 2. 直接 goto sandbox SPA login(绕过 /select 和 root 跳转)
|
||||
logger.info("platform_collector: 强制 goto sandbox SPA login, 当前 URL=%s", page.url)
|
||||
_goto(page, SPA_LOGIN_URL)
|
||||
# SPA hydration + framework 初始化时间(修复登录时间太短的核心点)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# 3. sandbox SPA 登录(demo/123456 已知可用)
|
||||
page.wait_for_timeout(1500)
|
||||
_fill_spa_login(page)
|
||||
# 等待 URL 真的跳离 login(settle_ms 已给到 4s,wait_for_url 又给 30s)
|
||||
if _fill_spa_login(page):
|
||||
# 等 SPA 路由稳定(之前这里没有 wait,直接 goto home 会打断 pending 跳转)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 4. 强制 goto SPA home
|
||||
if "/app/" not in page.url:
|
||||
logger.info("platform_collector: 强制 goto SPA_HOME, 当前 URL=%s", page.url)
|
||||
# 4. 强制 goto SPA home(如果还停在 login,重试登录一次)
|
||||
for retry in range(2):
|
||||
if "/app/" in page.url and "/login" not in page.url.lower():
|
||||
break
|
||||
logger.info(
|
||||
"platform_collector: 强制 goto SPA_HOME (retry=%d), 当前 URL=%s",
|
||||
retry, page.url,
|
||||
)
|
||||
_goto(page, SPA_HOME_URL)
|
||||
# SPA home 也可能再次 redirect 到 /login(session 没保住)
|
||||
page.wait_for_timeout(1500)
|
||||
page.wait_for_timeout(2500)
|
||||
if _fill_spa_login(page):
|
||||
_goto(page, SPA_HOME_URL)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 5. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session)
|
||||
try:
|
||||
# dump SPA home 一次(用于调试 selector)
|
||||
page.wait_for_timeout(2000)
|
||||
try:
|
||||
html = page.content()
|
||||
dump_path = download_dir.parent / "spa_home_dump.html"
|
||||
dump_path.write_text(html, encoding="utf-8")
|
||||
logger.info(
|
||||
"platform_collector: SPA home dump → %s (%d 字节)",
|
||||
dump_path, len(html),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_dump_page(page, download_dir.parent / "spa_home_dump.html", "SPA home")
|
||||
|
||||
# 等 SPA 真正加载完(侧边栏 link 出现,最长 15s)
|
||||
# 等 SPA 真正加载完(侧边栏 link 出现,最长 30s)
|
||||
# 修复:之前 15s 太短,sandbox mock 加载慢时直接 fallback 到 pushState → 表格加载不出来
|
||||
try:
|
||||
page.wait_for_selector(
|
||||
"a[href$='/hr/contracts']", timeout=15_000, state="attached"
|
||||
"a[href$='/hr/contracts']", timeout=30_000, state="attached"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("等侧边栏 hr/contracts link 超时: %s", e)
|
||||
@@ -246,25 +276,30 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
||||
# 5.5. 进入页面后刷新(SPA 进入新路由时 mock 数据可能未加载,需刷新触发)
|
||||
try:
|
||||
logger.info("platform_collector: 进入 hr/contracts 后刷新页面")
|
||||
page.reload(wait_until="networkidle", timeout=20_000)
|
||||
page.reload(wait_until="networkidle", timeout=30_000)
|
||||
except Exception as e:
|
||||
logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e)
|
||||
try:
|
||||
page.reload(wait_until="domcontentloaded", timeout=20_000)
|
||||
page.reload(wait_until="domcontentloaded", timeout=30_000)
|
||||
except Exception as e2:
|
||||
logger.warning("刷新 domcontentloaded 也失败: %s", e2)
|
||||
|
||||
# 7. dump 当前 hr/contracts 页面(用于调试)
|
||||
try:
|
||||
html = page.content()
|
||||
dump_path = download_dir.parent / "hr_contracts_dump.html"
|
||||
dump_path.write_text(html, encoding="utf-8")
|
||||
logger.info(
|
||||
"platform_collector: hr/contracts dump → %s (%d 字节)",
|
||||
dump_path, len(html),
|
||||
# 5.6. 刷新后可能被踢回 login(session 丢失),重新登录一次
|
||||
# 修复:之前没检查 URL,刷新后停在 login 页继续往后走,找不到表格就 dump 报错
|
||||
if "/login" in page.url.lower() or "/signin" in page.url.lower():
|
||||
logger.warning(
|
||||
"platform_collector: 刷新后回到登录页(%s),重新登录",
|
||||
page.url,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_goto(page, SPA_LOGIN_URL)
|
||||
page.wait_for_timeout(3000)
|
||||
if _fill_spa_login(page):
|
||||
page.wait_for_timeout(2000)
|
||||
_goto(page, PLATFORM_URL)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 7. dump 当前 hr/contracts 页面(用于调试)
|
||||
_dump_page(page, download_dir.parent / "hr_contracts_dump.html", "hr/contracts")
|
||||
|
||||
# 8. 等待表格加载(SPA 异步填充 mock 数据,多等几秒)
|
||||
page.wait_for_timeout(3000) # 给 React 初始化 + mock 数据填充时间
|
||||
@@ -283,16 +318,8 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
||||
)
|
||||
|
||||
if not found_table:
|
||||
try:
|
||||
html = page.content()
|
||||
dump_path = download_dir.parent / "sandbox_dump.html"
|
||||
dump_path.write_text(html, encoding="utf-8")
|
||||
logger.warning(
|
||||
"platform_collector: 40s 后未找到表格,已 dump 页面到 %s(%d 字节)",
|
||||
dump_path, len(html),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("dump 失败: %s", e)
|
||||
_dump_page(page, download_dir.parent / "sandbox_dump.html", "sandbox")
|
||||
logger.warning("platform_collector: 40s 后未找到表格")
|
||||
return _error_result("table_not_found_after_40s", 0)
|
||||
|
||||
# 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector)
|
||||
@@ -302,12 +329,10 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
||||
timeout=10_000,
|
||||
)
|
||||
page.wait_for_timeout(1500)
|
||||
html = page.content()
|
||||
full_dump = download_dir.parent / "hr_contracts_full_dump.html"
|
||||
full_dump.write_text(html, encoding="utf-8")
|
||||
logger.info(
|
||||
"platform_collector: 完整 hr/contracts dump → %s (%d 字节)",
|
||||
full_dump, len(html),
|
||||
_dump_page(
|
||||
page,
|
||||
download_dir.parent / "hr_contracts_full_dump.html",
|
||||
"完整 hr/contracts",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("等完整渲染超时: %s", e)
|
||||
@@ -376,7 +401,8 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
||||
)
|
||||
btn.click()
|
||||
# 等卡片出现(页面下方追加 h2「续聘准备任务书」/「单位清单」)
|
||||
page.wait_for_timeout(1500)
|
||||
# 修复:1500ms 太短,sandbox mock 渲染卡片动画慢,导致抓到的文本不全
|
||||
page.wait_for_timeout(3000)
|
||||
# 抓表格下方所有 card 文本
|
||||
cards_text = page.locator(
|
||||
"main .grid > div, main > div > div > div"
|
||||
@@ -398,12 +424,10 @@ def collect_all(limit: int = 0, headless: bool = True) -> dict:
|
||||
)
|
||||
# dump 一次(i==0)方便调试
|
||||
if i == 0:
|
||||
html_after = page.content()
|
||||
click_dump = download_dir.parent / "after_click_dump.html"
|
||||
click_dump.write_text(html_after, encoding="utf-8")
|
||||
logger.info(
|
||||
"platform_collector: 首行 click 后 dump → %s (%d 字节)",
|
||||
click_dump, len(html_after),
|
||||
_dump_page(
|
||||
page,
|
||||
download_dir.parent / "after_click_dump.html",
|
||||
"首行 click 后",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("抓卡片失败 %s: %s", contract_id, e)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"""
|
||||
|
||||
SKILL_SLUG = "alert-contract-expiring-hr"
|
||||
SKILL_VERSION = "1.1.4"
|
||||
SKILL_VERSION = "1.1.5"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user