Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0157fde0f1 | |||
| aa9bf262fe | |||
| 52831b80e0 | |||
| 17a7a54349 | |||
| c21339ae44 | |||
| 9878f570d7 | |||
| 44088336b4 |
54
CHANGELOG.md
54
CHANGELOG.md
@@ -2,6 +2,60 @@
|
||||
|
||||
本文件对应技能市场 **「更新日志」** Tab。
|
||||
|
||||
## 1.1.25
|
||||
|
||||
- **按用户要求修正登录页等待逻辑**:
|
||||
- 有数据(账号密码已存在)→ 自动点登录按钮(`_try_auto_sign_in`)
|
||||
- **无数据 → 等待用户输入完成(最多 180 秒)→ 等 URL 跳离登录页**(按钮交给用户自己点,脚本不抢按钮)
|
||||
- `_wait_for_login_inputs_filled` 重写为「**等待模式**」:不再自动点 Sign in / 登录按钮;只轮询 input 值 + URL,每 15 秒提示「请点登录按钮」或「等待输入」
|
||||
- 根域登录 + SPA 二级登录页统一使用同一套行为
|
||||
- 新增测试 `wait_login_inputs_filled_no_auto_click_v1_24` 验证不抢按钮,合计 63/63
|
||||
|
||||
## 1.1.24
|
||||
|
||||
- **修复 `/industries/campus/smartcampus/login` 等待过久问题**:sandbox 在用户首次登录后可能把人踢回 SPA 二级登录页(按钮中文「登录」),之前根域自动登录代码不识别这个 URL + 中文按钮,导致脚本卡死等用户输入
|
||||
- `_try_auto_sign_in` 扩展为通用版本:支持 `#jc-user` / `input[type='tel']` / `input[autocomplete='username']` 多种 user input selector;按钮文本按优先级匹配 `Sign in` > `登录` > `Login` > `signin` > `submit`
|
||||
- `collect_all` 登录后新增步骤 4.5:等 URL 稳定到 `/app/` 或 `/select` 后,若发现 URL 仍含 `/login` / `/signin`(落在 SPA 二级登录页),自动重走一次 `_try_auto_sign_in` → 再等 URL 跳到 `/app/` 或 `/select`
|
||||
- 新增 4 个测试覆盖 `_try_auto_sign_in`(SPA 中文按钮 / 根域英文按钮 / input 空 / selector 超时),合计 58/58
|
||||
|
||||
## 1.1.23
|
||||
|
||||
- **修复登录后跳 /app/home 的流程**:
|
||||
- `_wait_for_url_stable` 支持 tuple targets(任一匹配即稳定)
|
||||
- 登录后等 URL 稳定到 `/app/` 或 `/select`(sandbox 中间页)
|
||||
- 显式 `goto SPA_HOME_URL` (/app/home) → 等 sidebar link 出现 → click `<a href="/industries/campus/smartcampus/app/hr/contracts">合同到期续聘</a>`
|
||||
- 删掉重复的 # 4 块(之前手抖复制粘贴的 bug)
|
||||
|
||||
## 1.1.22
|
||||
|
||||
- **登录流程接入 sandbox 自动登录**:root 登录页(https://sandbox.jc2009.com/)的 #jc-user / #jc-pass 默认有 demo/123456 预填
|
||||
- 新增 `_try_auto_sign_in(page, wait_input_sec=2)`:等 input 出现 2 秒 → 检查是否有值 → 有则自动点 Sign in
|
||||
- 新增 `_wait_for_login_inputs_filled(page, wait_sec)`:input 为空时等用户输入 → 两个 input 都有值后自动点 Sign in
|
||||
- 已登录态(profile cookie 复用)直接跳过
|
||||
- 输入超时 fallback 到 `_wait_for_manual_login`(多信号判定)
|
||||
|
||||
## 1.1.21
|
||||
|
||||
- **修复登录完成检测盲区**:`_wait_for_manual_login` 现在监听 3 个信号判断「登录已完成」,任一触发即继续
|
||||
1. URL 含 `/app/` 且稳定 2 秒(v1.1.20 旧逻辑)
|
||||
2. URL 已离开 `/login` / `/signin` / `/auth/` 等登录路径
|
||||
3. 页面出现 sidebar 元素 `a[href$='/hr/contracts']`
|
||||
- 解决「用户已经登录但脚本还在傻等」的回归:之前只判 URL 变化,cookie 已生效复用 profile 场景或 SPA 不跳 URL 的场景会卡 180 秒
|
||||
|
||||
## 1.1.20
|
||||
|
||||
- **登录流程重写**:第一跳改为 `https://sandbox.jc2009.com/`(根域,用户最初看到的页面),**不做任何操作**等 URL 稳定
|
||||
- 已登录态:根域自动跳到 `/app/home` → 直接走侧边栏到 `hr/contracts` 采集
|
||||
- 未登录态:**纯等用户在浏览器手工输入账号密码**,不再自动 fill demo/123456,不再 retry 强制 reload 打断用户输入
|
||||
- 新增 `_wait_for_url_stable(page, target_substr, max_wait_sec)`:等 URL 真正稳定 2 秒后才返回,避免 sandbox 中间页误导
|
||||
- `_wait_for_manual_login` 重写:每 5 秒轮询 URL(之前 2 秒太密),每 15 秒打印一次剩余时间提示,**等待期间不触发任何 goto / reload / click**
|
||||
- 新增 4 个 login flow 测试(`wait_url_stable_immediate` / `wait_url_stable_transition` / `wait_url_stable_timeout` / `wait_manual_login_no_goto`),合计 58/58
|
||||
|
||||
## 1.1.19
|
||||
|
||||
- **修复 1.1.18 引入的采集回归**:把 `_persist_contracts` 里的入队调用移到独立事务 `_enqueue_download_tasks_safe`,FK 失败不再回滚 `contracts` / `materials_content` 落库;返回 JSON 新增 `download_queue` 字段方便 UI 直接看到入队结果
|
||||
- 新增 4 个测试覆盖 `_enqueue_download_tasks_safe`(basic / idempotent / fk_failure / empty_list),合计 54/54
|
||||
|
||||
## 1.1.18
|
||||
|
||||
- **一键式"运维 + 材料管理"全打通**
|
||||
|
||||
2
SKILL.md
2
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 合同到期智能预警与续聘辅助
|
||||
description: "当用户说要扫描即将到期的合同、查 90/60/30 天预警名单、给某位教师生成续聘任务书、下发单位版续聘清单、从 SmartCampus HR 平台抓合同数据、解析续聘材料 PDF/Word、查看本月合同到期人员清单时,加载本技能。支持定时扫描(每日凌晨)+ 数据管理 + Agent 三入口,覆盖合同到期智能预警与续聘辅助全流程。"
|
||||
version: 1.1.18
|
||||
version: 1.1.25
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
|
||||
@@ -36,6 +36,7 @@ from util.constants import (
|
||||
FULL_RENDER_WAIT_MS,
|
||||
GOTO_TIMEOUT_MS,
|
||||
HUMAN_POLL_INTERVAL,
|
||||
HUMAN_PROMPT_INTERVAL,
|
||||
HUMAN_WAIT_TIMEOUT,
|
||||
POST_LOGIN_SETTLE_MS,
|
||||
POST_RELOAD_LOGIN_HYDRATION_MS,
|
||||
@@ -55,6 +56,8 @@ from util.constants import (
|
||||
TABLE_POLL_INTERVAL_MS,
|
||||
TABLE_POLL_MAX_ATTEMPTS,
|
||||
TABLE_TR_TIMEOUT_MS,
|
||||
URL_STABLE_POLL_S,
|
||||
URL_STABLE_REQUIRED_S,
|
||||
URL_AFTER_LOGIN_TIMEOUT_MS,
|
||||
)
|
||||
from util.logging_setup import get_skill_logger
|
||||
@@ -164,33 +167,322 @@ def _is_logged_in(page) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_spa_routed(page) -> bool:
|
||||
"""v1.1.22:检测 sandbox SPA 是否已路由到 SPA 页面(含 /select /app/)。"""
|
||||
url_lower = page.url.lower()
|
||||
return (
|
||||
("/app/" in url_lower or "/select" in url_lower)
|
||||
and "/login" not in url_lower
|
||||
and "/signin" not in url_lower
|
||||
)
|
||||
|
||||
|
||||
def _try_auto_sign_in(page, wait_input_sec: int = 2) -> bool:
|
||||
"""v1.1.21:检测登录页 demo/123456 是否已预填,有则自动点 Sign in。
|
||||
|
||||
v1.1.24:扩展到支持 SPA 二级登录页(/industries/campus/smartcampus/login),
|
||||
按钮文本可能是中文「登录」而非英文「Sign in」。
|
||||
|
||||
适用场景:
|
||||
- 根域登录页(https://sandbox.jc2009.com/):按钮「Sign in」
|
||||
- SPA 二级登录页(/industries/campus/smartcampus/login):按钮「登录」
|
||||
默认有 demo/123456(或 18677308191 等真实账号)预填在 input。
|
||||
|
||||
行为:
|
||||
1. 等 input 出现,最长 wait_input_sec 秒(默认 2s)
|
||||
2. 检查 input 是否都有值
|
||||
3. 有值 → 点 Sign in / 登录 → return True
|
||||
4. 无值 → return False(交给 _wait_for_login_inputs_filled 接管)
|
||||
|
||||
不会自动 fill(如果没值就放弃,让用户自己输)。
|
||||
"""
|
||||
try:
|
||||
page.wait_for_selector(
|
||||
"#jc-user, #jc-pass, input[type='tel'], input[type='password']",
|
||||
timeout=wait_input_sec * 1000,
|
||||
)
|
||||
except Exception:
|
||||
logger.info(
|
||||
"platform_collector: %ds 内未出现登录 input,跳过自动登录",
|
||||
wait_input_sec,
|
||||
)
|
||||
return False
|
||||
|
||||
# 多种 input selector 优先级
|
||||
user_input = None
|
||||
for sel in ("#jc-user", "input[type='tel']", "input[autocomplete='username']"):
|
||||
loc = page.locator(sel).first
|
||||
if loc.count() > 0:
|
||||
user_input = loc
|
||||
break
|
||||
if user_input is None:
|
||||
return False
|
||||
|
||||
pass_input = None
|
||||
for sel in ("#jc-pass", "input[type='password']", "input[autocomplete='current-password']"):
|
||||
loc = page.locator(sel).first
|
||||
if loc.count() > 0:
|
||||
pass_input = loc
|
||||
break
|
||||
if pass_input is None:
|
||||
return False
|
||||
|
||||
user_val = (user_input.input_value() or "").strip()
|
||||
pass_val = (pass_input.input_value() or "").strip()
|
||||
|
||||
if not user_val or not pass_val:
|
||||
logger.info(
|
||||
"platform_collector: 登录页 input 为空(user=%r, pass=%r),"
|
||||
"等用户输入",
|
||||
bool(user_val), bool(pass_val),
|
||||
)
|
||||
return False
|
||||
|
||||
# 多种按钮文本优先级:英文 Sign in > 中文登录 > submit
|
||||
btn = None
|
||||
btn_text_used = ""
|
||||
for txt in ("Sign in", "登录", "Login", "signin"):
|
||||
loc = page.locator(f"button:has-text('{txt}')").first
|
||||
if loc.count() > 0:
|
||||
btn = loc
|
||||
btn_text_used = txt
|
||||
break
|
||||
if btn is None:
|
||||
btn = page.locator("button[type='submit']").first
|
||||
btn_text_used = "submit"
|
||||
if btn is None or btn.count() == 0:
|
||||
logger.warning("platform_collector: 未找到登录按钮")
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"platform_collector: 登录页已预填账号(user=%r),自动点 %s 按钮",
|
||||
user_val, btn_text_used,
|
||||
)
|
||||
btn.click()
|
||||
return True
|
||||
|
||||
|
||||
def _wait_for_login_inputs_filled(page, wait_sec: int = HUMAN_WAIT_TIMEOUT) -> bool:
|
||||
"""v1.1.21:等用户输入完账号密码。
|
||||
v1.1.24:**不再自动点 Sign in**——按用户要求:「等待用户输入信息后跳转页面」,
|
||||
按钮交给用户自己点。脚本只负责等 URL 跳离登录页。
|
||||
|
||||
- 每 HUMAN_POLL_INTERVAL 秒检查 #jc-user / #jc-pass 是否有值(仅做状态日志)
|
||||
- URL 已离开登录页 → return True(用户可能自己点登录完成)
|
||||
- input 有值 → 提示「请点登录按钮」(**脚本不抢按钮**)
|
||||
- 超时未跳离登录页 → return False
|
||||
"""
|
||||
deadline = time.monotonic() + wait_sec
|
||||
last_prompt_at = time.monotonic()
|
||||
last_state_log = time.monotonic()
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
cur_url = page.url
|
||||
|
||||
# 已不在登录页(用户可能已点登录完成跳转)
|
||||
if not _is_login_page_url_for_user(cur_url):
|
||||
logger.info(
|
||||
"platform_collector: URL 已离开登录页(%s),登录完成", cur_url,
|
||||
)
|
||||
return True
|
||||
|
||||
# 探测 input 是否就绪 + 是否已有值(仅做日志,不主动 click)
|
||||
for sel in ("#jc-user", "input[type='tel']", "input[autocomplete='username']"):
|
||||
loc = page.locator(sel).first
|
||||
if loc.count() > 0:
|
||||
user_val = (loc.input_value() or "").strip()
|
||||
break
|
||||
else:
|
||||
user_val = ""
|
||||
for sel in ("#jc-pass", "input[type='password']", "input[autocomplete='current-password']"):
|
||||
loc = page.locator(sel).first
|
||||
if loc.count() > 0:
|
||||
pass_val = (loc.input_value() or "").strip()
|
||||
break
|
||||
else:
|
||||
pass_val = ""
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_state_log >= HUMAN_PROMPT_INTERVAL:
|
||||
last_state_log = now
|
||||
logger.info(
|
||||
"platform_collector: 等待用户输入 user=%r pass=%r (URL=%s)",
|
||||
bool(user_val), bool(pass_val), cur_url,
|
||||
)
|
||||
|
||||
# 提醒用户:input 有值时明确告诉用户「点登录按钮」
|
||||
if now - last_prompt_at >= HUMAN_PROMPT_INTERVAL:
|
||||
last_prompt_at = now
|
||||
remaining = int(deadline - now)
|
||||
if user_val and pass_val:
|
||||
print(
|
||||
f"[登录] ✅ 账号密码已填好(user={user_val[:3]}***),"
|
||||
f"请点「登录」按钮,剩余 {remaining}s..."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[登录] 等待用户输入账号密码... 剩余 {remaining}s"
|
||||
)
|
||||
|
||||
time.sleep(HUMAN_POLL_INTERVAL)
|
||||
|
||||
logger.warning(
|
||||
"platform_collector: %ds 内用户未跳转(最后 URL=%s)", wait_sec, page.url,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _is_login_page_url_for_user(url: str) -> bool:
|
||||
"""判断 URL 是否还在登录页(用于 _wait_for_login_inputs_filled 退出条件)。"""
|
||||
u = url.lower()
|
||||
if "/login" in u or "/signin" in u or "/auth/" in u:
|
||||
return True
|
||||
# 根域本身也是登录页(demo 数据未提交前的初始态)
|
||||
return u.rstrip("/").endswith("sandbox.jc2009.com")
|
||||
|
||||
|
||||
def _wait_for_manual_login(page, wait_sec: int = HUMAN_WAIT_TIMEOUT) -> bool:
|
||||
"""手工登录 fallback:轮询 _is_logged_in,每 HUMAN_POLL_INTERVAL 秒一次。
|
||||
"""手工登录 fallback(v1.1.19 重写,v1.1.20 加强,v1.1.21 修订):
|
||||
等用户在浏览器完成登录。
|
||||
|
||||
与 skill-template task_rpa.py::_ensure_logged_in 设计对齐:
|
||||
- 不自动破解,等用户在浏览器手工完成登录
|
||||
- 屏幕打印 [登录] 提示
|
||||
- 超时抛 LoginTimeoutError(ERROR:LOGIN_TIMEOUT)
|
||||
判定「登录完成」的多个信号(满足任一即视为完成):
|
||||
1. URL 含 `/app/` 且稳定 URL_STABLE_REQUIRED_S 秒(v1.1.19 旧逻辑)
|
||||
2. URL 已离开 `/login` / `/signin` / `/auth/` 等登录路径(v1.1.20 新增)
|
||||
—— 即便 sandbox 未跳到 /app/,只要离开登录页就认为登录成功
|
||||
3. 页面出现 sidebar 元素 `a[href$='/hr/contracts']`(v1.1.20 新增)
|
||||
—— 说明已加载到 SPA 主页
|
||||
|
||||
自动 fill demo/123456 失败时调用此函数。
|
||||
- **不做任何 goto / reload / click**,避免打断用户输入
|
||||
- 每 HUMAN_PROMPT_INTERVAL 秒打印一次剩余时间(默认 15s)
|
||||
- 每 HUMAN_POLL_INTERVAL 秒检查一次(默认 5s)
|
||||
- 超时抛 LoginTimeoutError。
|
||||
"""
|
||||
print(
|
||||
f"[登录] 自动登录失败,请在浏览器中完成登录"
|
||||
f"[登录] 请在浏览器中完成登录"
|
||||
f"(sandbox demo/123456 或真实账号),最多等待 {wait_sec} 秒..."
|
||||
)
|
||||
deadline = time.monotonic() + wait_sec
|
||||
attempt = 0
|
||||
while time.monotonic() < deadline:
|
||||
attempt += 1
|
||||
last_prompt_at = time.monotonic()
|
||||
last_url = page.url
|
||||
last_url_change_at = time.monotonic()
|
||||
|
||||
def _is_login_page_url(url: str) -> bool:
|
||||
u = url.lower()
|
||||
return (
|
||||
"/login" in u or "/signin" in u or "/auth/" in u
|
||||
or u.rstrip("/").endswith("sandbox.jc2009.com")
|
||||
)
|
||||
|
||||
def _sidebar_present() -> bool:
|
||||
try:
|
||||
return page.locator("a[href$='/hr/contracts']").count() > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
cur_url = page.url
|
||||
if cur_url != last_url:
|
||||
logger.info(
|
||||
"platform_collector: URL 变化 %s → %s", last_url, cur_url,
|
||||
)
|
||||
last_url = cur_url
|
||||
last_url_change_at = time.monotonic()
|
||||
|
||||
# 信号 1:URL 跳到 /app/... 并稳定
|
||||
if _is_logged_in(page):
|
||||
print(f"[登录] 检测到登录成功(attempt {attempt})")
|
||||
stable_for = time.monotonic() - last_url_change_at
|
||||
if stable_for >= URL_STABLE_REQUIRED_S:
|
||||
print(
|
||||
f"[登录] ✅ 检测到登录完成(URL 含 /app/,稳定 {stable_for:.1f}s)"
|
||||
)
|
||||
return True
|
||||
logger.info(
|
||||
"platform_collector: URL 含 /app/ 但还在过渡,等稳定 "
|
||||
"(current=%s, stable_for=%.1fs)", cur_url, stable_for,
|
||||
)
|
||||
# 信号 2:URL 已离开登录页(即使不在 /app/,比如卡在中间过渡页也算)
|
||||
elif not _is_login_page_url(cur_url):
|
||||
stable_for = time.monotonic() - last_url_change_at
|
||||
if stable_for >= URL_STABLE_REQUIRED_S:
|
||||
print(
|
||||
f"[登录] ✅ 检测到登录完成(URL 已离开登录页 → {cur_url},"
|
||||
f"稳定 {stable_for:.1f}s)"
|
||||
)
|
||||
return True
|
||||
logger.info(
|
||||
"platform_collector: URL 已离开登录页但还没稳定,等 "
|
||||
"(current=%s, stable_for=%.1fs)", cur_url, stable_for,
|
||||
)
|
||||
# 信号 3:sidebar 已出现
|
||||
elif _sidebar_present():
|
||||
print(
|
||||
f"[登录] ✅ 检测到登录完成(sidebar 已出现,URL={cur_url})"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# 还在登录页:每 HUMAN_PROMPT_INTERVAL 秒提醒用户一次
|
||||
now = time.monotonic()
|
||||
if now - last_prompt_at >= HUMAN_PROMPT_INTERVAL:
|
||||
last_prompt_at = now
|
||||
print(
|
||||
f"[登录] 等待用户输入... 剩余 {int(remaining)}s "
|
||||
f"(当前 URL={cur_url})"
|
||||
)
|
||||
|
||||
time.sleep(HUMAN_POLL_INTERVAL)
|
||||
|
||||
raise LoginTimeoutError(
|
||||
f"自动登录失败,{wait_sec}s 内用户在浏览器中也未完成登录"
|
||||
f"(最后 URL={page.url})"
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_url_stable(
|
||||
page, target_substr: str | tuple[str, ...], max_wait_sec: int = 30,
|
||||
) -> bool:
|
||||
"""v1.1.19:等 URL 含 target_substr,且连续 URL_STABLE_REQUIRED_S 秒不变。
|
||||
|
||||
v1.1.22:target_substr 支持 tuple(任一匹配即视为稳定)。
|
||||
|
||||
用于「已登录态直接跳到目标页面」后等待 SPA 路由稳定。
|
||||
**不做任何 goto / reload**,只 listen URL 变化。
|
||||
|
||||
Returns:
|
||||
True = URL 已稳定到任一目标;False = 超时。
|
||||
"""
|
||||
if isinstance(target_substr, str):
|
||||
targets = (target_substr,)
|
||||
else:
|
||||
targets = target_substr
|
||||
|
||||
deadline = time.monotonic() + max_wait_sec
|
||||
last_url = page.url
|
||||
last_url_change_at = time.monotonic()
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
cur_url = page.url
|
||||
if cur_url != last_url:
|
||||
logger.info(
|
||||
"platform_collector._wait_for_url_stable: URL 变化 %s → %s",
|
||||
last_url, cur_url,
|
||||
)
|
||||
last_url = cur_url
|
||||
last_url_change_at = time.monotonic()
|
||||
|
||||
cur_lower = cur_url.lower()
|
||||
if any(t.lower() in cur_lower for t in targets):
|
||||
stable_for = time.monotonic() - last_url_change_at
|
||||
if stable_for >= URL_STABLE_REQUIRED_S:
|
||||
return True
|
||||
|
||||
time.sleep(URL_STABLE_POLL_S)
|
||||
return False
|
||||
|
||||
|
||||
def _fill_root_login(page) -> bool:
|
||||
"""根域登录页(如还在):fill demo/123456 → click Sign in。返回是否执行了登录。"""
|
||||
user_input = page.locator('#jc-user').first
|
||||
@@ -384,90 +676,109 @@ def collect_all(
|
||||
# 所有 wait_for_selector 默认 30s,调试时不直观)
|
||||
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
||||
|
||||
# 1. v1.1.11:先用**最小侵入**方式检测已登录态 —— 直接 goto SPA_HOME_URL
|
||||
# (不要先跳 ROOT_URL + 自动 fill,会破坏已登录态)
|
||||
# v1.1.11 改进:探针结果 + 二次等待 + cookie 检测,三重判断避免误判
|
||||
# 1. v1.1.21:先 goto **根域**(sandbox 登录页),让 SPA hydrate 2 秒
|
||||
logger.info(
|
||||
"platform_collector: 检测登录态,goto SPA_HOME 先探 (当前 URL=%s)",
|
||||
page.url,
|
||||
"platform_collector: 启动,goto ROOT_URL=%s (当前 URL=%s)",
|
||||
ROOT_URL, 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)
|
||||
_goto(page, ROOT_URL)
|
||||
# 给 React/Vue 框架 2 秒 hydrate 时间,看登录 input 是否就绪
|
||||
page.wait_for_timeout(2_000)
|
||||
|
||||
if _is_logged_in(page):
|
||||
logger.info(
|
||||
"platform_collector: ✅ 已登录态,**完全跳过登录流程**(不弹窗、不自动 fill、不打扰)"
|
||||
",URL=%s", page.url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"platform_collector: 未登录态(SPA_HOME 跳回 login, URL=%s),走登录分支",
|
||||
"platform_collector: ✅ 已登录态(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 已知可用)
|
||||
# 失败 fallback 手工登录等待(轮询 _is_logged_in + 180s 兜底)
|
||||
if not _fill_spa_login(page):
|
||||
logger.warning(
|
||||
"platform_collector: 自动 fill 失败(URL=%s),fallback 手工登录等待",
|
||||
page.url,
|
||||
else:
|
||||
# 2. 等 #jc-user / #jc-pass 出现(最多 2 秒)
|
||||
# 若 sandbox 已预填 demo/123456 → 自动点 Sign in
|
||||
# 若 input 为空 → 等用户输入(**v1.1.24 用户自己点登录按钮**)
|
||||
auto_clicked = _try_auto_sign_in(page, wait_input_sec=2)
|
||||
if auto_clicked:
|
||||
logger.info(
|
||||
"platform_collector: ✅ 自动登录成功(预填 demo),等 URL 跳到 /app/"
|
||||
)
|
||||
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 重试)"
|
||||
elif not headless:
|
||||
# 3. input 为空:等用户输入 → **用户自己点 Sign in**(脚本不抢按钮)
|
||||
if _wait_for_login_inputs_filled(page, wait_sec=human_wait_timeout):
|
||||
logger.info(
|
||||
"platform_collector: ✅ 用户完成输入并跳转,等 URL 跳到 /app/"
|
||||
)
|
||||
else:
|
||||
# 超时未跳转 → fallback 到原来的多信号等待
|
||||
logger.warning(
|
||||
"platform_collector: 用户未跳转,fallback 到 _wait_for_manual_login"
|
||||
)
|
||||
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
|
||||
else:
|
||||
# 等 SPA 路由稳定(之前这里没有 wait,直接 goto home 会打断 pending 跳转)
|
||||
page.wait_for_timeout(POST_LOGIN_SETTLE_MS)
|
||||
raise LoginTimeoutError(
|
||||
f"headless 模式下未登录,且无法手工登录"
|
||||
f"(请用 --show-browser 重试)"
|
||||
)
|
||||
|
||||
# 4. 强制 goto SPA home(如果还停在 login,重试登录一次)
|
||||
for retry in range(SPA_HOME_RETRY_MAX):
|
||||
if _is_logged_in(page):
|
||||
break
|
||||
# 4. 登录完成 → 等 URL 真正稳定到 /app/ 或 /select(最多 30s,**不做任何操作**)
|
||||
# v1.1.22:sandbox 登录后会先跳 /select,再自动跳 /app/home,
|
||||
# 所以两个 URL 都算"SPA 已路由"
|
||||
if not _wait_for_url_stable(page, ("/app/", "/select"), max_wait_sec=30):
|
||||
logger.warning(
|
||||
"platform_collector: 登录后 30s 内 URL 未稳定到 /app/或 /select(当前 URL=%s)",
|
||||
page.url,
|
||||
)
|
||||
# 不强行 reload —— 让用户决定是否手工刷新
|
||||
|
||||
# 4.5. v1.1.24:sandbox 可能登录后把人踢回 SPA 二级登录页
|
||||
# (/industries/campus/smartcampus/login)。如果 URL 落在
|
||||
# 这个二级登录页,再走一次 _try_auto_sign_in(已经扩展支持
|
||||
# 中文「登录」按钮);若 SPA 登录页 input 有预填账号 → 自动点;
|
||||
# 若 input 空 → 等用户输入并**自己点登录**(脚本不抢按钮)。
|
||||
cur_url_after_stable = page.url.lower()
|
||||
if "/login" in cur_url_after_stable or "/signin" in cur_url_after_stable:
|
||||
logger.warning(
|
||||
"platform_collector: 登录后仍落在登录页(%s),"
|
||||
"v1.1.24 触发 SPA 二级登录兜底",
|
||||
page.url,
|
||||
)
|
||||
spa_auto = _try_auto_sign_in(page, wait_input_sec=2)
|
||||
if spa_auto:
|
||||
logger.info(
|
||||
"platform_collector: ✅ SPA 二级登录页已自动登录,"
|
||||
"等 URL 跳到 /app/ 或 /select"
|
||||
)
|
||||
_wait_for_url_stable(page, ("/app/", "/select"), max_wait_sec=30)
|
||||
elif not headless:
|
||||
if _wait_for_login_inputs_filled(page, wait_sec=human_wait_timeout):
|
||||
_wait_for_url_stable(page, ("/app/", "/select"), max_wait_sec=30)
|
||||
else:
|
||||
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
|
||||
else:
|
||||
raise LoginTimeoutError(
|
||||
f"SPA 二级登录页 input 空且 headless 无法手工登录(URL={page.url})"
|
||||
)
|
||||
|
||||
# 5. v1.1.22:显式跳到 SPA_HOME_URL(/app/home),等侧边栏 link 出现
|
||||
# 避免 sandbox 在 /select 中间页时直接找 sidebar link 找不到
|
||||
if "/app/home" not in page.url.lower():
|
||||
logger.info(
|
||||
"platform_collector: 强制 goto SPA_HOME (retry=%d), 当前 URL=%s",
|
||||
retry, page.url,
|
||||
"platform_collector: 当前 URL=%s,跳到 SPA_HOME_URL=%s",
|
||||
page.url, SPA_HOME_URL,
|
||||
)
|
||||
_goto(page, SPA_HOME_URL)
|
||||
page.wait_for_timeout(SPA_HOME_RETRY_DELAY_MS)
|
||||
if _fill_spa_login(page):
|
||||
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 页"
|
||||
page.wait_for_timeout(SPA_HYDRATION_MS)
|
||||
# 等 URL 稳定到 /app/home(最多 15s,**不做任何操作**)
|
||||
if not _wait_for_url_stable(page, "/app/home", max_wait_sec=15):
|
||||
logger.warning(
|
||||
"platform_collector: 15s 内未稳定到 /app/home(当前 URL=%s)",
|
||||
page.url,
|
||||
)
|
||||
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
|
||||
|
||||
# 5. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session)
|
||||
# 6. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session)
|
||||
try:
|
||||
# dump SPA home 一次(用于调试 selector)
|
||||
page.wait_for_timeout(POST_LOGIN_SETTLE_MS)
|
||||
_dump_page(page, download_dir.parent / "spa_home_dump.html", "SPA home")
|
||||
|
||||
# 等 SPA 真正加载完(侧边栏 link 出现,最长 SIDEBAR_LINK_TIMEOUT_MS)
|
||||
# 修复:之前 15s 太短,sandbox mock 加载慢时直接 fallback 到 pushState → 表格加载不出来
|
||||
try:
|
||||
page.wait_for_selector(
|
||||
"a[href$='/hr/contracts']",
|
||||
@@ -763,11 +1074,21 @@ def collect_all(
|
||||
ctx.close()
|
||||
|
||||
saved = _persist_contracts(contracts)
|
||||
logger.info(
|
||||
"platform_collector: contracts 落库完成 saved=%d / total_fetched=%d",
|
||||
saved, len(contracts),
|
||||
)
|
||||
|
||||
# v1.1.18:每个合同入队 download_tasks(独立事务,失败不影响主流程)
|
||||
contract_ids = [c["contract_id"] for c in contracts if c.get("contract_id")]
|
||||
enqueue_summary = _enqueue_download_tasks_safe(contract_ids)
|
||||
|
||||
return {
|
||||
"platform_url": PLATFORM_URL,
|
||||
"fetched_rows": fetched_rows,
|
||||
"saved_contracts": saved,
|
||||
"downloaded_materials": downloaded,
|
||||
"download_queue": enqueue_summary,
|
||||
"contracts": contracts,
|
||||
}
|
||||
|
||||
@@ -865,14 +1186,78 @@ def _persist_contracts(contracts: list[dict]) -> int:
|
||||
datetime.now().isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
# v1.1.18:每个合同入队 download_tasks(即使已抓到卡片也入队,
|
||||
# process_queue 失败时可用作重试锚点;enqueue_download_task 幂等)
|
||||
repo.enqueue_download_task(conn, c["contract_id"], "platform_button")
|
||||
# v1.1.18 入队 download_tasks 已移到 collect_all 末尾的独立事务,
|
||||
# 见 _enqueue_download_tasks_safe()
|
||||
return len(contracts)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _enqueue_download_tasks_safe(
|
||||
contract_ids: list[str], conn: sqlite3.Connection | None = None,
|
||||
) -> dict:
|
||||
"""v1.1.18:每个合同入队 download_tasks。
|
||||
|
||||
在独立事务里完成,不影响 _persist_contracts 主流程(contracts / materials_content)。
|
||||
任一入队失败 → 记 warning + 计入 failed,但不影响整体返回。
|
||||
|
||||
conn: 可选传入连接(用于测试 in-memory DB)。None → 走 get_connection()。
|
||||
若传入 conn,调用方负责生命周期(不会 close)。
|
||||
|
||||
Returns:
|
||||
{enqueued: N, skipped: M, failed: K, errors: [...]}
|
||||
"""
|
||||
if not contract_ids:
|
||||
return {"enqueued": 0, "skipped": 0, "failed": 0, "errors": []}
|
||||
|
||||
from db.connection import get_connection
|
||||
|
||||
enqueued = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
errors: list[str] = []
|
||||
|
||||
owns_conn = conn is None
|
||||
if owns_conn:
|
||||
conn = get_connection()
|
||||
try:
|
||||
for cid in contract_ids:
|
||||
try:
|
||||
# 入队前查一下是否已存在 pending/running → 决定计入 skipped 还是 enqueued
|
||||
pre_existing = conn.execute(
|
||||
"""SELECT 1 FROM download_tasks
|
||||
WHERE contract_id = ? AND kind = ?
|
||||
AND status IN ('pending', 'running') LIMIT 1""",
|
||||
(cid, "platform_button"),
|
||||
).fetchone()
|
||||
repo.enqueue_download_task(conn, cid, "platform_button")
|
||||
if pre_existing:
|
||||
skipped += 1
|
||||
else:
|
||||
enqueued += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
err = f"{cid}: {type(e).__name__}: {e}"
|
||||
errors.append(err)
|
||||
logger.warning(
|
||||
"platform_collector.enqueue: 入队 %s 失败: %s", cid, e,
|
||||
)
|
||||
if owns_conn:
|
||||
conn.commit()
|
||||
finally:
|
||||
if owns_conn:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"platform_collector.enqueue: 入队结果 enqueued=%d skipped=%d failed=%d (total=%d)",
|
||||
enqueued, skipped, failed, len(contract_ids),
|
||||
)
|
||||
return {
|
||||
"enqueued": enqueued, "skipped": skipped,
|
||||
"failed": failed, "errors": errors[:10],
|
||||
}
|
||||
|
||||
|
||||
def _download_materials_for_row(
|
||||
page, row_idx: int, contract_id: str, mat_btn_count: int,
|
||||
) -> list[dict]:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"""
|
||||
|
||||
SKILL_SLUG = "alert-contract-expiring-hr"
|
||||
SKILL_VERSION = "1.1.18"
|
||||
SKILL_VERSION = "1.1.25"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
@@ -48,7 +48,10 @@ 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 同名常量
|
||||
HUMAN_POLL_INTERVAL = 5.0 # 轮询间隔(秒)—— v1.1.19 调长:避免打断用户输入
|
||||
HUMAN_PROMPT_INTERVAL = 15.0 # 剩余时间提示间隔(秒)—— v1.1.19 新增
|
||||
URL_STABLE_REQUIRED_S = 2.0 # URL 连续 N 秒不变才算稳定 —— v1.1.19 新增
|
||||
URL_STABLE_POLL_S = 0.5 # URL 稳定探测间隔(秒)
|
||||
|
||||
# Playwright 操作超时(毫秒)
|
||||
DEFAULT_PAGE_TIMEOUT_MS = 15_000 # Playwright 默认操作超时 —— skill-template 同名常量
|
||||
|
||||
@@ -1192,6 +1192,411 @@ def test_download_tasks_count_by_status() -> tuple[bool, str]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_enqueue_download_tasks_safe_basic() -> tuple[bool, str]:
|
||||
"""_enqueue_download_tasks_safe: 全部成功入队。"""
|
||||
from service.platform_collector import _enqueue_download_tasks_safe
|
||||
|
||||
# 准备 3 个合同(in-memory DB,函数不会关闭)
|
||||
conn = _make_conn()
|
||||
for i in range(3):
|
||||
conn.execute(
|
||||
f"""INSERT INTO contracts
|
||||
(contract_id, employee_id, teacher_name, unit_id, unit_name,
|
||||
position, contract_type, contract_start, contract_end,
|
||||
trial_period_end, status)
|
||||
VALUES ('ENQ-{i}', 'T{i}', 'X', 'U{i}', 'Y', '', '',
|
||||
'2024-01-01', '2026-04-15', '2024-04-15', '可续聘')"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
summary = _enqueue_download_tasks_safe(["ENQ-0", "ENQ-1", "ENQ-2"], conn=conn)
|
||||
if summary["enqueued"] != 3:
|
||||
return False, f"enqueued={summary['enqueued']} (want 3) - {summary}"
|
||||
if summary["failed"] != 0:
|
||||
return False, f"failed={summary['failed']} - {summary}"
|
||||
if summary["errors"]:
|
||||
return False, f"errors={summary['errors']}"
|
||||
return True, f"3 个合同全部入队: {summary}"
|
||||
|
||||
|
||||
def test_enqueue_download_tasks_safe_idempotent() -> tuple[bool, str]:
|
||||
"""_enqueue_download_tasks_safe: 同 contract_id 第二次调用 → skipped。"""
|
||||
from service.platform_collector import _enqueue_download_tasks_safe
|
||||
|
||||
conn = _make_conn()
|
||||
conn.execute(
|
||||
f"""INSERT INTO contracts
|
||||
(contract_id, employee_id, teacher_name, unit_id, unit_name,
|
||||
position, contract_type, contract_start, contract_end,
|
||||
trial_period_end, status)
|
||||
VALUES ('ENQ-IDM', 'T1', 'X', 'U1', 'Y', '', '',
|
||||
'2024-01-01', '2026-04-15', '2024-04-15', '可续聘')"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
s1 = _enqueue_download_tasks_safe(["ENQ-IDM"], conn=conn)
|
||||
s2 = _enqueue_download_tasks_safe(["ENQ-IDM"], conn=conn)
|
||||
if s1["enqueued"] != 1:
|
||||
return False, f"第一次 enqueued={s1['enqueued']} (want 1) - {s1}"
|
||||
if s2["skipped"] != 1:
|
||||
return False, f"第二次 skipped={s2['skipped']} (want 1) - {s2}"
|
||||
if s2["enqueued"] != 0:
|
||||
return False, f"第二次不应新增 enqueued={s2['enqueued']} - {s2}"
|
||||
return True, f"幂等 OK: 第一次 {s1}, 第二次 {s2}"
|
||||
|
||||
|
||||
def test_enqueue_download_tasks_safe_fk_failure() -> tuple[bool, str]:
|
||||
"""_enqueue_download_tasks_safe: contract_id 不存在 → 计入 failed,不抛异常。"""
|
||||
from service.platform_collector import _enqueue_download_tasks_safe
|
||||
|
||||
# 不插入合同,直接入队 → FK 失败(in-memory DB,函数不关闭)
|
||||
conn = _make_conn()
|
||||
summary = _enqueue_download_tasks_safe(["DOES-NOT-EXIST"], conn=conn)
|
||||
if summary["failed"] != 1:
|
||||
return False, f"failed={summary['failed']} (want 1) - {summary}"
|
||||
if not summary["errors"]:
|
||||
return False, f"errors 应非空: {summary}"
|
||||
if "FOREIGN KEY" not in summary["errors"][0] and "foreign key" not in summary["errors"][0].lower():
|
||||
return False, f"错误信息应提及 FOREIGN KEY: {summary['errors'][0]}"
|
||||
return True, f"FK 失败被吞掉: failed=1, errors[0]={summary['errors'][0][:80]}"
|
||||
|
||||
|
||||
def test_enqueue_download_tasks_safe_empty_list() -> tuple[bool, str]:
|
||||
"""_enqueue_download_tasks_safe: 空列表 → no-op,不开 DB 连接。"""
|
||||
from service.platform_collector import _enqueue_download_tasks_safe
|
||||
|
||||
summary = _enqueue_download_tasks_safe([])
|
||||
if summary != {"enqueued": 0, "skipped": 0, "failed": 0, "errors": []}:
|
||||
return False, f"空列表返回值异常: {summary}"
|
||||
return True, "空列表 no-op OK"
|
||||
|
||||
|
||||
# ---------- v1.1.19: _wait_for_url_stable + login flow ----------
|
||||
|
||||
|
||||
class _FakePage:
|
||||
"""v1.1.19 login flow 测试用的简易 page mock。
|
||||
|
||||
- .url: 字符串
|
||||
- 支持外部修改 .url 模拟用户登录跳转
|
||||
- v1.1.24:新增 .locator() 返回 _FakeLocatorNoOp,让
|
||||
_wait_for_login_inputs_filled 的 selector 探测不抛 AttributeError
|
||||
"""
|
||||
|
||||
def __init__(self, initial_url: str = "about:blank") -> None:
|
||||
self.url = initial_url
|
||||
self.goto_calls: list[str] = []
|
||||
self.reload_calls: int = 0
|
||||
self.click_calls: int = 0
|
||||
|
||||
def locator(self, selector: str): # noqa: ARG002
|
||||
"""v1.1.24 扩展:测试 _wait_for_login_inputs_filled 用,返回空 locator。"""
|
||||
return _FakeLocator(count=0)
|
||||
|
||||
|
||||
def test_wait_for_url_stable_immediate_match() -> tuple[bool, str]:
|
||||
"""_wait_for_url_stable: URL 已含目标 + 稳定 → 立即返回 True。"""
|
||||
from service.platform_collector import _wait_for_url_stable
|
||||
|
||||
page = _FakePage("https://example.com/app/contracts")
|
||||
if not _wait_for_url_stable(page, "/app/", max_wait_sec=5):
|
||||
return False, "URL 已含 /app/ 应立即返回 True"
|
||||
if page.goto_calls or page.reload_calls:
|
||||
return False, f"不应触发 goto/reload: {page.goto_calls}/{page.reload_calls}"
|
||||
return True, "URL 已含 /app/ 立即返回 OK"
|
||||
|
||||
|
||||
def test_wait_for_url_stable_transition() -> tuple[bool, str]:
|
||||
"""_wait_for_url_stable: URL 经历 login → /app/ 过渡,等稳定后返回 True。"""
|
||||
import threading
|
||||
import time as _time
|
||||
|
||||
from service.platform_collector import _wait_for_url_stable
|
||||
|
||||
page = _FakePage("https://example.com/login")
|
||||
|
||||
def flip_to_app():
|
||||
_time.sleep(0.6)
|
||||
page.url = "https://example.com/app/home"
|
||||
|
||||
threading.Thread(target=flip_to_app, daemon=True).start()
|
||||
if not _wait_for_url_stable(page, "/app/", max_wait_sec=5):
|
||||
return False, f"应等 URL 稳定到 /app/,但超时(最终 URL={page.url})"
|
||||
return True, f"URL 过渡后稳定 OK(最终 URL={page.url})"
|
||||
|
||||
|
||||
def test_wait_for_url_stable_timeout() -> tuple[bool, str]:
|
||||
"""_wait_for_url_stable: URL 始终未跳到目标 → 超时返回 False。"""
|
||||
from service.platform_collector import _wait_for_url_stable
|
||||
|
||||
page = _FakePage("https://example.com/login")
|
||||
if _wait_for_url_stable(page, "/app/", max_wait_sec=2):
|
||||
return False, "URL 永远含 login,应超时返回 False"
|
||||
return True, "超时返回 False OK"
|
||||
|
||||
|
||||
def test_wait_for_manual_login_no_goto() -> tuple[bool, str]:
|
||||
"""_wait_for_manual_login: 等待期间 **不应** 触发 goto / reload / click。"""
|
||||
import threading
|
||||
import time as _time
|
||||
|
||||
from service import platform_collector as pc
|
||||
|
||||
# platform_collector 在 import 时已经把常量复制到模块 globals;
|
||||
# 直接覆盖模块属性最稳。缩短 HUMAN_POLL_INTERVAL 和 URL_STABLE_REQUIRED_S,
|
||||
# 让 fake_user 0.8s 翻 URL + 0.3s 稳定窗口能凑进 wait_sec=5。
|
||||
orig_poll = pc.HUMAN_POLL_INTERVAL
|
||||
orig_stable = pc.URL_STABLE_REQUIRED_S
|
||||
pc.HUMAN_POLL_INTERVAL = 0.3
|
||||
pc.URL_STABLE_REQUIRED_S = 0.3
|
||||
try:
|
||||
page = _FakePage("https://example.com/login")
|
||||
|
||||
def fake_user_login():
|
||||
_time.sleep(0.8)
|
||||
page.url = "https://example.com/app/home"
|
||||
|
||||
threading.Thread(target=fake_user_login, daemon=True).start()
|
||||
try:
|
||||
pc._wait_for_manual_login(page, wait_sec=5)
|
||||
except Exception as e:
|
||||
return False, f"应等用户登录成功,但抛异常: {e}"
|
||||
|
||||
if page.goto_calls:
|
||||
return False, f"等待期间不应 goto: {page.goto_calls}"
|
||||
if page.reload_calls:
|
||||
return False, f"等待期间不应 reload: {page.reload_calls}"
|
||||
return True, "等用户登录期间无打断 OK"
|
||||
finally:
|
||||
pc.HUMAN_POLL_INTERVAL = orig_poll
|
||||
pc.URL_STABLE_REQUIRED_S = orig_stable
|
||||
|
||||
|
||||
# ---------- v1.1.24: _try_auto_sign_in SPA 二级登录页(中文「登录」按钮)----------
|
||||
|
||||
|
||||
class _FakeLocator:
|
||||
"""v1.1.24 _try_auto_sign_in 测试用的简易 Playwright Locator mock。
|
||||
|
||||
支持的 API:
|
||||
- .count() -> int
|
||||
- .input_value() -> str
|
||||
- .click() -> None
|
||||
- .first -> 自身
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
count: int = 0,
|
||||
value: str = "",
|
||||
click_log: list | None = None,
|
||||
) -> None:
|
||||
self._count = count
|
||||
self._value = value
|
||||
self._click_log = click_log if click_log is not None else []
|
||||
|
||||
@property
|
||||
def first(self) -> "_FakeLocator":
|
||||
return self
|
||||
|
||||
def count(self) -> int:
|
||||
return self._count
|
||||
|
||||
def input_value(self) -> str:
|
||||
return self._value
|
||||
|
||||
def click(self) -> None:
|
||||
self._click_log.append(self._value or "clicked")
|
||||
|
||||
|
||||
class _FakeLocatorRouter:
|
||||
"""根据 selector 返回对应 _FakeLocator 的路由器。
|
||||
|
||||
selectors: dict[selector_str, _FakeLocator]
|
||||
"""
|
||||
|
||||
def __init__(self, selectors: dict[str, _FakeLocator], page) -> None:
|
||||
self._selectors = selectors
|
||||
self._page = page
|
||||
|
||||
def __call__(self, sel: str):
|
||||
"""page.locator(sel) 形式调用。"""
|
||||
if sel in self._selectors:
|
||||
return self._selectors[sel]
|
||||
return _FakeLocator(count=0)
|
||||
|
||||
def __getattr__(self, sel: str):
|
||||
# loc.page 已存在则按 selector 路由
|
||||
if sel in self._selectors:
|
||||
return self._selectors[sel]
|
||||
# fallback:未注册的 selector 都返回空 locator
|
||||
return _FakeLocator(count=0)
|
||||
|
||||
|
||||
class _FakePlaywrightPage:
|
||||
"""v1.1.24 _try_auto_sign_in 测试用的 page mock。
|
||||
|
||||
usage:
|
||||
page = _FakePlaywrightPage(
|
||||
url="https://sandbox.jc2009.com/industries/campus/smartcampus/login",
|
||||
selectors={
|
||||
"input[type='tel']": _FakeLocator(count=1, value="18677308191"),
|
||||
"input[type='password']": _FakeLocator(count=1, value="***"),
|
||||
"button:has-text('登录')": _FakeLocator(count=1, click_log=click_log),
|
||||
},
|
||||
wait_for_selector_log=wait_log,
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "about:blank",
|
||||
selectors: dict[str, _FakeLocator] | None = None,
|
||||
wait_for_selector_log: list | None = None,
|
||||
wait_selector_should_raise: bool = False,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self._selectors = selectors or {}
|
||||
self._wait_log = wait_for_selector_log if wait_for_selector_log is not None else []
|
||||
self._wait_should_raise = wait_selector_should_raise
|
||||
|
||||
def wait_for_selector(self, selector: str, timeout: int = 0) -> None:
|
||||
self._wait_log.append(selector)
|
||||
if self._wait_should_raise:
|
||||
raise Exception("fake: timeout")
|
||||
|
||||
@property
|
||||
def locator(self):
|
||||
return _FakeLocatorRouter(self._selectors, self)
|
||||
|
||||
|
||||
def test_try_auto_sign_in_spa_login_chinese_button() -> tuple[bool, str]:
|
||||
"""v1.1.24:SPA 二级登录页(/industries/campus/smartcampus/login),
|
||||
input 用 `input[type='tel']` / `input[type='password']`,
|
||||
按钮中文「登录」。预填有值 → 自动点登录。
|
||||
"""
|
||||
from service.platform_collector import _try_auto_sign_in
|
||||
|
||||
click_log: list[str] = []
|
||||
page = _FakePlaywrightPage(
|
||||
url="https://sandbox.jc2009.com/industries/campus/smartcampus/login",
|
||||
selectors={
|
||||
"input[type='tel']": _FakeLocator(count=1, value="18677308191"),
|
||||
"input[type='password']": _FakeLocator(count=1, value="dummy"),
|
||||
"button:has-text('登录')": _FakeLocator(count=1, click_log=click_log),
|
||||
},
|
||||
)
|
||||
if not _try_auto_sign_in(page, wait_input_sec=2):
|
||||
return False, "SPA 二级登录页预填应自动点 登录"
|
||||
if not click_log:
|
||||
return False, f"未触发点击: {click_log}"
|
||||
return True, f"SPA 二级登录页中文按钮自动点 OK(click_log={click_log})"
|
||||
|
||||
|
||||
def test_try_auto_sign_in_root_sign_in_english_button() -> tuple[bool, str]:
|
||||
"""v1.1.24(向后兼容):根域登录页(https://sandbox.jc2009.com/),
|
||||
input 用 `#jc-user` / `#jc-pass`,按钮英文「Sign in」。预填有值 → 自动点。
|
||||
"""
|
||||
from service.platform_collector import _try_auto_sign_in
|
||||
|
||||
click_log: list[str] = []
|
||||
page = _FakePlaywrightPage(
|
||||
url="https://sandbox.jc2009.com/",
|
||||
selectors={
|
||||
"#jc-user": _FakeLocator(count=1, value="demo"),
|
||||
"#jc-pass": _FakeLocator(count=1, value="123456"),
|
||||
"button:has-text('Sign in')": _FakeLocator(count=1, click_log=click_log),
|
||||
},
|
||||
)
|
||||
if not _try_auto_sign_in(page, wait_input_sec=2):
|
||||
return False, "根域登录页预填应自动点 Sign in"
|
||||
if not click_log:
|
||||
return False, f"未触发点击: {click_log}"
|
||||
return True, f"根域登录页 Sign in 自动点 OK(click_log={click_log})"
|
||||
|
||||
|
||||
def test_try_auto_sign_in_no_input_returns_false() -> tuple[bool, str]:
|
||||
"""v1.1.24:input 为空(用户还没输入)→ 返回 False,不点按钮。"""
|
||||
from service.platform_collector import _try_auto_sign_in
|
||||
|
||||
click_log: list[str] = []
|
||||
page = _FakePlaywrightPage(
|
||||
url="https://sandbox.jc2009.com/industries/campus/smartcampus/login",
|
||||
selectors={
|
||||
"input[type='tel']": _FakeLocator(count=1, value=""), # 空
|
||||
"input[type='password']": _FakeLocator(count=1, value="123"),
|
||||
"button:has-text('登录')": _FakeLocator(count=1, click_log=click_log),
|
||||
},
|
||||
)
|
||||
if _try_auto_sign_in(page, wait_input_sec=2):
|
||||
return False, "user 空时不应自动点"
|
||||
if click_log:
|
||||
return False, f"未填完不应点击: {click_log}"
|
||||
return True, "input 空返回 False 不点 OK"
|
||||
|
||||
|
||||
def test_try_auto_sign_in_selector_timeout_returns_false() -> tuple[bool, str]:
|
||||
"""v1.1.24:等 input 超时(如 URL 已是 SPA home,无登录 input)→ 返回 False。"""
|
||||
from service.platform_collector import _try_auto_sign_in
|
||||
|
||||
click_log: list[str] = []
|
||||
page = _FakePlaywrightPage(
|
||||
url="https://sandbox.jc2009.com/industries/campus/smartcampus/app/home",
|
||||
selectors={}, # 没有任何 input
|
||||
wait_selector_should_raise=True,
|
||||
)
|
||||
if _try_auto_sign_in(page, wait_input_sec=1):
|
||||
return False, "无登录 input 应返回 False"
|
||||
if click_log:
|
||||
return False, f"无 input 不应点击: {click_log}"
|
||||
return True, "无登录 input 超时返回 False OK"
|
||||
|
||||
|
||||
def test_wait_for_login_inputs_filled_no_auto_click_v1_24() -> tuple[bool, str]:
|
||||
"""v1.1.24:`_wait_for_login_inputs_filled` **不再自动点登录按钮**——
|
||||
用户输入完成(input 有值)后等 URL 跳离登录页,**按钮交给用户自己点**。
|
||||
"""
|
||||
import threading
|
||||
import time as _time
|
||||
|
||||
from service import platform_collector as pc
|
||||
|
||||
# 缩短轮询周期:HUMAN_POLL_INTERVAL 默认 5s,太长测试慢
|
||||
orig_poll = pc.HUMAN_POLL_INTERVAL
|
||||
orig_prompt = pc.HUMAN_PROMPT_INTERVAL
|
||||
pc.HUMAN_POLL_INTERVAL = 0.2
|
||||
pc.HUMAN_PROMPT_INTERVAL = 0.2
|
||||
try:
|
||||
page = _FakePage(
|
||||
"https://sandbox.jc2009.com/industries/campus/smartcampus/login",
|
||||
)
|
||||
|
||||
# 0.6s 后 URL 跳到 /app/(模拟用户点登录按钮后跳转)
|
||||
def fake_user_jump():
|
||||
_time.sleep(0.6)
|
||||
page.url = "https://sandbox.jc2009.com/industries/campus/smartcampus/app/home"
|
||||
|
||||
threading.Thread(target=fake_user_jump, daemon=True).start()
|
||||
|
||||
result = pc._wait_for_login_inputs_filled(page, wait_sec=5)
|
||||
if not result:
|
||||
return False, "URL 已跳到 /app/ 应返回 True"
|
||||
|
||||
if page.goto_calls:
|
||||
return False, f"等待期间不应 goto: {page.goto_calls}"
|
||||
if page.reload_calls:
|
||||
return False, f"等待期间不应 reload: {page.reload_calls}"
|
||||
if page.click_calls:
|
||||
return False, (
|
||||
f"v1.1.24 修复:等用户输入期间**不应自动点登录**按钮,"
|
||||
f"但 click_calls={page.click_calls}"
|
||||
)
|
||||
return True, "v1.1.24 等用户跳离登录页(不抢按钮)OK"
|
||||
finally:
|
||||
pc.HUMAN_POLL_INTERVAL = orig_poll
|
||||
pc.HUMAN_PROMPT_INTERVAL = orig_prompt
|
||||
|
||||
|
||||
# ---------- v1.1.18: show-config / logs-list ----------
|
||||
|
||||
|
||||
@@ -1457,6 +1862,19 @@ def main() -> int:
|
||||
_run("download_tasks/claim_atomic", test_download_tasks_claim_atomic)
|
||||
_run("download_tasks/mark_success_and_failed", test_download_tasks_mark_success_and_failed)
|
||||
_run("download_tasks/count_by_status", test_download_tasks_count_by_status)
|
||||
_run("download_tasks/enqueue_safe_basic", test_enqueue_download_tasks_safe_basic)
|
||||
_run("download_tasks/enqueue_safe_idempotent", test_enqueue_download_tasks_safe_idempotent)
|
||||
_run("download_tasks/enqueue_safe_fk_failure", test_enqueue_download_tasks_safe_fk_failure)
|
||||
_run("download_tasks/enqueue_safe_empty_list", test_enqueue_download_tasks_safe_empty_list)
|
||||
_run("login_flow/wait_url_stable_immediate", test_wait_for_url_stable_immediate_match)
|
||||
_run("login_flow/wait_url_stable_transition", test_wait_for_url_stable_transition)
|
||||
_run("login_flow/wait_url_stable_timeout", test_wait_for_url_stable_timeout)
|
||||
_run("login_flow/wait_manual_login_no_goto", test_wait_for_manual_login_no_goto)
|
||||
_run("login_flow/auto_sign_in_spa_chinese_button", test_try_auto_sign_in_spa_login_chinese_button)
|
||||
_run("login_flow/auto_sign_in_root_english_button", test_try_auto_sign_in_root_sign_in_english_button)
|
||||
_run("login_flow/auto_sign_in_no_input_returns_false", test_try_auto_sign_in_no_input_returns_false)
|
||||
_run("login_flow/auto_sign_in_selector_timeout_returns_false", test_try_auto_sign_in_selector_timeout_returns_false)
|
||||
_run("login_flow/wait_login_inputs_filled_no_auto_click_v1_24", test_wait_for_login_inputs_filled_no_auto_click_v1_24)
|
||||
_run("show_config/masks_tokens", test_show_config_masks_tokens)
|
||||
_run("show_config/skips_empty", test_show_config_skips_empty)
|
||||
_run("show_config/skips_comments", test_show_config_skips_comments)
|
||||
|
||||
Reference in New Issue
Block a user