Compare commits

...

4 Commits

Author SHA1 Message Date
17a7a54349 chore: auto release commit (2026-09-15 01:07:11)
Some checks failed
技能自动化发布 / release (push) Failing after 4s
2026-09-15 01:07:11 +08:00
c21339ae44 chore: auto release commit (2026-09-15 01:02:50)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-09-15 01:02:50 +08:00
9878f570d7 chore: auto release commit (2026-09-15 00:57:09)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-09-15 00:57:09 +08:00
44088336b4 chore: auto release commit (2026-09-15 00:49:52)
All checks were successful
技能自动化发布 / release (push) Successful in 4s
2026-09-15 00:49:52 +08:00
5 changed files with 580 additions and 80 deletions

View File

@@ -2,6 +2,36 @@
本文件对应技能市场 **「更新日志」** Tab。
## 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
- **一键式"运维 + 材料管理"全打通**

View File

@@ -1,7 +1,7 @@
---
name: 合同到期智能预警与续聘辅助
description: "当用户说要扫描即将到期的合同、查 90/60/30 天预警名单、给某位教师生成续聘任务书、下发单位版续聘清单、从 SmartCampus HR 平台抓合同数据、解析续聘材料 PDF/Word、查看本月合同到期人员清单时,加载本技能。支持定时扫描(每日凌晨)+ 数据管理 + Agent 三入口,覆盖合同到期智能预警与续聘辅助全流程。"
version: 1.1.18
version: 1.1.22
author: 深圳匠厂科技有限公司
metadata:
openclaw:

View File

@@ -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,258 @@ def _is_logged_in(page) -> bool:
)
def _try_auto_sign_in(page, wait_input_sec: int = 2) -> bool:
"""v1.1.21:检测登录页 demo/123456 是否已预填,有则自动点 Sign in。
适用场景:sandbox root 登录页(https://sandbox.jc2009.com/)
默认有 demo + 123456 预填在 #jc-user / #jc-pass,脚本无需 fill 直接点。
行为:
1. 等 #jc-user 出现,最长 wait_input_sec 秒(默认 2s)
2. 检查 #jc-user 和 #jc-pass 是否都有值
3. 有值 → 点 Sign in → return True
4. 无值 → return False(交给 _wait_for_manual_login 接管)
不会自动 fill(如果没值就放弃,让用户自己输)。
"""
try:
page.wait_for_selector("#jc-user, #jc-pass", timeout=wait_input_sec * 1000)
except Exception:
logger.info(
"platform_collector: %ds 内未出现登录 input,跳过自动登录",
wait_input_sec,
)
return False
user_input = page.locator("#jc-user").first
pass_input = page.locator("#jc-pass").first
if user_input.count() == 0 or pass_input.count() == 0:
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
logger.info(
"platform_collector: 登录页已预填账号(user=%r),自动点 Sign in",
user_val,
)
btn = page.locator('button[type="submit"]:has-text("Sign in")').first
if btn.count() == 0:
logger.warning("platform_collector: 未找到 Sign in 按钮")
return False
btn.click()
return True
def _wait_for_login_inputs_filled(page, wait_sec: int = HUMAN_WAIT_TIMEOUT) -> bool:
"""v1.1.21:等用户输入完账号密码,然后自动点 Sign in。
- 每 HUMAN_POLL_INTERVAL 秒检查 #jc-user / #jc-pass 是否有值
- 两个都有值 → 自动点 Sign in → return True
- 超时未填完 → 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
user_input = page.locator("#jc-user").first
pass_input = page.locator("#jc-pass").first
# 已不在登录页(用户可能手动点了别的)
if not _is_login_page_url_for_user(cur_url):
logger.info(
"platform_collector: URL 已离开登录页(%s),停止等输入", cur_url,
)
return True
if user_input.count() > 0 and pass_input.count() > 0:
user_val = (user_input.input_value() or "").strip()
pass_val = (pass_input.input_value() or "").strip()
now = time.monotonic()
if now - last_state_log >= HUMAN_PROMPT_INTERVAL:
last_state_log = now
logger.info(
"platform_collector: input 状态 user=%r pass=%r",
bool(user_val), bool(pass_val),
)
if user_val and pass_val:
logger.info(
"platform_collector: ✅ 用户输入完成(user=%r),自动点 Sign in",
user_val,
)
btn = page.locator('button[type="submit"]:has-text("Sign in")').first
if btn.count() > 0:
btn.click()
return True
# 提醒用户
now = time.monotonic()
if now - last_prompt_at >= HUMAN_PROMPT_INTERVAL:
last_prompt_at = now
remaining = int(deadline - now)
print(f"[登录] 等待用户输入账号密码... 剩余 {remaining}s")
time.sleep(HUMAN_POLL_INTERVAL)
logger.warning(
"platform_collector: %ds 内用户未完成输入", wait_sec,
)
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, max_wait_sec: int = 30,
) -> bool:
"""v1.1.19:等 URL 含 target_substr,且连续 URL_STABLE_REQUIRED_S 秒不变。
用于「已登录态直接跳到目标页面」后等待 SPA 路由稳定。
**不做任何 goto / reload**,只 listen URL 变化。
Returns:
True = URL 已稳定到目标;False = 超时。
"""
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()
if target_substr in cur_url.lower():
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,81 +612,64 @@ 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 为空 → 等用户输入完成后再自动点 Sign in
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)
# 4. 强制 goto SPA home(如果还停在 login,重试登录一次)
for retry in range(SPA_HOME_RETRY_MAX):
if _is_logged_in(page):
break
logger.info(
"platform_collector: 强制 goto SPA_HOME (retry=%d), 当前 URL=%s",
retry, page.url,
)
_goto(page, SPA_HOME_URL)
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 页"
f"headless 模式下未登录,且无法手工登录"
f"(请用 --show-browser 重试)"
)
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
# 4. 登录完成 → 等 URL 真正稳定到 /app/(最多 30s,**不做任何操作**)
# 之前 retry loop 会强制 goto home / fill login,**打断用户刚登录态**。
if not _wait_for_url_stable(page, "/app/", max_wait_sec=30):
logger.warning(
"platform_collector: 登录后 30s 内 URL 未稳定到 /app/(当前 URL=%s)",
page.url,
)
# 不强行 reload —— 让用户决定是否手工刷新
# 5. 登录完成 → 等 URL 真正稳定到 /app/(最多 30s,**不做任何操作**)
# 之前 retry loop 会强制 goto home / fill login,**打断用户刚登录态**。
if not _wait_for_url_stable(page, "/app/", max_wait_sec=30):
logger.warning(
"platform_collector: 登录后 30s 内 URL 未稳定到 /app/(当前 URL=%s)",
page.url,
)
# 不强行 reload —— 让用户决定是否手工刷新
# 5. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session)
try:
@@ -763,11 +974,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 +1086,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]:

View File

@@ -6,7 +6,7 @@
"""
SKILL_SLUG = "alert-contract-expiring-hr"
SKILL_VERSION = "1.1.18"
SKILL_VERSION = "1.1.22"
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 同名常量

View File

@@ -1192,6 +1192,180 @@ 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 模拟用户登录跳转
"""
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 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.18: show-config / logs-list ----------
@@ -1457,6 +1631,14 @@ 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("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)