Files
verify-invoice-default/scripts/service/rpa/login_handler.py
chenfuqiang 3a80db8a7e
All checks were successful
技能自动化发布 / release (push) Successful in 7s
chore: auto release commit (2026-09-14 22:22:49)
2026-09-14 22:22:49 +08:00

323 lines
12 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_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