"""登录处理器:两层登录(sandbox 门户 + 智慧校园)。 流程: 1. 检测 sandbox.jc2009.com 登录页 → 手机号+密码登录 2. 平台首页找到"智慧校园" → 点击进入 3. 智慧校园师生登录页 → demo 账号登录 """ from __future__ import annotations import asyncio import logging import re from typing import Any, Optional from jiangchang_skill_core.activity import interruptible_sleep logger = logging.getLogger(__name__) # ── Sandbox 门户登录配置 ── SANDBOX_LOGIN_PHONE = "15527406659" SANDBOX_LOGIN_PASSWORD = "qwer1234" # ── 智慧校园默认凭据 ── SMART_CAMPUS_DEFAULT_USER = "demo" SMART_CAMPUS_DEFAULT_PASSWORD = "demo123" # ── 超时 ── NAVIGATION_TIMEOUT_MS = 30_000 POLL_INTERVAL_SEC = 1.0 # 轮询间隔(秒) TYPE_DELAY_MS = 50 # 逐字符输入延迟(毫秒) async def login_if_needed( page: Any, username: str, password: str, wait_sec: int = 120, ) -> bool: """检查登录状态,如需要则执行登录。""" phone = username or SANDBOX_LOGIN_PHONE pwd = password or SANDBOX_LOGIN_PASSWORD sandbox_ok = await login_sandbox(page, phone, pwd) if not sandbox_ok: return False campus_entered = await enter_smart_campus(page) if not campus_entered: return False campus_ok = await login_smart_campus(page) return campus_ok # ── 工具函数 ── def _is_sandbox_home(page_url: str) -> bool: return "select" in page_url or "/industries" in page_url async def _type_field(locator, text: str) -> None: """快速填入字段:fill 直接设值(比 keyboard.type 快 10 倍)。""" await locator.click() await locator.fill(text) async def _poll_until(check_fn, timeout_sec: float = 30.0) -> bool: """快速轮询:每 1 秒检测一次,检测到立刻返回。check_fn 必须是无参 async callable。""" elapsed = 0.0 while elapsed < timeout_sec: if await check_fn(): return True await asyncio.sleep(POLL_INTERVAL_SEC) elapsed += POLL_INTERVAL_SEC return False # ── 阶段一:登录 sandbox.jc2009.com ── async def login_sandbox(page: Any, phone: str, password: str) -> bool: """登录 sandbox.jc2009.com 门户。已登录则直接返回 True。""" logger.info("sandbox_login_start phone=%s***", phone[:3]) try: if _is_sandbox_home(page.url) or await page.get_by_role("button", name=re.compile(r"Sign out|退出")).count() > 0 or await page.get_by_text("智慧校园", exact=False).count() > 0: logger.info("sandbox_already_logged_in url=%s", page.url) return True # 找登录表单 phone_input = page.get_by_role("textbox", name=re.compile(r"Phone|phone|手机")) if await phone_input.count() == 0: await page.goto("https://sandbox.jc2009.com/", wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS) # 用轮询替代固定等待 async def _home_check(): return _is_sandbox_home(page.url) or await page.get_by_text("智慧校园", exact=False).count() > 0 await _poll_until(_home_check, timeout_sec=10.0) if _is_sandbox_home(page.url) or await page.get_by_text("智慧校园", exact=False).count() > 0: logger.info("sandbox_already_logged_in_after_redirect") return True phone_input = page.get_by_role("textbox", name=re.compile(r"Phone|phone|手机")) if await phone_input.count() == 0: logger.warning("sandbox_login_page_not_found url=%s", page.url) return False logger.info("sandbox_login_page_detected") # 输入手机号(fill 直接设值,比 type 快) await _type_field(phone_input, phone) # 输入密码 pwd_input = page.get_by_role("textbox", name=re.compile(r"Password|password|密码")) if await pwd_input.count() == 0: pwd_input = page.get_by_label("Password") await _type_field(pwd_input, password) # 点击 Sign in sign_in_btn = page.get_by_role("button", name=re.compile(r"Sign in|登录")) await sign_in_btn.click() logger.info("sandbox_login_click_sign_in") # 快速轮询等待登录成功 async def _login_done(): return ( _is_sandbox_home(page.url) or await page.get_by_role("button", name=re.compile(r"Sign out|退出")).count() > 0 or await page.get_by_text("智慧校园", exact=False).count() > 0 ) if await _poll_until(_login_done, timeout_sec=30.0): logger.info("sandbox_login_success url=%s", page.url) return True logger.warning("sandbox_login_timeout") return False except Exception as e: logger.error("sandbox_login_failed error=%s url=%s", str(e), page.url) return False # ── 阶段二:进入智慧校园 ── async def enter_smart_campus(page: Any) -> bool: """在平台首页找到并点击"智慧校园"卡片。""" logger.info("enter_smart_campus_start url=%s", page.url) try: campus_card = page.get_by_text("智慧校园", exact=False) count = await campus_card.count() if count == 0: logger.warning("smart_campus_not_found") return False pages_before = len(page.context.pages) # 点击卡片 clicked = False for i in range(count): try: el = campus_card.nth(i) parent = el.locator("xpath=ancestor::a[1]") if await parent.count() > 0: await parent.first.click() clicked = True break parent = el.locator("xpath=ancestor::div[contains(@class,'card') or contains(@class,'item') or contains(@class,'clickable')][1]") if await parent.count() > 0: await parent.first.click() clicked = True break except Exception: continue if not clicked: await campus_card.first.click() # 快速检测是否有新 tab await asyncio.sleep(1.0) pages_after = len(page.context.pages) active_page = page if pages_after > pages_before: active_page = page.context.pages[-1] logger.info("new_tab url=%s", active_page.url) else: active_page = page.context.pages[-1] # 快速轮询等待校园登录页 async def _campus_page_ready(): p = active_page if await p.get_by_text("师生登录", exact=False).count() > 0: return True if await p.get_by_label("账号").count() > 0: return True if await p.locator('input[type="password"]').count() > 0: return True if await p.get_by_text("工作台", exact=False).count() > 0: return True if await p.get_by_text("华江示范大学", exact=False).count() > 0: return True if "smartcampus" in p.url or "campus" in p.url: return True # 排除还在 sandbox 门户的情况 if await p.get_by_text("Password sign-in", exact=False).count() > 0: return False login_btn = p.get_by_role("button", name=re.compile(r"登录")) if await login_btn.count() > 0: return True return False if await _poll_until(_campus_page_ready, timeout_sec=30.0): logger.info("smart_campus_page_ready url=%s", active_page.url) return True logger.warning("smart_campus_timeout url=%s", active_page.url) return False except Exception as e: logger.error("enter_smart_campus_failed error=%s url=%s", str(e), page.url) return False # ── 阶段三:登录智慧校园 ── async def login_smart_campus(page: Any) -> bool: """登录智慧校园系统。""" logger.info("smart_campus_login_start") try: # 已登录直接返回 if await page.get_by_text("工作台", exact=False).count() > 0: logger.info("smart_campus_already_logged_in") return True # 填账号(如果为空) account_input = page.get_by_label("账号") if await account_input.count() == 0: account_input = page.locator('input[placeholder*="账号"], input[placeholder*="用户名"]') if await account_input.count() > 0: val = await account_input.input_value() if not val or not val.strip(): await _type_field(account_input, SMART_CAMPUS_DEFAULT_USER) # 填密码(如果为空) pwd_input = page.get_by_label("密码") if await pwd_input.count() == 0: pwd_input = page.locator('input[type="password"]') if await pwd_input.count() > 0: val = await pwd_input.input_value() if not val or not val.strip(): await _type_field(pwd_input, SMART_CAMPUS_DEFAULT_PASSWORD) # 点击登录 login_btn = page.get_by_role("button", name=re.compile(r"登录|登 录")) if await login_btn.count() == 0: login_btn = page.locator('button:has-text("登录"), button[type="submit"]') await login_btn.first.click() logger.info("smart_campus_login_click") # 快速轮询等待进入系统 async def _workbench_ready(): return await page.get_by_text("工作台", exact=False).count() > 0 if await _poll_until(_workbench_ready, timeout_sec=20.0): logger.info("smart_campus_login_success") return True logger.warning("smart_campus_login_timeout") return False except Exception as e: logger.error("smart_campus_login_failed error=%s", str(e)) return False # ── 导航到票据合规预审 ── async def navigate_to_invoice_precheck(page: Any) -> bool: """从智慧校园主界面导航到票据合规预审。""" logger.info("navigate_to_invoice_precheck_start") try: # 直接 URL 导航 target_url = "https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck" await page.goto(target_url, wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS) # 快速轮询等待页面标题出现 async def _title_ready(): return await page.get_by_text("票据合规性秒级预审", exact=False).count() > 0 if await _poll_until(_title_ready, timeout_sec=15.0): logger.info("invoice_precheck_page_loaded") return True # fallback: 通过菜单 logger.info("direct_nav_failed_try_menu") await page.goto("https://sandbox.jc2009.com/industries/campus/smartcampus/", wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS) finance_menu = page.get_by_text("财务处", exact=True) if await finance_menu.count() > 0: await finance_menu.click() await asyncio.sleep(0.5) invoice_menu = page.get_by_text("票据合规预审", exact=True) if await invoice_menu.count() == 0: invoice_menu = page.get_by_text(re.compile(r"票据合规")) if await invoice_menu.count() > 0: await invoice_menu.first.click() if await _poll_until(_title_ready, timeout_sec=15.0): logger.info("invoice_precheck_page_loaded_via_menu") return True logger.warning("invoice_precheck_page_not_loaded") return False except Exception as e: logger.error("navigate_to_invoice_precheck_failed error=%s", str(e)) return False