⚒️ 重大重构 LoveACE V2

引入了 mongodb
对数据库进行了一定程度的数据加密
性能改善
代码简化
统一错误模型和响应
使用 apifox 作为文档
This commit is contained in:
2025-11-20 20:44:25 +08:00
parent 6b90c6d7bb
commit bbc86b8330
168 changed files with 14264 additions and 19152 deletions

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter
from loveace.router.endpoint.jwc.academic import jwc_academic_router
from loveace.router.endpoint.jwc.competition import jwc_competition_router
from loveace.router.endpoint.jwc.exam import jwc_exam_router
from loveace.router.endpoint.jwc.plan import jwc_plan_router
from loveace.router.endpoint.jwc.schedule import jwc_schedules_router
from loveace.router.endpoint.jwc.score import jwc_score_router
from loveace.router.endpoint.jwc.term import jwc_term_router
jwc_base_router = APIRouter(prefix="/jwc", tags=["教务处"])
jwc_base_router.include_router(jwc_exam_router)
jwc_base_router.include_router(jwc_academic_router)
jwc_base_router.include_router(jwc_term_router)
jwc_base_router.include_router(jwc_score_router)
jwc_base_router.include_router(jwc_plan_router)
jwc_base_router.include_router(jwc_schedules_router)
jwc_base_router.include_router(jwc_competition_router)

View File

