📚新增 培养方案 查询模块
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
import json
|
||||
import asyncio
|
||||
from typing import List, Optional, Dict
|
||||
from loguru import logger
|
||||
@@ -20,6 +21,16 @@ from provider.aufe.jwc.model import (
|
||||
ErrorAcademicInfo,
|
||||
ErrorTrainingPlanInfo,
|
||||
)
|
||||
from provider.aufe.jwc.plan_completion_model import (
|
||||
PlanCompletionInfo,
|
||||
PlanCompletionCategory,
|
||||
PlanCompletionCourse,
|
||||
ErrorPlanCompletionInfo,
|
||||
)
|
||||
from provider.aufe.jwc.semester_week_model import (
|
||||
SemesterWeekInfo,
|
||||
ErrorSemesterWeekInfo,
|
||||
)
|
||||
from provider.aufe.client import (
|
||||
AUFEConnection,
|
||||
aufe_config_global,
|
||||
@@ -40,6 +51,7 @@ class JWCConfig:
|
||||
ENDPOINTS = {
|
||||
"academic_info": "/student/integratedQuery/scoreQuery/index",
|
||||
"training_plan": "/student/integratedQuery/planCompletion/index",
|
||||
"plan_completion": "/student/integratedQuery/planCompletion/index",
|
||||
"course_selection_status": "/main/checkSelectCourseStatus?sf_request_type=ajax",
|
||||
"evaluation_token": "/student/teachingEvaluation/evaluation/index",
|
||||
"course_list": "/student/teachingEvaluation/teachingEvaluation/search?sf_request_type=ajax",
|
||||
@@ -904,7 +916,7 @@ class JWCClient:
|
||||
"""
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/student/integratedQuery/scoreQuery/allTermScores/index"
|
||||
url = f"{self.base_url}/student/courseSelect/calendarSemesterCurriculum/index"
|
||||
|
||||
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",
|
||||
@@ -928,7 +940,7 @@ class JWCClient:
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# 查找学期选择下拉框
|
||||
select_element = soup.find("select", {"id": "zxjxjhh"})
|
||||
select_element = soup.find("select", {"id": "planCode"})
|
||||
if not select_element:
|
||||
logger.error("未找到学期选择框")
|
||||
return {}
|
||||
@@ -1386,3 +1398,379 @@ class JWCClient:
|
||||
except Exception as e:
|
||||
logger.error(f"获取处理后的课表数据异常: {str(e)}")
|
||||
return None
|
||||
|
||||
# ==================== 培养方案完成情况相关方法 ====================
|
||||
|
||||
@activity_tracker
|
||||
@retry_async()
|
||||
async def fetch_plan_completion_info(self) -> PlanCompletionInfo:
|
||||
"""
|
||||
获取培养方案完成情况信息,使用重试机制
|
||||
|
||||
Returns:
|
||||
PlanCompletionInfo: 培养方案完成情况信息,失败时返回错误模型
|
||||
"""
|
||||
def _create_error_completion_info(error_msg: str = "请求失败,请稍后重试") -> ErrorPlanCompletionInfo:
|
||||
"""创建错误培养方案完成情况信息"""
|
||||
return ErrorPlanCompletionInfo(
|
||||
plan_name=error_msg,
|
||||
major="请求失败",
|
||||
grade="",
|
||||
total_categories=-1,
|
||||
total_courses=-1,
|
||||
passed_courses=-1,
|
||||
failed_courses=-1,
|
||||
unread_courses=-1
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("开始获取培养方案完成情况信息")
|
||||
|
||||
headers = self._get_default_headers()
|
||||
|
||||
# 请求培养方案完成情况页面
|
||||
response = await self.vpn_connection.requester().get(
|
||||
f"{self.base_url}/student/integratedQuery/planCompletion/index",
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise AUFEConnectionError(f"获取培养方案完成情况页面失败,状态码: {response.status_code}")
|
||||
|
||||
html_content = response.text
|
||||
|
||||
# 使用BeautifulSoup解析HTML
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
# 提取培养方案名称
|
||||
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
|
||||
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:
|
||||
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)
|
||||
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 = json.loads(json_part)
|
||||
logger.info(f"JSON解析成功,共{len(ztree_data)}个节点")
|
||||
break
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"JSON解析失败: {str(e)}")
|
||||
# 如果JSON解析失败,不使用手动解析,直接跳过
|
||||
continue
|
||||
else:
|
||||
logger.warning("未能通过模式匹配提取zTree数据")
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not ztree_data:
|
||||
logger.warning("未找到有效的zTree数据")
|
||||
|
||||
# 输出调试信息
|
||||
logger.debug(f"HTML内容长度: {len(html_content)}")
|
||||
logger.debug(f"找到的script标签数量: {len(soup.find_all('script'))}")
|
||||
|
||||
# 检查是否包含关键词
|
||||
contains_ztree = "zTree" in html_content
|
||||
contains_flagid = "flagId" in html_content
|
||||
contains_plan = "培养方案" in html_content
|
||||
|
||||
logger.debug(f"HTML包含关键词: zTree={contains_ztree}, flagId={contains_flagid}, 培养方案={contains_plan}")
|
||||
|
||||
if contains_plan:
|
||||
logger.warning("检测到培养方案内容,但zTree数据解析失败,可能页面结构已变化")
|
||||
else:
|
||||
logger.warning("未检测到培养方案相关内容,可能需要重新登录或检查访问权限")
|
||||
|
||||
return _create_error_completion_info("未找到培养方案数据,请检查登录状态或访问权限")
|
||||
|
||||
# 解析zTree数据构建分类和课程信息
|
||||
completion_info = self._build_completion_info_from_ztree(
|
||||
ztree_data, plan_name, major, grade
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"培养方案完成情况获取成功: {completion_info.plan_name}, "
|
||||
f"总分类数: {completion_info.total_categories}, "
|
||||
f"总课程数: {completion_info.total_courses}"
|
||||
)
|
||||
|
||||
return completion_info
|
||||
|
||||
except (AUFEConnectionError, AUFEParseError) as e:
|
||||
logger.error(f"获取培养方案完成情况失败: {str(e)}")
|
||||
return _create_error_completion_info(f"请求失败: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取培养方案完成情况异常: {str(e)}")
|
||||
return _create_error_completion_info()
|
||||
|
||||
def _build_completion_info_from_ztree(
|
||||
self,
|
||||
ztree_data: List[dict],
|
||||
plan_name: str,
|
||||
major: str,
|
||||
grade: str
|
||||
) -> PlanCompletionInfo:
|
||||
"""从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)
|
||||
|
||||
logger.info(f"zTree数据分析: 总节点数={len(ztree_data)}, 根节点数={len(root_nodes)}, 不同父ID数={len(all_parent_ids)}")
|
||||
logger.debug(f"所有父ID: {sorted(all_parent_ids)}")
|
||||
|
||||
# 构建分类树
|
||||
for node in root_nodes:
|
||||
category = PlanCompletionCategory.from_ztree_node(node)
|
||||
self._populate_category_children(category, node["id"], nodes_by_id)
|
||||
root_categories.append(category)
|
||||
logger.debug(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()
|
||||
|
||||
return completion_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"构建培养方案完成情况信息异常: {str(e)}")
|
||||
return ErrorPlanCompletionInfo(
|
||||
plan_name="解析失败",
|
||||
major="解析失败",
|
||||
grade="",
|
||||
total_categories=-1,
|
||||
total_courses=-1,
|
||||
passed_courses=-1,
|
||||
failed_courses=-1,
|
||||
unread_courses=-1
|
||||
)
|
||||
|
||||
def _populate_category_children(
|
||||
self,
|
||||
category: PlanCompletionCategory,
|
||||
category_id: str,
|
||||
nodes_by_id: dict
|
||||
):
|
||||
"""填充分类的子分类和课程(支持多层嵌套)"""
|
||||
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)
|
||||
# 递归处理子项,支持多层嵌套
|
||||
self._populate_category_children(subcategory, node["id"], nodes_by_id)
|
||||
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)
|
||||
self._populate_category_children(subcategory, node["id"], nodes_by_id)
|
||||
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:
|
||||
logger.debug(f"分类 '{category.category_name}' (ID: {category_id}) 的子项: 总数={children_count}, 子分类={subcategory_count}, 课程={course_count}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"填充分类子项异常: {str(e)}")
|
||||
logger.error(f"异常节点信息: category_id={category_id}, 错误详情: {str(e)}")
|
||||
|
||||
async def fetch_semester_week_info(self) -> SemesterWeekInfo:
|
||||
"""
|
||||
获取当前学期周数信息
|
||||
|
||||
Returns:
|
||||
SemesterWeekInfo: 学期周数信息,失败时返回错误模型
|
||||
"""
|
||||
def _create_error_week_info(error_msg: str = "请求失败,请稍后重试") -> ErrorSemesterWeekInfo:
|
||||
"""创建错误学期周数信息"""
|
||||
return ErrorSemesterWeekInfo(
|
||||
academic_year=error_msg,
|
||||
semester="请求失败",
|
||||
week_number=-1,
|
||||
is_end=False,
|
||||
weekday="请求失败",
|
||||
raw_text=""
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("开始获取学期周数信息")
|
||||
|
||||
headers = self._get_default_headers()
|
||||
|
||||
# 请求主页以获取当前学期周数信息
|
||||
response = await self.vpn_connection.requester().get(
|
||||
f"{self.base_url}/",
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise AUFEConnectionError(f"获取学期周数信息页面失败,状态码: {response.status_code}")
|
||||
|
||||
html_content = 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:
|
||||
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)
|
||||
logger.info(f"通过正则表达式找到学期信息: {calendar_text}")
|
||||
else:
|
||||
logger.debug(f"HTML内容长度: {len(html_content)}")
|
||||
logger.debug("未检测到学期周数相关内容,可能需要重新登录或检查访问权限")
|
||||
return _create_error_week_info("未找到学期周数信息,请检查登录状态或访问权限")
|
||||
else:
|
||||
# 提取文本内容
|
||||
calendar_text = calendar_element.get_text(strip=True)
|
||||
logger.info(f"找到学期周数信息: {calendar_text}")
|
||||
|
||||
# 解析学期周数信息
|
||||
week_info = SemesterWeekInfo.from_calendar_text(calendar_text)
|
||||
|
||||
logger.info(
|
||||
f"学期周数信息获取成功: {week_info.academic_year} {week_info.semester} "
|
||||
f"第{week_info.week_number}周 {week_info.weekday}, 是否结束: {week_info.is_end}"
|
||||
)
|
||||
|
||||
return week_info
|
||||
|
||||
except (AUFEConnectionError, AUFEParseError) as e:
|
||||
logger.error(f"获取学期周数信息失败: {str(e)}")
|
||||
return _create_error_week_info(f"请求失败: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取学期周数信息异常: {str(e)}")
|
||||
return _create_error_week_info()
|
||||
|
||||
342
provider/aufe/jwc/plan_completion_model.py
Normal file
342
provider/aufe/jwc/plan_completion_model.py
Normal file
@@ -0,0 +1,342 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
import re
|
||||
|
||||
|
||||
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' ', ' ', 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' ', ' ', 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
|
||||
|
||||
|
||||
class PlanCompletionResponse(BaseModel):
|
||||
"""培养方案完成情况响应"""
|
||||
|
||||
code: int = Field(0, description="响应码")
|
||||
message: str = Field("success", description="响应消息")
|
||||
data: Optional[PlanCompletionInfo] = Field(None, description="培养方案完成情况数据")
|
||||
|
||||
|
||||
class ErrorPlanCompletionInfo(PlanCompletionInfo):
|
||||
"""错误的培养方案完成情况信息"""
|
||||
|
||||
plan_name: str = Field("请求失败", description="培养方案名称")
|
||||
major: str = Field("请求失败", description="专业名称")
|
||||
grade: str = Field("", description="年级")
|
||||
total_categories: int = Field(-1, description="总分类数")
|
||||
total_courses: int = Field(-1, description="总课程数")
|
||||
passed_courses: int = Field(-1, description="已通过课程数")
|
||||
failed_courses: int = Field(-1, description="未通过课程数")
|
||||
unread_courses: int = Field(-1, description="未修读课程数")
|
||||
|
||||
|
||||
class ErrorPlanCompletionResponse(BaseModel):
|
||||
"""错误的培养方案完成情况响应"""
|
||||
|
||||
code: int = Field(-1, description="响应码")
|
||||
message: str = Field("请求失败,请稍后重试", description="响应消息")
|
||||
data: Optional[ErrorPlanCompletionInfo] = Field(default=None, description="错误数据")
|
||||
102
provider/aufe/jwc/semester_week_model.py
Normal file
102
provider/aufe/jwc/semester_week_model.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
import re
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class SemesterWeekInfo(BaseModel):
|
||||
"""学期周数信息"""
|
||||
|
||||
academic_year: str = Field("", description="学年,如 2025-2026")
|
||||
semester: str = Field("", description="学期,如 秋、春")
|
||||
week_number: int = Field(0, description="当前周数")
|
||||
is_end: bool = Field(False, description="是否为学期结束")
|
||||
weekday: str = Field("", description="星期几")
|
||||
raw_text: str = Field("", description="原始文本")
|
||||
|
||||
def calculate_statistics(self):
|
||||
"""计算统计信息(如果需要的话)"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def from_calendar_text(cls, calendar_text: str) -> "SemesterWeekInfo":
|
||||
"""从日历文本解析学期周数信息
|
||||
|
||||
Args:
|
||||
calendar_text: 日历文本,例如 "2025-2026 秋 第1周 星期三"
|
||||
|
||||
Returns:
|
||||
SemesterWeekInfo: 学期周数信息对象
|
||||
"""
|
||||
# 清理文本
|
||||
clean_text = re.sub(r'\s+', ' ', calendar_text.strip())
|
||||
|
||||
# 初始化默认值
|
||||
academic_year = ""
|
||||
semester = ""
|
||||
week_number = 0
|
||||
is_end = False
|
||||
weekday = ""
|
||||
|
||||
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:
|
||||
semester = semester_match.group(1)
|
||||
|
||||
# 解析周数:第1周、第15周等
|
||||
week_match = re.search(r'第(\d+)周', clean_text)
|
||||
if week_match:
|
||||
week_number = int(week_match.group(1))
|
||||
|
||||
# 解析星期:星期一、星期二等
|
||||
weekday_match = re.search(r'星期([一二三四五六日天])', clean_text)
|
||||
if weekday_match:
|
||||
weekday = weekday_match.group(1)
|
||||
|
||||
# 判断是否为学期结束(通常第16周以后或包含"结束"等关键词)
|
||||
if week_number >= 16 or "结束" in clean_text or "考试" in clean_text:
|
||||
is_end = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"解析学期周数信息时出错: {str(e)}")
|
||||
|
||||
return cls(
|
||||
academic_year=academic_year,
|
||||
semester=semester,
|
||||
week_number=week_number,
|
||||
is_end=is_end,
|
||||
weekday=weekday,
|
||||
raw_text=clean_text
|
||||
)
|
||||
|
||||
|
||||
class ErrorSemesterWeekInfo(SemesterWeekInfo):
|
||||
"""错误的学期周数信息"""
|
||||
|
||||
academic_year: str = Field("解析失败", description="学年")
|
||||
semester: str = Field("解析失败", description="学期")
|
||||
week_number: int = Field(-1, description="当前周数")
|
||||
is_end: bool = Field(False, description="是否为学期结束")
|
||||
weekday: str = Field("解析失败", description="星期几")
|
||||
|
||||
|
||||
class SemesterWeekResponse(BaseModel):
|
||||
"""学期周数信息响应模型"""
|
||||
|
||||
code: int = Field(0, description="响应码")
|
||||
message: str = Field("获取成功", description="响应消息")
|
||||
data: Optional[SemesterWeekInfo] = Field(None, description="学期周数数据")
|
||||
|
||||
|
||||
class ErrorSemesterWeekResponse(BaseModel):
|
||||
"""错误的学期周数信息响应"""
|
||||
|
||||
code: int = Field(-1, description="响应码")
|
||||
message: str = Field("请求失败,请稍后重试", description="响应消息")
|
||||
data: Optional[ErrorSemesterWeekInfo] = Field(default=None, description="错误数据")
|
||||
Reference in New Issue
Block a user