import re import json import asyncio from typing import List, Optional, Dict from loguru import logger from provider.aufe.jwc.model import ( AcademicDataItem, AcademicInfo, TrainingPlanResponseWrapper, TrainingPlanInfo, CourseSelectionStatusDirectResponse, CourseSelectionStatus, Course, CourseListResponse, EvaluationResponse, EvaluationRequestParam, ExamScheduleItem, OtherExamResponse, UnifiedExamInfo, ExamInfoResponse, 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, activity_tracker, retry_async, AUFEConnectionError, AUFEParseError, RetryConfig ) from bs4 import BeautifulSoup class JWCConfig: """教务系统配置常量""" DEFAULT_BASE_URL = "http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/" # 各类请求的相对路径 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", "exam_terms": "/student/integratedQuery/scoreQuery/courseScore/getTermList?sf_request_type=ajax", "student_schedule": "/student/courseSchedule/thisSemesterCurriculum/index", "course_schedule": "/student/courseSchedule/courseSchedule/ajaxStudentSchedule/curr/callback" } # 默认分页参数 DEFAULT_PAGE_SIZE = 50 MAX_REDIRECTS = 10 class JWCClient: """教务系统客户端""" def __init__( self, vpn_connection: AUFEConnection, base_url: str = JWCConfig.DEFAULT_BASE_URL, retry_config: Optional[RetryConfig] = None ): """ 初始化教务系统客户端 Args: vpn_connection: VPN连接实例 base_url: 教务系统基础URL retry_config: 重试配置 """ self.vpn_connection = vpn_connection self.base_url = base_url.rstrip("/") self.retry_config = retry_config or RetryConfig() # 保存课程列表响应结果,以便在后续操作中使用 self.course_list_response: Optional[CourseListResponse] = None logger.info(f"教务系统客户端初始化: base_url={self.base_url}") def _get_default_headers(self) -> dict: """获取默认请求头""" return aufe_config_global.DEFAULT_HEADERS.copy() def _get_endpoint_url(self, endpoint: str) -> str: """获取端点完整URL""" path = JWCConfig.ENDPOINTS.get(endpoint, endpoint) return f"{self.base_url}{path}" @activity_tracker @retry_async() async def validate_environment_and_cookie(self) -> bool: """ 验证环境(VPN或校园网)和Cookie有效性 Returns: bool: Cookie是否有效 Raises: AUFEConnectionError: 连接失败 """ try: # 检查是否能访问教务系统首页 headers = self._get_default_headers() response = await self.vpn_connection.requester().get( f"{self.base_url}/", headers=headers, follow_redirects=True ) is_valid = response.status_code == 200 logger.info( f"环境和Cookie验证结果: {'有效' if is_valid else '无效'} (HTTP状态码: {response.status_code})" ) # 如果Cookie无效或不在VPN/校园网环境,返回false以提示用户重新登录 if not is_valid: logger.error("Cookie无效或不在VPN/校园网环境,需要重新登录") raise AUFEConnectionError(f"Cookie验证失败,状态码: {response.status_code}") return is_valid except AUFEConnectionError: raise except Exception as e: logger.error(f"验证环境和Cookie异常: {str(e)}") raise AUFEConnectionError(f"环境验证失败: {str(e)}") from e @activity_tracker @retry_async() async def check_network_connection(self) -> bool: """ 获取网络连接状态 Returns: bool: 网络是否可用 Raises: AUFEConnectionError: 连接失败 """ try: response = await self.vpn_connection.requester().get(self.base_url) is_success = response.status_code in [200, 302] logger.info( f"网络连接检查结果: {is_success} (HTTP状态码: {response.status_code})" ) if not is_success: raise AUFEConnectionError(f"网络连接失败,状态码: {response.status_code}") return is_success except AUFEConnectionError: raise except Exception as e: logger.error(f"网络连接检查异常: {str(e)}") raise AUFEConnectionError(f"网络检查失败: {str(e)}") from e @activity_tracker @retry_async() async def fetch_academic_info(self) -> AcademicInfo: """ 获取学术信息(课程数量、绩点等),使用重试机制 Returns: AcademicInfo: 学术信息,失败时返回错误模型 """ def _create_error_info() -> ErrorAcademicInfo: """创建错误学术信息""" return ErrorAcademicInfo(count=-1, countNotPass=-1, gpa=-1.0) try: logger.info("开始获取学术信息") headers = self._get_default_headers() data = {"flag": ""} # 由于这个API返回的是数组格式,需要特殊处理 response = await self.vpn_connection.requester().post( f"{self.base_url}/main/academicInfo?sf_request_type=ajax", headers=headers, data=data, follow_redirects=True, ) if response.status_code != 200: raise AUFEConnectionError(f"获取学术信息失败,状态码: {response.status_code}") try: json_data = response.json() # 按数组格式解析响应 academic_data_items = [ AcademicDataItem.parse_obj(item) for item in json_data ] if not academic_data_items: raise AUFEParseError("未获取到学术信息数据") item = academic_data_items[0] logger.info( f"学术信息获取成功: 课程数={item.completed_courses}, 绩点={item.gpa}" ) # 转换为AcademicInfo格式返回,保持兼容性 return AcademicInfo( count=item.completed_courses, countNotPass=item.failed_courses, gpa=item.gpa, ) except Exception as e: logger.error(f"解析学术信息异常: {str(e)}") raise AUFEParseError(f"学术信息解析失败: {str(e)}") from e except (AUFEConnectionError, AUFEParseError) as e: logger.error(f"获取学术信息失败: {str(e)}") return _create_error_info() except Exception as e: logger.error(f"获取学术信息异常: {str(e)}") return _create_error_info() @activity_tracker async def fetch_training_plan_info(self) -> TrainingPlanInfo: """ 获取培养方案信息,使用重试机制 Returns: TrainingPlanInfo: 培养方案信息,失败时返回错误模型 """ def _create_error_plan_info(error_msg: str = "请求失败,请稍后重试") -> ErrorTrainingPlanInfo: """创建错误培养方案信息""" return ErrorTrainingPlanInfo( pyfa=error_msg, term="", courseCount=-1, major="请求失败", grade="", ) def _convert_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 try: logger.info("开始获取培养方案信息") headers = self._get_default_headers() # 使用重试机制获取培养方案基本信息 plan_response = await self.vpn_connection.model_request( model=TrainingPlanResponseWrapper, url=f"{self.base_url}/main/showPyfaInfo?sf_request_type=ajax", method="GET", headers=headers, follow_redirects=True, ) if not plan_response or plan_response.count <= 0 or not plan_response.data: return _create_error_plan_info("未获取到培养方案信息") plan_data_list = plan_response.data[0] if len(plan_data_list) < 2: return _create_error_plan_info("培养方案信息数据格式不正确") plan_name = plan_data_list[0] plan_id = plan_data_list[1] logger.info(f"培养方案信息获取成功: {plan_name} (ID: {plan_id})") # 提取年级信息 - 假设格式为"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 "" # 获取学术信息来补全学期和课程数量信息 term_name = "" course_count = 0 try: # 调用学术信息接口获取当前学期和课程数量 academic_response = await self.vpn_connection.requester().post( f"{self.base_url}/main/academicInfo?sf_request_type=ajax", headers=headers, data={"flag": ""}, follow_redirects=True, ) if academic_response.status_code == 200: academic_data = academic_response.json() if academic_data and isinstance(academic_data, list) and len(academic_data) > 0: academic_item = academic_data[0] # 获取学期代码并转换格式 zxjxjhh = academic_item.get("zxjxjhh", "") if zxjxjhh: term_name = _convert_term_format(zxjxjhh) logger.info(f"从学术信息获取学期: {zxjxjhh} -> {term_name}") # 获取课程数量 course_count = academic_item.get("courseNum", 0) logger.info(f"从学术信息获取课程数量: {course_count}") except Exception as e: logger.warning(f"获取学术信息补全培养方案失败: {str(e)}") # 使用默认值 term_name = "当前学期" # 转换为TrainingPlanInfo格式返回 return TrainingPlanInfo( pyfa=plan_name, major=major_name, grade=grade, term=term_name, courseCount=course_count, ) except (AUFEConnectionError, AUFEParseError) as e: logger.error(f"获取培养方案信息失败: {str(e)}") return _create_error_plan_info(f"请求失败: {str(e)}") except Exception as e: logger.error(f"获取培养方案信息异常: {str(e)}") return _create_error_plan_info() @activity_tracker @retry_async() async def check_course_selection_status(self) -> Optional[CourseSelectionStatus]: """ 检查选课状态 Returns: Optional[CourseSelectionStatus]: 选课状态信息,失败时返回None Raises: AUFEConnectionError: 连接失败 AUFEParseError: 数据解析失败 """ try: logger.info("开始检查选课状态") headers = self._get_default_headers() response = await self.vpn_connection.requester().post( f"{self.base_url}/main/checkSelectCourseStatus?sf_request_type=ajax", headers=headers, data={}, # 空POST请求 follow_redirects=True, # 处理可能的重定向 ) if response.status_code != 200: raise AUFEConnectionError(f"检查选课状态失败,状态码: {response.status_code}") json_data = response.json() try: # 解析新的选课状态响应格式 status_response = CourseSelectionStatusDirectResponse.parse_obj( json_data ) # 解析选课状态码 - "0"表示不可选, "1"表示可选 can_select = status_response.status_code == "1" logger.info( f"选课状态检查成功: 当前学期={status_response.term_name}, 可选课={can_select}" ) # 返回兼容的选课状态对象 return CourseSelectionStatus( isCanSelect=can_select, startTime="", # API未提供 endTime="", # API未提供 ) except Exception as e: logger.error(f"解析选课状态异常: {str(e)}") raise AUFEParseError(f"选课状态解析失败: {str(e)}") from e except (AUFEConnectionError, AUFEParseError): raise except Exception as e: logger.error(f"检查选课状态异常: {str(e)}") raise AUFEConnectionError(f"检查选课状态失败: {str(e)}") from e @activity_tracker @retry_async() async def get_token(self) -> Optional[str]: """ 获取CSRF Token Returns: Optional[str]: CSRF Token,失败时返回None Raises: AUFEConnectionError: 连接失败 AUFEParseError: Token解析失败 """ try: headers = self._get_default_headers() response = await self.vpn_connection.requester().get( f"{self.base_url}/student/teachingEvaluation/evaluation/index", headers=headers, follow_redirects=True, # 处理可能的重定向 ) if response.status_code != 200: raise AUFEConnectionError(f"获取Token失败,状态码: {response.status_code}") html = response.text # 使用简单字符串匹配查找token token_start = html.find('id="tokenValue" value="') if token_start != -1: token_start += 24 # len('id="tokenValue" value="') token_end = html.find('"', token_start) if token_end != -1: token = html[token_start:token_end] if token: logger.info(f"获取Token成功: {token[:5]}***{token[-5:]}") return token raise AUFEParseError("未找到Token值") except (AUFEConnectionError, AUFEParseError): raise except Exception as e: logger.error(f"获取Token异常: {str(e)}") raise AUFEConnectionError(f"获取Token失败: {str(e)}") from e @activity_tracker @retry_async() async def fetch_evaluation_course_list(self) -> List[Course]: """ 获取课程列表 Returns: List[Course]: 课程列表 Raises: AUFEConnectionError: 连接失败 AUFEParseError: 数据解析失败 """ try: headers = self._get_default_headers() data = {"optType": "1", "pagesize": "50"} # 增加页面大小以获取更多课程 response = await self.vpn_connection.requester().post( f"{self.base_url}/student/teachingEvaluation/teachingEvaluation/search?sf_request_type=ajax", headers=headers, data=data, follow_redirects=True, # 处理可能的重定向 ) if response.status_code != 200: raise AUFEConnectionError(f"获取课程列表失败,状态码: {response.status_code}") json_data = response.json() try: course_response = CourseListResponse.parse_obj(json_data) self.course_list_response = course_response logger.info( f"获取课程成功,总数: {len(course_response.data)},未完成: {course_response.not_finished_num},总评价数: {course_response.evaluation_num}" ) return course_response.data except Exception as e: logger.error(f"解析课程列表JSON异常: {str(e)}") raise AUFEParseError(f"课程列表解析失败: {str(e)}") from e except (AUFEConnectionError, AUFEParseError) as e: logger.error(f"获取课程列表失败: {str(e)}") return [] except Exception as e: logger.error(f"获取课程列表异常: {str(e)}") return [] async def access_evaluation_page(self, token: str, course: Course) -> bool: """ 访问评价页面(准备评价) Args: token: CSRF Token course: 课程信息 Returns: bool: 是否成功访问 """ try: evaluated_people = course.evaluated_people evaluated_people_number = course.id.evaluated_people if course.id else "" questionnaire_code = ( course.questionnaire.questionnaire_number if course.questionnaire else "" ) questionnaire_name = ( course.questionnaire.questionnaire_name if course.questionnaire else "" ) coure_sequence_number = course.id.coure_sequence_number if course.id else "" evaluation_content_number = ( course.id.evaluation_content_number if course.id else "" ) # 使用从课程列表获取的评价总数 evaluation_count = ( str(self.course_list_response.evaluation_num) if self.course_list_response else "28" ) headers = self._get_default_headers() data = { "count": evaluation_count, "evaluatedPeople": evaluated_people, "evaluatedPeopleNumber": evaluated_people_number, "questionnaireCode": questionnaire_code, "questionnaireName": questionnaire_name, "coureSequenceNumber": coure_sequence_number, "evaluationContentNumber": evaluation_content_number, "evaluationContentContent": "", "tokenValue": token, } response = await self.vpn_connection.requester().post( f"{self.base_url}/student/teachingEvaluation/teachingEvaluation/evaluationPage", headers=headers, data=data, follow_redirects=True, # 处理可能的重定向 ) is_success = response.status_code == 200 logger.info( f"访问评价页面{'成功' if is_success else '失败'}: {questionnaire_name or course.evaluation_content}, 使用count={evaluation_count}" ) return is_success except Exception as e: logger.error(f"访问评价页面异常: {str(e)}") return False async def submit_evaluation( self, evaluation_param: EvaluationRequestParam ) -> EvaluationResponse: """ 提交课程评价 Args: evaluation_param: 评价请求参数 Returns: EvaluationResponse: 评价提交响应 """ try: form_data = evaluation_param.to_form_data() headers = self._get_default_headers() response = await self.vpn_connection.requester().post( f"{self.base_url}/student/teachingEvaluation/teachingEvaluation/assessment?sf_request_type=ajax", headers=headers, data=form_data, follow_redirects=True, # 处理可能的重定向 ) if response.status_code != 200: logger.error(f"提交评价失败: HTTP状态码 {response.status_code}") return EvaluationResponse( result="error", msg=f"网络请求失败 ({response.status_code})" ) json_data = response.json() eval_response = EvaluationResponse.parse_obj(json_data) logger.info(f"评价提交结果: {eval_response.result} - {eval_response.msg}") return eval_response except Exception as e: logger.error(f"提交评价异常: {str(e)}") return EvaluationResponse(result="error", msg=f"请求异常: {str(e)}") async def fetch_unified_exam_info( self, 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: # 获取校统考信息 school_exams = await self._fetch_school_exam_schedule(start_date, end_date) # 获取座位号信息 seat_info = await self._fetch_exam_seat_info() # 获取其他考试信息 other_exams = await self._fetch_other_exam_records(term_code) # 合并并转换为统一格式 unified_exams = [] # 处理校统考数据 for exam in school_exams: unified_exam = self._convert_school_exam_to_unified(exam, seat_info) if unified_exam: unified_exams.append(unified_exam) # 处理其他考试数据 for record in other_exams: unified_exam = self._convert_other_exam_to_unified(record) if unified_exam: unified_exams.append(unified_exam) # 按考试日期排序 unified_exams.sort(key=lambda x: x.exam_date) return ExamInfoResponse(exams=unified_exams, total_count=len(unified_exams)) except Exception as e: logger.error(f"获取考试信息异常: {str(e)}") return ExamInfoResponse(exams=[], total_count=0) async def _fetch_school_exam_schedule( self, start_date: str, end_date: str ) -> List[ExamScheduleItem]: """ 获取校统考考试安排 Args: start_date: 开始日期 (YYYY-MM-DD) end_date: 结束日期 (YYYY-MM-DD) Returns: List[ExamScheduleItem]: 校统考列表 """ try: import time timestamp = int(time.time() * 1000) headers = { **self._get_default_headers(), "Accept": "application/json, text/javascript, */*; q=0.01", "X-Requested-With": "XMLHttpRequest", } url = f"{self.base_url}/student/examinationManagement/examPlan/detail" params = { "start": start_date, "end": end_date, "_": str(timestamp), "sf_request_type": "ajax", } await self.vpn_connection.requester().get( "http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/examinationManagement/examPlan/index", follow_redirects=True, headers=headers, ) response = await self.vpn_connection.requester().get( url, headers=headers, params=params, follow_redirects=True ) if response.status_code != 200: logger.error(f"获取校统考信息失败: HTTP状态码 {response.status_code}") return [] json_data = response.json() # 解析为ExamScheduleItem列表 school_exams = [] if isinstance(json_data, list): for item in json_data: exam_item = ExamScheduleItem.parse_obj(item) school_exams.append(exam_item) logger.info(f"获取校统考信息成功,共 {len(school_exams)} 场考试") return school_exams except Exception as e: logger.error(f"获取校统考信息异常: {str(e)}") return [] async def _fetch_other_exam_records(self, term_code: str) -> List: """ 获取其他考试记录 Args: term_code: 学期代码 Returns: List: 其他考试记录列表 """ try: headers = { **self._get_default_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 self.vpn_connection.requester().post( f"{self.base_url}/student/examinationManagement/othersExamPlan/queryScores?sf_request_type=ajax", headers=headers, data=data, follow_redirects=True, ) if response.status_code != 200: logger.error(f"获取其他考试信息失败: HTTP状态码 {response.status_code}") return [] json_data = response.json() exam_response = OtherExamResponse.parse_obj(json_data) logger.info( f"获取其他考试信息成功,共 {len(exam_response.records)} 条记录" ) return exam_response.records except Exception as e: logger.error(f"获取其他考试信息异常: {str(e)}") return [] async def _fetch_exam_seat_info(self) -> Dict[str, str]: """ 获取考试座位号信息 Returns: Dict[str, str]: 课程名到座位号的映射 """ try: headers = { **self._get_default_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", } url = f"{self.base_url}/student/examinationManagement/examPlan/index" response = await self.vpn_connection.requester().get( url, headers=headers, follow_redirects=True ) if response.status_code != 200: logger.error(f"获取考试座位号信息失败: HTTP状态码 {response.status_code}") return {} # 解析HTML获取座位号信息 from bs4 import BeautifulSoup soup = BeautifulSoup(response.text, "html.parser") seat_info = {} # 查找所有考试信息区块 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_info[course_name] = seat_number logger.info(f"获取考试座位号信息成功,共 {len(seat_info)} 条记录") return seat_info except Exception as e: logger.error(f"获取考试座位号信息异常: {str(e)}") return {} def _convert_school_exam_to_unified( self, exam: ExamScheduleItem, seat_info: Optional[Dict[str, str]] = None ) -> 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 = "" if seat_info and course_name in seat_info: note = f"座位号: {seat_info[course_name]}" 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: logger.error(f"转换校统考数据异常: {str(e)}") return None def _convert_other_exam_to_unified(self, record) -> 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: logger.error(f"转换其他考试数据异常: {str(e)}") return None # ==================== 学期和成绩相关方法 ==================== async def fetch_all_terms(self) -> Dict[str, str]: """ 获取所有学期信息 Returns: Dict[str, str]: 学期ID到学期名称的映射 """ try: 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", "Accept-Language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6", "Cache-Control": "max-age=0", "Connection": "keep-alive", "Referer": f"{self.base_url}/student/integratedQuery/scoreQuery/scoreCard/index", "Upgrade-Insecure-Requests": "1", **self._get_default_headers(), } response = await self.vpn_connection.requester().get( url, headers=headers, follow_redirects=True ) if response.status_code != 200: logger.error(f"获取学期信息失败,状态码: {response.status_code}") return {} # 解析HTML获取学期选项 soup = BeautifulSoup(response.text, "html.parser") # 查找学期选择下拉框 select_element = soup.find("select", {"id": "planCode"}) if not select_element: logger.error("未找到学期选择框") return {} 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: logger.error("解析学期选项失败") return {} logger.info(f"成功获取{len(terms)}个学期信息") # 将学期中的 "春" 替换为 "下" , "秋" 替换为 "上" for key, value in terms.items(): terms[key] = value.replace("春", "下").replace("秋", "上") return terms except Exception as e: logger.error(f"获取学期信息异常: {str(e)}") return {} async def fetch_term_score( self, term_id: str, course_code: str = "", course_name: str = "", page_num: int = 1, page_size: int = 50, ) -> Optional[Dict]: """ 获取指定学期的成绩信息 Args: term_id: 学期ID,如:2024-2025-2-1 course_code: 课程代码(可选,用于筛选) course_name: 课程名称(可选,用于筛选) page_num: 页码,默认为1 page_size: 每页大小,默认为50 Returns: Optional[Dict]: 成绩数据 """ from bs4 import BeautifulSoup try: # 首先需要获取正确的URL中的动态路径参数 # 这通常需要先访问成绩查询页面来获取 initial_url = f"{self.base_url}/student/integratedQuery/scoreQuery/allTermScores/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", "Accept-Language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6", **self._get_default_headers(), } # 先访问成绩查询页面 response = await self.vpn_connection.requester().get( initial_url, headers=headers, follow_redirects=True ) if response.status_code != 200: logger.error(f"访问成绩查询页面失败,状态码: {response.status_code}") return None # 从页面中提取动态路径参数 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 # 构建成绩数据请求URL data_url = f"{self.base_url}/student/integratedQuery/scoreQuery/{dynamic_path}/allTermScores/data" # 请求成绩数据 data_headers = { "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6", "Connection": "keep-alive", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": self.base_url, "Referer": initial_url, **self._get_default_headers(), "X-Requested-With": "XMLHttpRequest", } data_params = { "zxjxjhh": term_id, "kch": course_code, "kcm": course_name, "pageNum": str(page_num), "pageSize": str(page_size), "sf_request_type": "ajax", } data_response = await self.vpn_connection.requester().post( data_url, headers=data_headers, data=data_params, follow_redirects=True ) if data_response.status_code != 200: logger.error(f"获取成绩数据失败,状态码: {data_response.status_code}") return None result = data_response.json() logger.info(f"成功获取学期 {term_id} 的成绩数据") return result except Exception as e: logger.error(f"获取学期成绩异常: {str(e)}") return None # ==================== 课表相关方法 ==================== async def fetch_student_schedule(self, plan_code: str) -> Optional[Dict]: """ 获取学生课表信息 Args: plan_code: 培养方案代码,如:2024-2025-2-1 Returns: Optional[Dict]: 课表数据 """ try: logger.info(f"开始获取课表信息,培养方案代码: {plan_code}") # 首先需要获取动态路径参数 # 先访问课表页面 initial_url = f"{self.base_url}/student/courseSelect/calendarSemesterCurriculum/index" headers = { **self._get_default_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", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "Cache-Control": "no-cache", "Connection": "keep-alive", "Pragma": "no-cache", "Upgrade-Insecure-Requests": "1", } response = await self.vpn_connection.requester().get( initial_url, headers=headers, follow_redirects=True ) if response.status_code != 200: logger.error(f"访问课表页面失败,状态码: {response.status_code}") return None # 从页面中提取动态路径参数 from bs4 import BeautifulSoup soup = BeautifulSoup(response.text, "html.parser") # 查找动态路径参数 dynamic_path = "B2RMNJkT95" # 默认值 # 尝试从页面中提取动态路径 scripts = soup.find_all("script") 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 # 构建课表数据请求URL schedule_url = f"{self.base_url}/student/courseSelect/thisSemesterCurriculum/{dynamic_path}/ajaxStudentSchedule/past/callback" # 请求课表数据 schedule_headers = { "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "Cache-Control": "no-cache", "Connection": "keep-alive", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": self.base_url, "Pragma": "no-cache", "Referer": initial_url, **self._get_default_headers(), "X-Requested-With": "XMLHttpRequest", } schedule_params = { "planCode": plan_code, "sf_request_type": "ajax", } schedule_response = await self.vpn_connection.requester().post( schedule_url, headers=schedule_headers, data=schedule_params, follow_redirects=True ) if schedule_response.status_code != 200: logger.error(f"获取课表数据失败,状态码: {schedule_response.status_code}") return None schedule_data = schedule_response.json() logger.info("成功获取课表数据") return schedule_data except Exception as e: logger.error(f"获取课表数据异常: {str(e)}") return None async def fetch_section_and_time(self) -> Optional[Dict]: """ 获取时间段信息 Returns: Optional[Dict]: 时间段数据 """ try: logger.info("开始获取时间段信息") headers = { "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "Cache-Control": "no-cache", "Connection": "keep-alive", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": self.base_url, "Pragma": "no-cache", "Referer": f"{self.base_url}/student/courseSelect/calendarSemesterCurriculum/index", **self._get_default_headers(), "X-Requested-With": "XMLHttpRequest", } data = { "planNumber": "", "ff": "f", "sf_request_type": "ajax", } response = await self.vpn_connection.requester().post( f"{self.base_url}/ajax/getSectionAndTime", headers=headers, data=data, follow_redirects=True, ) if response.status_code != 200: logger.error(f"获取时间段信息失败,状态码: {response.status_code}") return None time_data = response.json() logger.info("成功获取时间段信息") return time_data except Exception as e: logger.error(f"获取时间段信息异常: {str(e)}") return None async def fetch_course_schedule(self, plan_code: str) -> Optional[Dict]: """ 获取聚合的课表信息(课程数据 + 时间段数据) Args: plan_code: 培养方案代码,如:2024-2025-2-1 Returns: Optional[Dict]: 聚合的课表数据 """ try: logger.info(f"开始获取聚合课表信息,培养方案代码: {plan_code}") # 并行获取课程数据和时间段数据 schedule_data, time_data = await asyncio.gather( self.fetch_student_schedule(plan_code), self.fetch_section_and_time(), return_exceptions=True, ) # 检查是否有异常 if isinstance(schedule_data, Exception): logger.error(f"获取课程数据异常: {str(schedule_data)}") return None if isinstance(time_data, Exception): logger.error(f"获取时间段数据异常: {str(time_data)}") return None if not schedule_data or not time_data: logger.error("未能获取到完整的课表数据") return None # 聚合数据 aggregated_data = { "schedule": schedule_data, "time_sections": time_data, } logger.info("成功获取聚合课表数据") return aggregated_data except Exception as e: logger.error(f"获取聚合课表数据异常: {str(e)}") return None def _process_schedule_data(self, raw_data: Dict) -> Optional[Dict]: """ 处理和过滤课表数据 Args: raw_data: 原始聚合数据 Returns: Optional[Dict]: 处理后的课表数据 """ try: schedule_data = raw_data.get("schedule", {}) time_data = raw_data.get("time_sections", {}) if not schedule_data or not time_data: logger.error("缺少必要的课表数据") return None # 处理时间段信息 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) # 提取学期信息 semester_info = {} section_info = time_data.get("section", {}) if section_info: semester_info = { "total_weeks": str(section_info.get("zs", 0)), "week_description": section_info.get("zcsm", ""), "total_sessions": str(section_info.get("tjc", 0)), "first_day": str(time_data.get("firstday", 1)), } # 构建最终数据 processed_data = { "total_units": float(schedule_data.get("allUnits", 0)), "time_slots": time_slots, "courses": courses, "semester_info": semester_info, } logger.info(f"成功处理课表数据:共{len(courses)}门课程,{len(time_slots)}个时间段") return processed_data except Exception as e: logger.error(f"处理课表数据异常: {str(e)}") return None async def get_processed_schedule(self, plan_code: str) -> Optional[Dict]: """ 获取处理后的课表数据 Args: plan_code: 培养方案代码,如:2024-2025-2-1 Returns: Optional[Dict]: 处理后的课表数据 """ try: # 获取原始聚合数据 raw_data = await self.fetch_course_schedule(plan_code) if not raw_data: return None # 处理数据 processed_data = self._process_schedule_data(raw_data) return processed_data 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()