@@ -0,0 +1,245 @@
import re
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.academic import (
AcademicInfo,
AcademicInfoTransformer,
CourseSelectionStatus,
CourseSelectionStatusTransformer,
TrainingPlanInfo,
TrainingPlanInfoTransformer,
)
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_academic_router = APIRouter(
prefix="/academic",
responses=ProtectRouterErrorToCode.gen_code_table(),
)
ENDPOINTS = {
"academic_info": "/main/academicInfo?sf_request_type=ajax",
"training_plan": "/main/showPyfaInfo?sf_request_type=ajax",
"course_selection_status": "/main/checkSelectCourseStatus?sf_request_type=ajax",
}
@jwc_academic_router.get(
"/info", response_model=UniResponseModel[AcademicInfo], summary="获取学业信息"
)
async def get_academic_info(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[AcademicInfo] | JSONResponse:
"""
获取用户的学业信息GPA、学分等
✅ 功能特性:
- 获取当前学期学业情况
- 获取平均学分绩点GPA
- 实时从教务系统查询
💡 使用场景:
- 个人中心查看学业成绩概览
- 了解学业进展情况
- 毕业时验证学业要求
Returns:
AcademicInfo: 包含 GPA、学分、学业状态等信息
"""
try:
conn.logger.info(f"获取用户 {conn.userid} 的学业信息")
academic_info = await conn.client.post(
JWCConfig().to_full_url(ENDPOINTS["academic_info"]),
data={"flag": ""},
follow_redirects=True,
timeout=conn.timeout,
)
if not academic_info.status_code == 200:
conn.logger.error(
f"获取用户 {conn.userid} 的学业信息失败,状态码: {academic_info.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
try:
data = academic_info.json()
# 数组格式特殊处理
data_to_validate = data[0]
result = AcademicInfoTransformer.model_validate(
data_to_validate
).to_academic_info()
return UniResponseModel[AcademicInfo](
success=True,
data=result,
message="获取学业信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error("数据验证失败")
conn.logger.debug(f"数据验证失败详情: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
@jwc_academic_router.get(
"/training_plan",
response_model=UniResponseModel[TrainingPlanInfo],
summary="获取培养方案信息",
)
async def get_training_plan_info(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[TrainingPlanInfo] | JSONResponse:
"""
获取用户的培养方案信息
✅ 功能特性:
- 获取所属专业的培养方案
- 获取年级和专业名称
- 提取关键信息(年级、专业)
💡 使用场景:
- 了解培养方案要求
- 查看所属年级和专业
- 课程规划参考
Returns:
TrainingPlanInfo: 包含方案名称、专业名称、年级信息
"""
try:
conn.logger.info(f"获取用户 {conn.userid} 的培养方案信息")
training_plan_info = await conn.client.get(
JWCConfig().to_full_url(ENDPOINTS["training_plan"]),
follow_redirects=True,
timeout=conn.timeout,
)
if not training_plan_info.status_code == 200:
conn.logger.error(
f"获取用户 {conn.userid} 的培养方案信息失败,状态码: {training_plan_info.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
try:
data = training_plan_info.json()
transformer = TrainingPlanInfoTransformer.model_validate(data)
if transformer.count > 0 and len(transformer.data) > 0:
first_plan = transformer.data[0]
if len(first_plan) >= 2:
plan_name = first_plan[0]
# 提取年级信息 - 假设格式为"20XX级..."
grade_match = re.search(r"(\d{4})级", plan_name)
grade = grade_match.group(1) if grade_match else ""
# 提取专业名称 - 假设格式为"20XX级XXX本科培养方案"
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
major_name = major_match.group(1) if major_match else ""
result = TrainingPlanInfo(
plan_name=plan_name, major_name=major_name, grade=grade
)
return UniResponseModel[TrainingPlanInfo](
success=True,
data=result,
message="获取培养方案信息成功",
error=None,
)
else:
conn.logger.error("培养方案数据格式不正确,字段数量不足")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
else:
conn.logger.error("培养方案数据为空")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except ValidationError as ve:
conn.logger.error("数据验证失败")
conn.logger.debug(f"数据验证失败详情: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
@jwc_academic_router.get(
"/course_selection_status",
response_model=UniResponseModel[CourseSelectionStatus],
summary="获取选课状态信息",
)
async def get_course_selection_status(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[CourseSelectionStatus] | JSONResponse:
"""
获取用户的选课状态
✅ 功能特性:
- 获取当前选课时间窗口
- 获取选课开放状态
- 显示选课时间提醒
💡 使用场景:
- 查看当前是否在选课时间内
- 获取选课开始和结束时间
- 选课前的状态检查
Returns:
CourseSelectionStatus: 包含选课状态、开始时间、结束时间等
"""
try:
conn.logger.info(f"获取用户 {conn.userid} 的选课状态信息")
course_selection_status = await conn.client.get(
JWCConfig().to_full_url(ENDPOINTS["course_selection_status"]),
follow_redirects=True,
timeout=conn.timeout,
)
if not course_selection_status.status_code == 200:
conn.logger.error(
f"获取用户 {conn.userid} 的选课状态信息失败,状态码: {course_selection_status.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
try:
data = course_selection_status.json()
result = CourseSelectionStatus(
can_select=(
True
if CourseSelectionStatusTransformer.model_validate(data).status_code
== "1"
else False
)
)
return UniResponseModel[CourseSelectionStatus](
success=True,
data=result,
message="获取选课状态成功",
error=None,
)
except ValidationError as ve:
conn.logger.error("数据验证失败")
conn.logger.debug(f"数据验证失败详情: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,121 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.competition import (
CompetitionFullResponse,
)
from loveace.router.endpoint.jwc.utils.aspnet_form_parser import ASPNETFormParser
from loveace.router.endpoint.jwc.utils.competition import CompetitionInfoParser
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_competition_router = APIRouter(
prefix="/competition",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
ENDPOINT = {
"awards_page": "http://211-86-241-245.vpn2.aufe.edu.cn:8118/xsXmMain.aspx",
}
@jwc_competition_router.get(
"/info",
summary="获取完整学科竞赛信息",
response_model=UniResponseModel[CompetitionFullResponse],
)
async def get_full_competition_info(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[CompetitionFullResponse] | JSONResponse:
"""
获取用户的完整学科竞赛信息(一次请求获取所有数据)
✅ 功能特性:
- 一次请求获取获奖项目列表和学分汇总
- 减少网络IO调用提高性能
- 返回完整的竞赛相关数据
📊 返回数据:
- 获奖项目列表(包含项目信息、学分、奖励等)
- 学分汇总(各类学分统计)
- 学生基本信息
💡 使用场景:
- 需要完整竞赛信息的仪表板
- 移动端应用(减少请求次数)
- 性能敏感的场景
Returns:
CompetitionFullResponse: 包含完整竞赛信息的响应对象
"""
try:
conn.logger.info(f"获取用户 {conn.userid} 的完整学科竞赛信息")
# 第一次访问页面获取 HTML 内容和 Cookie
conn.logger.debug("第一次访问创新创业管理平台页面获取表单数据")
index_response = await conn.client.get(
ENDPOINT["awards_page"],
follow_redirects=True,
timeout=conn.timeout,
)
if index_response.status_code != 200:
conn.logger.error(f"第一次访问创新创业管理平台失败,状态码: {index_response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 从第一次响应中提取动态表单数据
conn.logger.debug("从页面中提取动态表单数据")
try:
form_data = ASPNETFormParser.get_awards_list_form_data(index_response.text)
conn.logger.debug(f"成功提取表单数据__VIEWSTATE 长度: {len(form_data.get('__VIEWSTATE', ''))}")
except Exception as e:
conn.logger.error(f"提取表单数据失败: {e}")
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
# 第二次请求:使用动态表单数据请求已申报奖项页面
conn.logger.debug("使用动态表单数据请求已申报奖项页面")
result_response = await conn.client.post(
ENDPOINT["awards_page"],
follow_redirects=True,
data=form_data,
timeout=conn.timeout,
)
if result_response.status_code != 200:
conn.logger.error(f"请求已申报奖项页面失败,状态码: {result_response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 一次性解析所有数据
parser = CompetitionInfoParser(result_response.text)
full_response = parser.parse_full_competition_info()
conn.logger.info(
f"成功获取用户 {conn.userid} 的完整竞赛信息,共 {full_response.total_awards_count} 项获奖"
)
return UniResponseModel[CompetitionFullResponse](
success=True,
data=full_response,
message="获取竞赛信息成功",
error=None,
)
except ValidationError as e:
conn.logger.error(f"用户 {conn.userid} 的竞赛信息数据验证失败: {e}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.error(f"用户 {conn.userid} 的完整竞赛信息获取失败: {e}")
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,97 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from loveace.router.endpoint.jwc.academic import get_academic_info
from loveace.router.endpoint.jwc.model.academic import AcademicInfo
from loveace.router.endpoint.jwc.model.exam import ExamInfoResponse
from loveace.router.endpoint.jwc.utils.exam import fetch_unified_exam_info
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_exam_router = APIRouter(
prefix="/exam",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
@jwc_exam_router.get(
"/info", response_model=UniResponseModel[ExamInfoResponse], summary="获取考试信息"
)
async def get_exam_info(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[ExamInfoResponse] | JSONResponse:
"""
获取用户的考试信息
✅ 功能特性:
- 获取当前学期的考试安排
- 自动确定考试时间范围
- 显示考试时间、地点、课程等信息
💡 使用场景:
- 查看即将进行的考试
- 了解考试安排和地点
- 提前规划复习计划
Returns:
ExamInfoResponse: 包含考试列表和总数
"""
try:
academic_info = await get_academic_info(conn)
if isinstance(academic_info, UniResponseModel):
if academic_info.data and isinstance(academic_info.data, AcademicInfo):
term_code = academic_info.data.current_term
else:
result = ExamInfoResponse(exams=[], total_count=0)
return UniResponseModel[ExamInfoResponse](
success=False,
data=result,
message="无法获取学期信息",
error=None,
)
elif isinstance(academic_info, AcademicInfo):
term_code = academic_info.current_term
else:
result = ExamInfoResponse(exams=[], total_count=0)
return UniResponseModel[ExamInfoResponse](
success=False,
data=result,
message="无法获取学期信息",
error=None,
)
conn.logger.info(f"获取用户 {conn.userid} 的考试信息")
start_date = datetime.now()
# termcode 结尾为 1 为秋季学期考试应在3月之前2为春季学期考试应在9月之前
end_date = datetime(
year=start_date.year + (1 if term_code.endswith("1") else 0),
month=3 if term_code.endswith("1") else 9,
day=30,
)
exam_info = await fetch_unified_exam_info(
conn,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
term_code=term_code,
)
return UniResponseModel[ExamInfoResponse](
success=True,
data=exam_info,
message="获取考试信息成功",
error=None,
)
except ValidationError as e:
conn.logger.error(f"用户 {conn.userid} 的考试信息数据验证失败: {e}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.error(f"用户 {conn.userid} 的考试信息获取失败: {e}")
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,67 @@
from typing import List
from pydantic import BaseModel, Field
from loveace.router.endpoint.jwc.utils.zxjxjhh_to_term_format import (
convert_zxjxjhh_to_term_format,
)
class AcademicInfoTransformer(BaseModel):
"""学术信息数据项"""
completed_courses: int = Field(0, alias="courseNum")
failed_courses: int = Field(0, alias="coursePas")
gpa: float = Field(0, alias="gpa")
current_term: str = Field("", alias="zxjxjhh")
pending_courses: int = Field(0, alias="courseNum_bxqyxd")
def to_academic_info(self) -> "AcademicInfo":
"""转换为 AcademicInfo"""
return AcademicInfo(
completed_courses=self.completed_courses,
failed_courses=self.failed_courses,
pending_courses=self.pending_courses,
gpa=self.gpa,
current_term=self.current_term,
current_term_name=convert_zxjxjhh_to_term_format(self.current_term),
)
class AcademicInfo(BaseModel):
"""学术信息数据模型"""
completed_courses: int = Field(0, description="已修课程数")
failed_courses: int = Field(0, description="不及格课程数")
pending_courses: int = Field(0, description="本学期待修课程数")
gpa: float = Field(0, description="绩点")
current_term: str = Field("", description="当前学期")
current_term_name: str = Field("", description="当前学期名称")
class TrainingPlanInfoTransformer(BaseModel):
"""培养方案响应模型"""
count: int = 0
data: List[List[str]] = []
class TrainingPlanInfo(BaseModel):
"""培养方案信息模型"""
plan_name: str = Field("", description="培养方案名称")
major_name: str = Field("", description="专业名称")
grade: str = Field("", description="年级")
class CourseSelectionStatusTransformer(BaseModel):
"""选课状态响应模型新格式"""
term_name: str = Field("", alias="zxjxjhm")
status_code: str = Field("", alias="retString")
class CourseSelectionStatus(BaseModel):
"""选课状态信息"""
can_select: bool = Field(False, description="是否可以选课")

View File

@@ -0,0 +1,10 @@
class JWCConfig:
"""教务系统配置常量"""
DEFAULT_BASE_URL = "http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/"
def to_full_url(self, path: str) -> str:
"""将路径转换为完整URL"""
if path.startswith("http://") or path.startswith("https://"):
return path
return self.DEFAULT_BASE_URL.rstrip("/") + "/" + path.lstrip("/")

View File

@@ -0,0 +1,84 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class AwardProject(BaseModel):
"""
获奖项目信息模型
表示用户通过创新创业管理平台申报的单个获奖项目
"""
project_id: str = Field("", description="申报ID唯一标识符")
project_name: str = Field("", description="项目名称/赛事名称")
level: str = Field("", description="级别(校级/省部级/国家级等)")
grade: str = Field("", description="等级/奖项等级(一等奖/二等奖等)")
award_date: str = Field("", description="获奖日期,格式为 YYYY/M/D")
applicant_id: str = Field("", description="主持人姓名")
applicant_name: str = Field("", description="参与人姓名(作为用户)")
order: int = Field(0, description="顺序号(多人项目的排序)")
credits: float = Field(0.0, description="获奖学分")
bonus: float = Field(0.0, description="奖励金额")
status: str = Field("", description="申报状态(提交/审核中/已审核等)")
verification_status: str = Field(
"", description="学校审核状态(通过/未通过/待审核等)"
)
class CreditsSummary(BaseModel):
"""
学分汇总信息模型
存储用户在创新创业管理平台的各类学分统计
"""
discipline_competition_credits: Optional[float] = Field(
None, description="学科竞赛学分"
)
scientific_research_credits: Optional[float] = Field(
None, description="科研项目学分"
)
transferable_competition_credits: Optional[float] = Field(
None, description="可转竞赛类学分"
)
innovation_practice_credits: Optional[float] = Field(
None, description="创新创业实践学分"
)
ability_certification_credits: Optional[float] = Field(
None, description="能力资格认证学分"
)
other_project_credits: Optional[float] = Field(None, description="其他项目学分")
class CompetitionAwardsResponse(BaseModel):
"""
获奖项目列表响应模型
"""
student_id: str = Field("", description="学生ID/工号")
total_count: int = Field(0, description="获奖项目总数")
awards: List[AwardProject] = Field(default_factory=list, description="获奖项目列表")
class CompetitionCreditsSummaryResponse(BaseModel):
"""
学分汇总响应模型
"""
student_id: str = Field("", description="学生ID/工号")
credits_summary: Optional[CreditsSummary] = Field(None, description="学分汇总详情")
class CompetitionFullResponse(BaseModel):
"""
学科竞赛完整信息响应模型
整合了获奖项目列表和学分汇总信息减少网络IO调用
在单次请求中返回所有竞赛相关数据
"""
student_id: str = Field("", description="学生ID/工号")
total_awards_count: int = Field(0, description="获奖项目总数")
awards: List[AwardProject] = Field(default_factory=list, description="获奖项目列表")
credits_summary: Optional[CreditsSummary] = Field(None, description="学分汇总详情")

View File

@@ -0,0 +1,65 @@
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class ExamScheduleItem(BaseModel):
"""考试安排项目 - 校统考格式"""
title: str = "" # 考试标题,包含课程名、时间、地点等信息
start: str = "" # 考试日期 (YYYY-MM-DD)
color: str = "" # 显示颜色
class OtherExamRecord(BaseModel):
"""其他考试记录"""
term_code: str = Field("", alias="ZXJXJHH") # 学期代码
term_name: str = Field("", alias="ZXJXJHM") # 学期名称
exam_name: str = Field("", alias="KSMC") # 考试名称
course_code: str = Field("", alias="KCH") # 课程代码
course_name: str = Field("", alias="KCM") # 课程名称
class_number: str = Field("", alias="KXH") # 课序号
student_id: str = Field("", alias="XH") # 学号
student_name: str = Field("", alias="XM") # 姓名
exam_location: str = Field("", alias="KSDD") # 考试地点
exam_date: str = Field("", alias="KSRQ") # 考试日期
exam_time: str = Field("", alias="KSSJ") # 考试时间
note: str = Field("", alias="BZ") # 备注
row_number: str = Field("", alias="RN") # 行号
class OtherExamResponse(BaseModel):
"""其他考试查询响应"""
page_size: int = Field(0, alias="pageSize")
page_num: int = Field(0, alias="pageNum")
page_context: Dict[str, int] = Field(default_factory=dict, alias="pageContext")
records: Optional[List[OtherExamRecord]] = Field(alias="records")
class UnifiedExamInfo(BaseModel):
"""统一考试信息模型 - 对外提供的统一格式"""
course_name: str = Field("", description="课程名称")
exam_date: str = Field("", description="考试日期")
exam_time: str = Field("", description="考试时间")
exam_location: str = Field("", description="考试地点")
exam_type: str = Field("", description="考试类型")
note: str = Field("", description="备注")
class ExamInfoResponse(BaseModel):
"""考试信息统一响应模型"""
exams: List[UnifiedExamInfo] = Field(
default_factory=list, description="考试信息列表"
)
total_count: int = Field(0, description="考试总数")
class SeatInfo(BaseModel):
"""座位信息模型"""
course_name: str = Field("", description="课程名称")
seat_number: str = Field("", description="座位号")

View File

@@ -0,0 +1,348 @@
import re
from typing import List, Optional
from pydantic import BaseModel, Field
class PlanCompletionCourse(BaseModel):
"""培养方案课程完成情况"""
flag_id: str = Field("", description="课程标识ID")
flag_type: str = Field("", description="节点类型001=分类, 002=子分类, kch=课程")
course_code: str = Field("", description="课程代码,如 PDA2121005")
course_name: str = Field("", description="课程名称")
is_passed: bool = Field(False, description="是否通过基于CSS图标解析")
status_description: str = Field("", description="状态描述:未修读/已通过/未通过")
credits: Optional[float] = Field(None, description="学分")
score: Optional[str] = Field(None, description="成绩")
exam_date: Optional[str] = Field(None, description="考试日期")
course_type: str = Field("", description="课程类型:必修/任选等")
parent_id: str = Field("", description="父节点ID")
level: int = Field(0, description="层级0=根分类1=子分类2=课程")
@classmethod
def from_ztree_node(cls, node: dict) -> "PlanCompletionCourse":
"""从 zTree 节点数据创建课程对象"""
# 解析name字段中的信息
name = node.get("name", "")
flag_id = node.get("flagId", "")
flag_type = node.get("flagType", "")
parent_id = node.get("pId", "")
# 根据CSS图标判断通过状态
is_passed = False
status_description = "未修读"
if "fa-smile-o fa-1x green" in name:
is_passed = True
status_description = "已通过"
elif "fa-meh-o fa-1x light-grey" in name:
is_passed = False
status_description = "未修读"
elif "fa-frown-o fa-1x red" in name:
is_passed = False
status_description = "未通过"
# 从name中提取纯文本内容
# 移除HTML标签和图标
clean_name = re.sub(r"<[^>]*>", "", name)
clean_name = re.sub(r"&nbsp;", " ", clean_name).strip()
# 解析课程信息
course_code = ""
course_name = ""
credits = None
score = None
exam_date = None
course_type = ""
if flag_type == "kch": # 课程节点
# 解析课程代码:[PDA2121005]形势与政策
code_match = re.search(r"\[([^\]]+)\]", clean_name)
if code_match:
course_code = code_match.group(1)
remaining_text = clean_name.split("]", 1)[1].strip()
# 解析学分信息:[0.3学分]
credit_match = re.search(r"\[([0-9.]+)学分\]", remaining_text)
if credit_match:
credits = float(credit_match.group(1))
remaining_text = re.sub(
r"\[[0-9.]+学分\]", "", remaining_text
).strip()
# 处理复杂的括号内容
# 例如85.0(20250626 成绩,都没把日期解析上,中国近现代史纲要)
# 或者:(任选,87.0(20250119))
# 找到最外层的括号
paren_match = re.search(
r"\(([^)]+(?:\([^)]*\)[^)]*)*)\)$", remaining_text
)
if paren_match:
paren_content = paren_match.group(1)
course_name_candidate = re.sub(
r"\([^)]+(?:\([^)]*\)[^)]*)*\)$", "", remaining_text
).strip()
# 检查括号内容的格式
if "" in paren_content:
# 处理包含中文逗号的复杂格式
parts = paren_content.split("")
# 最后一部分可能是课程名
last_part = parts[-1].strip()
if (
re.search(r"[\u4e00-\u9fff]", last_part)
and len(last_part) > 1
):
# 最后一部分包含中文,很可能是真正的课程名
course_name = last_part
# 从前面的部分提取成绩和其他信息
remaining_parts = "".join(parts[:-1])
# 提取成绩
score_match = re.search(r"([0-9.]+)", remaining_parts)
if score_match:
score = score_match.group(1)
# 提取日期
date_match = re.search(r"(\d{8})", remaining_parts)
if date_match:
exam_date = date_match.group(1)
# 提取课程类型(如果有的话)
if len(parts) > 2:
potential_type = parts[0].strip()
if not re.search(r"[0-9.]", potential_type):
course_type = potential_type
else:
# 最后一部分不是课程名,使用括号外的内容
course_name = (
course_name_candidate
if course_name_candidate
else "未知课程"
)
# 从整个括号内容提取信息
score_match = re.search(r"([0-9.]+)", paren_content)
if score_match:
score = score_match.group(1)
date_match = re.search(r"(\d{8})", paren_content)
if date_match:
exam_date = date_match.group(1)
elif "," in paren_content:
# 处理标准格式:(任选,87.0(20250119))
type_score_parts = paren_content.split(",", 1)
if len(type_score_parts) == 2:
course_type = type_score_parts[0].strip()
score_info = type_score_parts[1].strip()
# 解析成绩和日期
score_date_match = re.search(
r"([0-9.]+)\((\d{8})\)", score_info
)
if score_date_match:
score = score_date_match.group(1)
exam_date = score_date_match.group(2)
else:
score_match = re.search(r"([0-9.]+)", score_info)
if score_match:
score = score_match.group(1)
# 使用括号外的内容作为课程名
course_name = (
course_name_candidate
if course_name_candidate
else "未知课程"
)
else:
# 括号内只有简单内容
course_name = (
course_name_candidate
if course_name_candidate
else "未知课程"
)
# 尝试从括号内容提取成绩
score_match = re.search(r"([0-9.]+)", paren_content)
if score_match:
score = score_match.group(1)
# 尝试提取日期
date_match = re.search(r"(\d{8})", paren_content)
if date_match:
exam_date = date_match.group(1)
else:
# 没有括号,直接使用剩余文本作为课程名
course_name = remaining_text
# 清理课程名
course_name = re.sub(r"\s+", " ", course_name).strip()
course_name = course_name.strip(",。.")
# 如果课程名为空或太短,尝试从原始名称提取
if not course_name or len(course_name) < 2:
chinese_match = re.search(
r"[\u4e00-\u9fff]+(?:[\u4e00-\u9fff\s]*[\u4e00-\u9fff]+)*",
clean_name,
)
if chinese_match:
course_name = chinese_match.group(0).strip()
else:
course_name = clean_name
else:
# 分类节点
course_name = clean_name
# 清理分类名称中的多余括号,但保留重要信息
# 如果是包含学分信息的分类名,保留学分信息
if not re.search(r"学分", course_name):
# 删除分类名称中的统计信息括号,如 "通识通修(已完成20.0/需要20.0)"
course_name = re.sub(r"\([^)]*完成[^)]*\)", "", course_name).strip()
# 删除其他可能的统计括号
course_name = re.sub(
r"\([^)]*\d+\.\d+/[^)]*\)", "", course_name
).strip()
# 清理多余的空格和空括号
course_name = re.sub(r"\(\s*\)", "", course_name).strip()
course_name = re.sub(r"\s+", " ", course_name).strip()
# 确定层级
level = 0
if flag_type == "002":
level = 1
elif flag_type == "kch":
level = 2
return cls(
flag_id=flag_id,
flag_type=flag_type,
course_code=course_code,
course_name=course_name,
is_passed=is_passed,
status_description=status_description,
credits=credits,
score=score,
exam_date=exam_date,
course_type=course_type,
parent_id=parent_id,
level=level,
)
class PlanCompletionCategory(BaseModel):
"""培养方案分类完成情况"""
category_id: str = Field("", description="分类ID")
category_name: str = Field("", description="分类名称")
min_credits: float = Field(0.0, description="最低修读学分")
completed_credits: float = Field(0.0, description="通过学分")
total_courses: int = Field(0, description="已修课程门数")
passed_courses: int = Field(0, description="已及格课程门数")
failed_courses: int = Field(0, description="未及格课程门数")
missing_required_courses: int = Field(0, description="必修课缺修门数")
subcategories: List["PlanCompletionCategory"] = Field(
default_factory=list, description="子分类"
)
courses: List[PlanCompletionCourse] = Field(
default_factory=list, description="课程列表"
)
@classmethod
def from_ztree_node(cls, node: dict) -> "PlanCompletionCategory":
"""从 zTree 节点创建分类对象"""
name = node.get("name", "")
flag_id = node.get("flagId", "")
# 移除HTML标签获取纯文本
clean_name = re.sub(r"<[^>]*>", "", name)
clean_name = re.sub(r"&nbsp;", " ", clean_name).strip()
# 解析分类统计信息
# 格式:通识通修(最低修读学分:68,通过学分:34.4,已修课程门数:26,已及格课程门数:26,未及格课程门数:0,必修课缺修门数:12)
stats_match = re.search(
r"([^(]+)\(最低修读学分:([0-9.]+),通过学分:([0-9.]+),已修课程门数:(\d+),已及格课程门数:(\d+),未及格课程门数:(\d+),必修课缺修门数:(\d+)\)",
clean_name,
)
if stats_match:
category_name = stats_match.group(1)
min_credits = float(stats_match.group(2))
completed_credits = float(stats_match.group(3))
total_courses = int(stats_match.group(4))
passed_courses = int(stats_match.group(5))
failed_courses = int(stats_match.group(6))
missing_required_courses = int(stats_match.group(7))
else:
# 子分类可能没有完整的统计信息
category_name = clean_name
min_credits = 0.0
completed_credits = 0.0
total_courses = 0
passed_courses = 0
failed_courses = 0
missing_required_courses = 0
return cls(
category_id=flag_id,
category_name=category_name,
min_credits=min_credits,
completed_credits=completed_credits,
total_courses=total_courses,
passed_courses=passed_courses,
failed_courses=failed_courses,
missing_required_courses=missing_required_courses,
)
class PlanCompletionInfo(BaseModel):
"""培养方案完成情况总信息"""
plan_name: str = Field("", description="培养方案名称")
major: str = Field("", description="专业名称")
grade: str = Field("", description="年级")
categories: List[PlanCompletionCategory] = Field(
default_factory=list, description="分类列表"
)
total_categories: int = Field(0, description="总分类数")
total_courses: int = Field(0, description="总课程数")
passed_courses: int = Field(0, description="已通过课程数")
failed_courses: int = Field(0, description="未通过课程数")
unread_courses: int = Field(0, description="未修读课程数")
def calculate_statistics(self):
"""计算统计信息"""
total_courses = 0
passed_courses = 0
failed_courses = 0
unread_courses = 0
def count_courses(categories: List[PlanCompletionCategory]):
nonlocal total_courses, passed_courses, failed_courses, unread_courses
for category in categories:
for course in category.courses:
total_courses += 1
if course.is_passed:
passed_courses += 1
elif course.status_description == "未通过":
failed_courses += 1
else:
unread_courses += 1
# 递归处理子分类
count_courses(category.subcategories)
count_courses(self.categories)
self.total_categories = len(self.categories)
self.total_courses = total_courses
self.passed_courses = passed_courses
self.failed_courses = failed_courses
self.unread_courses = unread_courses

View File

@@ -0,0 +1,49 @@
from typing import List
from pydantic import BaseModel, Field
class TimeSlot(BaseModel):
"""时间段模型"""
session: int = Field(..., description="节次")
session_name: str = Field(..., description="节次名称")
start_time: str = Field(..., description="开始时间格式HHMM")
end_time: str = Field(..., description="结束时间格式HHMM")
time_length: str = Field(..., description="时长(分钟)")
djjc: int = Field(..., description="大节节次")
class CourseTimeLocation(BaseModel):
"""课程时间地点模型"""
class_day: int = Field(..., description="上课星期几1-7")
class_sessions: int = Field(..., description="上课节次")
continuing_session: int = Field(..., description="持续节次数")
class_week: str = Field(..., description="上课周次24位二进制字符串")
week_description: str = Field(..., description="上课周次描述")
campus_name: str = Field(..., description="校区名称")
teaching_building_name: str = Field(..., description="教学楼名称")
classroom_name: str = Field(..., description="教室名称")
class ScheduleCourse(BaseModel):
"""课表课程模型"""
course_name: str = Field(..., description="课程名称")
course_code: str = Field(..., description="课程代码")
course_sequence: str = Field(..., description="课程序号")
teacher_name: str = Field(..., description="授课教师")
course_properties: str = Field(..., description="课程性质")
exam_type: str = Field(..., description="考试类型")
unit: float = Field(..., description="学分")
time_locations: List[CourseTimeLocation] = Field(..., description="时间地点列表")
is_no_schedule: bool = Field(False, description="是否无具体时间安排")
class ScheduleData(BaseModel):
"""课表数据模型"""
total_units: float = Field(..., description="总学分")
time_slots: List[TimeSlot] = Field(..., description="时间段列表")
courses: List[ScheduleCourse] = Field(..., description="课程列表")

View File

@@ -0,0 +1,28 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class ScoreRecord(BaseModel):
"""成绩记录模型"""
sequence: int = Field(0, description="序号")
term_id: str = Field("", description="学期ID")
course_code: str = Field("", description="课程代码")
course_class: str = Field("", description="课程班级")
course_name_cn: str = Field("", description="课程名称(中文)")
course_name_en: str = Field("", description="课程名称(英文)")
credits: str = Field("", description="学分")
hours: int = Field(0, description="学时")
course_type: Optional[str] = Field(None, description="课程性质")
exam_type: Optional[str] = Field(None, description="考试性质")
score: str = Field("", description="成绩")
retake_score: Optional[str] = Field(None, description="重修成绩")
makeup_score: Optional[str] = Field(None, description="补考成绩")
class TermScoreResponse(BaseModel):
"""学期成绩响应模型"""
total_count: int = Field(0, description="总记录数")
records: List[ScoreRecord] = Field(default_factory=list, description="成绩记录列表")

View File

@@ -0,0 +1,20 @@
from pydantic import BaseModel, Field
class TermItem(BaseModel):
"""学期信息项"""
term_code: str = Field(..., description="学期代码")
term_name: str = Field(..., description="学期名称")
is_current: bool = Field(..., description="是否为当前学期")
class CurrentTermInfo(BaseModel):
"""学期周数信息"""
academic_year: str = Field("", description="学年,如 2025-2026")
current_term_name: str = Field("", description="学期,如 秋、春")
week_number: int = Field(0, description="当前周数")
start_at: str = Field("", description="学期开始时间,格式 YYYY-MM-DD")
is_end: bool = Field(False, description="是否为学期结束")
weekday: int = Field(0, description="星期几")

View File

@@ -0,0 +1,262 @@
import re
import ujson
from bs4 import BeautifulSoup
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from httpx import HTTPError
from pydantic import ValidationError
from ujson import JSONDecodeError
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.plan import (
PlanCompletionCategory,
PlanCompletionInfo,
)
from loveace.router.endpoint.jwc.utils.plan import populate_category_children
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
ENDPOINT = {
"plan": "/student/integratedQuery/planCompletion/index",
}
jwc_plan_router = APIRouter(
prefix="/plan",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
@jwc_plan_router.get(
"/current",
summary="获取当前培养方案完成信息",
response_model=UniResponseModel[PlanCompletionInfo],
)
async def get_current_plan_completion(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[PlanCompletionInfo] | JSONResponse:
"""
获取用户的培养方案完成情况
✅ 功能特性:
- 获取培养方案的总体完成进度
- 按类别显示各类课程的完成情况
- 显示已完成、未完成、可选课程等
💡 使用场景:
- 查看毕业要求的完成进度
- 了解还需要修读哪些课程
- 规划后续选课
Returns:
PlanCompletionInfo: 包含方案完成情况和各类别详情
"""
try:
conn.logger.info("获取当前培养方案完成信息")
response = await conn.client.get(
JWCConfig().to_full_url(ENDPOINT["plan"]),
follow_redirects=True,
timeout=600,
)
if response.status_code != 200:
conn.logger.error(f"获取培养方案信息失败,状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
html_content = response.text
# 使用BeautifulSoup解析HTML
soup = BeautifulSoup(html_content, "lxml")
# 提取培养方案名称
plan_name = ""
# 查找包含"培养方案"的h4标签
h4_elements = soup.find_all("h4")
for h4 in h4_elements:
text = h4.get_text(strip=True) if h4 else ""
if "培养方案" in text:
plan_name = text
conn.logger.info(f"找到培养方案标题: {plan_name}")
break
# 解析专业和年级信息
major = ""
grade = ""
if plan_name:
grade_match = re.search(r"(\d{4})级", plan_name)
if grade_match:
grade = grade_match.group(1)
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
if major_match:
major = major_match.group(1)
# 查找zTree数据
ztree_data = []
# 在script标签中查找zTree初始化数据
scripts = soup.find_all("script")
for script in scripts:
try:
script_text = script.get_text() if script else ""
if "$.fn.zTree.init" in script_text and "flagId" in script_text:
conn.logger.info("找到包含zTree初始化的script标签")
# 提取zTree数据
# 尝试多种模式匹配
patterns = [
r'\$\.fn\.zTree\.init\(\$\("#treeDemo"\),\s*setting,\s*(\[.*?\])\s*\);',
r"\.zTree\.init\([^,]+,\s*[^,]+,\s*(\[.*?\])\s*\);",
r'init\(\$\("#treeDemo"\)[^,]*,\s*[^,]*,\s*(\[.*?\])',
]
json_part = None
for pattern in patterns:
match = re.search(pattern, script_text, re.DOTALL)
if match:
json_part = match.group(1)
conn.logger.info(
f"使用模式匹配成功提取zTree数据: {len(json_part)}字符"
)
break
if json_part:
# 清理和修复JSON格式
# 移除JavaScript注释和多余的逗号
json_part = re.sub(r"//.*?\n", "\n", json_part)
json_part = re.sub(r"/\*.*?\*/", "", json_part, flags=re.DOTALL)
json_part = re.sub(r",\s*}", "}", json_part)
json_part = re.sub(r",\s*]", "]", json_part)
try:
ztree_data = ujson.loads(json_part)
conn.logger.info(f"JSON解析成功{len(ztree_data)}个节点")
break
except JSONDecodeError as e:
conn.logger.warning(f"JSON解析失败: {str(e)}")
# 如果JSON解析失败不使用手动解析直接跳过
continue
else:
conn.logger.warning("未能通过模式匹配提取zTree数据")
continue
except Exception:
continue
if not ztree_data:
conn.logger.warning("未找到有效的zTree数据")
# 输出调试信息
conn.logger.info(f"HTML内容长度: {len(html_content)}")
conn.logger.info(f"找到的script标签数量: {len(soup.find_all('script'))}")
# 检查是否包含关键词
contains_ztree = "zTree" in html_content
contains_flagid = "flagId" in html_content
contains_plan = "培养方案" in html_content
conn.logger.info(
f"HTML包含关键词: zTree={contains_ztree}, flagId={contains_flagid}, 培养方案={contains_plan}"
)
conn.logger.warning("未找到有效的zTree数据")
if contains_plan:
conn.logger.warning(
"检测到培养方案内容但zTree数据解析失败可能页面结构已变化"
)
else:
conn.logger.warning(
"未检测到培养方案相关内容,可能需要重新登录或检查访问权限"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id,
message="未找到有效的培养方案数据,请检查登录状态或稍后再试",
)
# 解析zTree数据构建分类和课程信息
try:
# 按层级组织数据
nodes_by_id = {node["id"]: node for node in ztree_data}
root_categories = []
# 统计根分类和所有节点信息,用于调试
all_parent_ids = set()
root_nodes = []
for node in ztree_data:
parent_id = node.get("pId", "")
all_parent_ids.add(parent_id)
# 根分类的判断条件pId为"-1"这是zTree中真正的根节点标识
# 从HTML示例可以看出真正的根分类的pId是"-1"
is_root_category = parent_id == "-1"
if is_root_category:
root_nodes.append(node)
conn.logger.info(
f"zTree数据分析: 总节点数={len(ztree_data)}, 根节点数={len(root_nodes)}, 不同父ID数={len(all_parent_ids)}"
)
conn.logger.debug(f"所有父ID: {sorted(all_parent_ids)}")
# 构建分类树
for node in root_nodes:
category = PlanCompletionCategory.from_ztree_node(node)
# 填充分类的子分类和课程(支持多层嵌套)
try:
populate_category_children(category, node["id"], nodes_by_id, conn)
except Exception as e:
conn.logger.error(f"填充分类子项异常: {str(e)}")
conn.logger.error(
f"异常节点信息: category_id={node['id']}, 错误详情: {str(e)}"
)
root_categories.append(category)
conn.logger.info(
f"创建根分类: {category.category_name} (ID: {node['id']})"
)
# 创建完成情况信息
completion_info = PlanCompletionInfo(
plan_name=plan_name,
major=major,
grade=grade,
categories=root_categories,
total_categories=0,
total_courses=0,
passed_courses=0,
failed_courses=0,
unread_courses=0,
)
# 计算统计信息
completion_info.calculate_statistics()
conn.logger.info(
f"培养方案完成信息统计: 分类数={completion_info.total_categories}, 课程数={completion_info.total_courses}, 已过课程={completion_info.passed_courses}, 未过课程={completion_info.failed_courses}, 未修读课程={completion_info.unread_courses}"
)
return UniResponseModel[PlanCompletionInfo](
success=True,
data=completion_info,
message="获取培养方案完成信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"数据验证错误: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
except HTTPError as he:
conn.logger.error(f"HTTP请求错误: {he}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,246 @@
import asyncio
import re
from bs4 import BeautifulSoup
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.schedule import ScheduleData
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_schedules_router = APIRouter(
prefix="/schedule",
responses=ProtectRouterErrorToCode.gen_code_table(),
)
ENDPOINTS = {
"student_schedule_pre": "/student/courseSelect/calendarSemesterCurriculum/index",
"student_schedule": "/student/courseSelect/thisSemesterCurriculum/{dynamic_path}/ajaxStudentSchedule/past/callback",
"section_and_time": "/ajax/getSectionAndTime",
}
@jwc_schedules_router.get(
"/{term_code}/table",
summary="获取课表信息",
response_model=UniResponseModel[ScheduleData],
)
async def get_schedule_table(
term_code: str, conn: AUFEConnection = Depends(get_aufe_conn)
) -> UniResponseModel[ScheduleData] | JSONResponse:
"""
获取指定学期的课程表
✅ 功能特性:
- 获取指定学期的完整课程表
- 显示课程名称、教室、时间、教师等信息
- 支持按周查询
💡 使用场景:
- 查看本周课程安排
- 了解完整学期课程表
- 课表分享和导出
Args:
term_code: 学期代码2023-2024-1
Returns:
ScheduleData: 包含课程表数据和课程详情
"""
try:
conn.logger.info(f"开始获取学期 {term_code} 的课表信息")
# 第一步:访问课表预备页面,获取动态路径
dynamic_page = JWCConfig().to_full_url(ENDPOINTS["student_schedule_pre"])
dynamic_page_response = await conn.client.get(
dynamic_page, follow_redirects=True, timeout=conn.timeout
)
if dynamic_page_response.status_code != 200:
conn.logger.error(
f"获取课表预备页面失败,状态码: {dynamic_page_response.status_code}"
)
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
conn.logger.trace_id
)
soup = BeautifulSoup(dynamic_page_response.text, "lxml")
# 尝试从页面中提取动态路径
scripts = soup.find_all("script")
dynamic_path = "B2RMNJkT95" # 默认值
for script in scripts:
try:
script_text = script.string # type: ignore
if script_text and "ajaxStudentSchedule" in script_text:
# 使用正则表达式提取路径
match = re.search(
r"/([A-Za-z0-9]+)/ajaxStudentSchedule", script_text
)
if match:
dynamic_path = match.group(1)
break
except AttributeError:
continue
section_and_time_headers = {
**conn.client.headers,
"Referer": JWCConfig().to_full_url(ENDPOINTS["student_schedule"]),
}
select_and_time_url = JWCConfig().to_full_url(ENDPOINTS["section_and_time"])
select_and_time_data = {
"planNumber": "",
"ff": "f",
"sf_request_type": "ajax",
}
section_and_time_response_coro = conn.client.post(
select_and_time_url,
data=select_and_time_data,
headers=section_and_time_headers,
follow_redirects=True,
timeout=conn.timeout,
)
student_schedule_url = JWCConfig().to_full_url(
ENDPOINTS["student_schedule"].format(dynamic_path=dynamic_path)
)
schedule_params = {
"planCode": term_code,
"sf_request_type": "ajax",
}
student_schedule_response_coro = conn.client.get(
student_schedule_url,
params=schedule_params,
follow_redirects=True,
timeout=conn.timeout,
)
section_and_time_response, student_schedule_response = await asyncio.gather(
section_and_time_response_coro, student_schedule_response_coro
)
if section_and_time_response.status_code != 200:
conn.logger.error(
f"获取节次时间信息失败,状态码: {section_and_time_response.status_code}"
)
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
conn.logger.trace_id, message="无法获取节次时间信息,请稍后再试"
)
if student_schedule_response.status_code != 200:
conn.logger.error(
f"获取课表信息失败,状态码: {student_schedule_response.status_code}"
)
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
conn.logger.trace_id, message="无法获取课表信息,请稍后再试"
)
time_data = section_and_time_response.json()
schedule_data = student_schedule_response.json()
# 处理时间段信息
time_slots = []
section_time = time_data.get("sectionTime", [])
for time_slot in section_time:
time_slots.append(
{
"session": time_slot.get("id", {}).get("session", 0),
"session_name": time_slot.get("sessionName", ""),
"start_time": time_slot.get("startTime", ""),
"end_time": time_slot.get("endTime", ""),
"time_length": time_slot.get("timeLength", ""),
"djjc": time_slot.get("djjc", 0),
}
)
# 处理课程信息
courses = []
xkxx_list = schedule_data.get("xkxx", [])
for xkxx_item in xkxx_list:
if isinstance(xkxx_item, dict):
for course_key, course_data in xkxx_item.items():
if isinstance(course_data, dict):
# 提取基本课程信息
course_name = course_data.get("courseName", "")
course_code = course_data.get("id", {}).get("coureNumber", "")
course_sequence = course_data.get("id", {}).get(
"coureSequenceNumber", ""
)
teacher_name = (
course_data.get("attendClassTeacher", "")
.replace("* ", "")
.strip()
)
course_properties = course_data.get("coursePropertiesName", "")
exam_type = course_data.get("examTypeName", "")
unit = float(course_data.get("unit", 0))
# 处理时间地点列表
time_locations = []
time_place_list = course_data.get("timeAndPlaceList", [])
# 检查是否有具体时间安排
is_no_schedule = len(time_place_list) == 0
for time_place in time_place_list:
# 过滤掉无用的字段,只保留关键信息
time_location = {
"class_day": time_place.get("classDay", 0),
"class_sessions": time_place.get("classSessions", 0),
"continuing_session": time_place.get(
"continuingSession", 0
),
"class_week": time_place.get("classWeek", ""),
"week_description": time_place.get(
"weekDescription", ""
),
"campus_name": time_place.get("campusName", ""),
"teaching_building_name": time_place.get(
"teachingBuildingName", ""
),
"classroom_name": time_place.get("classroomName", ""),
}
time_locations.append(time_location)
# 只保留有效的课程(有课程名称的)
if course_name:
course = {
"course_name": course_name,
"course_code": course_code,
"course_sequence": course_sequence,
"teacher_name": teacher_name,
"course_properties": course_properties,
"exam_type": exam_type,
"unit": unit,
"time_locations": time_locations,
"is_no_schedule": is_no_schedule,
}
courses.append(course)
# 构建最终数据
processed_data = {
"total_units": float(schedule_data.get("allUnits", 0)),
"time_slots": time_slots,
"courses": courses,
}
conn.logger.info(
f"成功处理课表数据:共{len(courses)}门课程,{len(time_slots)}个时间段"
)
result = ScheduleData.model_validate(processed_data)
return UniResponseModel[ScheduleData](
success=True,
data=result,
message="获取课表信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"数据验证错误: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "数据验证错误"
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id,
)

View File

@@ -0,0 +1,176 @@
import re
from bs4 import BeautifulSoup
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from httpx import HTTPError
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.score import ScoreRecord, TermScoreResponse
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_score_router = APIRouter(
prefix="/score",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
ENDPOINT = {
"term_score_pre": "/student/integratedQuery/scoreQuery/allTermScores/index",
"term_score": "/student/integratedQuery/scoreQuery/{dynamic_path}/allTermScores/data",
}
@jwc_score_router.get(
"/{term_code}/list",
summary="获取给定学期成绩列表",
response_model=UniResponseModel[TermScoreResponse],
)
async def get_term_score(
term_code: str,
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[TermScoreResponse] | JSONResponse:
"""
获取指定学期的详细成绩单
✅ 功能特性:
- 获取指定学期所有课程成绩
- 包含补考和重修成绩
- 显示学分、绩点等详细信息
💡 使用场景:
- 查看历史学期的成绩
- 导出成绩单
- 分析学业成绩趋势
Args:
term_code: 学期代码2023-2024-1
Returns:
TermScoreResponse: 包含该学期所有成绩记录和总数
"""
try:
response = await conn.client.get(
JWCConfig().to_full_url(ENDPOINT["term_score_pre"]),
follow_redirects=True,
timeout=conn.timeout,
)
if response.status_code != 200:
conn.logger.error(f"访问成绩查询页面失败,状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 从页面中提取动态路径参数
soup = BeautifulSoup(response.text, "html.parser")
# 查找表单或Ajax请求的URL
# 通常在JavaScript代码中或表单action中
dynamic_path = "M1uwxk14o6" # 默认值,如果无法提取则使用
# 尝试从页面中提取动态路径
scripts = soup.find_all("script")
for script in scripts:
try:
script_text = script.string # type: ignore
if script_text and "allTermScores/data" in script_text:
# 使用正则表达式提取路径
match = re.search(
r"/([A-Za-z0-9]+)/allTermScores/data", script_text
)
if match:
dynamic_path = match.group(1)
break
except AttributeError:
continue
data_url = JWCConfig().to_full_url(
ENDPOINT["term_score"].format(dynamic_path=dynamic_path)
)
data_params = {
"zxjxjhh": term_code,
"kch": "",
"kcm": "",
"pageNum": "1",
"pageSize": "50",
"sf_request_type": "ajax",
}
data_response = await conn.client.post(
data_url,
data=data_params,
follow_redirects=True,
timeout=conn.timeout,
)
if data_response.status_code != 200:
conn.logger.error(f"获取成绩数据失败,状态码: {data_response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
data_json = data_response.json()
data_list = data_json.get("list", {})
if not data_list:
result = TermScoreResponse(records=[], total_count=0)
return UniResponseModel[TermScoreResponse](
success=True,
data=result,
message="获取成绩单成功",
error=None,
)
records = data_list.get("records", [])
r_total_count = data_list.get("pageContext", {}).get("totalCount", 0)
term_scores = []
for record in records:
term_scores.append(
ScoreRecord(
sequence=record[0],
term_id=record[1],
course_code=record[2],
course_class=record[3],
course_name_cn=record[4],
course_name_en=record[5],
credits=record[6],
hours=record[7],
course_type=record[8],
exam_type=record[9],
score=record[10],
retake_score=record[11] if record[11] else None,
makeup_score=record[12] if record[12] else None,
)
)
l_total_count = len(term_scores)
assert r_total_count == l_total_count
result = TermScoreResponse(records=term_scores, total_count=r_total_count)
return UniResponseModel[TermScoreResponse](
success=True,
data=result,
message="获取成绩单成功",
error=None,
)
except AssertionError as ae:
conn.logger.error(f"数据属性错误: {ae}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except IndexError as ie:
conn.logger.error(f"数据解析错误: {ie}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except ValidationError as ve:
conn.logger.error(f"数据验证错误: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except HTTPError as he:
conn.logger.error(f"HTTP请求错误: {he}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,306 @@
import re
from datetime import datetime
from bs4 import BeautifulSoup
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from httpx import HTTPError
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.term import CurrentTermInfo, TermItem
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_term_router = APIRouter(
prefix="/term",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
ENDPOINT = {
"all_terms": "/student/courseSelect/calendarSemesterCurriculum/index",
"calendar": "/indexCalendar",
}
@jwc_term_router.get(
"/all",
summary="获取所有学期信息",
response_model=UniResponseModel[list[TermItem]],
)
async def get_all_terms(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[list[TermItem]] | JSONResponse:
"""
获取用户可选的所有学期列表
✅ 功能特性:
- 获取从入学至今的所有学期
- 标记当前学期
- 学期名称格式统一处理
💡 使用场景:
- 选课系统的学期选择菜单
- 成绩查询的学期选择
- 课程表查询的学期选择
Returns:
list[TermItem]: 学期列表,包含学期代码、名称、是否为当前学期
"""
try:
all_terms = []
response = await conn.client.get(
JWCConfig().to_full_url(ENDPOINT["all_terms"]),
follow_redirects=True,
timeout=conn.timeout,
)
if response.status_code != 200:
conn.logger.error(f"获取学期信息失败,状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 解析HTML获取学期选项
soup = BeautifulSoup(response.text, "lxml")
# 查找学期选择下拉框
select_element = soup.find("select", {"id": "planCode"})
if not select_element:
conn.logger.error("未找到学期选择框")
return UniResponseModel[list[TermItem]](
success=False,
data=[],
message="未找到学期选择框",
error=None,
)
terms = {}
# 使用更安全的方式处理选项
try:
options = select_element.find_all("option") # type: ignore
for option in options:
value = option.get("value") # type: ignore
text = option.get_text(strip=True) # type: ignore
# 跳过空值选项(如"全部"
if value and str(value).strip() and text != "全部":
terms[str(value)] = text
except AttributeError:
conn.logger.error("解析学期选项失败")
return UniResponseModel[list[TermItem]](
success=False,
data=[],
message="解析学期选项失败",
error=None,
)
conn.logger.info(f"成功获取{len(terms)}个学期信息")
counter = 0
# 遍历学期选项,提取学期代码和名称
# 将学期中的 "春" 替换为 "下" "秋" 替换为 "上"
for key, value in terms.items():
counter += 1
value = value.replace("", "").replace("", "")
all_terms.append(
TermItem(term_code=key, term_name=value, is_current=counter == 1)
)
return UniResponseModel[list[TermItem]](
success=True,
data=all_terms,
message="获取学期信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"数据验证错误: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except HTTPError as he:
conn.logger.error(f"HTTP请求错误: {he}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
@jwc_term_router.get(
"/current",
summary="获取当前学期信息",
response_model=UniResponseModel[CurrentTermInfo],
)
async def get_current_term(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[CurrentTermInfo] | JSONResponse:
"""
获取当前学期的详细信息
✅ 功能特性:
- 获取当前学期的开始和结束日期
- 获取学期周数信息
- 实时从教务系统获取
💡 使用场景:
- 显示当前学期进度
- 课程表的周次显示参考
- 学期时间提醒
Returns:
CurrentTermInfo: 包含学期代码、名称、开始日期、结束日期等
"""
try:
info_response = await conn.client.get(
JWCConfig().DEFAULT_BASE_URL, follow_redirects=True, timeout=conn.timeout
)
if info_response.status_code != 200:
conn.logger.error(
f"获取学期信息页面失败,状态码: {info_response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
start_response = await conn.client.get(
JWCConfig().to_full_url(ENDPOINT["calendar"]),
follow_redirects=True,
timeout=conn.timeout,
)
if start_response.status_code != 200:
conn.logger.error(
f"获取学期开始时间失败,状态码: {start_response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 提取学期开始时间
flexible_pattern = r'var\s+rq\s*=\s*"(\d{8})";\s*//.*'
match = re.findall(flexible_pattern, start_response.text)
if not match:
conn.logger.error("未找到学期开始时间")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
start_date_str = match[0]
try:
start_date = datetime.strptime(start_date_str, "%Y%m%d").date()
except ValueError:
conn.logger.error(f"学期开始时间格式错误: {start_date_str}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
start_date = datetime.strptime(start_date_str, "%Y%m%d").date()
html_content = info_response.text
# 使用BeautifulSoup解析HTML
soup = BeautifulSoup(html_content, "html.parser")
# 查找包含学期周数信息的元素
# 使用CSS选择器查找
calendar_element = soup.select_one(
"#navbar-container > div.navbar-buttons.navbar-header.pull-right > ul > li.light-red > a"
)
if not calendar_element:
# 如果CSS选择器失败尝试其他方法
# 查找包含"第X周"的元素
potential_elements = soup.find_all("a", class_="dropdown-toggle")
calendar_element = None
for element in potential_elements:
text = element.get_text(strip=True) if element else ""
if "" in text and "" in text:
calendar_element = element
break
# 如果还是找不到,尝试查找任何包含学期信息的元素
if not calendar_element:
all_elements = soup.find_all(text=re.compile(r"\d{4}-\d{4}.*第\d+周"))
if all_elements:
# 找到包含学期信息的文本,查找其父元素
for text_node in all_elements:
parent = text_node.parent
if parent:
calendar_element = parent
break
if not calendar_element:
conn.logger.warning("未找到学期周数信息元素")
# 尝试在整个页面中搜索学期信息模式
semester_pattern = re.search(
r"(\d{4}-\d{4})\s*(春|秋|夏)?\s*第(\d+)周\s*(星期[一二三四五六日天])?",
html_content,
)
if semester_pattern:
calendar_text = semester_pattern.group(0)
conn.logger.info(f"通过正则表达式找到学期信息: {calendar_text}")
else:
conn.logger.debug(f"HTML内容长度: {len(html_content)}")
conn.logger.debug(
"未检测到学期周数相关内容,可能需要重新登录或检查访问权限"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
else:
# 提取文本内容
calendar_text = calendar_element.get_text(strip=True)
conn.logger.info(f"找到学期周数信息: {calendar_text}")
clean_text = re.sub(r"\s+", " ", calendar_text.strip())
# 初始化默认值
academic_year = ""
term = ""
week_number = 0
is_end = False
try:
# 解析学年2025-2026
year_match = re.search(r"(\d{4}-\d{4})", clean_text)
if year_match:
academic_year = year_match.group(1)
# 解析学期:秋、春
semester_match = re.search(r"(春|秋)", clean_text)
if semester_match:
term = semester_match.group(1)
# 解析周数第1周、第15周等
week_match = re.search(r"第(\d+)周", clean_text)
if week_match:
week_number = int(week_match.group(1))
# 判断是否为学期结束通常第16周以后或包含"结束"等关键词)
if week_number >= 16 or "结束" in clean_text or "考试" in clean_text:
is_end = True
except Exception as e:
conn.logger.warning(f"解析学期周数信息时出错: {str(e)}")
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
result = CurrentTermInfo(
academic_year=academic_year,
current_term_name=term,
week_number=week_number,
start_at=start_date.strftime("%Y-%m-%d"),
is_end=is_end,
weekday=datetime.now().weekday(),
)
return UniResponseModel[CurrentTermInfo](
success=True,
data=result,
message="获取当前学期信息成功",
error=None,
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,96 @@
"""
ASP.NET 表单解析器
用于从 ASP.NET 页面中提取动态表单数据
"""
import re
from typing import Dict, Optional, Any
from bs4 import BeautifulSoup
class ASPNETFormParser:
"""ASP.NET 表单解析器"""
@staticmethod
def extract_form_data(html_content: str) -> Dict[str, str]:
"""
从 ASP.NET 页面 HTML 中提取表单数据
Args:
html_content: HTML 页面内容
Returns:
包含表单字段的字典
"""
return ASPNETFormParser._extract_with_beautifulsoup(html_content)
@staticmethod
def _extract_with_beautifulsoup(html_content: str) -> Dict[str, str]:
"""
使用 BeautifulSoup 提取表单数据
Args:
html_content: HTML 页面内容
Returns:
包含表单字段的字典
"""
form_data = {}
# 使用 BeautifulSoup 解析 HTML
soup = BeautifulSoup(html_content, "lxml")
# 查找表单
form = soup.find("form", {"method": "post"})
if not form:
raise ValueError("未找到 POST 表单")
# 提取隐藏字段
hidden_fields = [
"__EVENTTARGET",
"__EVENTARGUMENT",
"__LASTFOCUS",
"__VIEWSTATE",
"__VIEWSTATEGENERATOR",
"__EVENTVALIDATION",
]
for field_name in hidden_fields:
input_element = form.find("input", {"name": field_name})
if input_element and input_element.get("value"):
form_data[field_name] = input_element.get("value")
else:
form_data[field_name] = ""
# 添加其他表单字段的默认值
form_data.update(
{
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$ddlSslb": "%",
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$txtSsmc": "",
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$gvSb$ctl28$txtNewPageIndex": "1",
}
)
return form_data
@staticmethod
def get_awards_list_form_data(html_content: str) -> Dict[str, str]:
"""
获取已申报奖项列表页面的表单数据
Args:
html_content: HTML 页面内容
Returns:
用于请求已申报奖项的表单数据
"""
base_form_data = ASPNETFormParser.extract_form_data(html_content)
# 设置 EVENTTARGET 为"已申报奖项"选项卡
base_form_data["__EVENTTARGET"] = (
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$DataList1$ctl01$LinkButton1"
)
return base_form_data

View File

@@ -0,0 +1,267 @@
from typing import Optional
from bs4 import BeautifulSoup
from loveace.router.endpoint.jwc.model.competition import (
AwardProject,
CompetitionAwardsResponse,
CompetitionCreditsSummaryResponse,
CompetitionFullResponse,
CreditsSummary,
)
class CompetitionInfoParser:
"""
创新创业管理平台信息解析器
功能:
- 解析获奖项目列表(表格数据)
- 解析学分汇总信息
- 提取学生基本信息
"""
def __init__(self, html_content: str):
"""
初始化解析器
参数:
html_content: HTML页面内容字符串
"""
self.soup = BeautifulSoup(html_content, "html.parser")
def parse_awards(self) -> CompetitionAwardsResponse:
"""
解析获奖项目列表
返回:
CompetitionAwardsResponse: 包含获奖项目列表的响应对象
"""
# 解析学生ID
student_id = self._parse_student_id()
# 解析项目列表
projects = self._parse_projects()
response = CompetitionAwardsResponse(
student_id=student_id,
total_count=len(projects),
awards=projects,
)
return response
def parse_credits_summary(self) -> CompetitionCreditsSummaryResponse:
"""
解析学分汇总信息
返回:
CompetitionCreditsSummaryResponse: 包含学分汇总信息的响应对象
"""
# 解析学生ID
student_id = self._parse_student_id()
# 解析学分汇总
credits_summary = self._parse_credits_summary()
response = CompetitionCreditsSummaryResponse(
student_id=student_id,
credits_summary=credits_summary,
)
return response
def parse_full_competition_info(self) -> CompetitionFullResponse:
"""
解析完整的学科竞赛信息(获奖项目 + 学分汇总)
一次性解析HTML同时提取获奖项目列表和学分汇总信息
减少网络IO和数据库查询次数
返回:
CompetitionFullResponse: 包含完整竞赛信息的响应对象
"""
# 解析学生ID
student_id = self._parse_student_id()
# 解析项目列表
projects = self._parse_projects()
# 解析学分汇总
credits_summary = self._parse_credits_summary()
response = CompetitionFullResponse(
student_id=student_id,
total_awards_count=len(projects),
awards=projects,
credits_summary=credits_summary,
)
return response
def _parse_student_id(self) -> str:
"""
解析学生基本信息 - 学生ID/工号
返回:
str: 学生ID如果未找到返回空字符串
"""
student_span = self.soup.find("span", id="ContentPlaceHolder1_lblXM")
if student_span:
text = student_span.get_text(strip=True)
# 格式: "欢迎您20244787"
if "" in text:
return text.split("")[1].strip()
return ""
def _parse_projects(self) -> list:
"""
解析获奖项目列表
数据来源: 页面中ID为 ContentPlaceHolder1_ContentPlaceHolder2_gvHj 的表格
表格结构:
- 第一行为表头
- 后续行为项目数据
- 包含15列数据
返回:
list[AwardProject]: 获奖项目列表
"""
projects = []
# 查找项目列表表格
table = self.soup.find(
"table", id="ContentPlaceHolder1_ContentPlaceHolder2_gvHj"
)
if not table:
return projects
rows = table.find_all("tr")
# 跳过表头行(第一行)
for row in rows[1:]:
cells = row.find_all("td")
if len(cells) < 9: # 至少需要9列数据
continue
try:
project = AwardProject(
project_id=cells[0].get_text(strip=True),
project_name=cells[1].get_text(strip=True),
level=cells[2].get_text(strip=True),
grade=cells[3].get_text(strip=True),
award_date=cells[4].get_text(strip=True),
applicant_id=cells[5].get_text(strip=True),
applicant_name=cells[6].get_text(strip=True),
order=int(cells[7].get_text(strip=True)),
credits=float(cells[8].get_text(strip=True)),
bonus=float(cells[9].get_text(strip=True)),
status=cells[10].get_text(strip=True),
verification_status=cells[11].get_text(strip=True),
)
projects.append(project)
except (ValueError, IndexError):
# 数据格式异常,记录但继续处理
continue
return projects
def _parse_credits_summary(self) -> Optional[CreditsSummary]:
"""
解析学分汇总信息
数据来源: 页面中的学分汇总表中的各类学分 span 元素
提取内容:
- 学科竞赛学分
- 科研项目学分
- 可转竞赛类学分
- 创新创业实践学分
- 能力资格认证学分
- 其他项目学分
返回:
CreditsSummary: 学分汇总对象,如果无法解析则返回 None
"""
discipline_competition_credits = None
scientific_research_credits = None
transferable_competition_credits = None
innovation_practice_credits = None
ability_certification_credits = None
other_project_credits = None
# 查找学科竞赛学分
xkjs_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblXkjsxf"
)
if xkjs_span:
text = xkjs_span.get_text(strip=True)
discipline_competition_credits = self._parse_credit_value(text)
# 查找科研项目学分
ky_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblKyxf"
)
if ky_span:
text = ky_span.get_text(strip=True)
scientific_research_credits = self._parse_credit_value(text)
# 查找可转竞赛类学分
kzjsl_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblKzjslxf"
)
if kzjsl_span:
text = kzjsl_span.get_text(strip=True)
transferable_competition_credits = self._parse_credit_value(text)
# 查找创新创业实践学分
cxcy_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblCxcyxf"
)
if cxcy_span:
text = cxcy_span.get_text(strip=True)
innovation_practice_credits = self._parse_credit_value(text)
# 查找能力资格认证学分
nlzg_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblNlzgxf"
)
if nlzg_span:
text = nlzg_span.get_text(strip=True)
ability_certification_credits = self._parse_credit_value(text)
# 查找其他项目学分
qt_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblQtxf"
)
if qt_span:
text = qt_span.get_text(strip=True)
other_project_credits = self._parse_credit_value(text)
return CreditsSummary(
discipline_competition_credits=discipline_competition_credits,
scientific_research_credits=scientific_research_credits,
transferable_competition_credits=transferable_competition_credits,
innovation_practice_credits=innovation_practice_credits,
ability_certification_credits=ability_certification_credits,
other_project_credits=other_project_credits,
)
@staticmethod
def _parse_credit_value(text: str) -> Optional[float]:
"""
解析学分值
参数:
text: 文本值,可能为"0", "16.60", ""
返回:
float: 学分值,如果为""或无法解析则返回 None
"""
text = text.strip()
if text == "" or text == "":
return None
try:
return float(text)
except ValueError:
return None

View File

@@ -0,0 +1,337 @@
import time
from json import JSONDecodeError
from typing import List, Optional
from bs4 import BeautifulSoup
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.exam import (
ExamInfoResponse,
ExamScheduleItem,
OtherExamRecord,
OtherExamResponse,
SeatInfo,
UnifiedExamInfo,
)
from loveace.service.remote.aufe import AUFEConnection
ENDPOINTS = {
"school_exam_pre_request": "/student/examinationManagement/examPlan/index",
"school_exam_request": "/student/examinationManagement/examPlan/detail",
"seat_info": "/student/examinationManagement/examPlan/index",
"other_exam_record": "/student/examinationManagement/othersExamPlan/queryScores?sf_request_type=ajax",
}
# +++++===== 考试信息前置方法 =====+++++ #
async def fetch_school_exam_schedule(
start_date: str, end_date: str, conn: AUFEConnection
) -> List[ExamScheduleItem]:
"""
获取校统考考试安排
Args:
start_date: 开始日期 (YYYY-MM-DD)
end_date: 结束日期 (YYYY-MM-DD)
Returns:
List[ExamScheduleItem]: 校统考列表
"""
try:
timestamp = int(time.time() * 1000)
headers = {
**conn.client.headers,
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
}
params = {
"start": start_date,
"end": end_date,
"_": str(timestamp),
"sf_request_type": "ajax",
}
await conn.client.get(
url=JWCConfig().to_full_url(ENDPOINTS["school_exam_pre_request"]),
follow_redirects=True,
headers=headers,
timeout=conn.timeout,
)
response = await conn.client.get(
url=JWCConfig().to_full_url(ENDPOINTS["school_exam_request"]),
headers=headers,
params=params,
follow_redirects=True,
timeout=conn.timeout,
)
if response.status_code != 200:
conn.logger.error(f"获取校统考信息失败: HTTP状态码 {response.status_code}")
return []
if "]" == response.text:
conn.logger.warning("获取校统考信息成功,但无数据")
return []
try:
json_data = response.json()
except JSONDecodeError as e:
conn.logger.error(f"解析校统考信息JSON失败: {str(e)}")
return []
# 解析为ExamScheduleItem列表
school_exams = []
if isinstance(json_data, list):
for item in json_data:
exam_item = ExamScheduleItem.model_validate(item)
school_exams.append(exam_item)
conn.logger.info(f"获取校统考信息成功,共 {len(school_exams)} 场考试")
return school_exams
except Exception as e:
conn.logger.error(f"获取校统考信息出现如下异常: {str(e)}")
return []
async def fetch_exam_seat_info(conn: AUFEConnection) -> List[SeatInfo]:
"""
获取考试座位号信息
conn: AUFEConnection
Returns:
List[SeatInfo]: 座位信息列表
"""
try:
headers = {
**conn.client.headers,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
}
response = await conn.client.get(
url=JWCConfig().to_full_url(ENDPOINTS["seat_info"]),
headers=headers,
follow_redirects=True,
timeout=conn.timeout,
)
if response.status_code != 200:
conn.logger.error(
f"获取考试座位号信息失败: HTTP状态码 {response.status_code}"
)
return []
soup = BeautifulSoup(response.text, "lxml")
seat_infos = []
# 查找所有考试信息区块
exam_blocks = soup.find_all("div", {"class": "widget-box"})
for block in exam_blocks:
course_name = ""
seat_number = ""
# 获取课程名
title = block.find("h5", {"class": "widget-title"}) # type: ignore
if title:
course_text = title.get_text(strip=True) # type: ignore
# 提取课程名,格式可能是: "(课程代码-班号)课程名"
if "" in course_text:
course_name = course_text.split("", 1)[1].strip()
else:
course_name = course_text.strip()
# 获取座位号
widget_main = block.find("div", {"class": "widget-main"}) # type: ignore
if widget_main:
content = widget_main.get_text() # type: ignore
for line in content.split("\n"):
if "座位号" in line:
try:
seat_number = line.split("座位号:")[1].strip()
except Exception:
try:
seat_number = line.split("座位号:")[1].strip()
except Exception:
pass
break
if course_name and seat_number:
seat_infos.append(
SeatInfo(course_name=course_name, seat_number=seat_number)
)
conn.logger.info(f"获取考试座位号信息成功,共 {len(seat_infos)} 条记录")
return seat_infos
except Exception as e:
conn.logger.error(f"获取考试座位号信息异常: {str(e)}")
return []
def convert_school_exam_to_unified(
exam: ExamScheduleItem, seat_infos: List[SeatInfo], conn: AUFEConnection
) -> Optional[UnifiedExamInfo]:
"""
将校统考数据转换为统一格式
Args:
exam: 校统考项目
seat_info: 座位号信息映射
Returns:
Optional[UnifiedExamInfo]: 统一格式的考试信息
"""
try:
# 解析title信息格式如: "新媒体导论\n08:30-10:30\n西校\n西校通慧楼\n通慧楼-308\n"
title_parts = exam.title.strip().split("\n")
if len(title_parts) < 2:
return None
course_name = title_parts[0]
exam_time = title_parts[1] if len(title_parts) > 1 else ""
# 拼接地点信息
location_parts = title_parts[2:] if len(title_parts) > 2 else []
exam_location = " ".join([part for part in location_parts if part.strip()])
# 添加座位号到备注
note = ""
for seat in seat_infos:
if seat.course_name == course_name:
note = f"座位号: {seat.seat_number}"
note = note.removesuffix("准考证号:")
break
return UnifiedExamInfo(
course_name=course_name,
exam_date=exam.start,
exam_time=exam_time,
exam_location=exam_location,
exam_type="校统考",
note=note,
)
except Exception as e:
conn.logger.error(f"转换校统考数据异常: {str(e)}")
return None
async def fetch_other_exam_records(
term_code: str, conn: AUFEConnection
) -> List[OtherExamRecord]:
"""
获取其他考试记录
Args:
term_code: 学期代码
conn: AUFEConnection
Returns:
List: 其他考试记录列表
"""
try:
headers = {
**conn.client.headers,
"Accept": "application/json, text/javascript, */*; q=0.01",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
}
data = {"zxjxjhh": term_code, "tab": "0", "pageNum": "1", "pageSize": "30"}
response = await conn.client.post(
url=JWCConfig().to_full_url(ENDPOINTS["other_exam_record"]),
headers=headers,
data=data,
follow_redirects=True,
timeout=conn.timeout,
)
valid = OtherExamResponse.model_validate_json(response.text)
if valid.records:
conn.logger.info(f"获取其他考试信息成功,共 {len(valid.records)} 条记录")
return valid.records
else:
conn.logger.warning("获取其他考试信息成功,但无记录")
return []
except Exception as e:
conn.logger.error(f"获取其他考试信息出现如下异常: {str(e)}")
return []
def convert_other_exam_to_unified(
record: OtherExamRecord, conn: AUFEConnection
) -> Optional[UnifiedExamInfo]:
"""
将其他考试记录转换为统一格式
Args:
record: 其他考试记录
Returns:
Optional[UnifiedExamInfo]: 统一格式的考试信息
"""
try:
return UnifiedExamInfo(
course_name=record.course_name,
exam_date=record.exam_date,
exam_time=record.exam_time,
exam_location=record.exam_location,
exam_type="其他考试",
note=record.note,
)
except Exception as e:
conn.logger.error(f"转换其他考试数据异常: {str(e)}")
return None
async def fetch_unified_exam_info(
conn: AUFEConnection,
start_date: str,
end_date: str,
term_code: str = "2024-2025-2-1",
) -> ExamInfoResponse:
"""
获取统一的考试信息,包括校统考和其他考试
Args:
start_date: 开始日期 (YYYY-MM-DD)
end_date: 结束日期 (YYYY-MM-DD)
term_code: 学期代码,默认为当前学期
Returns:
ExamInfoResponse: 统一的考试信息响应
"""
try:
# 合并并转换为统一格式
unified_exams = []
# 获取校统考信息
if school_exams := await fetch_school_exam_schedule(start_date, end_date, conn):
# 获取座位号信息
seat_info = await fetch_exam_seat_info(conn)
# 处理校统考数据
for exam in school_exams:
unified_exam = convert_school_exam_to_unified(exam, seat_info, conn)
if unified_exam:
unified_exams.append(unified_exam)
# 获取其他考试信息
other_exams = await fetch_other_exam_records(term_code, conn)
# 处理其他考试数据
for record in other_exams:
unified_exam = convert_other_exam_to_unified(record, conn)
if unified_exam:
unified_exams.append(unified_exam)
# 按考试日期排序
def _sort_key(exam: UnifiedExamInfo) -> str:
return exam.exam_date + " " + exam.exam_time
unified_exams.sort(key=_sort_key)
return ExamInfoResponse(
exams=unified_exams,
total_count=len(unified_exams),
)
except Exception:
raise

View File

@@ -0,0 +1,67 @@
from loveace.router.endpoint.jwc.model.plan import (
PlanCompletionCategory,
PlanCompletionCourse,
)
from loveace.service.remote.aufe import AUFEConnection
def populate_category_children(
category: PlanCompletionCategory,
category_id: str,
nodes_by_id: dict,
conn: AUFEConnection,
):
"""填充分类的子分类和课程(支持多层嵌套)"""
try:
children_count = 0
subcategory_count = 0
course_count = 0
for node in nodes_by_id.values():
if node.get("pId") == category_id:
children_count += 1
flag_type = node.get("flagType", "")
if flag_type in ["001", "002"]: # 分类或子分类
subcategory = PlanCompletionCategory.from_ztree_node(node)
# 递归处理子项,支持多层嵌套
populate_category_children(
subcategory, node["id"], nodes_by_id, conn
)
category.subcategories.append(subcategory)
subcategory_count += 1
elif flag_type == "kch": # 课程
course = PlanCompletionCourse.from_ztree_node(node)
category.courses.append(course)
course_count += 1
else:
# 处理其他类型的节点,也可能是分类
# 根据是否有子节点来判断是分类还是课程
has_children = any(
n.get("pId") == node["id"] for n in nodes_by_id.values()
)
if has_children:
# 有子节点,当作分类处理
subcategory = PlanCompletionCategory.from_ztree_node(node)
populate_category_children(
subcategory, node["id"], nodes_by_id, conn
)
category.subcategories.append(subcategory)
subcategory_count += 1
else:
# 无子节点,当作课程处理
course = PlanCompletionCourse.from_ztree_node(node)
category.courses.append(course)
course_count += 1
if children_count > 0:
conn.logger.info(
f"分类 '{category.category_name}' (ID: {category_id}) 的子项: 总数={children_count}, 子分类={subcategory_count}, 课程={course_count}"
)
except Exception as e:
conn.logger.error(f"填充分类子项异常: {str(e)}")
conn.logger.error(
f"异常节点信息: category_id={category_id}, 错误详情: {str(e)}"
)
raise

View File

@@ -0,0 +1,27 @@
def convert_zxjxjhh_to_term_format(zxjxjhh: str) -> str:
"""
转换学期格式
xxxx-yyyy-1-1 -> xxxx-yyyy秋季学期
xxxx-yyyy-2-1 -> xxxx-yyyy春季学期
Args:
zxjxjhh: 学期代码,如 "2025-2026-1-1"
Returns:
str: 转换后的学期名称,如 "2025-2026秋季学期"
"""
try:
parts = zxjxjhh.split("-")
if len(parts) >= 3:
year_start = parts[0]
year_end = parts[1]
semester_num = parts[2]
if semester_num == "1":
return f"{year_start}-{year_end}秋季学期"
elif semester_num == "2":
return f"{year_start}-{year_end}春季学期"
return zxjxjhh # 如果格式不匹配,返回原值
except Exception:
return zxjxjhh