This commit is contained in:
ChuXun
2026-01-29 03:39:01 +08:00
parent 0da692ab97
commit d1dc08a16d
12 changed files with 1607 additions and 39 deletions

View File

@@ -27,18 +27,31 @@ from typing import Optional, Dict
import signal
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='[%(levelname)s] [%(asctime)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
handlers=[
logging.FileHandler('monitor.log', encoding='utf-8'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# 避免重复添加处理器
if not logger.handlers:
logger.setLevel(logging.INFO)
# 设置日志格式
formatter = logging.Formatter(
'[%(levelname)s] [%(asctime)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# 文件处理器
file_handler = logging.FileHandler('monitor.log', encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 防止日志传播到根日志记录器
logger.propagate = False
class GradeMonitor:
"""成绩监控类"""
@@ -291,6 +304,11 @@ class GradeMonitor:
result = []
# 检查是否是登录页面
if soup.find('input', {'name': 'username'}) or soup.find('input', {'name': 'password'}):
logger.error("检测到登录页面,可能需要重新登录")
return "LOGIN_REQUIRED"
# 提取总平均绩点
gpa_div = soup.find('div', string=lambda x: x and '总平均绩点' in x)
if gpa_div:
@@ -299,21 +317,37 @@ class GradeMonitor:
result.append(gpa_text)
result.append("=" * 60)
result.append("")
logger.debug(f"找到总平均绩点: {gpa_text}")
# 提取成绩表格
# 尝试多种方式查找成绩表格
table = soup.find('table', {'class': 'gridtable'})
if not table:
table = soup.find('table', {'id': 'dataList'})
if not table:
# 尝试查找任何包含成绩相关标题的表格
for tbl in soup.find_all('table'):
headers = tbl.find_all('th')
if headers and any('课程名称' in th.get_text() or '成绩' in th.get_text() for th in headers):
table = tbl
logger.info("通过表头关键词找到成绩表格")
break
if table:
logger.debug("找到成绩表格")
# 提取表头
thead = table.find('thead')
if thead:
headers = [th.get_text(strip=True) for th in thead.find_all('th')]
result.append(' | '.join(headers))
result.append("-" * 120)
logger.debug(f"表头: {headers}")
# 提取每一行成绩
tbody = table.find('tbody')
if tbody:
for row in tbody.find_all('tr'):
rows = tbody.find_all('tr')
logger.debug(f"找到 {len(rows)} 行成绩数据")
for row in rows:
cells = row.find_all('td')
if cells:
row_data = []
@@ -322,11 +356,28 @@ class GradeMonitor:
text = cell.get_text(strip=True, separator=' ')
row_data.append(text)
result.append(' | '.join(row_data))
else:
logger.warning("未找到成绩表格")
if not result:
logger.warning("未能提取到成绩信息,返回原始文本")
logger.warning("未能提取到成绩信息,页面可能结构异常")
# 保存HTML以便调试
debug_file = self.script_dir / 'debug_page.html'
debug_file.write_text(html, encoding='utf-8')
logger.info(f"已保存HTML到 {debug_file} 供调试")
# 记录HTML摘要用于诊断
html_preview = html[:500].replace('\n', ' ')
logger.debug(f"HTML前500字符: {html_preview}")
# 检查是否是网络错误或空页面
if len(html) < 100:
logger.warning(f"HTML内容过短({len(html)}字节),可能是网络问题")
return "NETWORK_ERROR"
return self._fallback_extract(html)
logger.info(f"成功提取成绩信息,共 {len(result)}")
return '\n'.join(result)
except ImportError:
@@ -346,9 +397,27 @@ class GradeMonitor:
def parse_courses(self, grade_text: str) -> list:
"""解析成绩文本,提取课程列表"""
# 检查是否需要重新登录
if grade_text == "LOGIN_REQUIRED":
logger.warning("检测到登录失效,需要重新登录")
return []
# 检查是否是网络错误
if grade_text == "NETWORK_ERROR":
logger.warning("检测到网络错误,返回空列表")
return []
courses = []
lines = grade_text.split('\n')
# 检查是否是原始HTML未成功解析的标记
if '<html' in grade_text.lower() or '<body' in grade_text.lower():
logger.warning("检测到未解析的HTML内容成绩提取可能失败")
logger.debug(f"文本前100字符: {grade_text[:100]}")
return []
logger.debug(f"开始解析成绩,共 {len(lines)}")
for line in lines:
# 跳过标题行、分隔行和空行
stripped = line.strip()
@@ -367,15 +436,21 @@ class GradeMonitor:
if course_code and course_name:
course_key = f"{semester}|{course_code}|{course_name}"
courses.append(course_key)
logger.debug(f"解析到课程: {course_name}")
logger.info(f"共解析到 {len(courses)} 门课程")
return courses
def calculate_hash(self, content: str) -> str:
"""计算内容的哈希值"""
return hashlib.md5(content.encode('utf-8')).hexdigest()
def check_grade_changes(self, current_content: str, current_html: str = None) -> bool:
"""检查成绩是否有变化"""
def check_grade_changes(self, current_content: str, current_html: str = None) -> tuple:
"""检查成绩是否有变化
Returns:
tuple: (是否有变化: bool, 新增课程列表: list)
"""
# 保存当前内容(供用户查看)
self.last_grade_content_file.write_text(current_content, encoding='utf-8')
if current_html:
@@ -400,7 +475,7 @@ class GradeMonitor:
# 计算哈希
current_hash = self.calculate_hash(current_content)
self.last_grade_file.write_text(current_hash, encoding='utf-8')
return False
return (False, [])
# 读取上次的课程列表
courses_file = self.script_dir / '.last_courses.txt'
@@ -434,7 +509,7 @@ class GradeMonitor:
courses_file.write_text('\n'.join(current_courses), encoding='utf-8')
self.last_grade_file.write_text(current_hash, encoding='utf-8')
return True
return (True, new_courses)
elif content_changed and current_courses:
# 课程数量没变,但内容变了(可能是成绩更新)
logger.info("=" * 60)
@@ -446,10 +521,10 @@ class GradeMonitor:
# 更新哈希值
self.last_grade_file.write_text(current_hash, encoding='utf-8')
return True
return (True, []) # 内容变化但没有新增课程
else:
logger.info(f"成绩无变化(共 {len(current_courses)} 门课程)")
return False
return (False, [])
def send_email_notification(self, new_courses: list = None) -> bool:
"""发送邮件通知"""
@@ -641,10 +716,21 @@ class GradeMonitor:
consecutive_errors += 1
logger.error(f"获取成绩页面失败,等待下次检查 (连续失败: {consecutive_errors}/{max_consecutive_errors})")
# 连续失败3次尝试重新登录
if consecutive_errors >= 3:
logger.warning("⚠️ 尝试重新登录以解决页面获取问题...")
if self.login():
logger.info("✓ 重新登录成功,重置错误计数")
consecutive_errors = 0 # 重置错误计数
time.sleep(5)
continue
else:
logger.error("✗ 重新登录失败")
if consecutive_errors >= max_consecutive_errors:
error_msg = f"连续 {consecutive_errors} 次获取成绩页面失败"
logger.error(error_msg)
self.send_error_notification(error_msg)
self.send_error_notification(f"{error_msg}\n\n可能需要检查网络或账号状态")
consecutive_errors = 0 # 重置计数,避免重复发送
time.sleep(self.config['check_interval'])
@@ -653,15 +739,104 @@ class GradeMonitor:
# 提取成绩信息
grade_info = self.extract_grade_info(grade_html)
# 检查变化
if self.check_grade_changes(grade_info, grade_html):
# 获取新增课程列表
# 检查是否需要重新登录
if grade_info == "LOGIN_REQUIRED":
consecutive_errors += 1
logger.warning(f"检测到会话过期,尝试重新登录... (连续失败: {consecutive_errors}/{max_consecutive_errors})")
if self.login():
logger.info("重新登录成功,继续监控")
consecutive_errors = 0 # 重置错误计数
else:
logger.error("重新登录失败")
if consecutive_errors >= max_consecutive_errors:
error_msg = f"连续 {consecutive_errors} 次登录失败,会话可能已过期"
logger.error(error_msg)
self.send_error_notification(error_msg)
consecutive_errors = 0
time.sleep(self.config['check_interval'])
continue
# 检查是否是网络错误(临时性问题)
if grade_info == "NETWORK_ERROR":
consecutive_errors += 1
logger.warning(f"检测到网络错误或页面过短,可能是临时问题 (连续失败: {consecutive_errors}/{max_consecutive_errors})")
# 连续失败3次尝试重新登录
if consecutive_errors >= 3:
logger.warning("⚠️ 尝试重新登录以解决网络问题...")
if self.login():
logger.info("✓ 重新登录成功,重置错误计数")
consecutive_errors = 0 # 重置错误计数
time.sleep(5)
continue
else:
logger.error("✗ 重新登录失败")
if consecutive_errors >= max_consecutive_errors:
error_msg = f"连续 {consecutive_errors} 次网络错误"
logger.error(error_msg)
self.send_error_notification(f"{error_msg}\n\n可能需要检查网络连接或服务器状态")
consecutive_errors = 0
else:
# 临时错误,短暂等待后重试
logger.info(f"等待 30 秒后重试...")
time.sleep(30)
continue
# 解析课程列表
current_courses = self.parse_courses(grade_info)
# 如果解析失败0门课程且之前有课程可能是临时问题
if len(current_courses) == 0:
# 检查是否之前有课程记录
courses_file = self.script_dir / '.last_courses.txt'
if courses_file.exists():
current_courses = self.parse_courses(grade_info)
# 简单通知有新课程
self.send_email_notification(current_courses[:3]) # 只显示前3门
try:
previous_courses = courses_file.read_text(encoding='utf-8').strip().split('\n')
previous_courses = [c for c in previous_courses if c]
if len(previous_courses) > 0:
consecutive_errors += 1
logger.warning(f"⚠️ 解析到0门课程但之前有{len(previous_courses)}门课程,可能是临时问题")
logger.warning(f"连续异常: {consecutive_errors}/{max_consecutive_errors}")
if consecutive_errors >= 3: # 连续3次解析失败尝试重新登录
error_msg = f"连续 {consecutive_errors} 次无法解析课程(之前有{len(previous_courses)}门课程)"
logger.error(error_msg)
# 尝试重新登录
logger.warning("⚠️ 尝试重新登录以解决解析问题...")
if self.login():
logger.info("✓ 重新登录成功,重置错误计数")
consecutive_errors = 0 # 重置错误计数
time.sleep(5) # 等待一下再继续
continue
else:
logger.error("✗ 重新登录失败")
if consecutive_errors >= max_consecutive_errors:
self.send_error_notification(f"{error_msg}\n\n重新登录失败,可能需要人工介入")
consecutive_errors = 0
else:
# 短暂等待后重试
logger.info(f"等待 30 秒后重试...")
time.sleep(30)
continue
except Exception as e:
logger.debug(f"检查历史课程时出错: {e}")
# 检查变化
has_changes, new_courses = self.check_grade_changes(grade_info, grade_html)
if has_changes:
if new_courses:
# 有新增课程,发送包含课程列表的通知
logger.info(f"共解析到 {len(current_courses)} 门课程")
self.send_email_notification(new_courses)
else:
# 没有新增课程但内容变化(成绩更新),发送通用通知
self.send_email_notification()
# 成功执行,重置错误计数
@@ -709,6 +884,9 @@ def main():
# 测试模式(获取一次成绩并显示内容)
python3 monitor.py --test
# 调试模式(显示详细日志)
python3 monitor.py --debug
# 查看保存的成绩内容
cat .last_grade_content.txt
@@ -723,6 +901,12 @@ def main():
help='测试模式:仅获取一次成绩页面并显示内容,不进行监控'
)
parser.add_argument(
'--debug',
action='store_true',
help='调试模式:显示详细的调试信息'
)
parser.add_argument(
'--config',
default='config.ini',
@@ -731,6 +915,12 @@ def main():
args = parser.parse_args()
# 如果启用调试模式,调整日志级别
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logger.setLevel(logging.DEBUG)
logger.info("已启用调试模式")
monitor = GradeMonitor(config_file=args.config, test_mode=args.test)
monitor.run()