chore: auto release commit (2026-09-14 22:22:49)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
@@ -1,57 +1,34 @@
|
||||
"""登录处理器:处理网站登录流程。"""
|
||||
"""登录处理器:两层登录(sandbox 门户 + 智慧校园)。
|
||||
|
||||
流程:
|
||||
1. 检测 sandbox.jc2009.com 登录页 → 手机号+密码登录
|
||||
2. 平台首页找到"智慧校园" → 点击进入
|
||||
3. 智慧校园师生登录页 → demo 账号登录
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 登录相关选择器(需要根据实际页面调整)
|
||||
LOGIN_SELECTORS = {
|
||||
"username_input": [
|
||||
"input[name='username']",
|
||||
"input[placeholder*='用户名']",
|
||||
"input[placeholder*='账号']",
|
||||
"#username",
|
||||
"[data-testid='username']",
|
||||
],
|
||||
"password_input": [
|
||||
"input[name='password']",
|
||||
"input[type='password']",
|
||||
"#password",
|
||||
"[data-testid='password']",
|
||||
],
|
||||
"login_button": [
|
||||
"button[type='submit']",
|
||||
"button:has-text('登录')",
|
||||
"input[type='submit']",
|
||||
".login-btn",
|
||||
"[data-testid='login-button']",
|
||||
],
|
||||
"captcha_input": [
|
||||
"input[name='captcha']",
|
||||
"input[placeholder*='验证码']",
|
||||
"#captcha",
|
||||
],
|
||||
"captcha_image": [
|
||||
".captcha-image",
|
||||
"img[alt*='验证码']",
|
||||
"#captcha-image",
|
||||
],
|
||||
}
|
||||
# ── Sandbox 门户登录配置 ──
|
||||
SANDBOX_LOGIN_PHONE = "15527406659"
|
||||
SANDBOX_LOGIN_PASSWORD = "qwer1234"
|
||||
|
||||
# 登录成功检测
|
||||
LOGIN_SUCCESS_SELECTORS = [
|
||||
".user-info",
|
||||
".welcome",
|
||||
"[data-testid='user-avatar']",
|
||||
"a[href*='logout']",
|
||||
".dashboard",
|
||||
]
|
||||
# ── 智慧校园默认凭据 ──
|
||||
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(
|
||||
@@ -60,149 +37,286 @@ async def login_if_needed(
|
||||
password: str,
|
||||
wait_sec: int = 120,
|
||||
) -> bool:
|
||||
"""检查登录状态,如需要则执行登录。
|
||||
|
||||
Args:
|
||||
page: Playwright页面对象
|
||||
username: 用户名
|
||||
password: 密码
|
||||
wait_sec: 等待人工验证的超时时间
|
||||
|
||||
Returns:
|
||||
bool: 登录是否成功
|
||||
"""
|
||||
# 检查是否已登录
|
||||
if await _is_logged_in(page):
|
||||
logger.info("already_logged_in url=%s", page.url)
|
||||
return True
|
||||
|
||||
# 导航到登录页面
|
||||
if "/login" not in page.url:
|
||||
await page.goto("https://sandbox.jc2009.com/login", wait_until="domcontentloaded")
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 尝试自动登录
|
||||
login_success = await _try_auto_login(page, username, password)
|
||||
if login_success:
|
||||
return True
|
||||
|
||||
# 等待人工登录
|
||||
return await _wait_for_human_login(page, wait_sec)
|
||||
"""检查登录状态,如需要则执行登录。"""
|
||||
phone = username or SANDBOX_LOGIN_PHONE
|
||||
pwd = password or SANDBOX_LOGIN_PASSWORD
|
||||
|
||||
|
||||
async def _try_auto_login(page: Any, username: str, password: str) -> bool:
|
||||
"""尝试自动登录。"""
|
||||
try:
|
||||
# 查找用户名输入框
|
||||
username_input = await _find_element(page, LOGIN_SELECTORS["username_input"])
|
||||
if not username_input:
|
||||
logger.warning("username_input_not_found")
|
||||
return False
|
||||
|
||||
# 查找密码输入框
|
||||
password_input = await _find_element(page, LOGIN_SELECTORS["password_input"])
|
||||
if not password_input:
|
||||
logger.warning("password_input_not_found")
|
||||
return False
|
||||
|
||||
# 输入用户名(模拟人类输入)
|
||||
await username_input.click()
|
||||
await interruptible_sleep(0.5)
|
||||
await username_input.fill("")
|
||||
await page.keyboard.type(username, delay=100)
|
||||
await interruptible_sleep(0.5)
|
||||
|
||||
# 输入密码
|
||||
await password_input.click()
|
||||
await interruptible_sleep(0.5)
|
||||
await password_input.fill("")
|
||||
await page.keyboard.type(password, delay=100)
|
||||
await interruptible_sleep(0.5)
|
||||
|
||||
# 查找并点击登录按钮
|
||||
login_button = await _find_element(page, LOGIN_SELECTORS["login_button"])
|
||||
if not login_button:
|
||||
logger.warning("login_button_not_found")
|
||||
return False
|
||||
|
||||
await login_button.click()
|
||||
await interruptible_sleep(3.0)
|
||||
|
||||
# 检查是否登录成功
|
||||
return await _is_logged_in(page)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("auto_login_failed error=%s", str(e))
|
||||
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
|
||||
|
||||
async def _wait_for_human_login(page: Any, wait_sec: int) -> bool:
|
||||
"""等待人工完成登录。"""
|
||||
print(f"请在浏览器中完成登录,最多等待 {wait_sec} 秒...")
|
||||
deadline = time.monotonic() + wait_sec
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if await _is_logged_in(page):
|
||||
print("检测到登录成功")
|
||||
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
|
||||
|
||||
# 检查是否有验证码需要处理
|
||||
captcha_present = await _check_captcha(page)
|
||||
if captcha_present:
|
||||
print("检测到验证码,请人工完成验证...")
|
||||
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
print("登录超时")
|
||||
await asyncio.sleep(POLL_INTERVAL_SEC)
|
||||
elapsed += POLL_INTERVAL_SEC
|
||||
return False
|
||||
|
||||
|
||||
async def _is_logged_in(page: Any) -> bool:
|
||||
"""检查是否已登录。"""
|
||||
# ── 阶段一:登录 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:
|
||||
# 检查URL是否包含登录成功标识
|
||||
if "/login" not in page.url and "/index" in page.url:
|
||||
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
|
||||
|
||||
# 检查页面元素
|
||||
for selector in LOGIN_SUCCESS_SELECTORS:
|
||||
|
||||
# 找登录表单
|
||||
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:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
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:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("enter_smart_campus_failed error=%s url=%s", str(e), page.url)
|
||||
return False
|
||||
|
||||
|
||||
async def _check_captcha(page: Any) -> bool:
|
||||
"""检查是否有验证码。"""
|
||||
# ── 阶段三:登录智慧校园 ──
|
||||
|
||||
|
||||
async def login_smart_campus(page: Any) -> bool:
|
||||
"""登录智慧校园系统。"""
|
||||
logger.info("smart_campus_login_start")
|
||||
|
||||
try:
|
||||
for selector in LOGIN_SELECTORS["captcha_input"]:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
|
||||
for selector in LOGIN_SELECTORS["captcha_image"]:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
|
||||
# 已登录直接返回
|
||||
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:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("smart_campus_login_failed error=%s", str(e))
|
||||
return False
|
||||
|
||||
|
||||
async def _find_element(page: Any, selectors: list) -> Optional[Any]:
|
||||
"""根据选择器列表查找元素。"""
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return element
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
# ── 导航到票据合规预审 ──
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user