diff --git a/CHANGELOG.md b/CHANGELOG.md index c94546c..e4b2608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 本文件对应技能市场 **「更新日志」** Tab。 +## 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 直接看到入队结果 diff --git a/SKILL.md b/SKILL.md index 6a6f574..1cc6f64 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 合同到期智能预警与续聘辅助 description: "当用户说要扫描即将到期的合同、查 90/60/30 天预警名单、给某位教师生成续聘任务书、下发单位版续聘清单、从 SmartCampus HR 平台抓合同数据、解析续聘材料 PDF/Word、查看本月合同到期人员清单时,加载本技能。支持定时扫描(每日凌晨)+ 数据管理 + Agent 三入口,覆盖合同到期智能预警与续聘辅助全流程。" -version: 1.1.19 +version: 1.1.20 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/scripts/service/platform_collector.py b/scripts/service/platform_collector.py index 28354af..9ef35a5 100644 --- a/scripts/service/platform_collector.py +++ b/scripts/service/platform_collector.py @@ -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 @@ -165,32 +168,100 @@ def _is_logged_in(page) -> bool: def _wait_for_manual_login(page, wait_sec: int = HUMAN_WAIT_TIMEOUT) -> bool: - """手工登录 fallback:轮询 _is_logged_in,每 HUMAN_POLL_INTERVAL 秒一次。 + """手工登录 fallback(v1.1.19 重写):等用户在浏览器输完账号密码提交。 - 与 skill-template task_rpa.py::_ensure_logged_in 设计对齐: - - 不自动破解,等用户在浏览器手工完成登录 - - 屏幕打印 [登录] 提示 - - 超时抛 LoginTimeoutError(ERROR:LOGIN_TIMEOUT) + 与之前轮询 _is_logged_in 不同的设计: + - **不做任何 goto / reload / click**,避免打断用户输入 + - 每 HUMAN_PROMPT_INTERVAL 秒打印一次剩余时间(默认 15s) + - 每 HUMAN_POLL_INTERVAL 秒(默认 5s)只检查 URL,不做任何操作 + - 用户完成登录(URL 跳到 /app/...)后 **等 URL 真正稳定**,再返回 True - 自动 fill demo/123456 失败时调用此函数。 + 超时抛 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() + + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + + # 用户输入完提交后,URL 会从 /login 跳到 /app/... + 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() + if _is_logged_in(page): - print(f"[登录] 检测到登录成功(attempt {attempt})") - return True + # URL 已跳到 /app/...:等 URL 稳定 URL_STABLE_REQUIRED_S 秒再返回 + stable_for = time.monotonic() - last_url_change_at + if stable_for >= URL_STABLE_REQUIRED_S: + print(f"[登录] ✅ 检测到登录完成(URL={cur_url}, 稳定 {stable_for:.1f}s)") + return True + logger.info( + "platform_collector: 登录后 URL 还在过渡,等稳定 " + "(current=%s, stable_for=%.1fs)", cur_url, stable_for, + ) + 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 内用户在浏览器中也未完成登录" ) +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 +455,48 @@ 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.19:先 goto **根域**(用户最初看到的页面),**等 URL 稳定**, + # 避免被 sandbox 中间页误导或打断已登录态。 + # 已登录:根域会自动跳到 /app/home(满足 _is_logged_in) + # 未登录:根域就是登录页,用户在浏览器输入账号密码 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) + _goto(page, ROOT_URL) 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) + # 2. 等 URL 稳定:已登录→/app/xxx,未登录→根域或 /login + # **不做任何操作**,避免打断浏览器 session + _wait_for_url_stable(page, "/", max_wait_sec=10) if _is_logged_in(page): logger.info( - "platform_collector: ✅ 已登录态,**完全跳过登录流程**(不弹窗、不自动 fill、不打扰)" - ",URL=%s", page.url, + "platform_collector: ✅ 已登录态(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, - ) - 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 重试)" - ) + # 3. v1.1.19 用户反馈:不要自动 fill demo/123456,**纯等用户手工输入**。 + # 不强制 goto SPA_LOGIN_URL(避免打断根域的输入流程) + if not headless: + _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) + + # 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: diff --git a/scripts/util/constants.py b/scripts/util/constants.py index b2d5578..a68d7c3 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -6,7 +6,7 @@ """ SKILL_SLUG = "alert-contract-expiring-hr" -SKILL_VERSION = "1.1.19" +SKILL_VERSION = "1.1.20" 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 同名常量 diff --git a/tests/run_tests.py b/tests/run_tests.py index d1872bb..aa6455a 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1271,6 +1271,101 @@ def test_enqueue_download_tasks_safe_empty_list() -> tuple[bool, str]: 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 ---------- @@ -1540,6 +1635,10 @@ def main() -> int: _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)