grade_query / src /script.py
JerryZhouYG's picture
Add scores API endpoint
e75ba39
Raw History Blame Contribute Delete
41 kB
# https://ynsdfz.aliwork.com/APP_DKQG7TSUK6ZNU3RDFGVO/workbench?corpid=dingd16cf4422967594bf2c783f7214b6d69&dd_addcookie=true&from_login=success&login_host=ynsdfz.aliwork.com&login_pt=4-296-307-385&dtcode=e1bec99a908e374fa77d5e8a06109972&code_type=jsapi&dd_enable_replace=true&ddtab=true
import json
import requests
import os
import re
import subprocess
from pathlib import Path
from datetime import datetime
from flask import Flask, render_template, request, jsonify, send_from_directory, url_for
import socket
from xpinyin import Pinyin
import time
import playwright_worker
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
CURRENT_DIR = Path.cwd().resolve()
app = Flask(__name__, template_folder=str(CURRENT_DIR / "templates"), static_folder=None)
p = Pinyin()
STUDENTS_DATA = []
STUDENTS_FILE = CURRENT_DIR / "students.json"
DOT_NOTIFY_URL = os.environ.get("DOT_NOTIFY_URL", "https://dot.mindreset.tech/api/open/text")
DOT_AUTH_TOKEN = os.environ.get("DOT_AUTH_TOKEN", "")
TARGET_STUDENT_NAME = os.environ.get("TARGET_STUDENT_NAME", "")
DOT_DEVICE_ID = os.environ.get("DOT_DEVICE_ID", "E4B063CC56DC")
DOT_LINK_URL = os.environ.get("DOT_LINK_URL", "")
DOT_ICON = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAEj0lEQVR4nO2YXYhVVRTHf/fOTI2Og2apo6IDpZNFZkUfplRYPURRCX0oigZWlPQgEUlRhhVBUA+h9ZAWRBJFX2IPfuLLJGlFYkb6YGWNIPYxpKmYOjO3h/Vfs/c9c+49Z65jQcyCyz37Y/33f6+99lrrHBiU/7kUBgijmILVo99/JgWgLmNOMceczEVqkSLBOk3AlcBUoBnoBg4AO4EfUuafdXGLDAeWA38ApQq/zcDNml9gYFwqF7kZwH6R+AZYAkwHLsIsOQf4ICK6guCnZ41kUf83AieBY8DcDJ2pwOcYyTUEvx1wkr77FuBX7Fgvi8bqtXCRcDHqNX4usFYkn1XfGV2cNHHANVpoltrnZOg5yWHAPuBv4EL1FVM1ahAHmiByn6rdkFPfSd4n/VcS/WcsDdjxPqoFbiJfDHTxizEE+B34WeTSgnsfqWTmAsGvukXsWqAL2C29Uk6CJeGcAr4AWoGRhLhYR5WLk0awTqBdIue7n4BdkD/VX0k/Da9HOt8JuxUL8CX1uxEy8XxCM/AgsAn4Ddt9CQstq7FQExOoRg6gEZgH7CLExi6gA3gfuCMPnpObj/lKSf/rgVWE3ftvI9BWBdT77gR+ifROCW81duTe/xUW7PvgxQ7/qibvAm6n/KbO0dgi4EU9H8EuTRLUn5/UvD3AvUAnsDexkRbgmYjowiSePyzXhJWJxer1GwWcwCwKcAtwGDhKCNp1ke5jwntP7XvUXqY5jZT73OWE9HlrkuR1GnhXbb/FhcTElwlWBPPFbqxyiUPHFM3bpHljMOsdAUYTakhfy0+qTZs+iBUjvTd7mwCaCOkqFgdsAr4Xqbs0tkxk5kYbWYf52jDgPIKvPZC0TCSelR7W3KU+cCnB9FA5wjvpydjtKwFPAedjuXm7xkcSMsbVWE1YAl6oQg6CERqAQ8CPPvdxAVxCuemrkWwF2qV3ELP+aeAG4Gn1/6T/k8AjGeRc3DirpHsxwDtYEh8a7aSaxL6zEKsHT2PBOI5xncAbWI0Y62UR9LTag0USPsaOKF44S5KLPSfAJ4CPRHhsNJ43b7sF52EbnV3EUtdw7ALklR5sI42U3/jXgS3RQl4XdqdgVJICFtIAjhWBrwXSRrYPxuJZwSucLj0f0PhV9P9FyV3kerX3AUwkvDdA/jrNw9E4Ed2s/gvU/qSfeG6cZuAvzLd73W0tdtvG9wPU49ab2AZvi8beUt+0GvCel+6CmHkbdkTthKjeQHrAjt837hfYBrW9rhuPWWEvMCKBF19Cx/M1ZxHeFD0z9d6whwjvsi0JYmnV7yJCvBtLOCLH89y7EwvuWXizsVPsRPGPyEAOuligR7FKZGICZChWJKwnVCmTkmAR3gKC47+E5eiYWCMwE/hQczqwyj2JVwY6A9gRAXcAX2L14PGo/zUsPKWCRXhXYKHH9Q5htd+3mBt4/9tYURHr9jGzv4OApa27taMxWLbZD2wFPsOKUCdXKZzEeNdgBcZ0rKLxbzjtWHGR+ztO3jiY9wtBrre3SnjVFF3Bc2y8WC3f/hzPj9TXrxVvUAblX5F/AN1tJCuIaUiQAAAAAElFTkSuQmCC"
def load_students_data():
global STUDENTS_DATA
try:
if os.path.exists(STUDENTS_FILE):
with open(STUDENTS_FILE, 'r', encoding='utf-8') as f:
STUDENTS_DATA = json.load(f)
print(f"✅ 成功加载学生数据,共 {len(STUDENTS_DATA)} 条记录")
else:
print(f"⚠️ 学生数据文件 {STUDENTS_FILE} 不存在")
STUDENTS_DATA = []
except Exception as e:
print(f"❌ 加载学生数据失败: {e}")
STUDENTS_DATA = []
class DingTalkFormQueryClient:
def __init__(self):
self.base_url = "https://ynsdfz.aliwork.com/dingtalk/web/APP_DKQG7TSUK6ZNU3RDFGVO/v1/form/searchFormDatas.json"
self.form_uuid = "FORM-72EF2444A3564C6D8020F1CDC36487EDHWE1"
self.cookies_file = CURRENT_DIR / "cookies.txt"
# 字段映射表
self.field_mapping = {
# 基本信息
'textField_mhsv4lgl': '姓名', # 新版表单
'radioField_ts1jbr0': '姓名', # 旧版表单
'textField_lqrf9fj': '学号',
'textField_sm64ysf': '考试名称',
'textField_mhsv4lgk': '身份证号', # 新版表单
'radioField_8a4voiy': '身份证号', # 旧版表单
# 语文
'textField_4ecclpd': '语文',
'textField_jqsfnel': '语文校次',
# 数学
'textField_djh9jbz': '数学',
'textField_sm6gl9b': '数学校次',
# 英语
'textField_s5awu09': '英语',
'textField_yrtoxm6': '英语校次',
# 物理
'textField_j7nxew8': '物理',
'textField_8iavn0r': '物理校次',
# 历史
'textField_7vh26my': '历史',
'textField_3nigx7n': '历史校次',
# 化学
'textField_d9v8hqi': '化学',
'textField_eecpqqn': '化学校次',
# 生物
'textField_6mk8ivr': '生物',
'textField_gjcqbzz': '生物校次',
# 政治
'textField_mqmwhoa': '政治',
'textField_q60j1vs': '政治校次',
# 地理
'textField_5iiw6pt': '地理',
'textField_xshus0b': '地理校次',
# 总分和排名
'textField_4bphzli': '总分',
'textField_y4uc1lx': '组合排名',
'textField_a2l7vuc': '大类排名',
}
# 查询身份证号时优先尝试的字段顺序(兼容新旧表单)
self.id_field_priority = [
'textField_mhsv4lgk', # 新版
'radioField_8a4voiy', # 旧版
]
# 考试排序列表(时间正序)
self.exam_order = [
"26届高一上中",
"26届高一上末",
"26届高一下中",
"26届高一下末",
"26届高二上中",
"26届高二上末",
"26届高二下中",
"26届高二下末",
"26届高二月考1",
"26届高三月考2",
"26届高三月考3",
"26届高三月考4"
]
def load_cookies_from_file(self):
"""从文件加载cookies"""
if not self.cookies_file.exists():
# 在Web应用中,打印到控制台可能不是最佳选择,但为了简单起见,暂时保留
print(f"❌ Cookie文件 {self.cookies_file} 不存在")
return None
try:
with self.cookies_file.open('r', encoding='utf-8') as f:
cookie_string = f.read().strip()
if not cookie_string:
print(f"❌ Cookie文件 {self.cookies_file} 为空")
return None
cookies = {}
for item in cookie_string.split(';'):
if '=' in item:
key, value = item.strip().split('=', 1)
cookies[key.strip()] = value.strip()
print(f"✅ 成功从 {self.cookies_file} 加载cookies") # 控制台日志
return cookies
except Exception as e:
print(f"❌ 读取cookie文件失败: {e}") # 控制台日志
return None
def extract_csrf_token(self, cookies):
"""从cookies中提取CSRF令牌"""
csrf_token = cookies.get('tianshu_csrf_token')
if not csrf_token:
print("❌ 在cookies中未找到tianshu_csrf_token") # 控制台日志
return None
return csrf_token
def validate_id_number(self, id_number):
"""验证身份证号格式"""
pattern = r'^\d{17}[\dXx]$'
if not re.match(pattern, id_number):
return False
return True
def build_request_url(self, id_number, csrf_token, search_field_id):
"""构建请求URL"""
if not search_field_id:
raise ValueError("search_field_id 不能为空")
search_json = json.dumps({search_field_id: id_number})
params = {
'formUuid': self.form_uuid,
'searchFieldJson': search_json,
'currentPage': 1,
'pageSize': 30,
'_csrf_token': csrf_token
}
param_string = '&'.join([f"{k}={requests.utils.quote(str(v))}" for k, v in params.items()])
return f"{self.base_url}?{param_string}"
def query_scores(self, id_number):
"""查询成绩 - 修复版本"""
# 验证身份证号
if not self.validate_id_number(id_number):
return {"success": False, "error": "身份证号格式不正确,请输入18位身份证号"}
# 加载cookies
cookies = self.load_cookies_from_file()
if not cookies:
return {"success": False, "error": "COOKIE_EXPIRED", "detail": f"无法加载cookies,请检查服务器端的 {self.cookies_file} 文件"}
# 提取CSRF令牌
csrf_token = self.extract_csrf_token(cookies)
if not csrf_token:
return {"success": False, "error": "COOKIE_EXPIRED", "detail": "无法提取CSRF令牌,请检查cookie中的tianshu_csrf_token"}
headers = {
'Accept': 'application/json, text/json',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
'Referer': 'https://ynsdfz.aliwork.com/APP_DKQG7TSUK6ZNU3RDFGVO/workbench',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'X-Requested-With': 'XMLHttpRequest'
}
last_success_empty = None
for search_field in self.id_field_priority:
result = self._perform_query(
id_number=id_number,
cookies=cookies,
csrf_token=csrf_token,
headers=headers,
search_field=search_field
)
if not result.get("success", False):
# 直接返回错误信息
return result
if result.get("data"):
if search_field != self.id_field_priority[0]:
print(f"ℹ️ 通过备用字段 {search_field} 匹配到成绩数据")
return result
last_success_empty = result
# 所有字段都无数据,返回最后一次成功但无记录的结果或通用提示
if last_success_empty:
return last_success_empty
return {"success": True, "data": [], "message": "未找到相关成绩记录"}
def _perform_query(self, id_number, cookies, csrf_token, headers, search_field):
"""执行实际的HTTP查询,并统一处理异常"""
try:
url = self.build_request_url(id_number, csrf_token, search_field)
except ValueError as e:
return {"success": False, "error": str(e)}
try:
print(f"🔍 正在使用字段 {search_field} 查询身份证号: {id_number}") # 控制台日志
response = requests.get(
url,
headers=headers,
cookies=cookies,
timeout=(10, 30)
)
print(f"📡 HTTP状态码: {response.status_code}") # 控制台日志
if response.status_code in (401, 403):
return {
"success": False,
"error": "COOKIE_EXPIRED",
"detail": f"接口返回未授权状态码: {response.status_code}"
}
if response.status_code != 200:
return {"success": False, "error": f"HTTP请求失败,状态码: {response.status_code}"}
content_type = (response.headers.get("Content-Type") or "").lower()
if "json" not in content_type:
response_url = (response.url or "").lower()
response_text = (response.text or "")[:500].lower()
if "login" in response_url or "登录" in response_text or "unauthorized" in response_text:
return {
"success": False,
"error": "COOKIE_EXPIRED",
"detail": "接口返回登录页面或未授权内容,请重新登录"
}
try:
data = response.json()
print(f"📦 收到响应数据") # 控制台日志
except json.JSONDecodeError as e:
print(f"❌ JSON解析失败: {e}") # 控制台日志
return {"success": False, "error": "服务器返回的不是有效的JSON格式"}
parsed = self._parse_response(data)
if parsed.get("success") and not parsed.get("data"):
parsed.setdefault("message", "未找到相关成绩记录")
return parsed
except requests.exceptions.Timeout:
return {"success": False, "error": "请求超时,请检查网络连接"}
except requests.exceptions.ConnectionError:
return {"success": False, "error": "网络连接错误,请检查网络状态"}
except Exception as e:
return {"success": False, "error": f"请求失败: {e}"}
def _parse_response(self, data):
"""解析服务器响应 - 修复核心逻辑"""
try:
if not isinstance(data, dict):
return {"success": False, "error": "服务器返回的数据格式错误:不是字典类型"}
print(f"📋 响应字段: {list(data.keys())}") # 控制台日志
success = data.get('success')
if success is False:
error_msg = data.get('errorMsg', '未知API错误')
# Check for cookie expiration or unauthorized access
if "登录" in error_msg or "权限" in error_msg or "Unauthorized" in error_msg or "login" in error_msg.lower():
return {"success": False, "error": "COOKIE_EXPIRED"}
return {"success": False, "error": f"API返回错误: {error_msg}"}
if success is not True:
print(f"⚠️ success字段值: {success}") # 控制台日志
content = data.get('content')
if not content:
return {"success": False, "error": "响应中缺少content字段"}
if not isinstance(content, dict):
return {"success": False, "error": "content字段格式错误"}
records = content.get('data')
if records is None: # 可能表示没有数据,但请求本身是成功的
return {"success": True, "data": [], "message": "未找到相关成绩记录"}
if not isinstance(records, list):
return {"success": False, "error": "data字段不是数组格式"}
if len(records) == 0:
return {"success": True, "data": [], "message": "未找到相关成绩记录"}
print(f"📊 找到 {len(records)} 条成绩记录") # 控制台日志
processed_data = self.process_score_data(records)
return {"success": True, "data": processed_data}
except Exception as e:
return {"success": False, "error": f"解析响应数据时出错: {e}"}
def process_score_data(self, records):
"""处理成绩数据"""
processed_records = []
for i, record in enumerate(records):
try:
# print(f"处理第 {i+1} 条记录...") # 控制台日志,可选择性保留
form_data = record.get('formData', {})
if not form_data:
# print(f"⚠️ 第 {i+1} 条记录缺少formData") # 控制台日志
continue
basic_info = {
'考试时间': self.timestamp_to_date(record.get('gmtCreate', 0)),
'标题': record.get('title', ''),
'表单ID': record.get('formInstId', ''),
'timestamp': record.get('gmtCreate', 0)
}
mapped_data = {}
for field_id, value in form_data.items():
field_name = self.field_mapping.get(field_id, field_id)
if value == "" or value == "-" or value is None:
mapped_data[field_name] = "无数据"
else:
mapped_data[field_name] = str(value)
processed_record = {**basic_info, **mapped_data}
processed_records.append(processed_record)
except Exception as e:
# print(f"⚠️ 处理第 {i+1} 条记录时出错: {e}") # 控制台日志
continue
# 对成绩记录进行排序
def get_sort_key(record):
exam_name = record.get('考试名称', '')
try:
# 已知考试:基于其在列表中的位置(因为这些考试的时间不正确),作为较旧的记录排序
return (0, self.exam_order.index(exam_name))
except ValueError:
# 未知考试:不在列表中的考试,按照实际时间戳正序排序,作为较新的记录
return (1, record.get('timestamp', 0))
processed_records.sort(key=get_sort_key)
# 为每条记录添加排序索引,方便前端排序
for i, record in enumerate(processed_records):
record['sort_index'] = i
# 计算每次考试的趋势(相比上一次)
for i in range(len(processed_records)):
current = processed_records[i]
if i == 0:
current['rank_trend'] = "无变化"
current['score_trend'] = "N/A"
else:
prev = processed_records[i-1]
# 计算排名趋势
try:
curr_rank_str = str(current.get('大类排名', '0'))
prev_rank_str = str(prev.get('大类排名', '0'))
# 提取数字
curr_rank_match = re.search(r'\d+', curr_rank_str)
prev_rank_match = re.search(r'\d+', prev_rank_str)
if curr_rank_match and prev_rank_match:
curr_rank = int(curr_rank_match.group())
prev_rank = int(prev_rank_match.group())
diff = prev_rank - curr_rank
if diff == 0:
current['rank_trend'] = "无变化"
elif diff > 0:
current['rank_trend'] = f"↑{diff}名"
else:
current['rank_trend'] = f"↓{abs(diff)}名"
else:
current['rank_trend'] = "无数据"
except Exception:
current['rank_trend'] = "计算错误"
# 计算总分趋势
try:
curr_score_str = str(current.get('总分', '0'))
prev_score_str = str(prev.get('总分', '0'))
# 提取数字(支持小数)
curr_score_match = re.search(r'\d+(\.\d+)?', curr_score_str)
prev_score_match = re.search(r'\d+(\.\d+)?', prev_score_str)
if curr_score_match and prev_score_match:
curr_score = float(curr_score_match.group())
prev_score = float(prev_score_match.group())
diff = curr_score - prev_score
if diff == 0:
current['score_trend'] = "无变化"
elif diff > 0:
current['score_trend'] = f"↑{diff:.1f}"
else:
current['score_trend'] = f"↓{abs(diff):.1f}"
else:
current['score_trend'] = "无数据"
except Exception:
current['score_trend'] = "计算错误"
# 反转列表,使最新的考试排在前面
processed_records.reverse()
return processed_records
def timestamp_to_date(self, timestamp):
"""时间戳转日期"""
try:
if timestamp and timestamp > 0:
dt = datetime.fromtimestamp(timestamp / 1000)
return dt.strftime('%Y-%m-%d %H:%M:%S')
return "未知时间"
except:
return "时间转换错误"
def format_score_report(self, data):
"""格式化成绩报告"""
if not data:
return "无成绩数据"
report = []
report.append("=" * 80)
report.append(f"学生成绩查询报告")
report.append("=" * 80)
for i, record in enumerate(data, 1):
report.append(f"\n📊 考试记录 {i}:")
report.append("-" * 50)
# 基本信息
report.append(f"考试名称: {record.get('考试名称', '未知')}")
report.append(f"考试时间: {record.get('考试时间', '未知')}")
report.append(f"学生姓名: {record.get('姓名', '未知')}")
report.append(f"学号: {record.get('学号', '未知')}")
# 成绩信息
report.append("\n📝 各科成绩:")
subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '政治', '地理']
for subject in subjects:
score = record.get(subject, '无数据')
rank = record.get(f"{subject}校次", '无数据')
if score != '无数据' or rank != '无数据':
report.append(f" {subject}: {score} (校次: {rank})")
# 总分和排名
report.append("\n🏆 总分与排名:")
report.append(f" 总分: {record.get('总分', '无数据')}")
report.append(f" 大类排名: {record.get('大类排名', '无数据')}")
report.append(f" 组合排名: {record.get('组合排名', '无数据')}")
return "\n".join(report)
def compute_rank_trend(self, records):
"""计算相比上次考试的组合排名变化。返回字符串如 '↓3名' 或 '↑2名' 或 '无变化'。"""
try:
# records 按时间倒序排列(最新的在最前面)
if not records or len(records) < 2:
return "无变化"
# 提取最近两次的组合排名字段并尝试解析为整数
def parse_rank(rec):
v = rec.get('大类排名') or rec.get('大类排名'.strip())
if not v:
return None
# 移除非数字字符
s = re.sub(r"[^0-9]", "", str(v))
return int(s) if s.isdigit() else None
latest = parse_rank(records[0]) # 最新的在第一个
prev = parse_rank(records[1]) # 上一次的在第二个
if latest is None or prev is None:
return "无数据"
diff = prev - latest
if diff == 0:
return "无变化"
if diff > 0:
return f"↑{diff}名"
return f"↓{abs(diff)}名"
except Exception:
return "计算错误"
def _get_local_ip(self):
"""获取本机局域网IP地址"""
try:
import subprocess
# 使用ifconfig获取所有IP地址
result = subprocess.run(['ifconfig'], capture_output=True, text=True)
if result.returncode == 0:
ips = []
lines = result.stdout.split('\n')
for line in lines:
if 'inet ' in line and '127.0.0.1' not in line and 'inet 169.254.' not in line:
parts = line.strip().split()
for i, part in enumerate(parts):
if part == 'inet' and i + 1 < len(parts):
ip = parts[i + 1]
ips.append(ip)
# 优先级:192.168.x.x > 10.x.x.x > 172.x.x.x > 其他
for ip in ips:
if ip.startswith('192.168.'):
return ip
for ip in ips:
if ip.startswith('10.'):
return ip
for ip in ips:
if ip.startswith('172.'):
return ip
if ips:
return ips[0]
except Exception:
# 备用方法
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
return local_ip
except Exception:
pass
return "127.0.0.1"
def send_dot_notification(self, title, message, signature, icon=None, link=None):
"""发送到 Quote/0(dot.mindreset.tech)的通知请求。"""
if not DOT_AUTH_TOKEN:
return {"skipped": True, "reason": "DOT_AUTH_TOKEN_EMPTY"}
try:
url = DOT_NOTIFY_URL
headers = {
"Authorization": f"Bearer {DOT_AUTH_TOKEN}",
"Content-Type": "application/json"
}
# Spaces 环境无法提供可访问的局域网地址;优先使用环境变量,未配置时保留旧版本机链接逻辑。
local_ip = self._get_local_ip()
final_link = link or DOT_LINK_URL or f"https://{local_ip}:1111"
data = {
"refreshNow": False,
"deviceId": DOT_DEVICE_ID,
"title": title,
"message": message,
"signature": signature,
"icon": icon or DOT_ICON,
"link": final_link
}
resp = requests.post(url, json=data, headers=headers, timeout=10)
try:
body = resp.json()
except Exception:
body = {"status": "non-json-response", "code": resp.status_code}
if resp.status_code >= 400:
return {"error": f"HTTP {resp.status_code}", "body": body}
return body
except Exception as e:
return {"error": str(e)}
# 全局客户端实例
client = DingTalkFormQueryClient()
def build_cookie_expired_login_url():
return url_for('login_page', reason='cookie_expired')
def is_cookie_expired_result(result):
return bool(result) and result.get('error') == 'COOKIE_EXPIRED'
def enrich_cookie_expired_result(result):
login_url = build_cookie_expired_login_url()
detail = result.get('detail') if result else ''
message = "当前登录已失效,请先重新登录并更新 Cookie 后再继续查询。"
if detail:
message = f"{message} {detail}"
return {
**(result or {}),
"login_url": login_url,
"message": message
}
def find_student_name_by_id(id_number):
"""根据学号/身份证号在 students.json 中查找学生姓名。"""
for student in STUDENTS_DATA:
if student.get('学号') == id_number:
return student.get('学生姓名')
return None
def find_students_by_name(name):
"""根据姓名精确匹配 students.json 中的学生,并按身份证号去重。"""
name = (name or '').strip()
if not name:
return []
matched = []
seen_ids = set()
for student in STUDENTS_DATA:
student_name = (student.get('学生姓名') or '').strip()
student_id = (student.get('学号') or '').strip()
if student_name == name and student_id and student_id not in seen_ids:
matched.append({
"姓名": student_name,
"身份证号": student_id
})
seen_ids.add(student_id)
return matched
def get_api_payload():
"""统一读取 JSON、form 或 query string 参数。"""
payload = {}
if request.is_json:
payload = request.get_json(silent=True) or {}
elif request.form:
payload = request.form.to_dict()
else:
payload = request.args.to_dict()
return payload or {}
def query_scores_for_id(id_number, include_notification=True):
"""按身份证号查询成绩,并统一处理 Cookie 过期与目标学生通知。"""
result = client.query_scores(id_number)
if is_cookie_expired_result(result):
result = enrich_cookie_expired_result(result)
return result, 401
if include_notification:
result = maybe_notify_target_student(id_number, result)
return result, 200
def maybe_notify_target_student(id_number, result):
"""恢复目标学生成绩推送逻辑,并供页面查询/API 查询共用。"""
if not TARGET_STUDENT_NAME or not result or not result.get('success') or not result.get('data'):
return result
try:
student_name = find_student_name_by_id(id_number)
if student_name != TARGET_STUDENT_NAME:
return result
print(f"✅ 检测到目标学生 {TARGET_STUDENT_NAME},准备发送 Quote/0 通知...")
records = result.get('data') or []
latest = records[0] if records else {}
trend = client.compute_rank_trend(records)
latest_exam = latest.get('考试名称', '')
latest_rank = latest.get('大类排名') or latest.get('组合排名') or '最新排名'
latest_score = latest.get('总分') or '暂无总分'
title = f"{TARGET_STUDENT_NAME} 本次考试排名: {latest_rank}名"
message = f"总分: {latest_score}|相比上次考试趋势: {trend}"
signature = latest_exam or "成绩更新"
send_resp = client.send_dot_notification(title, message, signature)
print(f"Quote/0 通知发送结果: {send_resp}")
result['trend'] = trend
result['dot_notification'] = send_resp
except Exception as e:
print(f"发送 Quote/0 通知或计算趋势时出错: {e}")
result['dot_notification'] = {"error": str(e)}
return result
# 应用启动时加载学生数据
load_students_data()
@app.route('/', methods=['GET'])
def index():
return render_template('index.html', result=None)
@app.route('/login')
def login_page():
"""打开 Playwright 登录工作台"""
reason = request.args.get('reason', '').strip().lower()
notice = ""
if reason == 'cookie_expired':
notice = "当前 Cookie 已失效,请在此页面重新登录并抓取最新 Cookie。"
return render_template(
'login.html',
login_notice=notice,
show_cookie_in_frontend=playwright_worker.SHOW_COOKIE_IN_FRONTEND
)
@app.route('/api/state')
def api_state():
return jsonify(playwright_worker.get_state(include_sensitive=playwright_worker.SHOW_COOKIE_IN_FRONTEND))
@app.route('/api/start', methods=['POST'])
def api_start():
started = playwright_worker.start_worker_if_needed()
return jsonify({
"ok": True,
"started": started,
"state": playwright_worker.get_state(include_sensitive=playwright_worker.SHOW_COOKIE_IN_FRONTEND)
})
@app.route('/api/screenshot', methods=['POST'])
def api_screenshot():
state = playwright_worker.get_state()
current_url = state.get("current_url") or playwright_worker.TARGET_PAGE
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
if playwright_worker.STORAGE_STATE_FILE.exists():
context = browser.new_context(storage_state=str(playwright_worker.STORAGE_STATE_FILE), viewport={"width": 1440, "height": 960})
else:
context = browser.new_context(viewport={"width": 1440, "height": 960})
page = context.new_page()
page.goto(current_url, wait_until="load", timeout=45000)
page.screenshot(path=str(playwright_worker.SCREENSHOT_FILE), full_page=True)
browser.close()
playwright_worker.set_state(has_screenshot=True)
playwright_worker.add_log(f"已保存整页截图:{playwright_worker.SCREENSHOT_FILE.name}", "success")
return jsonify({"ok": True, "url": "/static/browser_screenshot.png?t=%d" % int(time.time())})
except Exception as e:
playwright_worker.add_log(f"截图失败:{e}", "error")
return jsonify({"ok": False, "error": str(e)}), 500
@app.route('/api/files/<path:name>')
def api_files(name):
if not playwright_worker.SHOW_COOKIE_IN_FRONTEND and name == playwright_worker.HEADERS_FILE.name:
return jsonify({"ok": False, "error": "DEBUG_FILE_DOWNLOAD_DISABLED"}), 403
return send_from_directory(str(playwright_worker.DATA_DIR), name, as_attachment=True)
@app.route('/api/cookie-file')
def api_cookie_file():
if not playwright_worker.SHOW_COOKIE_IN_FRONTEND:
return jsonify({"ok": False, "error": "COOKIE_DOWNLOAD_DISABLED"}), 403
if not playwright_worker.COOKIE_FILE.exists():
return jsonify({"ok": False, "error": "COOKIE_FILE_NOT_FOUND"}), 404
return send_from_directory(
str(playwright_worker.BASE_DIR),
playwright_worker.COOKIE_FILE.name,
as_attachment=True
)
@app.route('/static/<path:name>')
def static_files(name):
# 用来服务二维码或截图
return send_from_directory(str(playwright_worker.STATIC_DIR), name)
@app.route('/search_student', methods=['POST'])
def search_student():
"""根据姓名查找学生"""
name = request.form.get('name', '').strip()
if not name:
return jsonify({"success": False, "message": "请输入学生姓名"})
if not STUDENTS_DATA:
return jsonify({"success": False, "message": "学生数据未加载,请检查students.json文件"})
# 模糊匹配学生姓名(支持拼音,去除输入法可能产生的'分词符)
matched_students = []
# 核心优化:忽略拼音中的'分词符
search_keyword = name.lower().replace("'", "")
for student in STUDENTS_DATA:
student_name = student.get("学生姓名", "")
if not student_name:
continue
# 1. 直接名称包含匹配
if name in student_name:
match_type = "name"
# 2. 拼音匹配
else:
# 获取全拼 (e.g. "zhouyangguang")
pinyin_full = p.get_pinyin(student_name, '').lower()
# 获取首字母 (e.g. "zyg")
pinyin_initials = p.get_initials(student_name, '').lower()
if search_keyword in pinyin_full or search_keyword in pinyin_initials:
match_type = "pinyin"
else:
match_type = None
if match_type:
# 直接显示完整身份证号,不进行掩码处理
id_number = student.get("学号", "")
matched_students.append({
"姓名": student_name,
"身份证号": id_number,
"显示身份证号": id_number,
"is_self": False
})
if not matched_students:
return jsonify({"success": False, "message": "未找到匹配的学生"})
# 添加调试信息
print(f"🔍 搜索姓名: {name}")
print(f"📋 找到 {len(matched_students)} 个匹配的学生")
return jsonify({"success": True, "students": matched_students})
@app.route('/compare')
def compare_page():
"""成绩比较页面"""
return render_template('compare.html')
@app.route('/api/get_student_scores', methods=['POST'])
def get_student_scores():
"""获取学生成绩数据API"""
id_number = request.form.get('id_number')
if not id_number:
return jsonify({"success": False, "error": "身份证号不能为空"})
result, status_code = query_scores_for_id(id_number)
return jsonify(result), status_code
@app.route('/api/scores', methods=['GET', 'POST'])
def api_scores():
"""
公开成绩查询 API。
支持:
- JSON: {"id_number": "..."} 或 {"name": "..."}
- form: id_number=... 或 name=...
- query string: /api/scores?id_number=... 或 /api/scores?name=...
"""
payload = get_api_payload()
id_number = (
payload.get('id_number')
or payload.get('id')
or payload.get('身份证号')
or payload.get('学号')
or ''
).strip()
name = (
payload.get('name')
or payload.get('student_name')
or payload.get('姓名')
or ''
).strip()
if id_number:
student_name = find_student_name_by_id(id_number)
result, status_code = query_scores_for_id(id_number)
if status_code != 200:
return jsonify(result), status_code
return jsonify({
"success": result.get("success", False),
"query_type": "id_number",
"student": {
"姓名": student_name,
"身份证号": id_number
},
"scores": result.get("data", []),
"message": result.get("message"),
"error": result.get("error"),
"trend": result.get("trend"),
"dot_notification": result.get("dot_notification")
}), 200
if name:
students = find_students_by_name(name)
if not students:
return jsonify({
"success": False,
"query_type": "name",
"error": "未找到匹配的学生",
"results": []
}), 404
results = []
for student in students:
result, status_code = query_scores_for_id(student["身份证号"])
if status_code != 200:
return jsonify(result), status_code
results.append({
"student": student,
"success": result.get("success", False),
"scores": result.get("data", []),
"message": result.get("message"),
"error": result.get("error"),
"trend": result.get("trend"),
"dot_notification": result.get("dot_notification")
})
return jsonify({
"success": True,
"query_type": "name",
"count": len(results),
"results": results
}), 200
return jsonify({
"success": False,
"error": "请提供 id_number 或 name"
}), 400
@app.route('/query', methods=['POST'])
def query():
id_number = request.form.get('id_number')
result = None
if not id_number:
result = {"success": False, "error": "身份证号不能为空"}
else:
result = client.query_scores(id_number)
if is_cookie_expired_result(result):
result = enrich_cookie_expired_result(result)
else:
result = maybe_notify_target_student(id_number, result)
# 准备传递给模板的数据
template_data = {
'id_number_submitted': id_number,
'success': result.get('success'),
'error_message': result.get('message') or result.get('error'),
'message': result.get('message'),
'scores_data': result.get('data'),
'trend': result.get('trend'), # 添加趋势数据传递给模板
'login_url': result.get('login_url'),
'login_required': is_cookie_expired_result(result)
}
# 传递 self_name,用于模板判断显示“本人”徽章
self_name = find_student_name_by_id(id_number)
return render_template('index.html', result=template_data, self_name=self_name)
def main():
"""主函数 - 改为启动Flask服务器"""
# 确保 templates 文件夹存在
templates_dir = CURRENT_DIR / "templates"
if not templates_dir.exists():
templates_dir.mkdir(parents=True, exist_ok=True)
print("创建 'templates' 文件夹。请将 'index.html' 文件放入其中。")
# 提示信息
print("=" * 60)
print("钉钉表单成绩查询工具 - Web版")
print("=" * 60)
print("请在浏览器中打开 http://127.0.0.1:1111/ 进行访问")
print("注意:")
print(f"1. 请确保在程序目录下有 {client.cookies_file.name} 文件且内容正确")
print(f"2. 请确保在程序目录下有 {STUDENTS_FILE.name} 文件包含学生信息")
print("3. 服务器日志会显示在此控制台")
print("=" * 60)
app.run(host='0.0.0.0', port=1111, debug=True)
if __name__ == "__main__":
main()