1
This commit is contained in:
242
monitor.py
242
monitor.py
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user