Spaces:
Running
Running
Download src/script.py from jzyg123/grade_query: direct link, hf CLI and curl.
- Browser
- Download file 41 kB
-
https://huggingface.co/spaces/jzyg123/grade_query/resolve/main/src/script.py
- Command line
-
hf download hf://spaces/jzyg123/grade_query/src/script.py
-
curl -L -o script.py https://huggingface.co/spaces/jzyg123/grade_query/resolve/main/src/script.py
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() | |
| def index(): | |
| return render_template('index.html', result=None) | |
| 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 | |
| ) | |
| def api_state(): | |
| return jsonify(playwright_worker.get_state(include_sensitive=playwright_worker.SHOW_COOKIE_IN_FRONTEND)) | |
| 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) | |
| }) | |
| 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 | |
| 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) | |
| 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 | |
| ) | |
| def static_files(name): | |
| # 用来服务二维码或截图 | |
| return send_from_directory(str(playwright_worker.STATIC_DIR), name) | |
| 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}) | |
| def compare_page(): | |
| """成绩比较页面""" | |
| return render_template('compare.html') | |
| 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 | |
| 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 | |
| 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() | |