chore: auto release commit (2026-09-14 22:22:49)
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
chenfuqiang
2026-09-14 22:22:49 +08:00
parent 6998fb3bd7
commit 3a80db8a7e
155 changed files with 1365 additions and 21023 deletions

View File

@@ -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