import re import json import sys import base64 import os import requests # pyright: ignore[reportMissingModuleSource] sys.path.insert(0, os.path.join(os.path.dirname(__file__), "data")) import config as cfg # pyright: ignore[reportMissingImports] try: from Crypto.Cipher import AES # pyright: ignore[reportMissingImports] except ImportError: AES = None AES_KEY = "u2oh6Vu^HWe4_AES" LOGIN_PAGE = "https://passport2.chaoxing.com/login?fid=&newversion=true&refer=https%3A%2F%2Fi.chaoxing.com" def aes_encrypt(text: str) -> str: key = AES_KEY.encode("utf-8") iv = key raw = text.encode("utf-8") pad_len = AES.block_size - len(raw) % AES.block_size raw += bytes([pad_len] * pad_len) cipher = AES.new(key, AES.MODE_CBC, iv) encrypted = cipher.encrypt(raw) return base64.b64encode(encrypted).decode("utf-8") class ChaoxingEvaluator: def __init__(self, cookies: dict = None): self.session = requests.Session() if cookies: self.session.cookies.update(cookies) self.session.headers.update({ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9," "image/avif,image/webp,image/apng,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) self.base_url = "https://newes.chaoxing.com" @classmethod def login(cls, username: str, password: str) -> "ChaoxingEvaluator": if AES is None: raise ImportError("请先安装 pycryptodome: pip3 install pycryptodome") session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) login_page = "https://passport2.chaoxing.com/login?fid=&newversion=true&refer=https%3A%2F%2Fi.chaoxing.com" print("访问登录页面...") session.get(login_page) encrypted_uname = aes_encrypt(username) encrypted_pwd = aes_encrypt(password) login_data = { "fid": "-1", "uname": encrypted_uname, "password": encrypted_pwd, "refer": "https%253A%252F%252Fi.chaoxing.com", "t": "true", "forbidotherlogin": "0", "validate": "", "doubleFactorLogin": "0", "independentId": "0", "independentNameId": "0", } login_url = "https://passport2.chaoxing.com/fanyalogin" headers = { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Referer": login_page, "Origin": "https://passport2.chaoxing.com", } print("正在登录...") resp = session.post(login_url, data=login_data, headers=headers) result = resp.json() if not result.get("status"): print(f"登录失败: {result}") raise Exception(f"登录失败: {result}") print("登录成功!") evaluator = cls.__new__(cls) evaluator.session = session evaluator.base_url = "https://newes.chaoxing.com" cfg.save_cookies(dict(session.cookies)) return evaluator @classmethod def qr_login(cls) -> "ChaoxingEvaluator": import time import subprocess session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) print("获取登录页面...") resp = session.get(LOGIN_PAGE) uuid_match = re.search(r'id="uuid"\s*value="([^"]+)"', resp.text) or \ re.search(r'value="([^"]+)"\s+id="uuid"', resp.text) enc_match = re.search(r'id="enc"\s*value="([^"]+)"', resp.text) or \ re.search(r'value="([^"]+)"\s+id="enc"', resp.text) if not uuid_match or not enc_match: raise Exception("无法获取二维码参数") uuid = uuid_match.group(1) enc = enc_match.group(1) qr_url = f"https://passport2.chaoxing.com/createqr?uuid={uuid}&fid=-1" qr_resp = session.get(qr_url, headers={"Referer": LOGIN_PAGE}) qr_data = qr_resp.content qr_file = os.path.join(os.path.dirname(__file__), "data", "chaoxing_qr.png") with open(qr_file, "wb") as f: f.write(qr_data) print(f"二维码已保存到: {qr_file}") print("请使用超星学习通APP扫码登录...") if sys.platform == "darwin": subprocess.Popen(["open", qr_file]) elif sys.platform == "win32": os.startfile(qr_file) else: print(f"请打开文件扫码: {qr_file}") auth_url = "https://passport2.chaoxing.com/getauthstatus/v2" headers = { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Referer": LOGIN_PAGE, "Origin": "https://passport2.chaoxing.com", } for attempt in range(180): try: time.sleep(2) data = {"enc": enc, "uuid": uuid, "doubleFactorLogin": "0", "forbidotherlogin": "0"} poll_resp = session.post(auth_url, data=data, headers=headers) result = poll_resp.json() except KeyboardInterrupt: print("\n用户中断扫码") raise if result.get("status"): print(f"扫码登录成功!") evaluator = cls.__new__(cls) evaluator.session = session evaluator.base_url = "https://newes.chaoxing.com" cfg.save_cookies(dict(session.cookies)) return evaluator msg = result.get("mes", "") if msg == "已扫描": nickname = result.get("nickname", "") print(f"\r手机端已扫描,请在APP上确认登录 ({nickname})", end="", flush=True) elif msg == "验证通过": continue elif attempt < 3: print(f"\r等待扫码... ({attempt + 1}/3)", end="", flush=True) raise Exception("二维码登录超时") @classmethod def auto(cls) -> "ChaoxingEvaluator": cookies = cfg.load_cookies() if cookies: evaluator = cls(cookies) if evaluator.check_login_valid(): print("使用已保存的Cookie登录") return evaluator print("Cookie已失效,重新登录") cfg.clear_cookies() if cfg.QR_LOGIN: return cls.qr_login() if cfg.USERNAME and cfg.PASSWORD: try: return cls.login(cfg.USERNAME, cfg.PASSWORD) except Exception as e: print(f"密码登录失败: {e}") print("降级到二维码登录...") return cls.qr_login() return cls.qr_login() def check_login_valid(self) -> bool: try: resp = self._get("/pj/semesterV2/findSemesterList", referer=f"{self.base_url}/pj/frontv2/evaluateList/whatIEvaluated?_CP_=pj", accept_json=True) return resp.status_code == 200 and resp.json().get("status") == True except Exception: return False def get_cookies_dict(self) -> dict: return dict(self.session.cookies) def _get(self, path: str, params: dict = None, referer: str = None, accept_json: bool = False): url = f"{self.base_url}{path}" headers = {} if referer: headers["Referer"] = referer if accept_json: headers["Accept"] = "application/json, text/plain, */*" return self.session.get(url, params=params, headers=headers) def _post(self, path: str, data=None, params: dict = None, referer: str = None): url = f"{self.base_url}{path}" headers = { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", } if referer: headers["Referer"] = referer return self.session.post(url, data=data, params=params, headers=headers) def get_questionnaire_info(self, questionnaire_id: int) -> dict: path = "/pj/sendClassroomLogV2/getQuestionnaire" resp = self._get(path, params={"questionnaireId": questionnaire_id}, referer=f"{self.base_url}/pj/frontv2/evaluateList/whatIEvaluated?_CP_=pj", accept_json=True) return resp.json() def get_evaluation_list(self, questionnaire_id: int, page: int = 1, page_size: int = 100) -> dict: path = "/pj/newesReceptionV2/GetMyEvaluationQuestionnaireById" params = {"questionnaireId": questionnaire_id, "pageIndex": page, "pageSize": page_size, "kw": ""} referer = f"{self.base_url}/pj/frontv2/whatIEvaluatedDetails?_CP_=pj&questionnaireId={questionnaire_id}" resp = self._get(path, params=params, referer=referer, accept_json=True) return resp.json() def get_questionnaire_page(self, fid: int, uid: int, already_id: int, grant_id: int, questionnaire_id: int, classification_id: str = "") -> str: path = "/pj/newesReception/questionnaireInfo" params = { "fid": fid, "uId": uid, "alreadyId": already_id, "grantId": grant_id, "questionnaireId": questionnaire_id, "type": 1, "classificationId": classification_id, "isNewPage": "true", "source": 14, } referer = (f"{self.base_url}/pj/frontv2/whatIEvaluatedDetails?_CP_=pj" f"&questionnaireId={questionnaire_id}" f"&title=%E8%AF%84%E4%BB%B7%E8%A1%A8%E8%AF%A6%E6%83%85" f"&evaluateObjType=1&questionnaireType=2&sendType=18" f"&hasFinished=0&questionnaireStatus=0&showCourseProfile=0" f"&isShowTeacherPhoto=0&alreadyType=6&classGrade=0" f"&hideScoreLevel=0&optionrepeatcheck=0&granterType=8") resp = self._get(path, params=params, referer=referer) return resp.text @staticmethod def parse_questions(html: str) -> list: questions = [] input_pattern = re.compile( r']*name=["\']groupTargetIds["\'][^>]*value=["\'](\d+)["\']' ) type_pattern = re.compile( r']*name=["\'](\d+)_type["\'][^>]*value=["\'](\d+)["\']' ) maxscore_pattern = re.compile( r']*\bid=["\'](\d+)["\'][^>]*\bmaxScore=["\']([\d.]+)["\']' ) minscore_pattern = re.compile( r']*\bid=["\'](\d+)["\'][^>]*\bminScore=["\']([\d.]+)["\']' ) target_ids = input_pattern.findall(html) type_matches = {m[0]: m[1] for m in type_pattern.findall(html)} maxscore_matches = {m[0]: float(m[1]) for m in maxscore_pattern.findall(html)} minscore_matches = {m[0]: float(m[1]) for m in minscore_pattern.findall(html)} seen = set() for tid in target_ids: if tid in seen: continue seen.add(tid) ttype = int(type_matches.get(tid, 0)) item = {"id": tid, "type": ttype} if ttype == 5: item["maxScore"] = maxscore_matches.get(tid, 0) item["minScore"] = minscore_matches.get(tid, 0) questions.append(item) return questions def check_score_is_rightful(self, questionnaire_id: int, target_id: int, uid: int, score: float, referer: str) -> bool: path = "/pj/newesReception/checkScoreIsRightful" data = {"questionnaireId": questionnaire_id, "targetId": target_id, "uId": uid, "score": score} resp = self._post(path, data=data, referer=referer) result = resp.json() return not result.get("flag", True) def check_no_answer_target(self, questionnaire_id: int, total_score: float, no_answer_ids: list, referer: str) -> dict: path = "/pj/newesReception/checkNoAnswerTarget" data = { "questionnaireId": questionnaire_id, "questionnaireScore": total_score, "noAnswerTargetIds": json.dumps(no_answer_ids), } resp = self._post(path, data=data, referer=referer) return resp.json() def check_total_score_is_rightful(self, questionnaire_id: int, uid: int, score: float, referer: str) -> bool: path = "/pj/newesReception/checkTotalScoreIsRightful" data = {"questionnaireId": questionnaire_id, "uId": uid, "score": score} resp = self._post(path, data=data, referer=referer) result = resp.json() return not result.get("flag", True) def save_questionnaire(self, fid: int, uid: int, questionnaire_id: int, already_id: int, grant_id: int, questions: list, total_score: float, referer: str) -> dict: path = "/pj/newesReception/saveQuestionnaire" params = { "checkScore": total_score, "questionnaireId": questionnaire_id, "fid": fid, "uId": uid, "grantId": grant_id, "alreadyId": already_id, } form_data = [ ("uId", str(uid)), ("fid", str(fid)), ("questionnaireId", str(questionnaire_id)), ("alreadyId", str(already_id)), ("grantId", str(grant_id)), ] for q in questions: qid_str = str(q["id"]) form_data.append(("groupTargetIds", qid_str)) form_data.append((f"{qid_str}_type", str(q["type"]))) form_data.append((f"{qid_str}_chooseSetUp", "1")) if q["type"] == 5: score = q["maxScore"] form_data.append((qid_str, str(score))) elif q["type"] == 4: form_data.append(("jumpInfo", "")) form_data.append((qid_str, "")) form_data.append(("saveType", "2")) form_data.append(("submitreasons", "")) form_data.append(("submit_highscore_reasons", "")) form_data.append(("submit_lowscore_reasons", "")) form_data.append(("checkScore", str(total_score))) resp = self._post(path, data=form_data, params=params, referer=referer) return resp.json() def evaluate_single(self, fid: int, uid: int, already_id: int, grant_id: int, questionnaire_id: int, teacher_name: str = "", course_name: str = "") -> bool: print(f"\n{'='*60}") print(f"正在评教: {teacher_name} - {course_name}") referer = (f"{self.base_url}/pj/newesReception/questionnaireInfo" f"?fid={fid}&uId={uid}&alreadyId={already_id}" f"&grantId={grant_id}&questionnaireId={questionnaire_id}" f"&type=1&classificationId=&isNewPage=true&source=14") html = self.get_questionnaire_page(fid, uid, already_id, grant_id, questionnaire_id) questions = self.parse_questions(html) if not questions: print("未解析到任何题目,可能HTML结构已变更") return False print(f"解析到 {len(questions)} 道题目:") total_score = 0 has_scoring = False for q in questions: if q["type"] == 5: has_scoring = True print(f" 打分题 [{q['id']}] 满分 {q['maxScore']} -> 给满分") total_score += q["maxScore"] elif q["type"] == 4: print(f" 简答题 [{q['id']}] -> 留空") if has_scoring and total_score == 0: print("所有打分题满分为0,页面可能为只读状态(已评价),跳过") return False print(f"总分: {total_score}") no_answer_ids = [str(q["id"]) for q in questions if q["type"] != 5] print("正在验证各题分数...") all_score_ok = True for q in questions: if q["type"] == 5: score = q["maxScore"] ok = self.check_score_is_rightful(questionnaire_id, int(q["id"]), uid, score, referer) if not ok: print(f" 题目 {q['id']} 分数验证未通过") all_score_ok = False else: print(f" 题目 {q['id']} 分数验证通过") if not all_score_ok: print("分数验证失败,停止提交") return False print("正在检查未答题目...") no_answer_result = self.check_no_answer_target( questionnaire_id, total_score, no_answer_ids, referer ) print(f" 未答检查结果: {no_answer_result}") print("正在验证总分...") total_ok = self.check_total_score_is_rightful(questionnaire_id, uid, total_score, referer) if not total_ok: print("总分验证未通过") return False print(" 总分验证通过") print("正在提交评教...") result = self.save_questionnaire( fid, uid, questionnaire_id, already_id, grant_id, questions, total_score, referer ) print(f"提交结果: {result}") if result.get("status") == 1: print(f"评教成功: {teacher_name} - {course_name}") return True else: print(f"评教失败: {result}") return False def discover_questionnaires(self) -> list: semester_resp = self._get("/pj/semesterV2/findSemesterList", referer=f"{self.base_url}/pj/frontv2/evaluateList/whatIEvaluated?_CP_=pj", accept_json=True) semester_id = None try: semesters = semester_resp.json() if semesters.get("status"): for s in semesters.get("data", []): if s.get("iscurrent") == 1: semester_id = s.get("id") break if semester_id is None and semesters.get("data"): semester_id = semesters["data"][0].get("id") except (json.JSONDecodeError, ValueError, TypeError): pass if not semester_id: print("无法获取学期ID") return [] list_resp = self._get("/pj/newesReceptionV2/GetMyEvaluationList", params={"evaluateObjType": "", "semesterId": semester_id, "title": "", "sort": 2, "pageIndex": 1, "pageSize": 20}, referer=f"{self.base_url}/pj/frontv2/evaluateList/whatIEvaluated?_CP_=pj", accept_json=True) questionnaires = [] try: data = list_resp.json() if not data.get("status") and data.get("code") != 1: return [] items = [] if data.get("status"): items = data.get("data", {}).get("list", []) elif data.get("code") == 1: items = data.get("data", {}).get("list", []) for q in items: qid = q.get("questionnaireId") name = q.get("title") or q.get("name", "") if qid: questionnaires.append({"id": int(qid), "name": name}) except (json.JSONDecodeError, ValueError, TypeError): pass return questionnaires def batch_evaluate(self, questionnaire_id: int, fid: int = None, uid: int = None): info = self.get_questionnaire_info(questionnaire_id) if info.get("status") != 1: print("获取问卷信息失败") return eval_list_resp = self.get_evaluation_list(questionnaire_id) if not eval_list_resp.get("status"): print("获取评教列表失败") return data = eval_list_resp.get("data", {}) total = data.get("count", 0) items = data.get("list", []) print(f"共有 {total} 条待评记录") success = 0 fail = 0 for i, item in enumerate(items, 1): obj = item.get("alreadyObject", {}) teacher_name = obj.get("teacherName", "未知") course_name = obj.get("courseName", "未知") already_id = item.get("alreadyObjectId") grant_id = item.get("grantId") _fid = fid or obj.get("fid") or info.get("classroomlog", {}).get("fid") _uid = uid or int(self.session.cookies.get("_uid", 0)) print(f"\n[{i}/{total}] ", end="") ok = self.evaluate_single( fid=_fid, uid=_uid, already_id=already_id, grant_id=grant_id, questionnaire_id=questionnaire_id, teacher_name=teacher_name, course_name=course_name, ) if ok: success += 1 else: fail += 1 print(f"\n{'='*60}") print(f"评教完成! 成功: {success}, 失败: {fail}") def parse_cookies_from_file(filepath: str) -> dict: cookies = {} with open(filepath, "r", encoding="utf-8") as f: content = f.read() match = re.search(r"Cookie:\s*(.+)", content) if match: cookie_str = match.group(1) for part in cookie_str.split(";"): part = part.strip() if "=" in part: key, value = part.split("=", 1) cookies[key.strip()] = value.strip() return cookies def main(): import argparse parser = argparse.ArgumentParser(description="超星学习通自动评教脚本") parser.add_argument("--cookie-file", "-c", type=str, help="Cookie文件路径(从抓包数据中复制的包含Cookie的请求头文件)") parser.add_argument("--cookie-string", "-s", type=str, help="Cookie字符串,例如: `fid=356; _uid=123456; ...`") parser.add_argument("--username", "-u", type=str, help="手机号/学号(用于自动登录)") parser.add_argument("--password", "-p", type=str, help="密码(用于自动登录)") parser.add_argument("--use-config", action="store_true", help="使用config.py中的配置(自动管理Cookie)") parser.add_argument("--questionnaire-id", "-q", type=int, default=None, help="问卷ID,不传则自动发现") parser.add_argument("--fid", type=int, default=None, help="学校fid,默认从Cookie中自动获取") parser.add_argument("--uid", type=int, default=None, help="用户ID,默认从Cookie中自动获取") parser.add_argument("--list", "-l", action="store_true", help="仅列出可用问卷,不执行评教") parser.add_argument("--no-save-cookies", action="store_true", help="不保存Cookie到config.json") parser.add_argument("--qr", action="store_true", help="使用二维码登录") args = parser.parse_args() if args.qr: evaluator = ChaoxingEvaluator.qr_login() elif args.use_config: evaluator = ChaoxingEvaluator.auto() elif args.username and args.password: evaluator = ChaoxingEvaluator.login(args.username, args.password) if not args.no_save_cookies: cfg.save_cookies(evaluator.get_cookies_dict()) print("Cookie已保存到 config.json") elif args.cookie_file: cookies = parse_cookies_from_file(args.cookie_file) evaluator = ChaoxingEvaluator(cookies) elif args.cookie_string: cookies = {} for part in args.cookie_string.split(";"): part = part.strip() if "=" in part: key, value = part.split("=", 1) cookies[key.strip()] = value.strip() evaluator = ChaoxingEvaluator(cookies) else: parser.print_help() print("\n错误: 请使用 --qr 或 --use-config 或提供账号密码或Cookie") return if args.list: qs = evaluator.discover_questionnaires() if not qs: print("未发现可用问卷") return print("可用问卷:") for q in qs: print(f" ID: {q['id']} [{q['name']}]") return questionnaire_id = args.questionnaire_id if not questionnaire_id: qs = evaluator.discover_questionnaires() if not qs: print("错误: 无法自动发现问卷ID,请通过 --questionnaire-id 手动指定") return if len(qs) == 1: questionnaire_id = qs[0]["id"] print(f"自动发现问卷: ID={questionnaire_id} [{qs[0]['name']}]") else: print("发现多个可用问卷:") for i, q in enumerate(qs, 1): print(f" [{i}] ID: {q['id']} [{q['name']}]") try: choice = int(input("请选择序号: ").strip()) questionnaire_id = qs[choice - 1]["id"] except (ValueError, IndexError): print("无效选择") return cookies = evaluator.session.cookies uid = args.uid or _get_cookie_int(cookies, "_uid") fid = args.fid or _get_cookie_int(cookies, "fid") if not uid: print("错误: 无法获取用户ID,请通过 --uid 参数提供") return if not fid: print("错误: 无法获取fid,请通过 --fid 参数提供") return evaluator.batch_evaluate(questionnaire_id, fid=fid, uid=uid) def _get_cookie_int(cookies, name): val = cookies.get(name) if val: try: return int(val) except (ValueError, TypeError): pass return None if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\n\n用户中断,已退出") sys.exit(0)