Files
verify-invoice-default/scripts/service/rpa/login_handler.py
chenfuqiang 39eacc4c4a
All checks were successful
技能自动化发布 / release (push) Successful in 18s
chore: auto release commit (2026-09-15 10:19:55)
2026-09-15 10:19:55 +08:00

316 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""登录处理器:两层登录(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_WAIT_TIMEOUT_SEC = 180 # 等待人工完成 sandbox 登录的最长时间
# ── 智慧校园默认凭据 ──
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 = 180,
) -> bool:
"""检查登录状态,如需要则执行登录。"""
sandbox_ok = await login_sandbox(page, wait_sec=wait_sec)
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, wait_sec: int = SANDBOX_WAIT_TIMEOUT_SEC) -> bool:
"""登录 sandbox.jc2009.com 门户。
- 已登录(有智慧校园/Sign out/首页 URL)→ 直接返回 True
- 未登录 → 打开登录页,提示用户在浏览器中手动完成登录,轮询等待
"""
logger.info("sandbox_login_start")
try:
# 1. 检查是否已登录
if await _is_sandbox_logged_in(page):
logger.info("sandbox_already_logged_in url=%s", page.url)
return True
# 2. 打开 sandbox 首页
if await page.get_by_role("textbox", name=re.compile(r"Phone|phone|手机")).count() == 0:
await page.goto("https://sandbox.jc2009.com/", wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS)
await asyncio.sleep(2.0)
if await _is_sandbox_logged_in(page):
logger.info("sandbox_already_logged_in_after_redirect")
return True
# 3. 提示用户手动登录
print("\n" + "=" * 50)
print("请在浏览器中手动完成 sandbox 登录")
print(f"登录地址:{page.url}")
print(f"最多等待 {wait_sec} 秒...")
print("=" * 50 + "\n")
logger.info("sandbox_waiting_for_human_login url=%s timeout=%ss", page.url, wait_sec)
# 4. 轮询等待登录完成
async def _login_done():
return await _is_sandbox_logged_in(page)
if await _poll_until(_login_done, timeout_sec=float(wait_sec)):
logger.info("sandbox_login_success url=%s", page.url)
return True
logger.warning("sandbox_login_timeout")
print("❌ sandbox 登录超时,请重新运行。")
return False
except Exception as e:
logger.error("sandbox_login_failed error=%s url=%s", str(e), page.url)
return False
async def _is_sandbox_logged_in(page: Any) -> bool:
"""快速检查是否已在 sandbox 首页(已登录)。"""
if _is_sandbox_home(page.url):
return True
if await page.get_by_role("button", name=re.compile(r"Sign out|退出")).count() > 0:
return True
if await page.get_by_text("智慧校园", exact=False).count() > 0:
return True
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