From 15c20a37827f3d1e998c09a98c75e890529e14be Mon Sep 17 00:00:00 2001 From: Zropk Date: Mon, 1 Jun 2026 23:14:51 +0800 Subject: [PATCH 01/17] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E4=BB=BB=E5=8A=A1=E8=B0=83=E5=BA=A6=E3=80=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=9A=E7=BA=BF=E7=A8=8B=E5=86=B2=E7=AA=81=E4=B8=8E?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=A2=98=E5=BA=93=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复多课程并行时优先级队列的字典比较报错 (TypeError) - 移除 tqdm 动态替换锁导致的 loguru 异步日志线程锁释放报错 (RuntimeError) - 重构 Tiku 为抽象基类 (ABC),支持自定义配置文件路径 `-c` 的深层穿透 - 实现线程安全的 Cookie 自动重登录与 403 验证码 (ddddocr) 自动识别绕过 - 优化任务执行流为全局跨课程并发调度,新增 `retry_interval` 重试间隔配置 - 添加对 Python 3.12 及以下版本的兼容处理,使用 with 语句规范 tqdm 资源释放 - 扁平化重构局部嵌套函数,对 ddddocr 导包异常做防崩溃处理以提升平台兼容性 - 引入 tenacity 重试机制并升级 httpx[socks] 依赖,完美支持系统 SOCKS 代理 --- api/answer.py | 103 +++++---- api/base.py | 547 ++++++++++++++++++++++---------------------- api/captcha.py | 3 +- config_template.ini | 3 + main.py | 89 ++++--- requirements.txt | 6 +- 6 files changed, 402 insertions(+), 349 deletions(-) diff --git a/api/answer.py b/api/answer.py index af006ffe..32d2f15b 100644 --- a/api/answer.py +++ b/api/answer.py @@ -7,6 +7,7 @@ import tempfile import threading import time +from abc import ABC, abstractmethod from pathlib import Path from re import sub from typing import Optional @@ -134,18 +135,18 @@ def add_cache(self, question: str, answer: str) -> None: self._write_cache(data) -# TODO: 重构此部分代码,将此类改为抽象类,加载题库方法改为静态方法,禁止直接初始化此类 -class Tiku: - CONFIG_PATH = os.path.join(os.getcwd(), "config.ini") # TODO: 从运行参数中获取config路径 +class Tiku(ABC): + CONFIG_PATH = os.path.join(os.getcwd(), "config.ini") DISABLE = False # 停用标志 SUBMIT = False # 提交标志 COVER_RATE = 0.8 # 覆盖率 true_list = [] false_list = [] - def __init__(self) -> None: + def __init__(self, config_path: Optional[str] = None) -> None: self._name = None self._api = None self._conf = None + self._config_path = config_path or self.CONFIG_PATH @property def name(self): @@ -198,7 +199,7 @@ def _get_conf(self): """ try: config = configparser.ConfigParser() - config.read(self.CONFIG_PATH, encoding="utf8") + config.read(self._config_path, encoding="utf8") return config['tiku'] except (KeyError, FileNotFoundError): logger.info("未找到tiku配置, 已忽略题库功能") @@ -238,6 +239,7 @@ def query(self,q_info:dict) -> Optional[str]: + @abstractmethod def _query(self, q_info:dict) -> Optional[str]: """ 查询接口, 交由自定义题库实现 @@ -245,58 +247,67 @@ def _query(self, q_info:dict) -> Optional[str]: pass - def get_tiku_from_config(self): + @staticmethod + def get_tiku_from_config(config: Optional[dict] = None, config_path: Optional[str] = None): """ 从配置文件加载题库, 这个配置可以是用户提供, 可以是默认配置文件 """ - if not self._conf: + conf = config + path = config_path or Tiku.CONFIG_PATH + if not conf: # 尝试从默认配置文件加载 - self.config_set(self._get_conf()) - if self.DISABLE: - return self + try: + config_parser = configparser.ConfigParser() + config_parser.read(path, encoding="utf8") + conf = config_parser['tiku'] + except (KeyError, FileNotFoundError): + logger.error("未找到题库配置, 已忽略题库功能") + dummy = DummyTiku(config_path=path) + return dummy + try: - cls_name = self._conf['provider'] + cls_name = conf['provider'] if not cls_name: raise KeyError except KeyError: - self.DISABLE = True logger.error("未找到题库配置, 已忽略题库功能") - return self + dummy = DummyTiku(config_path=path) + return dummy providers = [name.strip() for name in cls_name.split(',') if name.strip()] if not providers: - self.DISABLE = True logger.error("题库provider配置为空, 已忽略题库功能") - return self + dummy = DummyTiku(config_path=path) + return dummy invalid_providers = [name for name in providers if name not in PROVIDER_REGISTRY] if invalid_providers: - self.DISABLE = True logger.error(f"题库provider配置无效: {', '.join(invalid_providers)}") - return self + dummy = DummyTiku(config_path=path) + return dummy if len(providers) == 1: provider_cls = PROVIDER_REGISTRY[providers[0]] if not isinstance(provider_cls, type) or not issubclass(provider_cls, Tiku): - self.DISABLE = True logger.error(f"题库provider配置无效: {providers[0]}") - return self - new_cls = provider_cls() - new_cls.config_set(self._conf) + dummy = DummyTiku(config_path=path) + return dummy + new_cls = provider_cls(config_path=path) + new_cls.config_set(conf) return new_cls chain_providers = [] for provider_name in providers: provider_cls = PROVIDER_REGISTRY[provider_name] if not isinstance(provider_cls, type) or not issubclass(provider_cls, Tiku): - self.DISABLE = True logger.error(f"题库provider配置无效: {provider_name}") - return self - provider = provider_cls() - provider.config_set(self._conf) + dummy = DummyTiku(config_path=path) + return dummy + provider = provider_cls(config_path=path) + provider.config_set(conf) chain_providers.append(provider) - fallback = TikuFallback(chain_providers) - fallback.config_set(self._conf) + fallback = TikuFallback(chain_providers, config_path=path) + fallback.config_set(conf) return fallback def judgement_select(self, answer: str) -> bool: @@ -337,8 +348,8 @@ def check_llm_connection(self) -> bool: class TikuFallback(Tiku): # 多题库回退实现,按 provider 中配置顺序依次查询。 - def __init__(self, providers=None): - super().__init__() + def __init__(self, providers=None, config_path: Optional[str] = None): + super().__init__(config_path) self.name = '多题库回退' self.providers = providers or [] @@ -390,8 +401,8 @@ def check_llm_connection(self) -> bool: class TikuYanxi(Tiku): # 言溪题库实现 - def __init__(self) -> None: - super().__init__() + def __init__(self, config_path: Optional[str] = None) -> None: + super().__init__(config_path) self.name = '言溪题库' self.api = 'https://tk.enncy.cn/query' self._token = None @@ -440,8 +451,8 @@ def _init_tiku(self): class TikuGo(Tiku): # GO题(网课小工具题库)实现 - def __init__(self) -> None: - super().__init__() + def __init__(self, config_path: Optional[str] = None) -> None: + super().__init__(config_path) self.name = 'GO题(网课小工具题库)' self.api = 'https://q.icodef.com/wyn-nb?v=4' self._headers = { @@ -609,8 +620,8 @@ def _init_tiku(self): class TikuLike(Tiku): # LIKE知识库实现 参考 https://www.datam.site/ - def __init__(self) -> None: - super().__init__() + def __init__(self, config_path: Optional[str] = None) -> None: + super().__init__(config_path) self.name = 'LIKE知识库' self.ver = '2.0.0' #对应官网API版本 self.query_api = 'https://app.datam.site/api/v1/query' @@ -928,8 +939,8 @@ def _init_tiku(self) -> None: class TikuAdapter(Tiku): # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter - def __init__(self) -> None: - super().__init__() + def __init__(self, config_path: Optional[str] = None) -> None: + super().__init__(config_path) self.name = 'TikuAdapter题库' self.api = '' @@ -976,8 +987,8 @@ def _init_tiku(self): class AI(Tiku): # AI大模型答题实现 - def __init__(self) -> None: - super().__init__() + def __init__(self, config_path: Optional[str] = None) -> None: + super().__init__(config_path) self.name = 'AI大模型答题' self.last_request_time = None @@ -1148,8 +1159,8 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): """硅基流动大模型答题实现""" - def __init__(self): - super().__init__() + def __init__(self, config_path: Optional[str] = None): + super().__init__(config_path) self.name = '硅基流动大模型' self.last_request_time = None @@ -1288,6 +1299,16 @@ def check_llm_connection(self) -> bool: return False +class DummyTiku(Tiku): + def __init__(self, config_path: Optional[str] = None) -> None: + super().__init__(config_path) + self.name = '空/禁用题库' + self.DISABLE = True + + def _query(self, q_info: dict) -> Optional[str]: + return None + + PROVIDER_REGISTRY = { 'TikuYanxi': TikuYanxi, 'TikuGo': TikuGo, diff --git a/api/base.py b/api/base.py index baa9af94..4ed4ef24 100644 --- a/api/base.py +++ b/api/base.py @@ -8,6 +8,7 @@ from enum import Enum, IntEnum from hashlib import md5 from typing import Self, Optional, Literal +from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception import requests from loguru import logger @@ -36,6 +37,7 @@ def get_timestamp(): class SessionManager: _instance = None + _login_lock = threading.Lock() def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -66,6 +68,25 @@ def get_session(cls) -> requests.Session: def update_cookies(cls): cls.get_instance()._session.cookies.update(use_cookies()) + @classmethod + def relogin_if_needed(cls, chaoxing_instance) -> bool: + with cls._login_lock: + # Check if cookie session is still invalid + if chaoxing_instance._validate_cookie_session(): + return True + + # Try to relogin + logger.info("Cookie session invalid, attempting thread-safe relogin...") + if chaoxing_instance.account and chaoxing_instance.account.username and chaoxing_instance.account.password: + login_result = chaoxing_instance.login(login_with_cookies=False) + if login_result.get("status"): + cls.update_cookies() + logger.info("Thread-safe relogin succeeded") + return True + else: + logger.warning(f"Thread-safe relogin failed: {login_result.get('msg')}") + return False + class Account: username = None @@ -123,6 +144,136 @@ class ActivityType(IntEnum): SIGNIN = 2 +def multi_cut(answer: str, origin_html_content="", logger=logger): + """ + 将多选题答案字符串按特定字符进行切割, 并返回切割后的答案列表 + """ + res = cut(answer) + if res is None: + logger.warning( + f"未能从网页中提取题目信息, 以下为相关信息:\n\t{answer}\n\n{origin_html_content}\n" + ) + logger.warning("未能正确提取题目选项信息! 请反馈并提供以上信息") + return None + else: + return res + +def clean_res(res): + cleaned_res = [] + if isinstance(res, str): + res = [res] + for c in res: + # 仅在字符串长度大于1时才尝试去除开头的字母编号,防止误删单个字母答案 + cleaned = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*|[.,!?;:,。!?;:]', '', c) if len(c) > 1 else c + cleaned_res.append(cleaned.strip()) + return cleaned_res + +def normalize_text(text: str) -> str: + if not isinstance(text, str): + text = str(text) + # 统一常见异体字符,降低“风/⻛”类差异导致的匹配失败。 + char_map = str.maketrans({ + '⻛': '风', + '⻔': '门', + '⻋': '车', + '⻢': '马', + }) + normalized = text.translate(char_map) + normalized = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', normalized) + normalized = re.sub(r'\s+', '', normalized) + normalized = re.sub(r'[,。!?;:,.!?;:()()\[\]【】"“”‘’\-_/\\|]', '', normalized) + return normalized.lower() + +def get_option_text(option: str) -> str: + return re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', option).strip() + +def best_option_by_similarity(target: str, options: list, threshold: float = 0.8) -> str: + if not target or not options: + return "" + target_norm = normalize_text(target) + if not target_norm: + return "" + + best_letter = "" + best_score = 0.0 + for option in options: + option_text = get_option_text(option) + option_norm = normalize_text(option_text) + if not option_norm: + continue + score = SequenceMatcher(None, target_norm, option_norm).ratio() + if score > best_score: + best_score = score + best_letter = option[:1] + + if best_score >= threshold: + logger.info(f"相似度兜底匹配成功: {best_letter} (score={best_score:.2f}, threshold={threshold:.2f})") + return best_letter + return "" + +def is_subsequence(a, o): + iter_o = iter(o) + return all(c in iter_o for c in a) + +def random_answer(options: str, q_type: str) -> str: + answer = "" + if not options: + return answer + + if q_type == "multiple": + logger.debug(f"当前选项列表[cut前] -> {options}") + _op_list = multi_cut(options) + logger.debug(f"当前选项列表[cut后] -> {_op_list}") + + if not _op_list: + logger.error( + "选项为空, 未能正确提取题目选项信息! 请反馈并提供以上信息" + ) + return answer + + available_options = len(_op_list) + select_count = 0 + + # 根据可用选项数量调整可能选择的选项数 + if available_options <= 1: + select_count = available_options + else: + max_possible = min(4, available_options) + min_possible = min(2, available_options) + + weights_map = { + 2: [1.0], + 3: [0.3, 0.7], + 4: [0.1, 0.5, 0.4], + 5: [0.1, 0.4, 0.3, 0.2], + } + + weights = weights_map.get(max_possible, [0.3, 0.4, 0.3]) + possible_counts = list(range(min_possible, max_possible + 1)) + + weights = weights[:len(possible_counts)] + + weights_sum = sum(weights) + if weights_sum > 0: + weights = [w / weights_sum for w in weights] + + select_count = random.choices(possible_counts, weights=weights, k=1)[0] + + selected_options = random.sample(_op_list, select_count) if select_count > 0 else [] + + for option in selected_options: + answer += option[:1] # 取首字为答案,例如A或B + + answer = "".join(sorted(answer)) + elif q_type == "single": + answer = random.choice(options.split("\n"))[:1] # 取首字为答案, 例如A或B + # 判断题处理 + elif q_type == "judgement": + answer = "true" if random.choice([True, False]) else "false" + logger.info(f"随机选择 -> {answer}") + return answer + + class Chaoxing: def __init__(self, account: Account = None, tiku: Tiku = None, **kwargs): self.account = account @@ -437,6 +588,41 @@ def video_progress_log( if att_duration_enc: params["attDurationEnc"] = att_duration_enc + def perform_request(rt_val): + params.update({"rt": rt_val, "_t": get_timestamp()}) + res = _session.get(_url, params=params, headers=headers) + if res.status_code == 403 or '验证码' in res.text or 'validate' in res.text: + logger.warning("检测到验证码拦截,正在尝试自动通过验证码...") + try: + from api.captcha import CxCaptcha + cookies_str = "; ".join([f"{k}={v}" for k, v in _session.cookies.items()]) + ua = headers.get("User-Agent", gc.HEADERS.get("User-Agent")) + ocr_inst = getattr(self, '_ocr', None) + if ocr_inst is None: + from api.captcha import ocr_init + ocr_inst = ocr_init() + if ocr_inst: + self._ocr = ocr_inst + captcha_solver = CxCaptcha(user_agent=ua, cookies=cookies_str, ocr=ocr_inst) + solved = False + for attempt in range(3): + logger.info(f"第 {attempt + 1} 次尝试通关验证码...") + if captcha_solver.try_pass(): + logger.success("验证码通关成功!") + solved = True + break + else: + logger.warning("验证码验证失败,正在重试...") + time.sleep(2) + if solved: + _session.cookies.update(captcha_solver.s.cookies) + res = _session.get(_url, params=params, headers=headers) + else: + logger.error("多次验证码通关失败,可能需要手动干预。") + except Exception as e: + logger.error(f"验证码通关逻辑异常: {e}") + return res + rt = _job['rt'] if not rt: rt_search = re.search(r"-rt_([1d])", _job['otherinfo']) @@ -448,24 +634,16 @@ def video_progress_log( if rt: logger.trace(f"Got rt: {rt}") _job['rt'] = rt - params.update({"rt": rt, - "_t": get_timestamp()}) - resp = _session.get(_url, params=params, headers=headers) + resp = perform_request(rt) else: logger.warning("Failed to get rt") for rt in [0.9, 1]: - params.update({"rt": rt, - "_t": get_timestamp()}) - resp = _session.get(_url, params=params, headers=headers) + resp = perform_request(rt) if resp.status_code == 200: logger.trace(resp.text) return resp.json()["isPassed"], 200 - # elif resp.ok: - # # TODO: 处理验证码 - # pass elif resp.status_code == 403: logger.warning("出现403报错, 正常尝试切换rt") - else: logger.warning("未知错误 jobid={}, status_code={}, 摘要:\n{}", _job.get("jobid"), @@ -531,13 +709,8 @@ def _recover_after_forbidden(self, session: requests.Session, job: dict, _type: if refreshed: return refreshed - # FIXME: Temporarily disabled for multithreading support - if False and self.account and self.account.username and self.account.password: - login_result = self.login(login_with_cookies=False) - if login_result.get("status"): - SessionManager.update_cookies() - return self._refresh_video_status(session, job, _type) - logger.warning("账号密码登录失败: {}", login_result.get("msg")) + if SessionManager.relogin_if_needed(self): + return self._refresh_video_status(session, job, _type) return None @@ -569,61 +742,59 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, logger.info(f"开始任务: {_job['name']}, 总时长: {duration}s, 已进行: {play_time}s") - pbar = tqdm(total=duration, initial=play_time, desc=_job["name"], - unit_scale=True, bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}') - - forbidden_retry = 0 - max_forbidden_retry = 2 - passed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, duration, duration, _type, headers=headers, _isdrag=4) if passed: logger.info("任务瞬间完成: {}", _job['name']) return StudyResult.SUCCESS - while not passed: - # Sometimes the last request needs to be sent several times to complete the task - if play_time - last_log_time >= wait_time or play_time == duration: - - passed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, duration, - int(play_time), _type, headers=headers) - - if state == 403: - if forbidden_retry >= max_forbidden_retry: - logger.warning("403重试失败, 跳过当前任务") - return StudyResult.FORBIDDEN - forbidden_retry += 1 - logger.warning( - "出现403报错, 正在尝试刷新会话状态 (第{}次)", - forbidden_retry, - ) - time.sleep(random.uniform(2, 4)) - refreshed_meta = self._recover_after_forbidden(_session, _job, _type) - if refreshed_meta: - # FIXME: if those keys aren't present, it should be considered an error rather than falling back - _dtoken = refreshed_meta.get("dtoken", _dtoken) - _duration = refreshed_meta.get("duration", duration) - play_time = refreshed_meta.get("playTime", play_time) - - logger.debug("Refreshed token: {}, duration: {}, play time: {}", _dtoken, _duration, play_time) - continue - - elif not passed and state != 200: - return StudyResult.ERROR - - wait_time = int(random.uniform(30, 90)) - last_log_time = play_time - - logger.trace("Progress logged") - - # Uploading the progress takes time, we assume that the video is still playing in the background, this manually calculates the time elapsed - dt = (time.time() - last_iter) * _speed - last_iter = time.time() - play_time = min(duration, play_time + dt) - - pbar.n = int(play_time) - pbar.refresh() - time.sleep(gc.THRESHOLD) + with tqdm(total=duration, initial=play_time, desc=_job["name"], + unit_scale=True, bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}') as pbar: + while not passed: + # Sometimes the last request needs to be sent several times to complete the task + if play_time - last_log_time >= wait_time or play_time == duration: + + passed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, duration, + int(play_time), _type, headers=headers) + + if state == 403: + if forbidden_retry >= max_forbidden_retry: + logger.warning("403重试失败, 跳过当前任务") + return StudyResult.FORBIDDEN + forbidden_retry += 1 + logger.warning( + "出现403报错, 正在尝试刷新会话状态 (第{}次)", + forbidden_retry, + ) + time.sleep(random.uniform(2, 4)) + refreshed_meta = self._recover_after_forbidden(_session, _job, _type) + if refreshed_meta and refreshed_meta.get("dtoken") and refreshed_meta.get("duration") is not None: + _dtoken = refreshed_meta["dtoken"] + _duration = refreshed_meta["duration"] + play_time = refreshed_meta.get("playTime", play_time) + + logger.debug("Refreshed token: {}, duration: {}, play time: {}", _dtoken, _duration, play_time) + continue + else: + logger.error("会话恢复失败,刷新后的元数据缺少必要字段 (dtoken, duration)") + return StudyResult.ERROR + + elif not passed and state != 200: + return StudyResult.ERROR + + wait_time = int(random.uniform(30, 90)) + last_log_time = play_time + + logger.trace("Progress logged") + + # Uploading the progress takes time, we assume that the video is still playing in the background, this manually calculates the time elapsed + dt = (time.time() - last_iter) * _speed + last_iter = time.time() + play_time = min(duration, play_time + dt) + + pbar.n = int(play_time) + pbar.refresh() + time.sleep(gc.THRESHOLD) logger.info("任务完成: {}", _job['name']) return StudyResult.SUCCESS @@ -661,215 +832,23 @@ def study_document(self, _course, _job) -> StudyResult: return StudyResult.SUCCESS def study_work(self, _course, _job, _job_info) -> StudyResult: - # FIXME: 这一块可以单独搞一个类出来了,方法里面又套方法,每一次调用都会创建新的方法,十分浪费 if self.tiku.DISABLE or not self.tiku: return StudyResult.SUCCESS - _ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码, 帮助修复#391错误 - - def random_answer(options: str) -> str: - answer = "" - if not options: - return answer - - if q["type"] == "multiple": - logger.debug(f"当前选项列表[cut前] -> {options}") - _op_list = multi_cut(options) - logger.debug(f"当前选项列表[cut后] -> {_op_list}") - - if not _op_list: - logger.error( - "选项为空, 未能正确提取题目选项信息! 请反馈并提供以上信息" - ) - return answer - - available_options = len(_op_list) - select_count = 0 - - # 根据可用选项数量调整可能选择的选项数 - if available_options <= 1: - select_count = available_options - else: - max_possible = min(4, available_options) - min_possible = min(2, available_options) - - weights_map = { - 2: [1.0], - 3: [0.3, 0.7], - 4: [0.1, 0.5, 0.4], - 5: [0.1, 0.4, 0.3, 0.2], - } - - weights = weights_map.get(max_possible, [0.3, 0.4, 0.3]) - possible_counts = list(range(min_possible, max_possible + 1)) - - weights = weights[:len(possible_counts)] - - weights_sum = sum(weights) - if weights_sum > 0: - weights = [w / weights_sum for w in weights] - - select_count = random.choices(possible_counts, weights=weights, k=1)[0] - - selected_options = random.sample(_op_list, select_count) if select_count > 0 else [] - - for option in selected_options: - answer += option[:1] # 取首字为答案,例如A或B - answer = "".join(sorted(answer)) - elif q["type"] == "single": - answer = random.choice(options.split("\n"))[:1] # 取首字为答案, 例如A或B - # 判断题处理 - elif q["type"] == "judgement": - # answer = self.tiku.jugement_select(_answer) - answer = "true" if random.choice([True, False]) else "false" - logger.info(f"随机选择 -> {answer}") - return answer - - def multi_cut(answer: str): - """ - 将多选题答案字符串按特定字符进行切割, 并返回切割后的答案列表 - - 参数: - answer(str): 多选题答案字符串. - - 返回: - list[str]: 切割后的答案列表,如果无法切割, 则返回默认的选项列表None - - 注意: - 如果无法从网页中提取题目信息,将记录警告日志并返回None - """ - # cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 - # ',' 在常规被正确划分的, 选项中出现, 导致 multi_cut 无法正确划分选项 #391 - # IndexError: Cannot choose from an empty sequence #391 - # 同时为了避免没有考虑到的 case, 应该先按照 '\n' 匹配, 匹配不到再按照其他字符匹配 - cut_char = [ - "\n", - ",", - ",", - "|", - "\r", - "\t", - "#", - "*", - "-", - "_", - "+", - "@", - "~", - "/", - "\\", - ".", - "&", - " ", - "、", - ] # 多选答案切割符 - res = cut(answer) - if res is None: - logger.warning( - f"未能从网页中提取题目信息, 以下为相关信息:\n\t{answer}\n\n{_ORIGIN_HTML_CONTENT}\n" - ) # 尝试输出网页内容和选项信息 - logger.warning("未能正确提取题目选项信息! 请反馈并提供以上信息") - return None - else: - return res - - def clean_res(res): - cleaned_res = [] - if isinstance(res, str): - res = [res] - for c in res: - # 仅在字符串长度大于1时才尝试去除开头的字母编号,防止误删单个字母答案 - cleaned = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*|[.,!?;:,。!?;:]', '', c) if len(c) > 1 else c - cleaned_res.append(cleaned.strip()) - - return cleaned_res - - def normalize_text(text: str) -> str: - if not isinstance(text, str): - text = str(text) - # 统一常见异体字符,降低“风/⻛”类差异导致的匹配失败。 - char_map = str.maketrans({ - '⻛': '风', - '⻔': '门', - '⻋': '车', - '⻢': '马', - }) - normalized = text.translate(char_map) - normalized = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', normalized) - normalized = re.sub(r'\s+', '', normalized) - normalized = re.sub(r'[,。!?;:,.!?;:()()\[\]【】"“”‘’\-_/\\|]', '', normalized) - return normalized.lower() - - def get_option_text(option: str) -> str: - return re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', option).strip() - - def best_option_by_similarity(target: str, options: list, threshold: float = 0.8) -> str: - if not target or not options: - return "" - target_norm = normalize_text(target) - if not target_norm: - return "" - - best_letter = "" - best_score = 0.0 - for option in options: - option_text = get_option_text(option) - option_norm = normalize_text(option_text) - if not option_norm: - continue - score = SequenceMatcher(None, target_norm, option_norm).ratio() - if score > best_score: - best_score = score - best_letter = option[:1] - - if best_score >= threshold: - logger.info(f"相似度兜底匹配成功: {best_letter} (score={best_score:.2f}, threshold={threshold:.2f})") - return best_letter - return "" - - def is_subsequence(a, o): - iter_o = iter(o) - return all(c in iter_o for c in a) - - # FIXME: Use tenacity for retrying - def with_retry(max_retries=3, delay=1): - def decorator(func): - def wrapper(*args, **kwargs): - retries = 0 - while retries < max_retries: - try: - _resp = func(*args, **kwargs) - - # 未创建完成该测验则不进行答题,目前遇到的情况是未创建完成等同于没题目 - if '教师未创建完成该测验' in _resp.text: - raise PermissionError("教师未创建完成该测验") - - questions = decode_questions_info(_resp.text) - - if _resp.status_code == 200 and questions.get("questions"): - return (_resp, questions) - - logger.warning( - f"无效响应 (Code: {getattr(_resp, 'status_code', 'Unknown')}), 重试中... ({retries + 1}/{max_retries})") - - except requests.exceptions.RequestException as e: - logger.warning(f"请求失败: {str(e)[:50]}, 重试中... ({retries + 1}/{max_retries})") - retries += 1 - time.sleep(delay * (2 ** retries)) - raise MaxRetryExceeded(f"超过最大重试次数 ({max_retries})") - - return wrapper - - return decorator - - # 学习通这里根据参数差异能重定向至两个不同接口, 需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle _session = SessionManager.get_session() - _url = "https://mooc1.chaoxing.com/mooc-ans/api/work" - @with_retry(max_retries=3, delay=1) - def fetch_response(): - return _session.get( + def is_not_permission_error(exception): + return not isinstance(exception, PermissionError) + + @retry( + stop=stop_after_attempt(3), + wait=wait_fixed(1), + retry=retry_if_exception(is_not_permission_error), + reraise=True + ) + def fetch_response_with_retry(): + _resp = _session.get( _url, params={ "api": "1", @@ -890,13 +869,29 @@ def fetch_response(): } ) + # 未创建完成该测验则不进行答题,目前遇到的情况是未创建完成等同于没题目 + if '教师未创建完成该测验' in _resp.text: + raise PermissionError("教师未创建完成该测验") + + questions = decode_questions_info(_resp.text) + + if _resp.status_code == 200 and questions.get("questions"): + return (_resp, questions) + + logger.warning( + f"无效响应 (Code: {getattr(_resp, 'status_code', 'Unknown')}), 重试中...") + raise RuntimeError(f"请求返回无效数据 (Code: {_resp.status_code})") + final_resp = {} questions = {} try: - final_resp, questions = fetch_response() + final_resp, questions = fetch_response_with_retry() + except PermissionError as e: + logger.warning(f"跳过章节检测: {e}") + return StudyResult.SUCCESS except Exception as e: - logger.error(f"请求失败: {e}") + logger.error(f"获取章节检测题目失败, 达到最大重试次数: {e}") return StudyResult.ERROR _ORIGIN_HTML_CONTENT = final_resp.text # 用于配合输出网页源码, 帮助修复#391错误 @@ -913,14 +908,14 @@ def fetch_response(): answer = "" if not res: # 随机答题 - answer = random_answer(q["options"]) + answer = random_answer(q["options"], q["type"]) q[f'answerSource{q["id"]}'] = "random" else: # 根据响应结果选择答案 if q["type"] == "multiple": # 多选处理 - options_list = multi_cut(q["options"]) - res_list = multi_cut(res) + options_list = multi_cut(q["options"], _ORIGIN_HTML_CONTENT) + res_list = multi_cut(res, _ORIGIN_HTML_CONTENT) if res_list is not None and options_list is not None: for _a in clean_res(res_list): matched = False @@ -940,7 +935,7 @@ def fetch_response(): # else 如果分割失败那么就直接到下面去随机选 elif q["type"] == "single": # 单选也进行切割,主要是防止返回的答案有异常字符 - options_list = multi_cut(q["options"]) + options_list = multi_cut(q["options"], _ORIGIN_HTML_CONTENT) if options_list is not None: t_res = clean_res(res) for o in options_list: @@ -962,7 +957,7 @@ def fetch_response(): if not answer: # 检查 answer 是否为空 logger.warning(f"找到答案但答案未能匹配 -> {res}\t随机选择答案") - answer = random_answer(q["options"]) # 如果为空,则随机选择答案 + answer = random_answer(q["options"], q["type"]) # 如果为空,则随机选择答案 q[f'answerSource{q["id"]}'] = "random" else: logger.info(f"成功获取到答案:{answer}") diff --git a/api/captcha.py b/api/captcha.py index 7935677b..1a0847ad 100644 --- a/api/captcha.py +++ b/api/captcha.py @@ -15,12 +15,11 @@ from random import randint from typing import Optional - from ddddocr import DdddOcr from requests import session -def ocr_init() -> DdddOcr: +def ocr_init() -> Optional[DdddOcr]: """ 初始化OCR对象 diff --git a/config_template.ini b/config_template.ini index a8f08519..3a37b25c 100644 --- a/config_template.ini +++ b/config_template.ini @@ -19,6 +19,9 @@ jobs = 4 ; 遇到关闭任务点时的行为: retry-重试(默认), continue-继续 notopen_action = retry + +; 任务失败/未开启章节的重试等待时间, 单位秒(默认1.0) +retry_interval = 1.0 [tiku] ; 可选项 : ; 1. TikuYanxi(言溪题库 https://tk.enncy.cn/) diff --git a/main.py b/main.py index d1118714..115048d3 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,12 @@ import traceback from concurrent.futures.thread import ThreadPoolExecutor from dataclasses import dataclass -from queue import PriorityQueue, ShutDown +try: + from queue import PriorityQueue, ShutDown +except ImportError: + from queue import PriorityQueue + class ShutDown(Exception): + pass from threading import RLock from typing import Any @@ -85,6 +90,9 @@ def parse_args(): ) parser.add_argument("--auto-sign", action="store_true", help="自动签到") + parser.add_argument( + "--retry-interval", type=float, default=1.0, help="重试等待时间, 单位秒 (默认1.0)" + ) # 在解析之前捕获 -h 的行为 if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"}: @@ -117,6 +125,10 @@ def load_config_from_file(config_path): # 处理notopen_action,设置默认值为retry if "notopen_action" not in common_config: common_config["notopen_action"] = "retry" + if "retry_interval" in common_config: + common_config["retry_interval"] = float(common_config["retry_interval"]) + else: + common_config["retry_interval"] = 1.0 if "use_cookies" in common_config: common_config["use_cookies"] = str_to_bool(common_config["use_cookies"]) if "username" in common_config and common_config["username"] is not None: @@ -148,7 +160,8 @@ def build_config_from_args(args): "course_list": [item.strip() for item in args.list.split(",") if item.strip()] if args.list else None, "speed": args.speed if args.speed else 1.0, "jobs": args.jobs, - "notopen_action": args.notopen_action if args.notopen_action else "retry" + "notopen_action": args.notopen_action if args.notopen_action else "retry", + "retry_interval": args.retry_interval if args.retry_interval else 1.0 } return common_config, {}, {} @@ -158,13 +171,14 @@ def init_config(): args = parse_args() if args.config: - return load_config_from_file(args.config) + common_config, tiku_config, notification_config = load_config_from_file(args.config) else: - return build_config_from_args(args) + common_config, tiku_config, notification_config = build_config_from_args(args) + return common_config, tiku_config, notification_config, args.config -def init_chaoxing(common_config, tiku_config): +def init_chaoxing(common_config, tiku_config, config_path=None): """初始化超星实例""" username = common_config.get("username", "") password = common_config.get("password", "") @@ -178,9 +192,7 @@ def init_chaoxing(common_config, tiku_config): account = Account(username, password) # 设置题库 - tiku = Tiku() - tiku.config_set(tiku_config) # 载入配置 - tiku = tiku.get_tiku_from_config() # 载入题库 + tiku = Tiku.get_tiku_from_config(tiku_config, config_path=config_path) # 载入题库 tiku.init_tiku() # 初始化题库 # 获取查询延迟设置 @@ -273,20 +285,25 @@ def process_job(chaoxing: Chaoxing, course: dict, job: dict, job_info: dict, spe return StudyResult.ERROR -@dataclass(order=True) +@dataclass class ChapterTask: index: int point: dict[str, Any] + course: dict[str, Any] result: ChapterResult = ChapterResult.PENDING tries: int = 0 + def __lt__(self, other): + if not isinstance(other, ChapterTask): + return NotImplemented + return self.index < other.index + class JobProcessor: - def __init__(self, chaoxing: Chaoxing, course: dict[str, Any], tasks: list[ChapterTask], config: dict[str, Any]): + def __init__(self, chaoxing: Chaoxing, tasks: list[ChapterTask], config: dict[str, Any]): if "jobs" not in config or not config["jobs"]: config["jobs"] = 4 self.chaoxing = chaoxing - self.course = course self.speed = config["speed"] self.max_tries = 5 self.tasks = tasks @@ -297,6 +314,7 @@ def __init__(self, chaoxing: Chaoxing, course: dict[str, Any], tasks: list[Chapt self.threads: list[threading.Thread] = [] self.worker_num = config["jobs"] self.config = config + self.retry_interval = config.get("retry_interval", 1.0) def run(self): for task in self.tasks: @@ -311,12 +329,12 @@ def run(self): self.task_queue.join() time.sleep(0.5) - self.task_queue.shutdown() + if hasattr(self.task_queue, "shutdown"): + self.task_queue.shutdown() @log_error def worker_thread(self): - tqdm.set_lock(tqdm.get_lock()) while True: try: task = self.task_queue.get() @@ -324,26 +342,26 @@ def worker_thread(self): logger.info("Queue shut down") return - task.result = process_chapter(self.chaoxing, self.course, task.point, self.speed) + task.result = process_chapter(self.chaoxing, task.course, task.point, self.speed) match task.result: case ChapterResult.SUCCESS: - logger.debug("Task success: {}", task.point["title"]) + logger.debug("Task success: {} - {}", task.course["title"], task.point["title"]) self.task_queue.task_done() logger.debug(f"unfinished task: {self.task_queue.unfinished_tasks}") case ChapterResult.NOT_OPEN: # task.tries += 1 if self.config["notopen_action"] == "continue": - logger.warning("章节未开启: {}, 正在跳过", task.point["title"]) + logger.warning("章节未开启: {} - {}, 正在跳过", task.course["title"], task.point["title"]) self.task_queue.task_done() continue if task.tries >= self.max_tries: logger.error( - "章节未开启: {} 可能由于上一章节的章节检测未完成, 也可能由于该章节因为时效已关闭," + "章节未开启: {} - {} 可能由于上一章节的章节检测未完成, 也可能由于该章节因为时效已关闭," "请手动检查完成并提交再重试。或者在配置中配置(自动跳过关闭章节/开启题库并启用提交)" - , task.point["title"]) + , task.course["title"], task.point["title"]) self.task_queue.task_done() continue @@ -352,10 +370,10 @@ def worker_thread(self): case ChapterResult.ERROR: task.tries += 1 - logger.warning("Retrying task {} ({}/{} attempts)", task.point["title"], task.tries, + logger.warning("Retrying task {} - {} ({}/{} attempts)", task.course["title"], task.point["title"], task.tries, self.max_tries) if task.tries >= self.max_tries: - logger.error("Max retries reached for task: {}", task.point["title"]) + logger.error("Max retries reached for task: {} - {}", task.course["title"], task.point["title"]) self.failed_tasks.append(task) self.task_queue.task_done() continue @@ -375,7 +393,7 @@ def retry_thread(self): # task_done is not called when a task failed and needs to be retried so if is reinserted into the queue, # the task num will increase by one and become more than the real task number self.task_queue.task_done() - time.sleep(1) # TODO: Replace with a configurable wait time + time.sleep(self.retry_interval) except ShutDown: pass @@ -402,7 +420,6 @@ def process_chapter(chaoxing: Chaoxing, course:dict[str, Any], point:dict[str, A if not jobs: pass - # TODO: 个别章节很恶心,多到5个点,可以并行处理,将来会让不同课程不同章节的所有任务点共享一个队列,从而实现全局并行 job_results:list[StudyResult]=[] with ThreadPoolExecutor(max_workers=5) as executor: for result in executor.map(lambda job: process_job(chaoxing, course, job, job_info, speed), jobs): @@ -429,14 +446,13 @@ def process_course(chaoxing: Chaoxing, course:dict[str, Any], config: dict): _old_format_sizeof = tqdm.format_sizeof tqdm.format_sizeof = format_time - tqdm.set_lock(RLock()) tasks=[] for i, point in enumerate(point_list["points"]): - task = ChapterTask(point=point, index=i) + task = ChapterTask(point=point, index=i, course=course) tasks.append(task) - p = JobProcessor(chaoxing, course, tasks, config) + p = JobProcessor(chaoxing, tasks, config) p.run() @@ -488,14 +504,14 @@ def main(): """主程序入口""" try: # 初始化配置 - common_config, tiku_config, notification_config = init_config() + common_config, tiku_config, notification_config, config_path = init_config() # 强制播放按照配置文件调节 common_config["speed"] = min(2.0, max(1.0, common_config.get("speed", 1.0))) common_config["notopen_action"] = common_config.get("notopen_action", "retry") # 初始化超星实例 - chaoxing = init_chaoxing(common_config, tiku_config) + chaoxing = init_chaoxing(common_config, tiku_config, config_path=config_path) # 设置外部通知 notification = Notification() @@ -516,8 +532,25 @@ def main(): # 开始学习 logger.info(f"课程列表过滤完毕, 当前课程任务数量: {len(course_task)}") + + _old_format_sizeof = tqdm.format_sizeof + tqdm.format_sizeof = format_time + + tasks = [] for course in course_task: - process_course(chaoxing, course, common_config) + logger.info(f"正在读取课程章节: {course['title']}") + point_list = chaoxing.get_course_point( + course["courseId"], course["clazzId"], course["cpi"] + ) + for i, point in enumerate(point_list["points"]): + task = ChapterTask(point=point, index=i, course=course) + tasks.append(task) + + # 全局并发执行所有课程的任务点 + p = JobProcessor(chaoxing, tasks, common_config) + p.run() + + tqdm.format_sizeof = _old_format_sizeof logger.info("所有课程学习任务已完成") notification.send("chaoxing : 所有课程学习任务已完成") diff --git a/requirements.txt b/requirements.txt index 1c85f542..5d392622 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,8 @@ flask>=3.1.2 fonttools>=4.60.1 openai>=1.109.1 tqdm>=4.67.1 -httpx>=0.28.1 +httpx[socks]>=0.28.1 urllib3>=2.5.0 -chardet>=3.0.2,<6.0.0 \ No newline at end of file +chardet>=3.0.2,<6.0.0 +ddddocr>=1.6.1 +tenacity>=8.2.0 \ No newline at end of file From 589e0bcc942d02e9550a202dab0d90638ac1b0c4 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 12:49:13 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat(tiku):=20=E6=96=B0=E5=A2=9E=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E8=BE=93=E5=85=A5=E6=A8=A1=E5=BC=8F=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=A4=9A=E7=BA=BF=E7=A8=8B=E5=B9=B6=E5=8F=91=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=B8=8E=E6=90=9C=E9=A2=98=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `TikuManual` 交互式手动答题模式,支持单题输入与批量粘贴答题,并提供即时输入校验。 - 在 `Tiku` 中实现 `query_all` 与 `_query_all` 批量搜题接口,并在 `TikuFallback` 中重写以支持批量回退搜题。 - 为 `TikuFallback._query_all` 增加防御性校验(检查返回类型与长度),防止多题库回退时答案配对错位。 - 在 `check_answer` 校验中放行手动模式与回退包装器,防止因格式严格校验误杀正确的答案。 - 为 `AI` 与 `SiliconFlow` 大模型查询引入线程锁 `threading.Lock` 以避免高并发下的状态冲突。 - 为 `cookies.py` 引入全局 `threading.RLock`,实现多线程并发读取/更新 Cookie 时的文件写入安全。 - 解析选项节点时优先提取 `aria-label` 属性,修复多选题选项文本截断缺失的问题。 - 增强 `captcha.py` 的 OCR 组件初始化兼容性与 `logger.py` 多线程日志文件切片安全性。 --- api/answer.py | 615 ++++++++++++++++++++++++++++++++++++++------ api/answer_check.py | 10 +- api/base.py | 72 ++++-- api/captcha.py | 36 ++- api/cookies.py | 43 +++- api/decode.py | 26 +- api/font_decoder.py | 5 +- api/live.py | 4 +- api/live_process.py | 8 +- api/logger.py | 25 +- config_template.ini | 8 + main.py | 29 +-- 12 files changed, 732 insertions(+), 149 deletions(-) diff --git a/api/answer.py b/api/answer.py index 32d2f15b..3a024c03 100644 --- a/api/answer.py +++ b/api/answer.py @@ -23,7 +23,7 @@ # 关闭警告 disable_warnings(exceptions.InsecureRequestWarning) -__all__ = ["CacheDAO", "Tiku", "TikuFallback", "TikuYanxi", "TikuGo", "TikuLike", "TikuAdapter", "AI", "SiliconFlow"] +__all__ = ["CacheDAO", "Tiku", "TikuFallback", "TikuYanxi", "TikuGo", "TikuLike", "TikuAdapter", "AI", "SiliconFlow", "TikuManual"] class CacheDAO: """ @@ -210,11 +210,19 @@ def query(self,q_info:dict) -> Optional[str]: if self.DISABLE: return None + is_manual = ( + getattr(self, 'is_manual', False) or + self.__class__.__name__ == 'TikuManual' or + (self.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self, 'providers', []))) + ) + # 预处理, 去除【单选题】这样与标题无关的字段 - logger.debug(f"原始标题:{q_info['title']}") + if not is_manual: + logger.debug(f"原始标题:{q_info['title']}") q_info['title'] = sub(r'^\d+', '', q_info['title']) q_info['title'] = sub(r'(\d+\.\d+分)$', '', q_info['title']) - logger.debug(f"处理后标题:{q_info['title']}") + if not is_manual: + logger.debug(f"处理后标题:{q_info['title']}") # 先过缓存 cache_dao = CacheDAO() @@ -237,6 +245,56 @@ def query(self,q_info:dict) -> Optional[str]: logger.error(f"从{self.name}获取答案失败:{q_info['title']}") return None + def query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optional[str]]: + if self.DISABLE: + return [None] * len(q_list) + + is_manual = ( + getattr(self, 'is_manual', False) or + self.__class__.__name__ == 'TikuManual' or + (self.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self, 'providers', []))) + ) + + results = [None] * len(q_list) + pending_indices = [] + + cache_dao = CacheDAO() + for idx, q in enumerate(q_list): + if not is_manual: + logger.debug(f"原始标题:{q['title']}") + q['title'] = sub(r'^\d+', '', q['title']) + q['title'] = sub(r'(\d+\.\d+分)$', '', q['title']) + if not is_manual: + logger.debug(f"处理后标题:{q['title']}") + + answer = cache_dao.get_cache(q['title']) + if answer: + logger.info(f"从缓存中获取答案:{q['title']} -> {answer}") + results[idx] = answer.strip() + else: + pending_indices.append(idx) + + if not pending_indices: + return results + + sub_q_list = [q_list[idx] for idx in pending_indices] + sub_results = self._query_all(sub_q_list, query_delay=query_delay) + + for idx, ans in zip(pending_indices, sub_results): + q_info = q_list[idx] + if ans: + ans = ans.strip() + logger.info(f"从{self.name}获取答案:{q_info['title']} -> {ans}") + if check_answer(ans, q_info['type'], self): + cache_dao.add_cache(q_info['title'], ans) + results[idx] = ans + else: + logger.info(f"从{self.name}获取到的答案类型与题目类型不符,已舍弃") + else: + logger.error(f"从{self.name}获取答案失败:{q_info['title']}") + + return results + @abstractmethod @@ -246,6 +304,22 @@ def _query(self, q_info:dict) -> Optional[str]: """ pass + def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optional[str]]: + """ + 批量查询的实现接口,默认循环调用单个查询 _query。 + 子类若有批量查询或交互需求(如手动模式),可重写此方法。 + """ + results = [] + for q in q_list: + if query_delay > 0: + time.sleep(query_delay) + try: + results.append(self._query(q)) + except Exception as e: + logger.error(f"{self.name} 查询单个题目发生异常: {e}") + results.append(None) + return results + @staticmethod def get_tiku_from_config(config: Optional[dict] = None, config_path: Optional[str] = None): @@ -318,10 +392,18 @@ def judgement_select(self, answer: str) -> bool: if self.DISABLE: return False # 对响应的答案作处理 - answer = answer.strip() - if answer in self.true_list: + answer = answer.strip().lower() + + # 内置的高频通用判断词规整 + if answer in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y']: return True - elif answer in self.false_list: + if answer in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确']: + return False + + # 兼容自定义配置列表 + if answer in [x.lower() for x in self.true_list] or answer in self.true_list: + return True + elif answer in [x.lower() for x in self.false_list] or answer in self.false_list: return False else: # 无法判断, 随机选择 @@ -389,6 +471,44 @@ def _query(self, q_info:dict) -> Optional[str]: logger.info(f'{provider.name} 返回答案类型不符,回退到下一个题库') return None + def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optional[str]]: + results = [None] * len(q_list) + pending_indices = list(range(len(q_list))) + + for provider in self.providers: + if not pending_indices: + break + if provider.DISABLE: + continue + + sub_q_list = [q_list[idx] for idx in pending_indices] + try: + sub_results = provider.query_all(sub_q_list, query_delay=query_delay) + except Exception as e: + provider_id = f'{provider.name}({provider.__class__.__name__})' + logger.exception(f'{self.name} 批量查询时 {provider_id} 异常: {e}') + continue + + if not isinstance(sub_results, list): + logger.error(f"{provider.name} 批量查询返回数据格式异常(非列表),跳过该题库") + continue + + if len(sub_results) != len(pending_indices): + logger.error(f"{provider.name} 批量查询返回结果长度({len(sub_results)})与请求题目数({len(pending_indices)})不匹配,跳过该题库以防答案错位") + continue + + next_pending_indices = [] + for sub_idx, (orig_idx, ans) in enumerate(zip(pending_indices, sub_results)): + if ans: + logger.info(f'{provider.name} 命中答案: {q_list[orig_idx]["title"]} -> {ans}') + results[orig_idx] = ans + else: + logger.info(f'{provider.name} 未命中或返回答案无效,将回退') + next_pending_indices.append(orig_idx) + pending_indices = next_pending_indices + + return results + def check_llm_connection(self) -> bool: for provider in self.providers: if not provider.check_llm_connection(): @@ -991,6 +1111,7 @@ def __init__(self, config_path: Optional[str] = None) -> None: super().__init__(config_path) self.name = 'AI大模型答题' self.last_request_time = None + self._lock = threading.Lock() def _is_deepseek_v4(self) -> bool: return ( @@ -1013,6 +1134,10 @@ def _wait_for_interval(self): time.sleep(sleep_time) def _query(self, q_info: dict): + with self._lock: + return self._query_locked(q_info) + + def _query_locked(self, q_info: dict): def remove_md_json_wrapper(md_str): # 使用正则表达式匹配Markdown代码块并提取内容 pattern = r'^\s*```(?:json)?\s*(.*?)\s*```\s*$' @@ -1123,38 +1248,39 @@ def check_llm_connection(self) -> bool: 检查大模型连接是否可用 发送一个简单的测试请求来验证 API 配置 """ - logger.info(f'正在检查 {self.name} 连接...') - try: - if self.http_proxy: - httpx_client = httpx.Client(proxy=self.http_proxy) - client = OpenAI(http_client=httpx_client, base_url=self.endpoint, api_key=self.key) - else: - client = OpenAI(base_url=self.endpoint, api_key=self.key) + with self._lock: + logger.info(f'正在检查 {self.name} 连接...') + try: + if self.http_proxy: + httpx_client = httpx.Client(proxy=self.http_proxy) + client = OpenAI(http_client=httpx_client, base_url=self.endpoint, api_key=self.key) + else: + client = OpenAI(base_url=self.endpoint, api_key=self.key) + + # 发送一个简单的测试请求 + self._wait_for_interval() + self.last_request_time = time.time() + completion = client.chat.completions.create(**self._completion_kwargs( + model=self.model, + messages=[ + { + 'role': 'user', + 'content': '你好,请回答:1+1 等于几?只回答数字。' + } + ], + max_tokens=64 + )) + + if completion.choices and completion.choices[0].message.content: + logger.info(f'{self.name} 连接检查成功') + return True + else: + logger.error(f'{self.name} 连接检查失败:未收到响应') + return False - # 发送一个简单的测试请求 - self._wait_for_interval() - self.last_request_time = time.time() - completion = client.chat.completions.create(**self._completion_kwargs( - model=self.model, - messages=[ - { - 'role': 'user', - 'content': '你好,请回答:1+1 等于几?只回答数字。' - } - ], - max_tokens=64 - )) - - if completion.choices and completion.choices[0].message.content: - logger.info(f'{self.name} 连接检查成功') - return True - else: - logger.error(f'{self.name} 连接检查失败:未收到响应') + except Exception as e: + logger.error(f'{self.name} 连接检查失败:{e}') return False - - except Exception as e: - logger.error(f'{self.name} 连接检查失败:{e}') - return False class SiliconFlow(Tiku): @@ -1163,8 +1289,13 @@ def __init__(self, config_path: Optional[str] = None): super().__init__(config_path) self.name = '硅基流动大模型' self.last_request_time = None + self._lock = threading.Lock() def _query(self, q_info: dict): + with self._lock: + return self._query_locked(q_info) + + def _query_locked(self, q_info: dict): def remove_md_json_wrapper(md_str): # 解析可能存在的JSON包装 pattern = r'^\s*```(?:json)?\s*(.*?)\s*```\s*$' @@ -1253,50 +1384,379 @@ def check_llm_connection(self) -> bool: 检查硅基流动大模型连接是否可用 发送一个简单的测试请求来验证 API 配置 """ - logger.info(f'正在检查 {self.name} 连接...') - try: - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } - - payload = { - 'model': self.model_name, - 'messages': [ - { - 'role': 'user', - 'content': '你好,请回答:1+1 等于几?只回答数字。' - } - ], - 'stream': False, - 'max_tokens': 10, - 'temperature': 0.7, - 'top_p': 0.7, - 'response_format': {'type': 'text'} - } - - response = requests.post( - self.api_endpoint, - headers=headers, - json=payload, - timeout=30 - ) - - if response.status_code == 200: - result = response.json() - if result.get('choices') and result['choices'][0]['message']['content']: - logger.info(f'{self.name} 连接检查成功') - return True + with self._lock: + logger.info(f'正在检查 {self.name} 连接...') + try: + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + payload = { + 'model': self.model_name, + 'messages': [ + { + 'role': 'user', + 'content': '你好,请回答:1+1 等于几?只回答数字。' + } + ], + 'stream': False, + 'max_tokens': 10, + 'temperature': 0.7, + 'top_p': 0.7, + 'response_format': {'type': 'text'} + } + + response = requests.post( + self.api_endpoint, + headers=headers, + json=payload, + timeout=30 + ) + + if response.status_code == 200: + result = response.json() + if result.get('choices') and result['choices'][0]['message']['content']: + logger.info(f'{self.name} 连接检查成功') + return True + else: + logger.error(f'{self.name} 连接检查失败:未收到有效响应') + return False else: - logger.error(f'{self.name} 连接检查失败:未收到有效响应') + logger.error(f'{self.name} 连接检查失败:{response.status_code} {response.text}') return False - else: - logger.error(f'{self.name} 连接检查失败:{response.status_code} {response.text}') + + except Exception as e: + logger.error(f'{self.name} 连接检查失败:{e}') return False - - except Exception as e: - logger.error(f'{self.name} 连接检查失败:{e}') - return False + + +class TikuManual(Tiku): + _manual_lock = threading.Lock() + is_manual = True + + def __init__(self, config_path: Optional[str] = None) -> None: + super().__init__(config_path) + self.name = '手动输入题库' + self.default_mode = 'batch' + + def _init_tiku(self): + self.default_mode = self._conf.get('manual_mode_default', 'batch').strip().lower() + if self.default_mode not in ['batch', 'single']: + self.default_mode = 'batch' + + self.separator = self._conf.get('manual_mode_separator', ';') + if self.separator.lower() in ['\\n', 'newline', '换行']: + self.separator = '\n' + elif self.separator.lower() in ['space', '空格']: + self.separator = ' ' + elif self.separator.lower() in ['tab', '制表符']: + self.separator = '\t' + + def _query(self, q_info: dict) -> Optional[str]: + # 强行关闭清除所有当前活动的 tqdm 进度条 + try: + from tqdm import tqdm + for instance in list(tqdm._instances): + instance.leave = False + instance.clear() + instance.close() + except Exception: + pass + + with self._manual_lock: + ans = self._single_query(q_info) + logger.debug("手动答题结束,冲刷缓存日志") + return ans + + def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optional[str]]: + # 强行关闭清除所有当前活动的 tqdm 进度条 + try: + from tqdm import tqdm + for instance in list(tqdm._instances): + instance.leave = False + instance.clear() + instance.close() + except Exception: + pass + + with self._manual_lock: + print(f"\n{'='*20} 手动输入题库 (共 {len(q_list)} 题) {'='*20}") + if self.default_mode == 'batch': + ans_list = self._batch_query_flow(q_list) + else: + ans_list = [self._single_query(q) for q in q_list] + logger.debug("手动答题结束,冲刷缓存日志") + return ans_list + + @staticmethod + def _get_type_display(type_str: str) -> str: + type_map = { + 'single': '单选题', + 'multiple': '多选题', + 'completion': '填空题', + 'judgement': '判断题' + } + return type_map.get(type_str, '其他类型') + + def _single_query(self, q: dict) -> Optional[str]: + type_str = self._get_type_display(q['type']) + if q['type'] in ['single', 'multiple'] and q.get('options'): + options = q['options'] + parts = [] + if isinstance(options, str): + parts = [o.strip() for o in options.split('\n') if o.strip()] + if len(parts) <= 1: + from api.answer_check import cut + cut_parts = cut(options) + if cut_parts: + parts = cut_parts + elif isinstance(options, list): + parts = [str(o).strip() for o in options if str(o).strip()] + + options_text = " ".join(parts) + print(f"\n【{type_str}】 {q['title']} 选项: {options_text}") + elif q['type'] == 'judgement': + print(f"\n【{type_str}】 {q['title']} 选项: 正确 / 错误") + else: + print(f"\n【{type_str}】 {q['title']}") + + while True: + ans = input("请输入答案 (直接回车表示跳过/无答案): ").strip() + if not ans: + print(f" [已记录] 题目: {q['title']} ---> 答案: [跳过/随机]") + return None + + # 即时校验 + ok, err_msg = self._validate_user_input(ans, q) + if not ok: + print(f" \033[31m[输入错误] {err_msg}\033[0m") + continue + + normalized_ans = self._normalize_user_input(ans, q) + print(f" [已记录] 题目: {q['title']} ---> 答案: {normalized_ans}") + return normalized_ans + + def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: + """ + 验证用户手动输入的答案是否合规。 + 返回 (是否合规, 错误提示信息) + """ + if not ans: + return True, "" + + ans = ans.strip() + if not ans: + return True, "" + + q_type = q.get('type') + if q_type == 'judgement': + val = ans.lower() + valid_judgements = [ + 'true', 't', '1', '对', '正确', '√', '是', 'yes', 'y', + 'false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确' + ] + if val not in valid_judgements: + return False, f"无法识别的判断词 '{ans}',请输入:对/错、正确/错误、T/F、1/0" + return True, "" + + elif q_type in ['single', 'multiple']: + options = q.get('options', '') + parts = [] + if isinstance(options, str): + parts = [o.strip() for o in options.split('\n') if o.strip()] + if len(parts) <= 1: + from api.answer_check import cut + cut_parts = cut(options) + if cut_parts: + parts = cut_parts + elif isinstance(options, list): + parts = [str(o).strip() for o in options if str(o).strip()] + + # 收集该题合法的选项字母 + valid_keys = [] + for p in parts: + first_char = p[:1].upper() + if first_char.isalpha(): + valid_keys.append(first_char) + + if valid_keys: + letters = [c.upper() for c in ans if re.match(r'[A-Za-z]', c)] + if not letters: + from api.answer_check import cut + split_ans = cut(ans) + if split_ans: + for item in split_ans: + matched = False + for p in parts: + p_norm = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', p).strip().lower() + if item.strip().lower() in p_norm or p_norm in item.strip().lower(): + matched = True + break + if not matched: + return False, f"输入的文本 '{item}' 在所有选项中均无法匹配,请输入合法的选项文本或字母" + return True, "" + + invalid_letters = [l for l in letters if l not in valid_keys] + if invalid_letters: + return False, f"输入包含无效的选项字母 {invalid_letters},当前题目的可用选项为: {', '.join(valid_keys)}" + + if q_type == 'single' and len(letters) > 1: + return False, "当前是单选题,但输入了多个选项字母!" + + return True, "" + + return True, "" + + def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: + if not ans: + return None + + ans = ans.strip() + if not ans: + return None + + q_type = q.get('type') + if q_type == 'judgement': + val = ans.lower() + if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y']: + return "正确" + elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确']: + return "错误" + return ans + + elif q_type in ['single', 'multiple']: + options = q.get('options', '') + parts = [] + if isinstance(options, str): + parts = [o.strip() for o in options.split('\n') if o.strip()] + if len(parts) <= 1: + from api.answer_check import cut + cut_parts = cut(options) + if cut_parts: + parts = cut_parts + elif isinstance(options, list): + parts = [str(o).strip() for o in options if str(o).strip()] + + valid_keys = [] + for p in parts: + first_char = p[:1].upper() + if first_char.isalpha(): + valid_keys.append(first_char) + + letters = [c.upper() for c in ans if re.match(r'[A-Za-z]', c)] + if letters and all(l in valid_keys for l in letters): + unique_ordered_letters = [] + for l in letters: + if l not in unique_ordered_letters: + unique_ordered_letters.append(l) + return "\n".join(unique_ordered_letters) + + from api.answer_check import cut + split_ans = cut(ans) + if split_ans: + return "\n".join(split_ans) + return ans + + return ans + + def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: + for idx, q in enumerate(q_list): + type_str = self._get_type_display(q['type']) + if q['type'] in ['single', 'multiple'] and q.get('options'): + options = q['options'] + parts = [] + if isinstance(options, str): + parts = [o.strip() for o in options.split('\n') if o.strip()] + if len(parts) <= 1: + from api.answer_check import cut + cut_parts = cut(options) + if cut_parts: + parts = cut_parts + elif isinstance(options, list): + parts = [str(o).strip() for o in options if str(o).strip()] + + options_text = " ".join(parts) + print(f"\n[{idx + 1}] 【{type_str}】 {q['title']} 选项: {options_text}") + elif q['type'] == 'judgement': + print(f"\n[{idx + 1}] 【{type_str}】 {q['title']} 选项: 正确 / 错误") + else: + print(f"\n[{idx + 1}] 【{type_str}】 {q['title']}") + + sep_desc = self.separator + if self.separator == '\n': + sep_desc = '换行 (每题一行)' + elif self.separator == ' ': + sep_desc = '空格' + elif self.separator == '\t': + sep_desc = 'Tab制表符' + + print("\n" + "="*50) + print("请依次输入每道题的答案。") + print(f"格式要求:当前配置要求使用【{sep_desc}】分割各题的答案。") + print("如果是多选题,答案中的多个选项直接连着写即可(例如:AB 或 AC)。") + print("直接按回车或输入空格跳过的题,对应的答案将为空(会触发随机答题)。") + if self.separator == '\n': + print("粘贴多行时,每行会被解析为对应一题的答案。") + else: + print(f"示例输入: A{self.separator} B{self.separator} 正确{self.separator} 答案1, 答案2{self.separator} 错") + print("="*50) + + while True: + answers = [] + if self.separator == '\n': + print(f"请直接粘贴或依次输入各题答案(每行一个,共 {len(q_list)} 行):") + for i in range(len(q_list)): + try: + ans = input(f" 第 {i + 1} 题答案: ").strip() + except EOFError: + ans = "" + answers.append(ans) + else: + raw_input = input(f"\n请一次性输入所有题目的答案 (使用 '{sep_desc}' 分割): ").strip() + if not raw_input: + answers = [''] * len(q_list) + else: + if self.separator in [';', ';']: + raw_input = raw_input.replace(';', ';') + answers = [ans.strip() for ans in raw_input.split(';')] + elif self.separator in [',', ',']: + raw_input = raw_input.replace(',', ',') + answers = [ans.strip() for ans in raw_input.split(',')] + else: + answers = [ans.strip() for ans in raw_input.split(self.separator)] + + if len(answers) < len(q_list): + answers.extend([''] * (len(q_list) - len(answers))) + elif len(answers) > len(q_list): + answers = answers[:len(q_list)] + + print("\n--- 解析答案结果 ---") + has_error = False + temp_answers = [] + for idx, (q, ans) in enumerate(zip(q_list, answers)): + ok, err_msg = self._validate_user_input(ans, q) + if not ok: + has_error = True + print(f"第 {idx + 1} 题: {q['title']} ---> \033[31m[错误: {err_msg}]\033[0m") + temp_answers.append(None) + else: + normalized_ans = self._normalize_user_input(ans, q) + temp_answers.append(normalized_ans) + print(f"第 {idx + 1} 题: {q['title']} ---> 答案: {normalized_ans if normalized_ans else '[跳过/随机]'}") + print("-------------------") + + if has_error: + print("\033[31m检测到存在不合规的答案,已拒绝确认,请重新输入!\033[0m") + continue + + confirm = input("确认使用上述答案?[Y/n]: ").strip().lower() + if confirm in ['', 'y', 'yes']: + return temp_answers + elif confirm == 'switch': + return [self._single_query(q) for q in q_list] + else: + print("已取消,请重新输入,或输入 'switch' 切换为单题输入模式。") class DummyTiku(Tiku): @@ -1316,4 +1776,5 @@ def _query(self, q_info: dict) -> Optional[str]: 'TikuAdapter': TikuAdapter, 'AI': AI, 'SiliconFlow': SiliconFlow, + 'TikuManual': TikuManual, } diff --git a/api/answer_check.py b/api/answer_check.py index 778f4b89..2937a378 100644 --- a/api/answer_check.py +++ b/api/answer_check.py @@ -25,9 +25,10 @@ def check_multiple(answer): def check_judgement(answer, true_list, false_list): - if answer in true_list: + val = str(answer).strip().lower() + if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y'] or val in [x.lower() for x in true_list] or val in true_list: return 1 - elif answer in false_list: + elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确'] or val in [x.lower() for x in false_list] or val in false_list: return 0 else: return -1 @@ -41,6 +42,11 @@ def check_completion(answer): def check_answer(answer, type, tiku): # 只会写小杯代码,这里用个tiku感觉怪怪的,但先这么写着 + # 如果是手动模式或多题库回退包装器,直接信任 + # (手动模式豁免常规校验;回退包装器因其子题库在各自环节均已单独校验过,此处无需二次校验,以防二次过滤误杀) + if getattr(tiku, 'is_manual', False) or tiku.__class__.__name__ in ['TikuManual', 'TikuFallback']: + return True + if type == 'single': if check_single(answer) and check_judgement(answer, tiku.true_list, tiku.false_list) == -1: return True diff --git a/api/base.py b/api/base.py index 4ed4ef24..75eec39e 100644 --- a/api/base.py +++ b/api/base.py @@ -8,12 +8,12 @@ from enum import Enum, IntEnum from hashlib import md5 from typing import Self, Optional, Literal -from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception import requests from loguru import logger from requests import RequestException from requests.adapters import HTTPAdapter +from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception from tqdm import tqdm from api.answer import * @@ -28,7 +28,6 @@ decode_course_folder, decode_questions_info, ) -from api.exceptions import MaxRetryExceeded def get_timestamp(): @@ -71,11 +70,10 @@ def update_cookies(cls): @classmethod def relogin_if_needed(cls, chaoxing_instance) -> bool: with cls._login_lock: - # Check if cookie session is still invalid + # 检查 cookie 会话是否仍然无效 if chaoxing_instance._validate_cookie_session(): return True - # Try to relogin logger.info("Cookie session invalid, attempting thread-safe relogin...") if chaoxing_instance.account and chaoxing_instance.account.username and chaoxing_instance.account.password: login_result = chaoxing_instance.login(login_with_cookies=False) @@ -212,8 +210,8 @@ def best_option_by_similarity(target: str, options: list, threshold: float = 0.8 return "" def is_subsequence(a, o): - iter_o = iter(o) - return all(c in iter_o for c in a) + iter_o = iter(o.lower()) + return all(c in iter_o for c in a.lower()) def random_answer(options: str, q_type: str) -> str: answer = "" @@ -742,14 +740,17 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, logger.info(f"开始任务: {_job['name']}, 总时长: {duration}s, 已进行: {play_time}s") + forbidden_retry = 0 + max_forbidden_retry = 2 + passed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, duration, duration, _type, headers=headers, _isdrag=4) if passed: logger.info("任务瞬间完成: {}", _job['name']) return StudyResult.SUCCESS - with tqdm(total=duration, initial=play_time, desc=_job["name"], - unit_scale=True, bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}') as pbar: + pbar = None + try: while not passed: # Sometimes the last request needs to be sent several times to complete the task if play_time - last_log_time >= wait_time or play_time == duration: @@ -773,7 +774,14 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, _duration = refreshed_meta["duration"] play_time = refreshed_meta.get("playTime", play_time) - logger.debug("Refreshed token: {}, duration: {}, play time: {}", _dtoken, _duration, play_time) + logger.debug("刷新后的令牌: {}, 持续时间: {}, 播放时间: {}", _dtoken, _duration, play_time) + if pbar is not None: + try: + pbar.leave = False + pbar.close() + except Exception: + pass + pbar = None continue else: logger.error("会话恢复失败,刷新后的元数据缺少必要字段 (dtoken, duration)") @@ -792,9 +800,36 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, last_iter = time.time() play_time = min(duration, play_time + dt) - pbar.n = int(play_time) - pbar.refresh() + # 检查手动模式锁是否被锁定 + manual_locked = False + try: + manual_locked = TikuManual._manual_lock.locked() + except Exception: + pass + + if manual_locked: + if pbar is not None: + try: + pbar.leave = False + pbar.close() + except Exception: + pass + pbar = None + else: + if pbar is None: + pbar = tqdm(total=duration, initial=int(play_time), desc=_job["name"], + unit_scale=True, bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}', leave=False) + pbar.n = int(play_time) + pbar.refresh() + time.sleep(gc.THRESHOLD) + finally: + if pbar is not None: + try: + pbar.leave = False + pbar.close() + except Exception: + pass logger.info("任务完成: {}", _job['name']) return StudyResult.SUCCESS @@ -899,12 +934,10 @@ def fetch_response_with_retry(): # 搜题 total_questions = len(questions["questions"]) found_answers = 0 - for q in questions["questions"]: + query_delay = self.kwargs.get("query_delay", 0) + answers = self.tiku.query_all(questions["questions"], query_delay=query_delay) + for q, res in zip(questions["questions"], answers): logger.debug(f"当前题目信息 -> {q}") - # 添加搜题延迟 #428 - 默认0s延迟 - query_delay = self.kwargs.get("query_delay", 0) - time.sleep(query_delay) - res = self.tiku.query(q) answer = "" if not res: # 随机答题 @@ -969,9 +1002,14 @@ def fetch_response_with_retry(): cover_rate = (found_answers / total_questions) * 100 logger.info(f"章节检测题库覆盖率: {cover_rate:.0f}%") # 提交模式 现在与题库绑定,留空直接提交, 1保存但不提交 + is_manual_mode = ( + getattr(self.tiku, 'is_manual', False) or + self.tiku.__class__.__name__ == 'TikuManual' or + (self.tiku.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self.tiku, 'providers', []))) + ) if self.tiku.get_submit_params() == "1": questions["pyFlag"] = "1" - elif cover_rate >= self.tiku.COVER_RATE * 100 or self.rollback_times >= 1: + elif is_manual_mode or cover_rate >= self.tiku.COVER_RATE * 100 or self.rollback_times >= 1: questions["pyFlag"] = "" else: questions["pyFlag"] = "1" diff --git a/api/captcha.py b/api/captcha.py index 1a0847ad..8cc6538d 100644 --- a/api/captcha.py +++ b/api/captcha.py @@ -15,9 +15,17 @@ from random import randint from typing import Optional -from ddddocr import DdddOcr + +from loguru import logger from requests import session +try: + from ddddocr import DdddOcr + HAS_DDDDOCR = True +except ImportError: + DdddOcr = None + HAS_DDDDOCR = False + def ocr_init() -> Optional[DdddOcr]: """ @@ -25,7 +33,14 @@ def ocr_init() -> Optional[DdddOcr]: Returns: DdddOcr对象 """ - return DdddOcr(show_ad=False) + if not HAS_DDDDOCR or DdddOcr is None: + logger.warning("未检测到 ddddocr 依赖,自动验证码识别将不可用。如遇403限制请在浏览器端手动完成验证。") + return None + try: + return DdddOcr(show_ad=False) + except Exception as e: + logger.warning(f"ddddocr 初始化失败: {e},自动验证码识别将不可用") + return None class CxCaptcha: @@ -114,11 +129,14 @@ def recognition(self, img: bytes) -> str: 使用 DdddOcr 对验证码图片进行识别。 Args: - img (bytes): 验证码图片的二进制数据。 + img (bytes): 验证码图片的二进制数据. Returns: str: 返回识别出的验证码字符串。 """ + if not self.ocr: + logger.error("ddddocr 实例未成功初始化,无法执行验证码识别") + raise RuntimeError("ddddocr is not available") res = self.ocr.classification(img) return res @@ -131,8 +149,12 @@ def try_pass(self) -> bool: Returns: bool: 如果验证码成功通过验证,则返回 True;否则返回 False。 """ - cap_img = self.getCaptcha() - if not cap_img: + try: + cap_img = self.getCaptcha() + if not cap_img: + return False + cap_token = self.recognition(cap_img) + return self.submitCaptcha(cap_token) + except Exception as e: + logger.error(f"验证码自动验证时发生异常: {e}") return False - cap_token = self.recognition(cap_img) - return self.submitCaptcha(cap_token) diff --git a/api/cookies.py b/api/cookies.py index 96612e51..4b81a291 100644 --- a/api/cookies.py +++ b/api/cookies.py @@ -1,29 +1,44 @@ # -*- coding: utf-8 -*- import os.path +import threading import requests from api.config import GlobalConst as gc +# 定义全局 Cookie 文件锁,保证读写绝对安全 +cookie_lock = threading.RLock() + def save_cookies(session: requests.Session): - buffer="" - with open(gc.COOKIES_PATH, "w") as f: + with cookie_lock: + buffer = "" for k, v in session.cookies.items(): buffer += f"{k}={v};" buffer = buffer.removesuffix(";") - f.write(buffer) + with open(gc.COOKIES_PATH, "w") as f: + f.write(buffer) def use_cookies() -> dict: - if not os.path.exists(gc.COOKIES_PATH): - return {} - - cookies={} - with open(gc.COOKIES_PATH, "r") as f: - buffer = f.read().strip() - for item in buffer.split(";"): - k, v = item.strip().split("=") - cookies[k] = v - - return cookies + with cookie_lock: + if not os.path.exists(gc.COOKIES_PATH): + return {} + + cookies = {} + try: + with open(gc.COOKIES_PATH, "r") as f: + buffer = f.read().strip() + if not buffer: + return {} + for item in buffer.split(";"): + item = item.strip() + if not item: + continue + parts = item.split("=", 1) + if len(parts) == 2: + cookies[parts[0]] = parts[1] + except Exception: + return {} + + return cookies diff --git a/api/decode.py b/api/decode.py index 0fe61cc6..3ba2b727 100644 --- a/api/decode.py +++ b/api/decode.py @@ -417,16 +417,32 @@ def _extract_form_data(soup: BeautifulSoup) -> Dict[str, Any]: """从BeautifulSoup对象中提取表单数据""" form_data = {} form_tag = soup.find("form") - + if not form_tag: return form_data - + # 提取所有非答案字段的input for input_tag in form_tag.find_all("input"): - if "name" not in input_tag.attrs or "answer" in input_tag.attrs["name"]: + name_attr = input_tag.attrs.get("name") + if name_attr is None: continue - form_data[input_tag.attrs["name"]] = input_tag.attrs.get("value", "") - + + if isinstance(name_attr, list): + name_str = str(name_attr[0]) if name_attr else "" + else: + name_str = str(name_attr) + + if not name_str or "answer" in name_str: + continue + + val_attr = input_tag.attrs.get("value", "") + if isinstance(val_attr, list): + val_str = "".join(str(v) for v in val_attr) + else: + val_str = str(val_attr) + + form_data[name_str] = val_str + return form_data diff --git a/api/font_decoder.py b/api/font_decoder.py index 259eff18..e2af4b8f 100644 --- a/api/font_decoder.py +++ b/api/font_decoder.py @@ -37,7 +37,10 @@ def __init_font_map(self, html_content: str) -> None: html_content: 包含加密字体信息的HTML内容 """ try: - soup = BeautifulSoup(html_content, "lxml") + try: + soup = BeautifulSoup(html_content, "lxml") + except Exception: + soup = BeautifulSoup(html_content, "html.parser") style_tag = soup.find("style", id="cxSecretStyle") if not style_tag or not style_tag.text: diff --git a/api/live.py b/api/live.py index 96628a52..59abaea8 100644 --- a/api/live.py +++ b/api/live.py @@ -1,12 +1,10 @@ # -*- coding: utf-8 -*- import json import time -from urllib import parse - -from api.logger import logger from api.base import SessionManager from api.config import GlobalConst as gc +from api.logger import logger class Live: diff --git a/api/live_process.py b/api/live_process.py index e724729d..bd9dde9a 100644 --- a/api/live_process.py +++ b/api/live_process.py @@ -1,10 +1,8 @@ import time -from api.config import GlobalConst as gc from api.live import Live from api.logger import logger -import time -import threading + class LiveProcessor: @staticmethod @@ -33,10 +31,10 @@ def run_live(live: Live, speed: float = 1.0): # 循环提交时长(每59秒一次,模拟持续观看) for i in range(total_minutes): - logger.info(f"直播'{live.name}'已观看{i+1}/{total_minutes}分钟") + logger.info(f"直播'{live.name}'已观看{i + 1}/{total_minutes}分钟") success = live.do_finish() # 提交当前时长 if not success: - logger.warning(f"第{i+1}分钟时长提交失败,将重试") + logger.warning(f"第{i + 1}分钟时长提交失败,将重试") # 失败重试一次 time.sleep(5) live.do_finish() diff --git a/api/logger.py b/api/logger.py index 2ba79f0d..fabaf692 100644 --- a/api/logger.py +++ b/api/logger.py @@ -1,11 +1,32 @@ +import sys + from loguru import logger from tqdm import tqdm -import sys tqdm_stream = sys.stderr +# 日志缓冲区,用于在手动答题时缓存后台日志,答题结束后统一输出 +log_buffer = [] + def tqdm_sink(msg): - tqdm.write(msg.rstrip(), file=tqdm_stream) + manual_locked = False + try: + # 动态获取 api.answer 模块中的 TikuManual 锁,避免循环导入 + if 'api.answer' in sys.modules: + TikuManual = getattr(sys.modules['api.answer'], 'TikuManual', None) + if TikuManual and getattr(TikuManual, '_manual_lock', None): + manual_locked = TikuManual._manual_lock.locked() + except Exception: + pass + + if manual_locked: + log_buffer.append(msg) + else: + if log_buffer: + for buffered_msg in log_buffer: + tqdm.write(buffered_msg.rstrip(), file=tqdm_stream) + log_buffer.clear() + tqdm.write(msg.rstrip(), file=tqdm_stream) tqdm_stream.flush() logger.remove() diff --git a/config_template.ini b/config_template.ini index 3a37b25c..9a1a14b3 100644 --- a/config_template.ini +++ b/config_template.ini @@ -30,6 +30,7 @@ retry_interval = 1.0 ; 4. AI(需自行寻找兼容openai格式的API Endpoint和Key) ; 5. SiliconFlow(硅基流动AI:https://siliconflow.cn/) ; 6. TikuGo(GO题/网课小工具题库 https://q.icodef.com/) +; 7. TikuManual(手动输入模式,命令行输入) ; 支持配置多个题库回退,按顺序依次查询,示例:provider=TikuGo,TikuYanxi,TikuLike provider=TikuYanxi @@ -100,6 +101,13 @@ siliconflow_endpoint = https://api.siliconflow.cn/v1/chat/completions ; 请求间隔时间 ; min_interval_seconds = 3 请将硅基流动请求间隔时间填入第52行,不要去掉此行注释,否则会重复导致报错(临时解决) +; 手动模式配置 +; manual_mode_default: batch-一次性输入所有(默认), single-一题一输入 +manual_mode_default=batch +; manual_mode_separator: 批量输入时的答案分割符,默认为分号“;” +; 可选值:分号 ;、逗号 ,、空格 space、换行 \n +manual_mode_separator=; + ; 用于判断判断题对应的选项,不要留有空格,不要留有引号,逗号为英文逗号 true_list=正确,对,√,是 false_list=错误,错,×,否,不对,不正确 diff --git a/main.py b/main.py index 115048d3..e985a1bf 100644 --- a/main.py +++ b/main.py @@ -6,19 +6,9 @@ import threading import time import traceback -from concurrent.futures.thread import ThreadPoolExecutor from dataclasses import dataclass -try: - from queue import PriorityQueue, ShutDown -except ImportError: - from queue import PriorityQueue - class ShutDown(Exception): - pass -from threading import RLock from typing import Any - from tqdm import tqdm - from api.answer import Tiku from api.base import Chaoxing, Account, StudyResult from api.exceptions import LoginError, InputFormatError @@ -27,6 +17,13 @@ class ShutDown(Exception): from api.live import Live from api.live_process import LiveProcessor +try: + from queue import PriorityQueue, ShutDown +except ImportError: + from queue import PriorityQueue + class ShutDown(Exception): + pass + class ChapterResult(enum.Enum): SUCCESS=0, ERROR=1, @@ -370,17 +367,17 @@ def worker_thread(self): case ChapterResult.ERROR: task.tries += 1 - logger.warning("Retrying task {} - {} ({}/{} attempts)", task.course["title"], task.point["title"], task.tries, + logger.warning("重试任务 {} - {} ({}/{} 次尝试)", task.course["title"], task.point["title"], task.tries, self.max_tries) if task.tries >= self.max_tries: - logger.error("Max retries reached for task: {} - {}", task.course["title"], task.point["title"]) + logger.error("任务重试次数达到上限: {} - {}", task.course["title"], task.point["title"]) self.failed_tasks.append(task) self.task_queue.task_done() continue self.retry_queue.put(task) case _: - logger.error("Invalid task state {} for task {}", task.result, task.point["title"]) + logger.error("任务 {} 的状态无效 {}", task.result, task.point["title"]) self.failed_tasks.append(task) self.task_queue.task_done() @@ -421,9 +418,9 @@ def process_chapter(chaoxing: Chaoxing, course:dict[str, Any], point:dict[str, A pass job_results:list[StudyResult]=[] - with ThreadPoolExecutor(max_workers=5) as executor: - for result in executor.map(lambda job: process_job(chaoxing, course, job, job_info, speed), jobs): - job_results.append(result) + for job in jobs: + result = process_job(chaoxing, course, job, job_info, speed) + job_results.append(result) for result in job_results: if result.is_failure(): From 8fc2e28b08c0a4cd228fa979d901096350f14c78 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 13:26:32 +0800 Subject: [PATCH 03/17] =?UTF-8?q?fix(tiku):=20=E5=A2=9E=E5=8A=A0=E6=90=9C?= =?UTF-8?q?=E9=A2=98=E9=95=BF=E5=BA=A6=E5=AF=B9=E9=BD=90=E9=98=B2=E5=BE=A1?= =?UTF-8?q?=E4=B8=8E=E5=AE=89=E5=85=A8=E5=85=B3=E9=97=AD=20tqdm=20?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/answer.py | 48 ++++++++++++++++++++++++++++++++---------------- api/base.py | 9 +++++++++ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/api/answer.py b/api/answer.py index 3a024c03..a8fad060 100644 --- a/api/answer.py +++ b/api/answer.py @@ -280,6 +280,15 @@ def query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Option sub_q_list = [q_list[idx] for idx in pending_indices] sub_results = self._query_all(sub_q_list, query_delay=query_delay) + if not isinstance(sub_results, list): + logger.error(f"{self.name} _query_all 返回结果格式异常,期望列表") + sub_results = [None] * len(pending_indices) + elif len(sub_results) != len(pending_indices): + logger.error(f"{self.name} _query_all 返回结果长度不匹配,期望 {len(pending_indices)},实际 {len(sub_results)}") + # 补齐或截断 sub_results 防止错位 + sub_results = list(sub_results) + [None] * (len(pending_indices) - len(sub_results)) + sub_results = sub_results[:len(pending_indices)] + for idx, ans in zip(pending_indices, sub_results): q_info = q_list[idx] if ans: @@ -1453,16 +1462,30 @@ def _init_tiku(self): elif self.separator.lower() in ['tab', '制表符']: self.separator = '\t' - def _query(self, q_info: dict) -> Optional[str]: - # 强行关闭清除所有当前活动的 tqdm 进度条 + @staticmethod + def _safe_close_tqdm_bars(): + """安全地清除并关闭所有活动的 tqdm 进度条,防止私有属性变更引发异常""" try: from tqdm import tqdm - for instance in list(tqdm._instances): - instance.leave = False - instance.clear() - instance.close() - except Exception: - pass + if hasattr(tqdm, '_instances') and hasattr(tqdm._instances, '__iter__'): + # 复制一份以防遍历时容器大小发生变化 (WeakSet/list) + instances = list(tqdm._instances) + for instance in instances: + try: + if hasattr(instance, 'leave'): + instance.leave = False + if hasattr(instance, 'clear'): + instance.clear() + if hasattr(instance, 'close'): + instance.close() + except Exception as ie: + logger.debug(f"清理单个 tqdm 实例失败: {ie}") + except Exception as e: + logger.debug(f"获取/清理 tqdm 实例列表失败: {e}") + + def _query(self, q_info: dict) -> Optional[str]: + # 强行关闭清除所有当前活动的 tqdm 进度条 + self._safe_close_tqdm_bars() with self._manual_lock: ans = self._single_query(q_info) @@ -1471,14 +1494,7 @@ def _query(self, q_info: dict) -> Optional[str]: def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optional[str]]: # 强行关闭清除所有当前活动的 tqdm 进度条 - try: - from tqdm import tqdm - for instance in list(tqdm._instances): - instance.leave = False - instance.clear() - instance.close() - except Exception: - pass + self._safe_close_tqdm_bars() with self._manual_lock: print(f"\n{'='*20} 手动输入题库 (共 {len(q_list)} 题) {'='*20}") diff --git a/api/base.py b/api/base.py index 75eec39e..b809dc41 100644 --- a/api/base.py +++ b/api/base.py @@ -936,6 +936,15 @@ def fetch_response_with_retry(): found_answers = 0 query_delay = self.kwargs.get("query_delay", 0) answers = self.tiku.query_all(questions["questions"], query_delay=query_delay) + + if not isinstance(answers, list): + logger.error("题库 query_all 返回的数据格式异常,期望列表。将采用随机答案答题") + answers = [None] * total_questions + elif len(answers) != total_questions: + logger.error(f"题库返回的答案数量({len(answers)})与题目数量({total_questions})不匹配,正在补齐或截断以防错位!") + answers = list(answers) + [None] * (total_questions - len(answers)) + answers = answers[:total_questions] + for q, res in zip(questions["questions"], answers): logger.debug(f"当前题目信息 -> {q}") answer = "" From ca6cb7067eb9703d275460fa5ff5ec121476f341 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 13:39:30 +0800 Subject: [PATCH 04/17] =?UTF-8?q?refactor(tiku):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95=E9=94=81=E7=B2=92=E5=BA=A6?= =?UTF-8?q?=E3=80=81=E8=A7=84=E6=95=B4=E6=89=8B=E5=8A=A8=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=B9=B6=E6=B8=85=E7=90=86=E5=86=97=E4=BD=99?= =?UTF-8?q?=E6=AF=94=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/answer.py | 185 ++++++++++++++++++++++---------------------- api/answer_check.py | 4 +- api/base.py | 37 ++++----- api/captcha.py | 11 ++- api/font_decoder.py | 3 +- api/logger.py | 5 +- 6 files changed, 124 insertions(+), 121 deletions(-) diff --git a/api/answer.py b/api/answer.py index a8fad060..c3f64e9c 100644 --- a/api/answer.py +++ b/api/answer.py @@ -140,13 +140,15 @@ class Tiku(ABC): DISABLE = False # 停用标志 SUBMIT = False # 提交标志 COVER_RATE = 0.8 # 覆盖率 - true_list = [] - false_list = [] + true_list = None + false_list = None def __init__(self, config_path: Optional[str] = None) -> None: self._name = None self._api = None self._conf = None self._config_path = config_path or self.CONFIG_PATH + self.true_list = [] + self.false_list = [] @property def name(self): @@ -206,22 +208,24 @@ def _get_conf(self): self.DISABLE = True return None - def query(self,q_info:dict) -> Optional[str]: - if self.DISABLE: - return None - - is_manual = ( + @property + def _is_manual_mode(self) -> bool: + return ( getattr(self, 'is_manual', False) or self.__class__.__name__ == 'TikuManual' or (self.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self, 'providers', []))) ) + def query(self,q_info:dict) -> Optional[str]: + if self.DISABLE: + return None + # 预处理, 去除【单选题】这样与标题无关的字段 - if not is_manual: + if not self._is_manual_mode: logger.debug(f"原始标题:{q_info['title']}") q_info['title'] = sub(r'^\d+', '', q_info['title']) q_info['title'] = sub(r'(\d+\.\d+分)$', '', q_info['title']) - if not is_manual: + if not self._is_manual_mode: logger.debug(f"处理后标题:{q_info['title']}") # 先过缓存 @@ -249,22 +253,16 @@ def query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Option if self.DISABLE: return [None] * len(q_list) - is_manual = ( - getattr(self, 'is_manual', False) or - self.__class__.__name__ == 'TikuManual' or - (self.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self, 'providers', []))) - ) - results = [None] * len(q_list) pending_indices = [] cache_dao = CacheDAO() for idx, q in enumerate(q_list): - if not is_manual: + if not self._is_manual_mode: logger.debug(f"原始标题:{q['title']}") q['title'] = sub(r'^\d+', '', q['title']) q['title'] = sub(r'(\d+\.\d+分)$', '', q['title']) - if not is_manual: + if not self._is_manual_mode: logger.debug(f"处理后标题:{q['title']}") answer = cache_dao.get_cache(q['title']) @@ -507,7 +505,7 @@ def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optio continue next_pending_indices = [] - for sub_idx, (orig_idx, ans) in enumerate(zip(pending_indices, sub_results)): + for orig_idx, ans in zip(pending_indices, sub_results): if ans: logger.info(f'{provider.name} 命中答案: {q_list[orig_idx]["title"]} -> {ans}') results[orig_idx] = ans @@ -1257,40 +1255,41 @@ def check_llm_connection(self) -> bool: 检查大模型连接是否可用 发送一个简单的测试请求来验证 API 配置 """ - with self._lock: - logger.info(f'正在检查 {self.name} 连接...') - try: - if self.http_proxy: - httpx_client = httpx.Client(proxy=self.http_proxy) - client = OpenAI(http_client=httpx_client, base_url=self.endpoint, api_key=self.key) - else: - client = OpenAI(base_url=self.endpoint, api_key=self.key) + logger.info(f'正在检查 {self.name} 连接...') + try: + if self.http_proxy: + httpx_client = httpx.Client(proxy=self.http_proxy) + client = OpenAI(http_client=httpx_client, base_url=self.endpoint, api_key=self.key) + else: + client = OpenAI(base_url=self.endpoint, api_key=self.key) - # 发送一个简单的测试请求 + # 仅在需要重置或访问时间戳时使用锁,防止在网络IO期间一直独占锁 + with self._lock: self._wait_for_interval() self.last_request_time = time.time() - completion = client.chat.completions.create(**self._completion_kwargs( - model=self.model, - messages=[ - { - 'role': 'user', - 'content': '你好,请回答:1+1 等于几?只回答数字。' - } - ], - max_tokens=64 - )) - - if completion.choices and completion.choices[0].message.content: - logger.info(f'{self.name} 连接检查成功') - return True - else: - logger.error(f'{self.name} 连接检查失败:未收到响应') - return False - except Exception as e: - logger.error(f'{self.name} 连接检查失败:{e}') + completion = client.chat.completions.create(**self._completion_kwargs( + model=self.model, + messages=[ + { + 'role': 'user', + 'content': '你好,请回答:1+1 等于几?只回答数字。' + } + ], + max_tokens=64 + )) + + if completion.choices and completion.choices[0].message.content: + logger.info(f'{self.name} 连接检查成功') + return True + else: + logger.error(f'{self.name} 连接检查失败:未收到响应') return False + except Exception as e: + logger.error(f'{self.name} 连接检查失败:{e}') + return False + class SiliconFlow(Tiku): """硅基流动大模型答题实现""" @@ -1393,52 +1392,56 @@ def check_llm_connection(self) -> bool: 检查硅基流动大模型连接是否可用 发送一个简单的测试请求来验证 API 配置 """ - with self._lock: - logger.info(f'正在检查 {self.name} 连接...') - try: - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } + logger.info(f'正在检查 {self.name} 连接...') + try: + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + payload = { + 'model': self.model_name, + 'messages': [ + { + 'role': 'user', + 'content': '你好,请回答:1+1 等于几?只回答数字。' + } + ], + 'stream': False, + 'max_tokens': 10, + 'temperature': 0.7, + 'top_p': 0.7, + 'response_format': {'type': 'text'} + } + + # 仅在获取/重置间隔时使用锁,防止在网络IO期间一直独占锁 + with self._lock: + # 硅基流动也可以参考限制,这里不显式调用 interval 等待但保留锁逻辑的一致性以防后续需要 + pass - payload = { - 'model': self.model_name, - 'messages': [ - { - 'role': 'user', - 'content': '你好,请回答:1+1 等于几?只回答数字。' - } - ], - 'stream': False, - 'max_tokens': 10, - 'temperature': 0.7, - 'top_p': 0.7, - 'response_format': {'type': 'text'} - } + response = requests.post( + self.api_endpoint, + headers=headers, + json=payload, + timeout=30 + ) - response = requests.post( - self.api_endpoint, - headers=headers, - json=payload, - timeout=30 - ) - - if response.status_code == 200: - result = response.json() - if result.get('choices') and result['choices'][0]['message']['content']: - logger.info(f'{self.name} 连接检查成功') - return True - else: - logger.error(f'{self.name} 连接检查失败:未收到有效响应') - return False + if response.status_code == 200: + result = response.json() + if result.get('choices') and result['choices'][0]['message']['content']: + logger.info(f'{self.name} 连接检查成功') + return True else: - logger.error(f'{self.name} 连接检查失败:{response.status_code} {response.text}') + logger.error(f'{self.name} 连接检查失败:未收到有效响应') return False - - except Exception as e: - logger.error(f'{self.name} 连接检查失败:{e}') + else: + logger.error(f'{self.name} 连接检查失败:{response.status_code} {response.text}') return False + except Exception as e: + logger.error(f'{self.name} 连接检查失败:{e}') + return False + class TikuManual(Tiku): _manual_lock = threading.Lock() @@ -1613,7 +1616,7 @@ def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: return False, f"输入的文本 '{item}' 在所有选项中均无法匹配,请输入合法的选项文本或字母" return True, "" - invalid_letters = [l for l in letters if l not in valid_keys] + invalid_letters = [letter for letter in letters if letter not in valid_keys] if invalid_letters: return False, f"输入包含无效的选项字母 {invalid_letters},当前题目的可用选项为: {', '.join(valid_keys)}" @@ -1661,11 +1664,11 @@ def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: valid_keys.append(first_char) letters = [c.upper() for c in ans if re.match(r'[A-Za-z]', c)] - if letters and all(l in valid_keys for l in letters): + if letters and all(letter in valid_keys for letter in letters): unique_ordered_letters = [] - for l in letters: - if l not in unique_ordered_letters: - unique_ordered_letters.append(l) + for letter in letters: + if letter not in unique_ordered_letters: + unique_ordered_letters.append(letter) return "\n".join(unique_ordered_letters) from api.answer_check import cut diff --git a/api/answer_check.py b/api/answer_check.py index 2937a378..41dd7502 100644 --- a/api/answer_check.py +++ b/api/answer_check.py @@ -26,9 +26,9 @@ def check_multiple(answer): def check_judgement(answer, true_list, false_list): val = str(answer).strip().lower() - if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y'] or val in [x.lower() for x in true_list] or val in true_list: + if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y'] or val in [x.lower() for x in true_list]: return 1 - elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确'] or val in [x.lower() for x in false_list] or val in false_list: + elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确'] or val in [x.lower() for x in false_list]: return 0 else: return -1 diff --git a/api/base.py b/api/base.py index b809dc41..3730388a 100644 --- a/api/base.py +++ b/api/base.py @@ -712,6 +712,16 @@ def _recover_after_forbidden(self, session: requests.Session, job: dict, _type: return None + @staticmethod + def _close_pbar_safe(pbar_ref): + if pbar_ref is not None: + try: + pbar_ref.leave = False + pbar_ref.close() + except Exception as e: + logger.trace(f"关闭进度条失败: {e}") + return None + def study_video(self, _course, _job, _job_info, _speed: float = 1.0, _type: Literal["Video", "Audio"] = "Video") -> StudyResult: _session = SessionManager.get_session() @@ -775,13 +785,7 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, play_time = refreshed_meta.get("playTime", play_time) logger.debug("刷新后的令牌: {}, 持续时间: {}, 播放时间: {}", _dtoken, _duration, play_time) - if pbar is not None: - try: - pbar.leave = False - pbar.close() - except Exception: - pass - pbar = None + pbar = self._close_pbar_safe(pbar) continue else: logger.error("会话恢复失败,刷新后的元数据缺少必要字段 (dtoken, duration)") @@ -804,17 +808,11 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, manual_locked = False try: manual_locked = TikuManual._manual_lock.locked() - except Exception: - pass + except Exception as e: + logger.trace(f"无法检查手动锁状态: {e}") if manual_locked: - if pbar is not None: - try: - pbar.leave = False - pbar.close() - except Exception: - pass - pbar = None + pbar = self._close_pbar_safe(pbar) else: if pbar is None: pbar = tqdm(total=duration, initial=int(play_time), desc=_job["name"], @@ -824,12 +822,7 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, time.sleep(gc.THRESHOLD) finally: - if pbar is not None: - try: - pbar.leave = False - pbar.close() - except Exception: - pass + pbar = self._close_pbar_safe(pbar) logger.info("任务完成: {}", _job['name']) return StudyResult.SUCCESS diff --git a/api/captcha.py b/api/captcha.py index 8cc6538d..15655a3f 100644 --- a/api/captcha.py +++ b/api/captcha.py @@ -33,7 +33,7 @@ def ocr_init() -> Optional[DdddOcr]: Returns: DdddOcr对象 """ - if not HAS_DDDDOCR or DdddOcr is None: + if not HAS_DDDDOCR: logger.warning("未检测到 ddddocr 依赖,自动验证码识别将不可用。如遇403限制请在浏览器端手动完成验证。") return None try: @@ -43,6 +43,9 @@ def ocr_init() -> Optional[DdddOcr]: return None +_MISSING = object() + + class CxCaptcha: """ CxCaptcha 类用于处理学习任务中出现的验证码 @@ -64,14 +67,14 @@ class CxCaptcha: 'submit': '/html/processVerify.ac' } - def __init__(self, user_agent: str, cookies: str, ocr: Optional[DdddOcr] = None): + def __init__(self, user_agent: str, cookies: str, ocr: Optional[DdddOcr] = _MISSING): """ 初始化 CxCaptcha 实例。 Args: user_agent (str): 用户代理字符串。 cookies (str): 会话 cookies。 - ocr (DdddOcr, optional): 已初始化的 DdddOcr 对象。默认为 None。据DdddOcr官方说明,每次初始化和初始化后的首次识别速度都非常慢,所以推荐传入一个现成的DdddOcr对象实现复用。 + ocr (DdddOcr, optional): 已初始化的 DdddOcr 对象。如果未提供则自动初始化;如果显式传入 None 则禁用 OCR。据DdddOcr官方说明,每次初始化和初始化后的首次识别速度都非常慢,所以推荐传入一个现成的DdddOcr对象实现复用。 """ self.user_agent = user_agent @@ -84,7 +87,7 @@ def __init__(self, user_agent: str, cookies: str, ocr: Optional[DdddOcr] = None) }) self.s.verify = False - self.ocr = ocr if ocr else ocr_init() + self.ocr = ocr_init() if ocr is _MISSING else ocr def getCaptcha(self) -> Optional[bytes]: """ diff --git a/api/font_decoder.py b/api/font_decoder.py index e2af4b8f..8a9b938a 100644 --- a/api/font_decoder.py +++ b/api/font_decoder.py @@ -39,7 +39,8 @@ def __init_font_map(self, html_content: str) -> None: try: try: soup = BeautifulSoup(html_content, "lxml") - except Exception: + except (ImportError, LookupError, ValueError) as e: + logger.trace(f"lxml parser not available, falling back to html.parser: {e}") soup = BeautifulSoup(html_content, "html.parser") style_tag = soup.find("style", id="cxSecretStyle") diff --git a/api/logger.py b/api/logger.py index fabaf692..34e16222 100644 --- a/api/logger.py +++ b/api/logger.py @@ -7,6 +7,8 @@ # 日志缓冲区,用于在手动答题时缓存后台日志,答题结束后统一输出 log_buffer = [] +MAX_LOG_BUFFER_SIZE = 1000 + def tqdm_sink(msg): manual_locked = False @@ -20,7 +22,8 @@ def tqdm_sink(msg): pass if manual_locked: - log_buffer.append(msg) + if len(log_buffer) < MAX_LOG_BUFFER_SIZE: + log_buffer.append(msg) else: if log_buffer: for buffered_msg in log_buffer: From 0acedc7b762e80372beb3bd2ed43f015862940aa Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 13:55:58 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix(tiku):=20=E6=94=B9=E5=96=84=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E6=A8=A1=E5=BC=8F=E6=96=87=E6=9C=AC=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BF=AE=E5=A4=8D403=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E5=88=B7=E6=96=B0=E4=B8=A2=E5=A4=B1=E4=B8=8E=E5=A4=A7?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=B9=B6=E5=8F=91=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/answer.py | 153 +++++++++++++++++++++++--------------------- api/answer_check.py | 2 +- api/base.py | 8 ++- 3 files changed, 85 insertions(+), 78 deletions(-) diff --git a/api/answer.py b/api/answer.py index c3f64e9c..fc19b105 100644 --- a/api/answer.py +++ b/api/answer.py @@ -441,6 +441,7 @@ def __init__(self, providers=None, config_path: Optional[str] = None): super().__init__(config_path) self.name = '多题库回退' self.providers = providers or [] + self.skip_answer_validation = True def _init_tiku(self): active = [] @@ -1255,41 +1256,41 @@ def check_llm_connection(self) -> bool: 检查大模型连接是否可用 发送一个简单的测试请求来验证 API 配置 """ - logger.info(f'正在检查 {self.name} 连接...') - try: - if self.http_proxy: - httpx_client = httpx.Client(proxy=self.http_proxy) - client = OpenAI(http_client=httpx_client, base_url=self.endpoint, api_key=self.key) - else: - client = OpenAI(base_url=self.endpoint, api_key=self.key) + with self._lock: + logger.info(f'正在检查 {self.name} 连接...') + try: + if self.http_proxy: + httpx_client = httpx.Client(proxy=self.http_proxy) + client = OpenAI(http_client=httpx_client, base_url=self.endpoint, api_key=self.key) + else: + client = OpenAI(base_url=self.endpoint, api_key=self.key) - # 仅在需要重置或访问时间戳时使用锁,防止在网络IO期间一直独占锁 - with self._lock: + # 发送一个简单的测试请求 self._wait_for_interval() self.last_request_time = time.time() - completion = client.chat.completions.create(**self._completion_kwargs( - model=self.model, - messages=[ - { - 'role': 'user', - 'content': '你好,请回答:1+1 等于几?只回答数字。' - } - ], - max_tokens=64 - )) + completion = client.chat.completions.create(**self._completion_kwargs( + model=self.model, + messages=[ + { + 'role': 'user', + 'content': '你好,请回答:1+1 等于几?只回答数字。' + } + ], + max_tokens=64 + )) + + if completion.choices and completion.choices[0].message.content: + logger.info(f'{self.name} 连接检查成功') + return True + else: + logger.error(f'{self.name} 连接检查失败:未收到响应') + return False - if completion.choices and completion.choices[0].message.content: - logger.info(f'{self.name} 连接检查成功') - return True - else: - logger.error(f'{self.name} 连接检查失败:未收到响应') + except Exception as e: + logger.error(f'{self.name} 连接检查失败:{e}') return False - except Exception as e: - logger.error(f'{self.name} 连接检查失败:{e}') - return False - class SiliconFlow(Tiku): """硅基流动大模型答题实现""" @@ -1392,55 +1393,51 @@ def check_llm_connection(self) -> bool: 检查硅基流动大模型连接是否可用 发送一个简单的测试请求来验证 API 配置 """ - logger.info(f'正在检查 {self.name} 连接...') - try: - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } - - payload = { - 'model': self.model_name, - 'messages': [ - { - 'role': 'user', - 'content': '你好,请回答:1+1 等于几?只回答数字。' - } - ], - 'stream': False, - 'max_tokens': 10, - 'temperature': 0.7, - 'top_p': 0.7, - 'response_format': {'type': 'text'} - } - - # 仅在获取/重置间隔时使用锁,防止在网络IO期间一直独占锁 - with self._lock: - # 硅基流动也可以参考限制,这里不显式调用 interval 等待但保留锁逻辑的一致性以防后续需要 - pass + with self._lock: + logger.info(f'正在检查 {self.name} 连接...') + try: + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } - response = requests.post( - self.api_endpoint, - headers=headers, - json=payload, - timeout=30 - ) + payload = { + 'model': self.model_name, + 'messages': [ + { + 'role': 'user', + 'content': '你好,请回答:1+1 等于几?只回答数字。' + } + ], + 'stream': False, + 'max_tokens': 10, + 'temperature': 0.7, + 'top_p': 0.7, + 'response_format': {'type': 'text'} + } - if response.status_code == 200: - result = response.json() - if result.get('choices') and result['choices'][0]['message']['content']: - logger.info(f'{self.name} 连接检查成功') - return True + response = requests.post( + self.api_endpoint, + headers=headers, + json=payload, + timeout=30 + ) + + if response.status_code == 200: + result = response.json() + if result.get('choices') and result['choices'][0]['message']['content']: + logger.info(f'{self.name} 连接检查成功') + return True + else: + logger.error(f'{self.name} 连接检查失败:未收到有效响应') + return False else: - logger.error(f'{self.name} 连接检查失败:未收到有效响应') + logger.error(f'{self.name} 连接检查失败:{response.status_code} {response.text}') return False - else: - logger.error(f'{self.name} 连接检查失败:{response.status_code} {response.text}') - return False - except Exception as e: - logger.error(f'{self.name} 连接检查失败:{e}') - return False + except Exception as e: + logger.error(f'{self.name} 连接检查失败:{e}') + return False class TikuManual(Tiku): @@ -1451,6 +1448,14 @@ def __init__(self, config_path: Optional[str] = None) -> None: super().__init__(config_path) self.name = '手动输入题库' self.default_mode = 'batch' + self.skip_answer_validation = True + + @staticmethod + def _extract_option_letters(ans: str) -> list[str]: + cleaned = re.sub(r'[\s,,;;、]+', '', ans) + if not cleaned or not re.fullmatch(r'[A-Za-z]+', cleaned): + return [] + return [c.upper() for c in cleaned] def _init_tiku(self): self.default_mode = self._conf.get('manual_mode_default', 'batch').strip().lower() @@ -1600,7 +1605,7 @@ def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: valid_keys.append(first_char) if valid_keys: - letters = [c.upper() for c in ans if re.match(r'[A-Za-z]', c)] + letters = self._extract_option_letters(ans) if not letters: from api.answer_check import cut split_ans = cut(ans) @@ -1663,7 +1668,7 @@ def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: if first_char.isalpha(): valid_keys.append(first_char) - letters = [c.upper() for c in ans if re.match(r'[A-Za-z]', c)] + letters = self._extract_option_letters(ans) if letters and all(letter in valid_keys for letter in letters): unique_ordered_letters = [] for letter in letters: diff --git a/api/answer_check.py b/api/answer_check.py index 41dd7502..e73061e4 100644 --- a/api/answer_check.py +++ b/api/answer_check.py @@ -44,7 +44,7 @@ def check_completion(answer): def check_answer(answer, type, tiku): # 只会写小杯代码,这里用个tiku感觉怪怪的,但先这么写着 # 如果是手动模式或多题库回退包装器,直接信任 # (手动模式豁免常规校验;回退包装器因其子题库在各自环节均已单独校验过,此处无需二次校验,以防二次过滤误杀) - if getattr(tiku, 'is_manual', False) or tiku.__class__.__name__ in ['TikuManual', 'TikuFallback']: + if getattr(tiku, 'is_manual', False) or getattr(tiku, 'skip_answer_validation', False): return True if type == 'single': diff --git a/api/base.py b/api/base.py index 3730388a..9b409da7 100644 --- a/api/base.py +++ b/api/base.py @@ -781,10 +781,12 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, refreshed_meta = self._recover_after_forbidden(_session, _job, _type) if refreshed_meta and refreshed_meta.get("dtoken") and refreshed_meta.get("duration") is not None: _dtoken = refreshed_meta["dtoken"] - _duration = refreshed_meta["duration"] - play_time = refreshed_meta.get("playTime", play_time) + duration = int(refreshed_meta["duration"]) + refreshed_play_time = refreshed_meta.get("playTime") + if refreshed_play_time is not None: + play_time = int(refreshed_play_time) - logger.debug("刷新后的令牌: {}, 持续时间: {}, 播放时间: {}", _dtoken, _duration, play_time) + logger.debug("刷新后的令牌: {}, 持续时间: {}, 播放时间: {}", _dtoken, duration, play_time) pbar = self._close_pbar_safe(pbar) continue else: From 57ac97624afc13332358a9bac02d8621003e0175 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 14:04:53 +0800 Subject: [PATCH 06/17] =?UTF-8?q?fix(tiku):=20=E5=9C=A8=20SiliconFlow=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95=E4=B8=AD=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E9=99=90=E9=A2=91=E9=97=B4=E9=9A=94=E7=AD=89=E5=BE=85=E4=BB=A5?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20API=20=E8=A7=A6=E5=8F=91=E9=A2=91=E7=8E=87?= =?UTF-8?q?=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/answer.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/api/answer.py b/api/answer.py index fc19b105..3a3011bb 100644 --- a/api/answer.py +++ b/api/answer.py @@ -1300,6 +1300,14 @@ def __init__(self, config_path: Optional[str] = None): self.last_request_time = None self._lock = threading.Lock() + def _wait_for_interval(self): + if self.last_request_time: + interval = time.time() - self.last_request_time + if interval < self.min_interval: + sleep_time = self.min_interval - interval + logger.debug(f"API请求间隔过短, 等待 {sleep_time} 秒") + time.sleep(sleep_time) + def _query(self, q_info: dict): with self._lock: return self._query_locked(q_info) @@ -1350,11 +1358,7 @@ def remove_md_json_wrapper(md_str): "response_format": {"type": "text"} } - # 处理请求间隔 - if self.last_request_time: - interval = time.time() - self.last_request_time - if interval < self.min_interval: - time.sleep(self.min_interval - interval) + self._wait_for_interval() try: response = requests.post( @@ -1416,12 +1420,16 @@ def check_llm_connection(self) -> bool: 'response_format': {'type': 'text'} } + # 在测试 API 连接时同样执行节流校验 + self._wait_for_interval() + response = requests.post( self.api_endpoint, headers=headers, json=payload, timeout=30 ) + self.last_request_time = time.time() if response.status_code == 200: result = response.json() From e3e88a55b47c11a3e71c9f36105460da5c26c3aa Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 14:38:58 +0800 Subject: [PATCH 07/17] =?UTF-8?q?refactor(tiku):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=9D=99=E6=80=81=E5=88=86=E6=9E=90=E5=92=8C=E5=9C=88=E5=A4=8D?= =?UTF-8?q?=E6=9D=82=E5=BA=A6=E8=BF=87=E9=AB=98=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/answer.py | 367 +++++++++++++++++++++++++++++--------------------- api/base.py | 3 +- api/logger.py | 2 +- main.py | 8 ++ 4 files changed, 227 insertions(+), 153 deletions(-) diff --git a/api/answer.py b/api/answer.py index 3a3011bb..7a19f65e 100644 --- a/api/answer.py +++ b/api/answer.py @@ -438,6 +438,12 @@ def check_llm_connection(self) -> bool: class TikuFallback(Tiku): # 多题库回退实现,按 provider 中配置顺序依次查询。 def __init__(self, providers=None, config_path: Optional[str] = None): + """初始化多题库回退。 + + Args: + providers: 子题库实例列表 + config_path: 配置文件路径 + """ super().__init__(config_path) self.name = '多题库回退' self.providers = providers or [] @@ -1295,6 +1301,11 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): """硅基流动大模型答题实现""" def __init__(self, config_path: Optional[str] = None): + """初始化硅基流动大模型题库。 + + Args: + config_path: 配置文件路径 + """ super().__init__(config_path) self.name = '硅基流动大模型' self.last_request_time = None @@ -1480,7 +1491,7 @@ def _init_tiku(self): @staticmethod def _safe_close_tqdm_bars(): - """安全地清除并关闭所有活动的 tqdm 进度条,防止私有属性变更引发异常""" + """安全地清除并关闭所有活动的 tqdm 进度条,防止私有属性变更引发异常。""" try: from tqdm import tqdm if hasattr(tqdm, '_instances') and hasattr(tqdm._instances, '__iter__'): @@ -1570,9 +1581,14 @@ def _single_query(self, q: dict) -> Optional[str]: return normalized_ans def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: - """ - 验证用户手动输入的答案是否合规。 - 返回 (是否合规, 错误提示信息) + """验证用户手动输入的答案是否合规。 + + Args: + ans: 用户输入的答案 + q: 题目信息 + + Returns: + 元组 (是否合规, 错误提示信息) """ if not ans: return True, "" @@ -1583,64 +1599,81 @@ def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: q_type = q.get('type') if q_type == 'judgement': - val = ans.lower() - valid_judgements = [ - 'true', 't', '1', '对', '正确', '√', '是', 'yes', 'y', - 'false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确' - ] - if val not in valid_judgements: - return False, f"无法识别的判断词 '{ans}',请输入:对/错、正确/错误、T/F、1/0" - return True, "" - + return self._validate_judgement_input(ans) elif q_type in ['single', 'multiple']: - options = q.get('options', '') - parts = [] - if isinstance(options, str): - parts = [o.strip() for o in options.split('\n') if o.strip()] - if len(parts) <= 1: - from api.answer_check import cut - cut_parts = cut(options) - if cut_parts: - parts = cut_parts - elif isinstance(options, list): - parts = [str(o).strip() for o in options if str(o).strip()] + return self._validate_choice_input(ans, q) + return True, "" - # 收集该题合法的选项字母 - valid_keys = [] - for p in parts: - first_char = p[:1].upper() - if first_char.isalpha(): - valid_keys.append(first_char) + def _validate_judgement_input(self, ans: str) -> tuple[bool, str]: + """验证判断题手动输入是否合规。""" + val = ans.lower() + valid_judgements = [ + 'true', 't', '1', '对', '正确', '√', '是', 'yes', 'y', + 'false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确' + ] + if val not in valid_judgements: + return False, f"无法识别的判断词 '{ans}',请输入:对/错、正确/错误、T/F、1/0" + return True, "" - if valid_keys: - letters = self._extract_option_letters(ans) - if not letters: - from api.answer_check import cut - split_ans = cut(ans) - if split_ans: - for item in split_ans: - matched = False - for p in parts: - p_norm = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', p).strip().lower() - if item.strip().lower() in p_norm or p_norm in item.strip().lower(): - matched = True - break - if not matched: - return False, f"输入的文本 '{item}' 在所有选项中均无法匹配,请输入合法的选项文本或字母" - return True, "" - - invalid_letters = [letter for letter in letters if letter not in valid_keys] - if invalid_letters: - return False, f"输入包含无效的选项字母 {invalid_letters},当前题目的可用选项为: {', '.join(valid_keys)}" - - if q_type == 'single' and len(letters) > 1: - return False, "当前是单选题,但输入了多个选项字母!" + def _validate_choice_input(self, ans: str, q: dict) -> tuple[bool, str]: + """验证选择题手动输入是否合规。""" + options = q.get('options', '') + parts = [] + if isinstance(options, str): + parts = [o.strip() for o in options.split('\n') if o.strip()] + if len(parts) <= 1: + from api.answer_check import cut + cut_parts = cut(options) + if cut_parts: + parts = cut_parts + elif isinstance(options, list): + parts = [str(o).strip() for o in options if str(o).strip()] + + # 收集该题合法的选项字母 + valid_keys = [] + for p in parts: + first_char = p[:1].upper() + if first_char.isalpha(): + valid_keys.append(first_char) + + if not valid_keys: + return True, "" + letters = self._extract_option_letters(ans) + if not letters: + from api.answer_check import cut + split_ans = cut(ans) + if split_ans: + for item in split_ans: + matched = False + for p in parts: + p_norm = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', p).strip().lower() + if item.strip().lower() in p_norm or p_norm in item.strip().lower(): + matched = True + break + if not matched: + return False, f"输入的文本 '{item}' 在所有选项中均无法匹配,请输入合法的选项文本或字母" return True, "" + invalid_letters = [letter for letter in letters if letter not in valid_keys] + if invalid_letters: + return False, f"输入包含无效的选项字母 {invalid_letters},当前题目的可用选项为: {', '.join(valid_keys)}" + + if q.get('type') == 'single' and len(letters) > 1: + return False, "当前是单选题,但输入了多个选项字母!" + return True, "" def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: + """规整化用户的手动输入答案。 + + Args: + ans: 用户原始输入 + q: 题目信息 + + Returns: + 规整后的答案字符串 + """ if not ans: return None @@ -1650,70 +1683,64 @@ def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: q_type = q.get('type') if q_type == 'judgement': - val = ans.lower() - if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y']: - return "正确" - elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确']: - return "错误" - return ans - + return self._normalize_judgement_input(ans) elif q_type in ['single', 'multiple']: - options = q.get('options', '') - parts = [] - if isinstance(options, str): - parts = [o.strip() for o in options.split('\n') if o.strip()] - if len(parts) <= 1: - from api.answer_check import cut - cut_parts = cut(options) - if cut_parts: - parts = cut_parts - elif isinstance(options, list): - parts = [str(o).strip() for o in options if str(o).strip()] - - valid_keys = [] - for p in parts: - first_char = p[:1].upper() - if first_char.isalpha(): - valid_keys.append(first_char) - - letters = self._extract_option_letters(ans) - if letters and all(letter in valid_keys for letter in letters): - unique_ordered_letters = [] - for letter in letters: - if letter not in unique_ordered_letters: - unique_ordered_letters.append(letter) - return "\n".join(unique_ordered_letters) + return self._normalize_choice_input(ans, q) + return ans - from api.answer_check import cut - split_ans = cut(ans) - if split_ans: - return "\n".join(split_ans) - return ans + def _normalize_judgement_input(self, ans: str) -> str: + """规整化判断题的手动输入。""" + val = ans.lower() + if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y']: + return "正确" + elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确']: + return "错误" + return ans + def _normalize_choice_input(self, ans: str, q: dict) -> str: + """规整化选择题的手动输入。""" + options = q.get('options', '') + parts = [] + if isinstance(options, str): + parts = [o.strip() for o in options.split('\n') if o.strip()] + if len(parts) <= 1: + from api.answer_check import cut + cut_parts = cut(options) + if cut_parts: + parts = cut_parts + elif isinstance(options, list): + parts = [str(o).strip() for o in options if str(o).strip()] + + valid_keys = [] + for p in parts: + first_char = p[:1].upper() + if first_char.isalpha(): + valid_keys.append(first_char) + + letters = self._extract_option_letters(ans) + if letters and all(letter in valid_keys for letter in letters): + unique_ordered_letters = [] + for letter in letters: + if letter not in unique_ordered_letters: + unique_ordered_letters.append(letter) + return "\n".join(unique_ordered_letters) + + from api.answer_check import cut + split_ans = cut(ans) + if split_ans: + return "\n".join(split_ans) return ans def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: - for idx, q in enumerate(q_list): - type_str = self._get_type_display(q['type']) - if q['type'] in ['single', 'multiple'] and q.get('options'): - options = q['options'] - parts = [] - if isinstance(options, str): - parts = [o.strip() for o in options.split('\n') if o.strip()] - if len(parts) <= 1: - from api.answer_check import cut - cut_parts = cut(options) - if cut_parts: - parts = cut_parts - elif isinstance(options, list): - parts = [str(o).strip() for o in options if str(o).strip()] + """执行批量手动搜题交互。 - options_text = " ".join(parts) - print(f"\n[{idx + 1}] 【{type_str}】 {q['title']} 选项: {options_text}") - elif q['type'] == 'judgement': - print(f"\n[{idx + 1}] 【{type_str}】 {q['title']} 选项: 正确 / 错误") - else: - print(f"\n[{idx + 1}] 【{type_str}】 {q['title']}") + Args: + q_list: 待搜题目列表 + + Returns: + 规整后的用户输入答案列表 + """ + self._print_batch_questions(q_list) sep_desc = self.separator if self.separator == '\n': @@ -1723,16 +1750,7 @@ def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: elif self.separator == '\t': sep_desc = 'Tab制表符' - print("\n" + "="*50) - print("请依次输入每道题的答案。") - print(f"格式要求:当前配置要求使用【{sep_desc}】分割各题的答案。") - print("如果是多选题,答案中的多个选项直接连着写即可(例如:AB 或 AC)。") - print("直接按回车或输入空格跳过的题,对应的答案将为空(会触发随机答题)。") - if self.separator == '\n': - print("粘贴多行时,每行会被解析为对应一题的答案。") - else: - print(f"示例输入: A{self.separator} B{self.separator} 正确{self.separator} 答案1, 答案2{self.separator} 错") - print("="*50) + self._print_batch_instructions(sep_desc) while True: answers = [] @@ -1746,37 +1764,9 @@ def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: answers.append(ans) else: raw_input = input(f"\n请一次性输入所有题目的答案 (使用 '{sep_desc}' 分割): ").strip() - if not raw_input: - answers = [''] * len(q_list) - else: - if self.separator in [';', ';']: - raw_input = raw_input.replace(';', ';') - answers = [ans.strip() for ans in raw_input.split(';')] - elif self.separator in [',', ',']: - raw_input = raw_input.replace(',', ',') - answers = [ans.strip() for ans in raw_input.split(',')] - else: - answers = [ans.strip() for ans in raw_input.split(self.separator)] - - if len(answers) < len(q_list): - answers.extend([''] * (len(q_list) - len(answers))) - elif len(answers) > len(q_list): - answers = answers[:len(q_list)] - - print("\n--- 解析答案结果 ---") - has_error = False - temp_answers = [] - for idx, (q, ans) in enumerate(zip(q_list, answers)): - ok, err_msg = self._validate_user_input(ans, q) - if not ok: - has_error = True - print(f"第 {idx + 1} 题: {q['title']} ---> \033[31m[错误: {err_msg}]\033[0m") - temp_answers.append(None) - else: - normalized_ans = self._normalize_user_input(ans, q) - temp_answers.append(normalized_ans) - print(f"第 {idx + 1} 题: {q['title']} ---> 答案: {normalized_ans if normalized_ans else '[跳过/随机]'}") - print("-------------------") + answers = self._split_batch_answers(raw_input, len(q_list)) + + has_error, temp_answers = self._parse_and_validate_batch(q_list, answers) if has_error: print("\033[31m检测到存在不合规的答案,已拒绝确认,请重新输入!\033[0m") @@ -1790,6 +1780,81 @@ def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: else: print("已取消,请重新输入,或输入 'switch' 切换为单题输入模式。") + def _print_batch_questions(self, q_list: list[dict]) -> None: + """批量打印题目内容及选项。""" + for idx, q in enumerate(q_list): + type_str = self._get_type_display(q['type']) + if q['type'] in ['single', 'multiple'] and q.get('options'): + options = q['options'] + parts = [] + if isinstance(options, str): + parts = [o.strip() for o in options.split('\n') if o.strip()] + if len(parts) <= 1: + from api.answer_check import cut + cut_parts = cut(options) + if cut_parts: + parts = cut_parts + elif isinstance(options, list): + parts = [str(o).strip() for o in options if str(o).strip()] + + options_text = " ".join(parts) + print(f"\n[{idx + 1}] 【{type_str}】 {q['title']} 选项: {options_text}") + elif q['type'] == 'judgement': + print(f"\n[{idx + 1}] 【{type_str}】 {q['title']} 选项: 正确 / 错误") + else: + print(f"\n[{idx + 1}] 【{type_str}】 {q['title']}") + + def _print_batch_instructions(self, sep_desc: str) -> None: + """打印批量输入的使用引导说明。""" + print("\n" + "="*50) + print("请依次输入每道题的答案。") + print(f"格式要求:当前配置要求使用【{sep_desc}】分割各题的答案。") + print("如果是多选题,答案中的多个选项直接连着写即可(例如:AB 或 AC)。") + print("直接按回车或输入空格跳过的题,对应的答案将为空(会触发随机答题)。") + if self.separator == '\n': + print("粘贴多行时,每行会被解析为对应一题的答案。") + else: + print(f"示例输入: A{self.separator} B{self.separator} 正确{self.separator} 答案1, 答案2{self.separator} 错") + print("="*50) + + def _split_batch_answers(self, raw_input: str, expected_len: int) -> list[str]: + """根据配置的分割符将批量的答案进行分拆和补齐。""" + if not raw_input: + return [''] * expected_len + + if self.separator in [';', ';']: + raw_input = raw_input.replace(';', ';') + answers = [ans.strip() for ans in raw_input.split(';')] + elif self.separator in [',', ',']: + raw_input = raw_input.replace(',', ',') + answers = [ans.strip() for ans in raw_input.split(',')] + else: + answers = [ans.strip() for ans in raw_input.split(self.separator)] + + if len(answers) < expected_len: + answers.extend([''] * (expected_len - len(answers))) + elif len(answers) > expected_len: + answers = answers[:expected_len] + return answers + + def _parse_and_validate_batch(self, q_list: list[dict], answers: list[str]) -> tuple[bool, list[Optional[str]]]: + """批量解析用户输入并进行合法性校验。""" + print("\n--- 解析答案结果 ---") + has_error = False + temp_answers = [] + for idx, (q, ans) in enumerate(zip(q_list, answers)): + ok, err_msg = self._validate_user_input(ans, q) + if not ok: + has_error = True + print(f"第 {idx + 1} 题: {q['title']} ---> \033[31m[错误: {err_msg}]\033[0m") + temp_answers.append(None) + else: + normalized_ans = self._normalize_user_input(ans, q) + temp_answers.append(normalized_ans) + print(f"第 {idx + 1} 题: {q['title']} ---> 答案: {normalized_ans if normalized_ans else '[跳过/随机]'}") + print("-------------------") + return has_error, temp_answers + class DummyTiku(Tiku): def __init__(self, config_path: Optional[str] = None) -> None: diff --git a/api/base.py b/api/base.py index 9b409da7..7f146e8f 100644 --- a/api/base.py +++ b/api/base.py @@ -17,6 +17,7 @@ from tqdm import tqdm from api.answer import * +from api.answer import TikuManual from api.answer_check import cut from api.cipher import AESCipher from api.config import GlobalConst as gc @@ -73,7 +74,7 @@ def relogin_if_needed(cls, chaoxing_instance) -> bool: # 检查 cookie 会话是否仍然无效 if chaoxing_instance._validate_cookie_session(): return True - + logger.info("Cookie session invalid, attempting thread-safe relogin...") if chaoxing_instance.account and chaoxing_instance.account.username and chaoxing_instance.account.password: login_result = chaoxing_instance.login(login_with_cookies=False) diff --git a/api/logger.py b/api/logger.py index 34e16222..a2948dc3 100644 --- a/api/logger.py +++ b/api/logger.py @@ -18,7 +18,7 @@ def tqdm_sink(msg): TikuManual = getattr(sys.modules['api.answer'], 'TikuManual', None) if TikuManual and getattr(TikuManual, '_manual_lock', None): manual_locked = TikuManual._manual_lock.locked() - except Exception: + except (AttributeError, KeyError, ImportError): pass if manual_locked: diff --git a/main.py b/main.py index e985a1bf..aea82d24 100644 --- a/main.py +++ b/main.py @@ -291,12 +291,20 @@ class ChapterTask: tries: int = 0 def __lt__(self, other): + """比较两个任务的索引大小,用于优先级队列排序。""" if not isinstance(other, ChapterTask): return NotImplemented return self.index < other.index class JobProcessor: def __init__(self, chaoxing: Chaoxing, tasks: list[ChapterTask], config: dict[str, Any]): + """初始化任务处理器。 + + Args: + chaoxing: Chaoxing API 实例 + tasks: 任务列表 + config: 配置字典 + """ if "jobs" not in config or not config["jobs"]: config["jobs"] = 4 From 90c77fd9e80adbe949cb6c4efa4c4c7e86f303a0 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 15:34:30 +0800 Subject: [PATCH 08/17] =?UTF-8?q?refactor:=20=E4=BF=AE=E5=A4=8D=E9=9D=99?= =?UTF-8?q?=E6=80=81=E5=88=86=E6=9E=90=E5=92=8C=E5=9C=88=E5=A4=8D=E6=9D=82?= =?UTF-8?q?=E5=BA=A6=E8=BF=87=E9=AB=98=E7=9A=84=E9=97=AE=E9=A2=98=E5=92=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96PEP=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/answer.py | 168 +++++++++++++++++++++++++------------------------- api/base.py | 3 +- main.py | 17 +++-- 3 files changed, 97 insertions(+), 91 deletions(-) diff --git a/api/answer.py b/api/answer.py index 7a19f65e..8f477720 100644 --- a/api/answer.py +++ b/api/answer.py @@ -438,11 +438,16 @@ def check_llm_connection(self) -> bool: class TikuFallback(Tiku): # 多题库回退实现,按 provider 中配置顺序依次查询。 def __init__(self, providers=None, config_path: Optional[str] = None): - """初始化多题库回退。 + """ + 初始化多题库回退. + + Args + ---- + providers : list, optional + 子题库实例列表. + config_path : str, optional + 配置文件路径. - Args: - providers: 子题库实例列表 - config_path: 配置文件路径 """ super().__init__(config_path) self.name = '多题库回退' @@ -1299,12 +1304,16 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): - """硅基流动大模型答题实现""" + """硅基流动大模型答题实现.""" def __init__(self, config_path: Optional[str] = None): - """初始化硅基流动大模型题库。 + """ + 初始化硅基流动大模型题库. + + Args + ---- + config_path : str, optional + 配置文件路径. - Args: - config_path: 配置文件路径 """ super().__init__(config_path) self.name = '硅基流动大模型' @@ -1491,7 +1500,7 @@ def _init_tiku(self): @staticmethod def _safe_close_tqdm_bars(): - """安全地清除并关闭所有活动的 tqdm 进度条,防止私有属性变更引发异常。""" + """安全地清除并关闭所有活动的 tqdm 进度条,防止私有属性变更引发异常.""" try: from tqdm import tqdm if hasattr(tqdm, '_instances') and hasattr(tqdm._instances, '__iter__'): @@ -1581,14 +1590,8 @@ def _single_query(self, q: dict) -> Optional[str]: return normalized_ans def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: - """验证用户手动输入的答案是否合规。 - - Args: - ans: 用户输入的答案 - q: 题目信息 - - Returns: - 元组 (是否合规, 错误提示信息) + """ + 验证用户手动输入的答案是否合规. """ if not ans: return True, "" @@ -1605,7 +1608,7 @@ def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: return True, "" def _validate_judgement_input(self, ans: str) -> tuple[bool, str]: - """验证判断题手动输入是否合规。""" + """验证判断题手动输入是否合规.""" val = ans.lower() valid_judgements = [ 'true', 't', '1', '对', '正确', '√', '是', 'yes', 'y', @@ -1616,8 +1619,33 @@ def _validate_judgement_input(self, ans: str) -> tuple[bool, str]: return True, "" def _validate_choice_input(self, ans: str, q: dict) -> tuple[bool, str]: - """验证选择题手动输入是否合规。""" + """ + 验证选择题手动输入是否合规. + """ options = q.get('options', '') + parts = self._parse_options(options) + valid_keys = self._extract_valid_keys(parts) + + if not valid_keys: + return True, "" + + letters = self._extract_option_letters(ans) + if not letters: + return self._validate_text_match(ans, parts) + + invalid_letters = [letter for letter in letters if letter not in valid_keys] + if invalid_letters: + return False, f"输入包含无效的选项字母 {invalid_letters},当前题目的可用选项为: {', '.join(valid_keys)}" + + if q.get('type') == 'single' and len(letters) > 1: + return False, "当前是单选题,但输入了多个选项字母!" + + return True, "" + + def _parse_options(self, options) -> list[str]: + """ + 解析选项. + """ parts = [] if isinstance(options, str): parts = [o.strip() for o in options.split('\n') if o.strip()] @@ -1628,51 +1656,40 @@ def _validate_choice_input(self, ans: str, q: dict) -> tuple[bool, str]: parts = cut_parts elif isinstance(options, list): parts = [str(o).strip() for o in options if str(o).strip()] + return parts - # 收集该题合法的选项字母 + def _extract_valid_keys(self, parts: list[str]) -> list[str]: + """ + 提取合法的选项字母. + """ valid_keys = [] for p in parts: first_char = p[:1].upper() if first_char.isalpha(): valid_keys.append(first_char) + return valid_keys - if not valid_keys: - return True, "" - - letters = self._extract_option_letters(ans) - if not letters: - from api.answer_check import cut - split_ans = cut(ans) - if split_ans: - for item in split_ans: - matched = False - for p in parts: - p_norm = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', p).strip().lower() - if item.strip().lower() in p_norm or p_norm in item.strip().lower(): - matched = True - break - if not matched: - return False, f"输入的文本 '{item}' 在所有选项中均无法匹配,请输入合法的选项文本或字母" - return True, "" - - invalid_letters = [letter for letter in letters if letter not in valid_keys] - if invalid_letters: - return False, f"输入包含无效的选项字母 {invalid_letters},当前题目的可用选项为: {', '.join(valid_keys)}" - - if q.get('type') == 'single' and len(letters) > 1: - return False, "当前是单选题,但输入了多个选项字母!" - + def _validate_text_match(self, ans: str, parts: list[str]) -> tuple[bool, str]: + """ + 验证用户输入的文本是否和选项文本匹配. + """ + from api.answer_check import cut + split_ans = cut(ans) + if split_ans: + for item in split_ans: + matched = False + for p in parts: + p_norm = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', p).strip().lower() + if item.strip().lower() in p_norm or p_norm in item.strip().lower(): + matched = True + break + if not matched: + return False, f"输入的文本 '{item}' 在所有选项中均无法匹配,请输入合法的选项文本或字母" return True, "" def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: - """规整化用户的手动输入答案。 - - Args: - ans: 用户原始输入 - q: 题目信息 - - Returns: - 规整后的答案字符串 + """ + 规整化用户的手动输入答案. """ if not ans: return None @@ -1689,7 +1706,9 @@ def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: return ans def _normalize_judgement_input(self, ans: str) -> str: - """规整化判断题的手动输入。""" + """ + 规整化判断题的手动输入. + """ val = ans.lower() if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y']: return "正确" @@ -1698,24 +1717,12 @@ def _normalize_judgement_input(self, ans: str) -> str: return ans def _normalize_choice_input(self, ans: str, q: dict) -> str: - """规整化选择题的手动输入。""" + """ + 规整化选择题的手动输入. + """ options = q.get('options', '') - parts = [] - if isinstance(options, str): - parts = [o.strip() for o in options.split('\n') if o.strip()] - if len(parts) <= 1: - from api.answer_check import cut - cut_parts = cut(options) - if cut_parts: - parts = cut_parts - elif isinstance(options, list): - parts = [str(o).strip() for o in options if str(o).strip()] - - valid_keys = [] - for p in parts: - first_char = p[:1].upper() - if first_char.isalpha(): - valid_keys.append(first_char) + parts = self._parse_options(options) + valid_keys = self._extract_valid_keys(parts) letters = self._extract_option_letters(ans) if letters and all(letter in valid_keys for letter in letters): @@ -1732,13 +1739,8 @@ def _normalize_choice_input(self, ans: str, q: dict) -> str: return ans def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: - """执行批量手动搜题交互。 - - Args: - q_list: 待搜题目列表 - - Returns: - 规整后的用户输入答案列表 + """ + 执行批量手动搜题交互. """ self._print_batch_questions(q_list) @@ -1781,7 +1783,7 @@ def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: print("已取消,请重新输入,或输入 'switch' 切换为单题输入模式。") def _print_batch_questions(self, q_list: list[dict]) -> None: - """批量打印题目内容及选项。""" + """批量打印题目内容及选项.""" for idx, q in enumerate(q_list): type_str = self._get_type_display(q['type']) if q['type'] in ['single', 'multiple'] and q.get('options'): @@ -1805,7 +1807,7 @@ def _print_batch_questions(self, q_list: list[dict]) -> None: print(f"\n[{idx + 1}] 【{type_str}】 {q['title']}") def _print_batch_instructions(self, sep_desc: str) -> None: - """打印批量输入的使用引导说明。""" + """打印批量输入的使用引导说明.""" print("\n" + "="*50) print("请依次输入每道题的答案。") print(f"格式要求:当前配置要求使用【{sep_desc}】分割各题的答案。") @@ -1818,7 +1820,7 @@ def _print_batch_instructions(self, sep_desc: str) -> None: print("="*50) def _split_batch_answers(self, raw_input: str, expected_len: int) -> list[str]: - """根据配置的分割符将批量的答案进行分拆和补齐。""" + """根据配置的分割符将批量的答案进行分拆和补齐.""" if not raw_input: return [''] * expected_len @@ -1838,7 +1840,7 @@ def _split_batch_answers(self, raw_input: str, expected_len: int) -> list[str]: return answers def _parse_and_validate_batch(self, q_list: list[dict], answers: list[str]) -> tuple[bool, list[Optional[str]]]: - """批量解析用户输入并进行合法性校验。""" + """批量解析用户输入并进行合法性校验.""" print("\n--- 解析答案结果 ---") has_error = False temp_answers = [] diff --git a/api/base.py b/api/base.py index 7f146e8f..8635fa12 100644 --- a/api/base.py +++ b/api/base.py @@ -16,8 +16,7 @@ from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception from tqdm import tqdm -from api.answer import * -from api.answer import TikuManual +from api.answer import Tiku, TikuFallback, TikuManual from api.answer_check import cut from api.cipher import AESCipher from api.config import GlobalConst as gc diff --git a/main.py b/main.py index aea82d24..f347e2f3 100644 --- a/main.py +++ b/main.py @@ -291,19 +291,24 @@ class ChapterTask: tries: int = 0 def __lt__(self, other): - """比较两个任务的索引大小,用于优先级队列排序。""" + """ + 比较两个任务的索引大小,用于优先级队列排序. + """ if not isinstance(other, ChapterTask): return NotImplemented return self.index < other.index class JobProcessor: def __init__(self, chaoxing: Chaoxing, tasks: list[ChapterTask], config: dict[str, Any]): - """初始化任务处理器。 + """ + 初始化任务处理器. + + Args + ---- + chaoxing: Chaoxing API 实例 + tasks: 任务列表 + config: 配置字典 - Args: - chaoxing: Chaoxing API 实例 - tasks: 任务列表 - config: 配置字典 """ if "jobs" not in config or not config["jobs"]: config["jobs"] = 4 From d382cf6d7de36fd6023c01f39f733c47698b6a50 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 15:34:30 +0800 Subject: [PATCH 09/17] =?UTF-8?q?refactor:=20=E4=BF=AE=E5=A4=8D=E9=9D=99?= =?UTF-8?q?=E6=80=81=E5=88=86=E6=9E=90=E5=92=8C=E5=9C=88=E5=A4=8D=E6=9D=82?= =?UTF-8?q?=E5=BA=A6=E8=BF=87=E9=AB=98=E7=9A=84=E9=97=AE=E9=A2=98=E5=92=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96PEP=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/answer.py | 158 +++++++++++++++++++++++--------------------------- api/base.py | 3 +- main.py | 10 +--- 3 files changed, 74 insertions(+), 97 deletions(-) diff --git a/api/answer.py b/api/answer.py index 7a19f65e..144d7264 100644 --- a/api/answer.py +++ b/api/answer.py @@ -438,12 +438,7 @@ def check_llm_connection(self) -> bool: class TikuFallback(Tiku): # 多题库回退实现,按 provider 中配置顺序依次查询。 def __init__(self, providers=None, config_path: Optional[str] = None): - """初始化多题库回退。 - - Args: - providers: 子题库实例列表 - config_path: 配置文件路径 - """ + """初始化多题库回退.""" super().__init__(config_path) self.name = '多题库回退' self.providers = providers or [] @@ -1299,13 +1294,9 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): - """硅基流动大模型答题实现""" + """硅基流动大模型答题实现.""" def __init__(self, config_path: Optional[str] = None): - """初始化硅基流动大模型题库。 - - Args: - config_path: 配置文件路径 - """ + """初始化硅基流动大模型题库.""" super().__init__(config_path) self.name = '硅基流动大模型' self.last_request_time = None @@ -1491,7 +1482,7 @@ def _init_tiku(self): @staticmethod def _safe_close_tqdm_bars(): - """安全地清除并关闭所有活动的 tqdm 进度条,防止私有属性变更引发异常。""" + """安全地清除并关闭所有活动的 tqdm 进度条,防止私有属性变更引发异常.""" try: from tqdm import tqdm if hasattr(tqdm, '_instances') and hasattr(tqdm._instances, '__iter__'): @@ -1581,14 +1572,8 @@ def _single_query(self, q: dict) -> Optional[str]: return normalized_ans def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: - """验证用户手动输入的答案是否合规。 - - Args: - ans: 用户输入的答案 - q: 题目信息 - - Returns: - 元组 (是否合规, 错误提示信息) + """ + 验证用户手动输入的答案是否合规. """ if not ans: return True, "" @@ -1605,7 +1590,7 @@ def _validate_user_input(self, ans: str, q: dict) -> tuple[bool, str]: return True, "" def _validate_judgement_input(self, ans: str) -> tuple[bool, str]: - """验证判断题手动输入是否合规。""" + """验证判断题手动输入是否合规.""" val = ans.lower() valid_judgements = [ 'true', 't', '1', '对', '正确', '√', '是', 'yes', 'y', @@ -1616,8 +1601,33 @@ def _validate_judgement_input(self, ans: str) -> tuple[bool, str]: return True, "" def _validate_choice_input(self, ans: str, q: dict) -> tuple[bool, str]: - """验证选择题手动输入是否合规。""" + """ + 验证选择题手动输入是否合规. + """ options = q.get('options', '') + parts = self._parse_options(options) + valid_keys = self._extract_valid_keys(parts) + + if not valid_keys: + return True, "" + + letters = self._extract_option_letters(ans) + if not letters: + return self._validate_text_match(ans, parts) + + invalid_letters = [letter for letter in letters if letter not in valid_keys] + if invalid_letters: + return False, f"输入包含无效的选项字母 {invalid_letters},当前题目的可用选项为: {', '.join(valid_keys)}" + + if q.get('type') == 'single' and len(letters) > 1: + return False, "当前是单选题,但输入了多个选项字母!" + + return True, "" + + def _parse_options(self, options) -> list[str]: + """ + 解析选项. + """ parts = [] if isinstance(options, str): parts = [o.strip() for o in options.split('\n') if o.strip()] @@ -1628,51 +1638,40 @@ def _validate_choice_input(self, ans: str, q: dict) -> tuple[bool, str]: parts = cut_parts elif isinstance(options, list): parts = [str(o).strip() for o in options if str(o).strip()] + return parts - # 收集该题合法的选项字母 + def _extract_valid_keys(self, parts: list[str]) -> list[str]: + """ + 提取合法的选项字母. + """ valid_keys = [] for p in parts: first_char = p[:1].upper() if first_char.isalpha(): valid_keys.append(first_char) + return valid_keys - if not valid_keys: - return True, "" - - letters = self._extract_option_letters(ans) - if not letters: - from api.answer_check import cut - split_ans = cut(ans) - if split_ans: - for item in split_ans: - matched = False - for p in parts: - p_norm = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', p).strip().lower() - if item.strip().lower() in p_norm or p_norm in item.strip().lower(): - matched = True - break - if not matched: - return False, f"输入的文本 '{item}' 在所有选项中均无法匹配,请输入合法的选项文本或字母" - return True, "" - - invalid_letters = [letter for letter in letters if letter not in valid_keys] - if invalid_letters: - return False, f"输入包含无效的选项字母 {invalid_letters},当前题目的可用选项为: {', '.join(valid_keys)}" - - if q.get('type') == 'single' and len(letters) > 1: - return False, "当前是单选题,但输入了多个选项字母!" - + def _validate_text_match(self, ans: str, parts: list[str]) -> tuple[bool, str]: + """ + 验证用户输入的文本是否和选项文本匹配. + """ + from api.answer_check import cut + split_ans = cut(ans) + if split_ans: + for item in split_ans: + matched = False + for p in parts: + p_norm = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', p).strip().lower() + if item.strip().lower() in p_norm or p_norm in item.strip().lower(): + matched = True + break + if not matched: + return False, f"输入的文本 '{item}' 在所有选项中均无法匹配,请输入合法的选项文本或字母" return True, "" def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: - """规整化用户的手动输入答案。 - - Args: - ans: 用户原始输入 - q: 题目信息 - - Returns: - 规整后的答案字符串 + """ + 规整化用户的手动输入答案. """ if not ans: return None @@ -1689,7 +1688,9 @@ def _normalize_user_input(self, ans: str, q: dict) -> Optional[str]: return ans def _normalize_judgement_input(self, ans: str) -> str: - """规整化判断题的手动输入。""" + """ + 规整化判断题的手动输入. + """ val = ans.lower() if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y']: return "正确" @@ -1698,24 +1699,12 @@ def _normalize_judgement_input(self, ans: str) -> str: return ans def _normalize_choice_input(self, ans: str, q: dict) -> str: - """规整化选择题的手动输入。""" + """ + 规整化选择题的手动输入. + """ options = q.get('options', '') - parts = [] - if isinstance(options, str): - parts = [o.strip() for o in options.split('\n') if o.strip()] - if len(parts) <= 1: - from api.answer_check import cut - cut_parts = cut(options) - if cut_parts: - parts = cut_parts - elif isinstance(options, list): - parts = [str(o).strip() for o in options if str(o).strip()] - - valid_keys = [] - for p in parts: - first_char = p[:1].upper() - if first_char.isalpha(): - valid_keys.append(first_char) + parts = self._parse_options(options) + valid_keys = self._extract_valid_keys(parts) letters = self._extract_option_letters(ans) if letters and all(letter in valid_keys for letter in letters): @@ -1732,13 +1721,8 @@ def _normalize_choice_input(self, ans: str, q: dict) -> str: return ans def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: - """执行批量手动搜题交互。 - - Args: - q_list: 待搜题目列表 - - Returns: - 规整后的用户输入答案列表 + """ + 执行批量手动搜题交互. """ self._print_batch_questions(q_list) @@ -1781,7 +1765,7 @@ def _batch_query_flow(self, q_list: list[dict]) -> list[Optional[str]]: print("已取消,请重新输入,或输入 'switch' 切换为单题输入模式。") def _print_batch_questions(self, q_list: list[dict]) -> None: - """批量打印题目内容及选项。""" + """批量打印题目内容及选项.""" for idx, q in enumerate(q_list): type_str = self._get_type_display(q['type']) if q['type'] in ['single', 'multiple'] and q.get('options'): @@ -1805,7 +1789,7 @@ def _print_batch_questions(self, q_list: list[dict]) -> None: print(f"\n[{idx + 1}] 【{type_str}】 {q['title']}") def _print_batch_instructions(self, sep_desc: str) -> None: - """打印批量输入的使用引导说明。""" + """打印批量输入的使用引导说明.""" print("\n" + "="*50) print("请依次输入每道题的答案。") print(f"格式要求:当前配置要求使用【{sep_desc}】分割各题的答案。") @@ -1818,7 +1802,7 @@ def _print_batch_instructions(self, sep_desc: str) -> None: print("="*50) def _split_batch_answers(self, raw_input: str, expected_len: int) -> list[str]: - """根据配置的分割符将批量的答案进行分拆和补齐。""" + """根据配置的分割符将批量的答案进行分拆和补齐.""" if not raw_input: return [''] * expected_len @@ -1838,7 +1822,7 @@ def _split_batch_answers(self, raw_input: str, expected_len: int) -> list[str]: return answers def _parse_and_validate_batch(self, q_list: list[dict], answers: list[str]) -> tuple[bool, list[Optional[str]]]: - """批量解析用户输入并进行合法性校验。""" + """批量解析用户输入并进行合法性校验.""" print("\n--- 解析答案结果 ---") has_error = False temp_answers = [] diff --git a/api/base.py b/api/base.py index 7f146e8f..3b7b81e7 100644 --- a/api/base.py +++ b/api/base.py @@ -16,8 +16,7 @@ from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception from tqdm import tqdm -from api.answer import * -from api.answer import TikuManual +from api.answer import Tiku, TikuManual from api.answer_check import cut from api.cipher import AESCipher from api.config import GlobalConst as gc diff --git a/main.py b/main.py index aea82d24..ac908efc 100644 --- a/main.py +++ b/main.py @@ -291,20 +291,14 @@ class ChapterTask: tries: int = 0 def __lt__(self, other): - """比较两个任务的索引大小,用于优先级队列排序。""" + """比较两个任务的索引大小,用于优先级队列排序.""" if not isinstance(other, ChapterTask): return NotImplemented return self.index < other.index class JobProcessor: def __init__(self, chaoxing: Chaoxing, tasks: list[ChapterTask], config: dict[str, Any]): - """初始化任务处理器。 - - Args: - chaoxing: Chaoxing API 实例 - tasks: 任务列表 - config: 配置字典 - """ + """初始化任务处理器.""" if "jobs" not in config or not config["jobs"]: config["jobs"] = 4 From 70dd90ec7cd8d09ecdd09af18017cdfe1309c41e Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 15:56:55 +0800 Subject: [PATCH 10/17] =?UTF-8?q?refactor:=20=E4=BF=AE=E5=A4=8D=E9=9D=99?= =?UTF-8?q?=E6=80=81=E5=88=86=E6=9E=90=E5=92=8C=E5=9C=88=E5=A4=8D=E6=9D=82?= =?UTF-8?q?=E5=BA=A6=E8=BF=87=E9=AB=98=E7=9A=84=E9=97=AE=E9=A2=98=E5=92=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96PEP=E7=BA=A6=E5=AE=9A,=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BA=86=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/__init__.py | 2 +- api/answer.py | 145 +++++++++++++++++++++++-------------------- api/answer_check.py | 3 +- api/base.py | 25 +++++--- api/captcha.py | 1 + api/cipher.py | 2 +- api/cxsecret_font.py | 20 +++--- api/decode.py | 93 +++++++++++++-------------- api/font_decoder.py | 14 ++--- api/live.py | 16 ++--- api/logger.py | 1 + api/notification.py | 4 +- api/process.py | 14 ++--- main.py | 96 ++++++++++++++-------------- 14 files changed, 233 insertions(+), 203 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index a8039929..605adbc2 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- def formatted_output(_status, _text, _data): - return {"status": _status, "msg": _text, "data": _data} \ No newline at end of file + return {"status": _status, "msg": _text, "data": _data} diff --git a/api/answer.py b/api/answer.py index 144d7264..e78a03a0 100644 --- a/api/answer.py +++ b/api/answer.py @@ -23,7 +23,9 @@ # 关闭警告 disable_warnings(exceptions.InsecureRequestWarning) -__all__ = ["CacheDAO", "Tiku", "TikuFallback", "TikuYanxi", "TikuGo", "TikuLike", "TikuAdapter", "AI", "SiliconFlow", "TikuManual"] +__all__ = ["CacheDAO", "Tiku", "TikuFallback", "TikuYanxi", "TikuGo", "TikuLike", "TikuAdapter", "AI", "SiliconFlow", + "TikuManual"] + class CacheDAO: """ @@ -57,7 +59,7 @@ def _read_cache(self) -> dict: end = text.rfind('}') if start != -1 and end != -1 and start < end: try: - return json.loads(text[start:end+1]) + return json.loads(text[start:end + 1]) except Exception: pass except Exception: @@ -80,7 +82,7 @@ def _read_cache(self) -> dict: end = text.rfind('}') if start != -1 and end != -1 and start < end: try: - return json.loads(text[start:end+1]) + return json.loads(text[start:end + 1]) except Exception: pass except Exception: @@ -137,11 +139,12 @@ def add_cache(self, question: str, answer: str) -> None: class Tiku(ABC): CONFIG_PATH = os.path.join(os.getcwd(), "config.ini") - DISABLE = False # 停用标志 - SUBMIT = False # 提交标志 - COVER_RATE = 0.8 # 覆盖率 + DISABLE = False # 停用标志 + SUBMIT = False # 提交标志 + COVER_RATE = 0.8 # 覆盖率 true_list = None false_list = None + def __init__(self, config_path: Optional[str] = None) -> None: self._name = None self._api = None @@ -192,7 +195,7 @@ def _init_tiku(self): # 仅用于题库初始化, 例如配置token, 交由自定义题库完成 pass - def config_set(self,config): + def config_set(self, config): self._conf = config def _get_conf(self): @@ -207,16 +210,18 @@ def _get_conf(self): logger.info("未找到tiku配置, 已忽略题库功能") self.DISABLE = True return None - + @property def _is_manual_mode(self) -> bool: return ( - getattr(self, 'is_manual', False) or - self.__class__.__name__ == 'TikuManual' or - (self.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self, 'providers', []))) + getattr(self, 'is_manual', False) or + self.__class__.__name__ == 'TikuManual' or + (self.__class__.__name__ == 'TikuFallback' and any( + getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in + getattr(self, 'providers', []))) ) - def query(self,q_info:dict) -> Optional[str]: + def query(self, q_info: dict) -> Optional[str]: if self.DISABLE: return None @@ -282,7 +287,8 @@ def query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Option logger.error(f"{self.name} _query_all 返回结果格式异常,期望列表") sub_results = [None] * len(pending_indices) elif len(sub_results) != len(pending_indices): - logger.error(f"{self.name} _query_all 返回结果长度不匹配,期望 {len(pending_indices)},实际 {len(sub_results)}") + logger.error( + f"{self.name} _query_all 返回结果长度不匹配,期望 {len(pending_indices)},实际 {len(sub_results)}") # 补齐或截断 sub_results 防止错位 sub_results = list(sub_results) + [None] * (len(pending_indices) - len(sub_results)) sub_results = sub_results[:len(pending_indices)] @@ -302,10 +308,8 @@ def query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Option return results - - @abstractmethod - def _query(self, q_info:dict) -> Optional[str]: + def _query(self, q_info: dict) -> Optional[str]: """ 查询接口, 交由自定义题库实现 """ @@ -327,7 +331,6 @@ def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optio results.append(None) return results - @staticmethod def get_tiku_from_config(config: Optional[dict] = None, config_path: Optional[str] = None): """ @@ -400,7 +403,7 @@ def judgement_select(self, answer: str) -> bool: return False # 对响应的答案作处理 answer = answer.strip().lower() - + # 内置的高频通用判断词规整 if answer in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y']: return True @@ -414,8 +417,9 @@ def judgement_select(self, answer: str) -> bool: return False else: # 无法判断, 随机选择 - logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误, 请自行判断并加入配置文件重启脚本, 本次将会随机选择选项') - return random.choice([True,False]) + logger.error( + f'无法判断答案 -> {answer} 对应的是正确还是错误, 请自行判断并加入配置文件重启脚本, 本次将会随机选择选项') + return random.choice([True, False]) def get_submit_params(self): """ @@ -460,7 +464,7 @@ def _init_tiku(self): else: logger.info(f"多题库回退已启用,查询顺序: {', '.join([p.__class__.__name__ for p in self.providers])}") - def _query(self, q_info:dict) -> Optional[str]: + def _query(self, q_info: dict) -> Optional[str]: for provider in self.providers: try: answer = provider._query(q_info) @@ -503,7 +507,8 @@ def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optio continue if len(sub_results) != len(pending_indices): - logger.error(f"{provider.name} 批量查询返回结果长度({len(sub_results)})与请求题目数({len(pending_indices)})不匹配,跳过该题库以防答案错位") + logger.error( + f"{provider.name} 批量查询返回结果长度({len(sub_results)})与请求题目数({len(pending_indices)})不匹配,跳过该题库以防答案错位") continue next_pending_indices = [] @@ -535,14 +540,14 @@ def __init__(self, config_path: Optional[str] = None) -> None: self.name = '言溪题库' self.api = 'https://tk.enncy.cn/query' self._token = None - self._token_index = 0 # token队列计数器 - self._times = 100 # 查询次数剩余, 初始化为100, 查询后校对修正 + self._token_index = 0 # token队列计数器 + self._times = 100 # 查询次数剩余, 初始化为100, 查询后校对修正 - def _query(self,q_info:dict): + def _query(self, q_info: dict): res = requests.get( self.api, params={ - 'question':q_info['title'], + 'question': q_info['title'], 'token': self._token, # 'type':q_info['type'], #修复478题目类型与答案类型不符(不想写后处理了) # 没用,就算有type和options,言溪题库还是可能返回类型不符,问了客服,type仅用于收集 @@ -559,9 +564,10 @@ def _query(self,q_info:dict): self.load_token() # 重新查询 return self._query(q_info) - logger.error(f'{self.name}查询失败:\n\t剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n\t消息:{res_json["message"]}') + logger.error( + f'{self.name}查询失败:\n\t剩余查询数{res_json["data"].get("times", f"{self._times}(仅参考)")}:\n\t消息:{res_json["message"]}') return None - self._times = res_json["data"].get("times",self._times) + self._times = res_json["data"].get("times", self._times) return res_json['data']['answer'].strip() else: logger.error(f'{self.name}查询失败:\n{res.text}') @@ -578,6 +584,7 @@ def load_token(self): def _init_tiku(self): self.load_token() + class TikuGo(Tiku): # GO题(网课小工具题库)实现 def __init__(self, config_path: Optional[str] = None) -> None: @@ -747,12 +754,13 @@ def _init_tiku(self): except (TypeError, ValueError): logger.warning(f'{self.name}配置 go_retry_backoff 无效,使用默认值 {self._retry_backoff}') + class TikuLike(Tiku): # LIKE知识库实现 参考 https://www.datam.site/ def __init__(self, config_path: Optional[str] = None) -> None: super().__init__(config_path) self.name = 'LIKE知识库' - self.ver = '2.0.0' #对应官网API版本 + self.ver = '2.0.0' # 对应官网API版本 self.query_api = 'https://app.datam.site/api/v1/query' self.models_api = 'https://app.datam.site/api/v1/query/models' self.balance_api = 'https://app.datam.site/api/v1/balance' @@ -768,11 +776,11 @@ def __init__(self, config_path: Optional[str] = None) -> None: self._count = 0 self._headers = {"Content-Type": "application/json"} - def _query(self, q_info:dict = None): + def _query(self, q_info: dict = None): if not q_info: logger.error("当前无题目信息,请检查") return "" - + q_info_map = {"single": "【单选题】", "multiple": "【多选题】", "completion": "【填空题】", "judgement": "【判断题】"} q_info_prefix = q_info_map.get(q_info['type'], "【其他类型题目】") options = ', '.join(q_info['options']) if isinstance(q_info['options'], list) else q_info['options'] @@ -783,7 +791,7 @@ def _query(self, q_info:dict = None): # 随机选择一个token进行查询 token = random.choice(self._tokens) - + # 检查该token是否有余额 if self._balance.get(token, 0) <= 0: logger.error(f'{self.name}当前Token查询次数不足: ...{token[-5:]}') @@ -797,7 +805,7 @@ def _query(self, q_info:dict = None): ans = None try_times = 0 - + # 尝试查询,直到成功或达到重试次数 while not ans and self._retry and try_times < self._retry_times: ans = self._query_single(token, question) @@ -808,14 +816,14 @@ def _query(self, q_info:dict = None): break elif try_times < self._retry_times: logger.warning(f'使用Token ...{token[-5:]} 查询失败,进行第 {try_times + 1} 次重试...') - + # 10次查询后更新余额 self._count = (self._count + 1) % 10 if self._count == 0: self.update_times() return ans - + def _query_single(self, token: str = "", query: str = "") -> str: """ 查询单个问题的答案 @@ -831,15 +839,15 @@ def _query_single(self, token: str = "", query: str = "") -> str: if not token: logger.error(f'{self.name}查询失败: 未提供有效的token') return None - + if not query: logger.error(f'{self.name}查询失败: 查询内容为空') return None - + # 设置请求头 temp_headers = self._headers.copy() temp_headers['Authorization'] = f'Bearer {token}' - + # 准备请求数据 request_data = { 'query': query, @@ -847,7 +855,7 @@ def _query_single(self, token: str = "", query: str = "") -> str: 'search': self._search, 'vision': self._vision } - + # 发送API请求 try: res = requests.post( @@ -885,9 +893,9 @@ def _query_single(self, token: str = "", query: str = "") -> str: logger.error(f'{self.name}访问被拒绝: 可能是Token权限不足') else: logger.error(f'{self.name}查询失败: 状态码 {res.status_code}, 响应内容: \n{res.text}') - + return None - + def _parse_response(self, response): """ 解析API响应 @@ -906,7 +914,7 @@ def _parse_response(self, response): except Exception as e: logger.error(f'{self.name}响应解析异常: {e}') return None - + # 记录响应消息 msg = res_json.get('message', '') if msg: @@ -916,25 +924,25 @@ def _parse_response(self, response): if not results or not isinstance(results, dict): logger.error(f'{self.name}查询结果格式错误: API返回结果中results字段格式不正确') return None - + output = results.get('output', None) if output is None or not isinstance(output, dict): logger.error(f'{self.name}查询结果中output字段格式错误或不存在') return None - + q_type = output.get('questionType', None) if q_type is None: logger.error(f'{self.name}查询结果中questionType字段不存在') return None - + answer = output.get('answer', None) if answer is None: logger.error(f'{self.name}查询结果中answer字段不存在') return None - + # 根据题目类型提取答案 return self._extract_answer_by_type(q_type, answer) - + def _extract_answer_by_type(self, q_type: str, answer: dict) -> str: """ 根据题目类型提取答案 @@ -949,7 +957,7 @@ def _extract_answer_by_type(self, q_type: str, answer: dict) -> str: if not isinstance(answer, dict): logger.error(f'{self.name}答案格式错误: 不是有效的字典格式') return None - + if q_type == "CHOICE": selected_options = answer.get('selectedOptions', None) if selected_options is not None: @@ -990,14 +998,14 @@ def _extract_answer_by_type(self, q_type: str, answer: dict) -> str: return str(otherText) else: logger.error(f'{self.name}未知题目类型{q_type}且缺少otherText字段') - + return None - - def get_api_balance(self, token:str = ""): + + def get_api_balance(self, token: str = ""): if not token: logger.error(f'{self.name}获取余额失败: 未提供有效的token') return 0 - + temp_headers = self._headers.copy() temp_headers['Authorization'] = f'Bearer {token}' try: @@ -1033,7 +1041,8 @@ def update_times(self) -> None: for token in self._tokens: balance = self.get_api_balance(token) self._balance[token] = balance - logger.info(f"当前LIKE知识库Token: ...{token[-5:]} 的剩余查询次数为: {balance} (仅供参考, 实际次数以查询结果为准)") + logger.info( + f"当前LIKE知识库Token: ...{token[-5:]} 的剩余查询次数为: {balance} (仅供参考, 实际次数以查询结果为准)") def load_tokens(self) -> None: tokens_str = self._conf.get('tokens') @@ -1066,6 +1075,7 @@ def _init_tiku(self) -> None: logger.error(f'{self.name}初始化失败: 未加载任何有效的Token') self.DISABLE = True + class TikuAdapter(Tiku): # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter def __init__(self, config_path: Optional[str] = None) -> None: @@ -1114,6 +1124,7 @@ def _init_tiku(self): # self.load_token() self.api = self._conf['url'] + class AI(Tiku): # AI大模型答题实现 def __init__(self, config_path: Optional[str] = None) -> None: @@ -1124,8 +1135,8 @@ def __init__(self, config_path: Optional[str] = None) -> None: def _is_deepseek_v4(self) -> bool: return ( - 'api.deepseek.com' in (self.endpoint or '').lower() - and (self.model or '').lower().startswith('deepseek-v4') + 'api.deepseek.com' in (self.endpoint or '').lower() + and (self.model or '').lower().startswith('deepseek-v4') ) def _completion_kwargs(self, **kwargs): @@ -1156,9 +1167,9 @@ def remove_md_json_wrapper(md_str): if self.http_proxy: proxy = self.http_proxy httpx_client = httpx.Client(proxy=proxy) - client = OpenAI(http_client=httpx_client, base_url = self.endpoint,api_key = self.key) + client = OpenAI(http_client=httpx_client, base_url=self.endpoint, api_key=self.key) else: - client = OpenAI(base_url = self.endpoint,api_key = self.key) + client = OpenAI(base_url=self.endpoint, api_key=self.key) # 去除选项字母,防止大模型直接输出字母而非内容 options_list = q_info['options'].split('\n') cleaned_options = [re.sub(r"^[A-Z]\s*", "", option) for option in options_list] @@ -1168,7 +1179,7 @@ def remove_md_json_wrapper(md_str): self.last_request_time = time.time() if q_info['type'] == "single": completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1182,7 +1193,7 @@ def remove_md_json_wrapper(md_str): )) elif q_info['type'] == 'multiple': completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1196,7 +1207,7 @@ def remove_md_json_wrapper(md_str): )) elif q_info['type'] == 'completion': completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1210,7 +1221,7 @@ def remove_md_json_wrapper(md_str): )) elif q_info['type'] == 'judgement': completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1224,7 +1235,7 @@ def remove_md_json_wrapper(md_str): )) else: completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1295,6 +1306,7 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): """硅基流动大模型答题实现.""" + def __init__(self, config_path: Optional[str] = None): """初始化硅基流动大模型题库.""" super().__init__(config_path) @@ -1391,7 +1403,6 @@ def _init_tiku(self): self.model_name = self._conf.get('siliconflow_model', 'deepseek-ai/DeepSeek-V3') - self.min_interval = int(self._conf.get('min_interval_seconds', 3)) def check_llm_connection(self) -> bool: @@ -1471,7 +1482,7 @@ def _init_tiku(self): self.default_mode = self._conf.get('manual_mode_default', 'batch').strip().lower() if self.default_mode not in ['batch', 'single']: self.default_mode = 'batch' - + self.separator = self._conf.get('manual_mode_separator', ';') if self.separator.lower() in ['\\n', 'newline', '换行']: self.separator = '\n' @@ -1515,7 +1526,7 @@ def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optio self._safe_close_tqdm_bars() with self._manual_lock: - print(f"\n{'='*20} 手动输入题库 (共 {len(q_list)} 题) {'='*20}") + print(f"\n{'=' * 20} 手动输入题库 (共 {len(q_list)} 题) {'=' * 20}") if self.default_mode == 'batch': ans_list = self._batch_query_flow(q_list) else: @@ -1790,7 +1801,7 @@ def _print_batch_questions(self, q_list: list[dict]) -> None: def _print_batch_instructions(self, sep_desc: str) -> None: """打印批量输入的使用引导说明.""" - print("\n" + "="*50) + print("\n" + "=" * 50) print("请依次输入每道题的答案。") print(f"格式要求:当前配置要求使用【{sep_desc}】分割各题的答案。") print("如果是多选题,答案中的多个选项直接连着写即可(例如:AB 或 AC)。") @@ -1799,7 +1810,7 @@ def _print_batch_instructions(self, sep_desc: str) -> None: print("粘贴多行时,每行会被解析为对应一题的答案。") else: print(f"示例输入: A{self.separator} B{self.separator} 正确{self.separator} 答案1, 答案2{self.separator} 错") - print("="*50) + print("=" * 50) def _split_batch_answers(self, raw_input: str, expected_len: int) -> list[str]: """根据配置的分割符将批量的答案进行分拆和补齐.""" diff --git a/api/answer_check.py b/api/answer_check.py index e73061e4..13b810a8 100644 --- a/api/answer_check.py +++ b/api/answer_check.py @@ -28,7 +28,8 @@ def check_judgement(answer, true_list, false_list): val = str(answer).strip().lower() if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y'] or val in [x.lower() for x in true_list]: return 1 - elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确'] or val in [x.lower() for x in false_list]: + elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确'] or val in [x.lower() for x in + false_list]: return 0 else: return -1 diff --git a/api/base.py b/api/base.py index 3b7b81e7..e89684cb 100644 --- a/api/base.py +++ b/api/base.py @@ -156,6 +156,7 @@ def multi_cut(answer: str, origin_html_content="", logger=logger): else: return res + def clean_res(res): cleaned_res = [] if isinstance(res, str): @@ -166,6 +167,7 @@ def clean_res(res): cleaned_res.append(cleaned.strip()) return cleaned_res + def normalize_text(text: str) -> str: if not isinstance(text, str): text = str(text) @@ -182,9 +184,11 @@ def normalize_text(text: str) -> str: normalized = re.sub(r'[,。!?;:,.!?;:()()\[\]【】"“”‘’\-_/\\|]', '', normalized) return normalized.lower() + def get_option_text(option: str) -> str: return re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', option).strip() + def best_option_by_similarity(target: str, options: list, threshold: float = 0.8) -> str: if not target or not options: return "" @@ -209,10 +213,12 @@ def best_option_by_similarity(target: str, options: list, threshold: float = 0.8 return best_letter return "" + def is_subsequence(a, o): iter_o = iter(o.lower()) return all(c in iter_o for c in a.lower()) + def random_answer(options: str, q_type: str) -> str: answer = "" if not options: @@ -411,7 +417,6 @@ def get_activity_list(self, course: dict) -> list[dict]: return data["data"]["activeList"] - def pre_sign(self, course: dict, activity_id): s = SessionManager.get_session() params = { @@ -434,7 +439,6 @@ def pre_sign(self, course: dict, activity_id): return resp_txt - def sign_in_normal(self, course: dict, activity_id, name="", obj_id="aaa", lat=-1, lon=-1, type_=SignType.NORMAL): s = SessionManager.get_session() params = { @@ -467,7 +471,6 @@ def sign_in_normal(self, course: dict, activity_id, name="", obj_id="aaa", lat=- # TOD0: Implement triangulation for location signs return resp_txt - def get_course_point(self, _courseid, _clazzid, _cpi): _session = SessionManager.get_session() _url = f"https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentcourse?courseid={_courseid}&clazzid={_clazzid}&cpi={_cpi}&ut=s" @@ -779,7 +782,8 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, ) time.sleep(random.uniform(2, 4)) refreshed_meta = self._recover_after_forbidden(_session, _job, _type) - if refreshed_meta and refreshed_meta.get("dtoken") and refreshed_meta.get("duration") is not None: + if refreshed_meta and refreshed_meta.get("dtoken") and refreshed_meta.get( + "duration") is not None: _dtoken = refreshed_meta["dtoken"] duration = int(refreshed_meta["duration"]) refreshed_play_time = refreshed_meta.get("playTime") @@ -936,7 +940,8 @@ def fetch_response_with_retry(): logger.error("题库 query_all 返回的数据格式异常,期望列表。将采用随机答案答题") answers = [None] * total_questions elif len(answers) != total_questions: - logger.error(f"题库返回的答案数量({len(answers)})与题目数量({total_questions})不匹配,正在补齐或截断以防错位!") + logger.error( + f"题库返回的答案数量({len(answers)})与题目数量({total_questions})不匹配,正在补齐或截断以防错位!") answers = list(answers) + [None] * (total_questions - len(answers)) answers = answers[:total_questions] @@ -962,7 +967,7 @@ def fetch_response_with_retry(): ): answer += o[:1] matched = True - break # 找到匹配项后立即停止,防止重复添加 + break # 找到匹配项后立即停止,防止重复添加 if not matched: best_letter = best_option_by_similarity(_a, options_list, threshold=0.8) if best_letter: @@ -1007,9 +1012,11 @@ def fetch_response_with_retry(): logger.info(f"章节检测题库覆盖率: {cover_rate:.0f}%") # 提交模式 现在与题库绑定,留空直接提交, 1保存但不提交 is_manual_mode = ( - getattr(self.tiku, 'is_manual', False) or - self.tiku.__class__.__name__ == 'TikuManual' or - (self.tiku.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self.tiku, 'providers', []))) + getattr(self.tiku, 'is_manual', False) or + self.tiku.__class__.__name__ == 'TikuManual' or + (self.tiku.__class__.__name__ == 'TikuFallback' and any( + getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in + getattr(self.tiku, 'providers', []))) ) if self.tiku.get_submit_params() == "1": questions["pyFlag"] = "1" diff --git a/api/captcha.py b/api/captcha.py index 15655a3f..8878530c 100644 --- a/api/captcha.py +++ b/api/captcha.py @@ -21,6 +21,7 @@ try: from ddddocr import DdddOcr + HAS_DDDDOCR = True except ImportError: DdddOcr = None diff --git a/api/cipher.py b/api/cipher.py index aa489349..9aa5ca12 100644 --- a/api/cipher.py +++ b/api/cipher.py @@ -7,7 +7,7 @@ def pkcs7_unpadding(string): - return string[0 : -ord(string[-1])] + return string[0: -ord(string[-1])] def pkcs7_padding(s, block_size=16): diff --git a/api/cxsecret_font.py b/api/cxsecret_font.py index aefae865..1445d1c5 100644 --- a/api/cxsecret_font.py +++ b/api/cxsecret_font.py @@ -44,7 +44,7 @@ def resource_path(relative_path: str) -> str: except Exception: # 非打包环境,使用当前目录 base_path = os.path.abspath(".") - + return os.path.join(base_path, relative_path) @@ -66,7 +66,7 @@ def __init__(self, file_path: str = "resource/font_map_table.json"): """ self.char_map: Dict[str, str] = {} # unicode -> hash self.hash_map: Dict[str, str] = {} # hash -> unicode - + full_path = resource_path(file_path) try: with open(full_path, "r", encoding="utf-8") as fp: @@ -122,10 +122,10 @@ def hash_glyph(glyph: Glyph) -> str: """ if glyph.numberOfContours <= 0: return "" - + pos_data = [] last_index = 0 - + for i in range(glyph.numberOfContours): end_point = glyph.endPtsOfContours[i] for j in range(last_index, end_point + 1): @@ -133,7 +133,7 @@ def hash_glyph(glyph: Glyph) -> str: flag = glyph.flags[j] & 0x01 pos_data.append(f"{x}{y}{flag}") last_index = end_point + 1 - + pos_bin = "".join(pos_data) return hashlib.md5(pos_bin.encode()).hexdigest() @@ -152,7 +152,7 @@ def font2map(font_data: Union[IO, Path, str]) -> Dict[str, str]: ValueError: 当无法解析字体数据时 """ font_hashmap = {} - + # 处理Base64编码的字体数据 if isinstance(font_data, str) and font_data.startswith("data:application/font-ttf;charset=utf-8;base64,"): try: @@ -186,11 +186,11 @@ def decrypt(dst_fontmap: Dict[str, str], encrypted_text: str) -> str: 解密后的文本 """ result = [] - + for char in encrypted_text: # 构造Unicode字符名称 (如 "uni4E00") char_code = f"uni{ord(char):X}" - + # 查找字符在目标字体中的哈希值 if char_code in dst_fontmap: dst_hash = dst_fontmap[char_code] @@ -204,10 +204,10 @@ def decrypt(dst_fontmap: Dict[str, str], encrypted_text: str) -> str: continue except (ValueError, IndexError): pass - + # 如果无法解密,则保留原字符 result.append(char) - + # 替换解密后的康熙部首 decrypted_text = "".join(result).translate(KX_RADICALS_TAB) return decrypted_text diff --git a/api/decode.py b/api/decode.py index 3ba2b727..c1d20a7b 100644 --- a/api/decode.py +++ b/api/decode.py @@ -29,12 +29,12 @@ def decode_course_list(html_text: str) -> List[Dict[str, str]]: soup = BeautifulSoup(html_text, "lxml") raw_courses = soup.select("div.course") course_list = [] - + for course in raw_courses: # 跳过未开放课程 if course.select_one("a.not-open-tip") or course.select_one("div.not-open-tip"): continue - + course_detail = { "id": course.attrs["id"], "info": course.attrs["info"], @@ -47,7 +47,7 @@ def decode_course_list(html_text: str) -> List[Dict[str, str]]: "teacher": course.select_one("p.color3").attrs["title"] } course_list.append(course_detail) - + return course_list @@ -65,17 +65,17 @@ def decode_course_folder(html_text: str) -> List[Dict[str, str]]: soup = BeautifulSoup(html_text, "lxml") raw_courses = soup.select("ul.file-list>li") course_folder_list = [] - + for course in raw_courses: if not course.attrs.get("fileid"): continue - + course_folder_detail = { "id": course.attrs["fileid"], "rename": course.select_one("input.rename-input").attrs["value"] } course_folder_list.append(course_folder_detail) - + return course_folder_list @@ -102,9 +102,9 @@ def decode_course_point(html_text: str) -> Dict[str, Any]: for point in points: if point.get("need_unlock", False): course_point["hasLocked"] = True - + course_point["points"].extend(points) - + return course_point @@ -120,15 +120,15 @@ def _extract_points_from_chapter(chapter_unit) -> List[Dict[str, Any]]: """ point_list = [] raw_points = chapter_unit.find_all("li") - + for raw_point in raw_points: point = raw_point.div if "id" not in point.attrs: continue - + point_id = re.findall(r"^cur(\d{1,20})$", point.attrs["id"])[0] point_title = point.select_one("a.clicktitle").text.replace("\n", "").strip() - + # 提取任务数量 job_count = 1 # 默认为1 need_unlock = False @@ -136,12 +136,12 @@ def _extract_points_from_chapter(chapter_unit) -> List[Dict[str, Any]]: job_count = point.select_one("input.knowledgeJobCount").attrs["value"] elif point.select_one("span.bntHoverTips") and "解锁" in point.select_one("span.bntHoverTips").text: need_unlock = True - + # 判断是否已完成 is_finished = False if point.select_one("span.bntHoverTips") and "已完成" in point.select_one("span.bntHoverTips").text: is_finished = True - + point_detail = { "id": point_id, "title": point_title, @@ -150,7 +150,7 @@ def _extract_points_from_chapter(chapter_unit) -> List[Dict[str, Any]]: "need_unlock": need_unlock } point_list.append(point_detail) - + return point_list @@ -165,7 +165,7 @@ def decode_course_card(html_text: str) -> Tuple[List[Dict[str, Any]], Dict[str, 任务点列表和任务信息的元组 """ logger.trace("开始解码任务点列表...") - + # 检查章节是否未开放 if "章节未开放" in html_text: return [], {"notOpen": True} @@ -204,7 +204,7 @@ def _extract_job_info(cards_data: Dict[str, Any]) -> Dict[str, Any]: defaults = cards_data.get("defaults", {}) if not defaults: return {} - + return { "ktoken": defaults.get("ktoken", ""), "mtEnc": defaults.get("mtEnc", ""), @@ -228,7 +228,7 @@ def _process_attachment_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any 处理后的任务列表 """ job_list = [] - + for index, card in enumerate(cards): # 跳过已通过的任务 if card.get("isPassed", False): @@ -254,17 +254,17 @@ def _process_attachment_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any property_data = card.get("property", {}) prop_type = property_data.get("type", "").lower() resource_type = property_data.get("resourceType", "").lower() - + # 直播任务特征:包含liveId、streamName等字段, # 或类型标识包含live(因为live和video有点类似,怕超星又搞出什么幺蛾子就加了一些关键字识别) is_live = ( - "live" in card_type - or "live" in prop_type - or "live" in resource_type - or "livestream" in card_type - or property_data.get("liveId") is not None - or property_data.get("streamName") is not None - or property_data.get("vdoid") is not None + "live" in card_type + or "live" in prop_type + or "live" in resource_type + or "livestream" in card_type + or property_data.get("liveId") is not None + or property_data.get("streamName") is not None + or property_data.get("vdoid") is not None ) # 根据任务类型处理 @@ -311,11 +311,13 @@ def _process_live_task(card: Dict[str, Any]) -> Optional[Dict[str, Any]]: except Exception as e: logger.error(f"解析直播任务失败: {str(e)}, 任务数据: {str(card)[:200]}") return None + + def _process_read_task(card: Dict[str, Any]) -> Optional[Dict[str, Any]]: """处理阅读类型任务""" if not (card.get("type") == "read" and not card.get("property", {}).get("read", False)): return None - + return { "title": card.get("property", {}).get("title", ""), "type": "read", @@ -389,27 +391,27 @@ def decode_questions_info(html_content: str) -> Dict[str, Any]: """ soup = BeautifulSoup(html_content, "lxml") form_data = _extract_form_data(soup) - + # 检查是否存在字体加密 has_font_encryption = bool(soup.find("style", id="cxSecretStyle")) font_decoder = None - + if has_font_encryption: font_decoder = FontDecoder(html_content) else: logger.warning("未找到字体文件,可能是未加密的题目不进行解密") - + # 处理所有问题 questions = [] for div_tag in soup.find("form").find_all("div", class_="singleQuesId"): question = _process_question(div_tag, font_decoder) if question: questions.append(question) - + # 更新表单数据 form_data["questions"] = questions form_data["answerwqbid"] = ",".join([q["id"] for q in questions]) + "," - + return form_data @@ -452,11 +454,11 @@ def _process_question(div_tag, font_decoder=None) -> Dict[str, Any]: question_id = div_tag.attrs.get("data", "") q_type_code = div_tag.find("div", class_="TiMu").attrs.get("data", "") q_type = _get_question_type(q_type_code) - + # 提取题目内容和选项 title_div = div_tag.find("div", class_="Zy_TItle") options_list = div_tag.find("ul").find_all("li") if div_tag.find("ul") else [] - + # 解析题目和选项 q_title = _extract_title(title_div, font_decoder) q_options = [] @@ -465,7 +467,7 @@ def _process_question(div_tag, font_decoder=None) -> Dict[str, Any]: # 排序选项 q_options.sort() q_options = '\n'.join(q_options) - + return { "id": question_id, "title": q_title, @@ -481,16 +483,16 @@ def _process_question(div_tag, font_decoder=None) -> Dict[str, Any]: def _get_question_type(type_code: str) -> str: """根据题型代码返回题型名称""" type_map = { - "0": "single", # 单选题 - "1": "multiple", # 多选题 + "0": "single", # 单选题 + "1": "multiple", # 多选题 "2": "completion", # 填空题 - "3": "judgement", # 判断题 - "4": "shortanswer", # 简答题 + "3": "judgement", # 判断题 + "4": "shortanswer", # 简答题 } - + if type_code in type_map: return type_map[type_code] - + logger.info(f"未知题型代码 -> {type_code}") return "unknown" @@ -499,7 +501,7 @@ def _extract_title(element, font_decoder=None) -> str: """提取标题内容,支持解码加密字体""" if not element: return "" - + # 收集元素中的所有文本和图片 content = [] for item in element.descendants: @@ -508,21 +510,22 @@ def _extract_title(element, font_decoder=None) -> str: elif item.name == "img": img_url = item.get("src", "") content.append(f'') - + raw_content = "".join(content) cleaned_content = raw_content.replace("\r", "").replace("\t", "").replace("\n", "") - + # 如果有字体解码器,进行解码 if font_decoder: return font_decoder.decode(cleaned_content) - + return cleaned_content + def _extract_choices(element, font_decoder=None) -> str: """提取选项内容,支持解码加密字体""" if not element: return "" - + # 提取aria-label属性值作为选项,解决#474 choice = element.get("aria-label") or element.get_text() if not choice: diff --git a/api/font_decoder.py b/api/font_decoder.py index 8a9b938a..1915c9e6 100644 --- a/api/font_decoder.py +++ b/api/font_decoder.py @@ -13,11 +13,11 @@ class FontDecoder: 用于解码超星平台使用特殊字体加密的内容。 """ - + # 正则表达式常量 FONT_BASE64_PATTERN = r"base64,([\w\W]+?)\'" FONT_DATA_URL_PREFIX = "data:application/font-ttf;charset=utf-8;base64," - + def __init__(self, html_content: Optional[str] = None): """初始化字体解码器。 @@ -26,10 +26,10 @@ def __init__(self, html_content: Optional[str] = None): """ self.html_content = html_content self.__font_map: Optional[Dict] = None - + if html_content: self.__init_font_map(html_content) - + def __init_font_map(self, html_content: str) -> None: """从HTML内容中提取字体信息并初始化字体映射。 @@ -43,7 +43,7 @@ def __init_font_map(self, html_content: str) -> None: logger.trace(f"lxml parser not available, falling back to html.parser: {e}") soup = BeautifulSoup(html_content, "html.parser") style_tag = soup.find("style", id="cxSecretStyle") - + if not style_tag or not style_tag.text: raise FontDecodeError("未找到加密字体样式标签") @@ -57,7 +57,7 @@ def __init_font_map(self, html_content: str) -> None: except Exception as e: logger.warning(f"初始化字体映射失败: {e}") self.__font_map = None - + def decode(self, target_str: str) -> str: """解码加密字符串。 @@ -74,7 +74,7 @@ def decode(self, target_str: str) -> str: raise FontDecodeError("字体映射未初始化,无法解码") return cxfont.decrypt(self.__font_map, target_str) - + def set_html_content(self, html_content: str) -> None: """设置新的HTML内容并重新初始化字体映射。 diff --git a/api/live.py b/api/live.py index 59abaea8..581d36d3 100644 --- a/api/live.py +++ b/api/live.py @@ -24,14 +24,14 @@ def do_finish(self): stream_name = self.attachment.get("property", {}).get("streamName") vdoid = self.attachment.get("property", {}).get("vdoid") user_id = self.defaults.get("userid") - + if not all([stream_name, vdoid, user_id]): logger.error("缺少直播必要参数,无法提交时长") return False - + # 构造时长记录请求URL(超星直播时长记录接口) - url = f"https://zhibo.chaoxing.com/saveTimePc?streamName={stream_name}&vdoid={vdoid}&userId={user_id}&isStart=0&t={int(time.time()*1000)}&courseId={self.course_id}" - + url = f"https://zhibo.chaoxing.com/saveTimePc?streamName={stream_name}&vdoid={vdoid}&userId={user_id}&isStart=0&t={int(time.time() * 1000)}&courseId={self.course_id}" + # 发送请求记录时长 session = SessionManager.get_session() try: @@ -43,20 +43,20 @@ def do_finish(self): logger.error(f"提交直播时长失败: {str(e)}") return False - def get_status(self) -> dict|None: + def get_status(self) -> dict | None: """获取直播状态(总时长等信息)""" live_id = self.attachment.get("property", {}).get("liveId") user_id = self.defaults.get("userid") clazz_id = self.defaults.get("clazzId") knowledge_id = self.defaults.get("knowledgeid") - + if not all([live_id, user_id, clazz_id, knowledge_id]): logger.error("缺少直播状态查询必要参数") return None - + # 构造直播状态请求URL status_url = f"https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid={live_id}&userid={user_id}&clazzid={clazz_id}&knowledgeid={knowledge_id}&courseid={self.course_id}&jobid={self.attachment.get('property', {}).get('_jobid', '')}&ut=s" - + # 发送请求并解析状态(包含总时长) session = SessionManager.get_session() try: diff --git a/api/logger.py b/api/logger.py index a2948dc3..42728ae5 100644 --- a/api/logger.py +++ b/api/logger.py @@ -32,6 +32,7 @@ def tqdm_sink(msg): tqdm.write(msg.rstrip(), file=tqdm_stream) tqdm_stream.flush() + logger.remove() logger.add(tqdm_sink, colorize=True, enqueue=True) logger.add("chaoxing.log", rotation="10 MB", level="TRACE") diff --git a/api/notification.py b/api/notification.py index 85247ff7..f68c95d9 100644 --- a/api/notification.py +++ b/api/notification.py @@ -275,6 +275,7 @@ def _send(self, message: str) -> None: except ValueError as e: logger.error(f"Bark返回数据解析失败: {e}") + class Telegram(NotificationService): """ 通过Telegram发送通知 @@ -316,5 +317,6 @@ def _send(self, message: str) -> None: except ValueError as e: logger.error(f"Telegram返回数据解析失败: {e}") + # 为了向后兼容,保留原来的Notification类 -Notification = DefaultNotification \ No newline at end of file +Notification = DefaultNotification diff --git a/api/process.py b/api/process.py index c4df1bbf..bd866c33 100644 --- a/api/process.py +++ b/api/process.py @@ -16,7 +16,7 @@ def sec2time(seconds: int) -> str: hours = int(seconds / 3600) minutes = int(seconds % 3600 / 60) secs = int(seconds % 60) - + if hours > 0: return f"{hours}:{minutes:02}:{secs:02}" if seconds > 0: @@ -24,8 +24,8 @@ def sec2time(seconds: int) -> str: return "--:--" -def show_progress(task_name: str, start_position: int, duration: int, - total_length: int, speed: float) -> None: +def show_progress(task_name: str, start_position: int, duration: int, + total_length: int, speed: float) -> None: """ 显示任务进度条,模拟任务进度。 @@ -41,22 +41,22 @@ def show_progress(task_name: str, start_position: int, duration: int, """ start_time = time.time() expected_end_time = start_time + (duration / speed) - + while time.time() < expected_end_time: # 计算当前进度 current_position = start_position + int((time.time() - start_time) * speed) percent_complete = min(int(current_position / total_length * 100), 100) - + # 生成进度条 bar_length = 40 filled_length = int(percent_complete * bar_length // 100) progress_bar = ("#" * filled_length).ljust(bar_length, " ") - + # 格式化输出进度信息 progress_text = ( f"\r当前任务: {task_name} |{progress_bar}| {percent_complete}% " f"{sec2time(current_position)}/{sec2time(total_length)}" ) - + print(progress_text, end="", flush=True) time.sleep(gc.THRESHOLD) diff --git a/main.py b/main.py index ac908efc..b17e8fbd 100644 --- a/main.py +++ b/main.py @@ -21,14 +21,17 @@ from queue import PriorityQueue, ShutDown except ImportError: from queue import PriorityQueue + + class ShutDown(Exception): pass + class ChapterResult(enum.Enum): - SUCCESS=0, - ERROR=1, - NOT_OPEN=2, - PENDING=3 + SUCCESS = 0, + ERROR = 1, + NOT_OPEN = 2, + PENDING = 3 def log_error(func): @@ -81,7 +84,7 @@ def parse_args(): help="启用调试模式, 输出DEBUG级别日志", ) parser.add_argument( - "-a", "--notopen-action", type=str, default="retry", + "-a", "--notopen-action", type=str, default="retry", choices=["retry", "ask", "continue"], help="遇到关闭任务点时的行为: retry-重试, ask-询问, continue-继续" ) @@ -103,17 +106,18 @@ def load_config_from_file(config_path): """从配置文件加载设置""" config = configparser.ConfigParser() config.read(config_path, encoding="utf8") - + common_config: dict[str, Any] = {} tiku_config: dict[str, Any] = {} notification_config: dict[str, Any] = {} - + # 检查并读取common节 if config.has_section("common"): common_config = dict(config.items("common")) # 处理course_list,将字符串转换为列表 if "course_list" in common_config and common_config["course_list"]: - common_config["course_list"] = [item.strip() for item in common_config["course_list"].split(",") if item.strip()] + common_config["course_list"] = [item.strip() for item in common_config["course_list"].split(",") if + item.strip()] # 处理speed,将字符串转换为浮点数 if "speed" in common_config: common_config["speed"] = float(common_config["speed"]) @@ -144,7 +148,7 @@ def load_config_from_file(config_path): # 检查并读取notification节 if config.has_section("notification"): notification_config = dict(config.items("notification")) - + return common_config, tiku_config, notification_config @@ -174,26 +178,25 @@ def init_config(): return common_config, tiku_config, notification_config, args.config - def init_chaoxing(common_config, tiku_config, config_path=None): """初始化超星实例""" username = common_config.get("username", "") password = common_config.get("password", "") use_cookies = common_config.get("use_cookies", False) - + # 如果没有提供用户名密码,从命令行获取 if (not username or not password) and not use_cookies: username = input("请输入你的手机号, 按回车确认\n手机号:") password = input("请输入你的密码, 按回车确认\n密码:") - + account = Account(username, password) - + # 设置题库 tiku = Tiku.get_tiku_from_config(tiku_config, config_path=config_path) # 载入题库 tiku.init_tiku() # 初始化题库 - + # 获取查询延迟设置 - + # 检查大模型连接(如果使用的是大模型题库) # 根据配置文件中的 provider 判断是否为大模型题库 provider = tiku_config.get('provider', '') @@ -211,12 +214,13 @@ def init_chaoxing(common_config, tiku_config, config_path=None): logger.info('用户选择继续运行...') query_delay = tiku_config.get("delay", 0) - + # 实例化超星API chaoxing = Chaoxing(account=account, tiku=tiku, query_delay=query_delay) - + return chaoxing + def process_job(chaoxing: Chaoxing, course: dict, job: dict, job_info: dict, speed: float) -> StudyResult: """处理单个任务点""" # 视频任务 @@ -257,14 +261,14 @@ def process_job(chaoxing: Chaoxing, course: dict, job: dict, job_info: dict, spe "clazzId": course.get("clazzId"), "knowledgeid": job_info.get("knowledgeid") } - + # 创建直播对象 live = Live( attachment=job, defaults=defaults, course_id=course.get("courseId") ) - + # 启动直播处理线程 thread = threading.Thread( target=LiveProcessor.run_live, @@ -296,12 +300,13 @@ def __lt__(self, other): return NotImplemented return self.index < other.index + class JobProcessor: def __init__(self, chaoxing: Chaoxing, tasks: list[ChapterTask], config: dict[str, Any]): """初始化任务处理器.""" if "jobs" not in config or not config["jobs"]: config["jobs"] = 4 - + self.chaoxing = chaoxing self.speed = config["speed"] self.max_tries = 5 @@ -331,7 +336,6 @@ def run(self): if hasattr(self.task_queue, "shutdown"): self.task_queue.shutdown() - @log_error def worker_thread(self): while True: @@ -360,7 +364,7 @@ def worker_thread(self): logger.error( "章节未开启: {} - {} 可能由于上一章节的章节检测未完成, 也可能由于该章节因为时效已关闭," "请手动检查完成并提交再重试。或者在配置中配置(自动跳过关闭章节/开启题库并启用提交)" - , task.course["title"], task.point["title"]) + , task.course["title"], task.point["title"]) self.task_queue.task_done() continue @@ -369,7 +373,8 @@ def worker_thread(self): case ChapterResult.ERROR: task.tries += 1 - logger.warning("重试任务 {} - {} ({}/{} 次尝试)", task.course["title"], task.point["title"], task.tries, + logger.warning("重试任务 {} - {} ({}/{} 次尝试)", task.course["title"], task.point["title"], + task.tries, self.max_tries) if task.tries >= self.max_tries: logger.error("任务重试次数达到上限: {} - {}", task.course["title"], task.point["title"]) @@ -397,16 +402,16 @@ def retry_thread(self): pass -def process_chapter(chaoxing: Chaoxing, course:dict[str, Any], point:dict[str, Any], speed:float) -> ChapterResult: +def process_chapter(chaoxing: Chaoxing, course: dict[str, Any], point: dict[str, Any], speed: float) -> ChapterResult: """处理单个章节""" logger.info(f'当前章节: {point["title"]}') if point["has_finished"]: logger.info(f'章节:{point["title"]} 已完成所有任务点') return ChapterResult.SUCCESS - + # 随机等待,避免请求过快 - chaoxing.rate_limiter.limit_rate(random_time=True,random_min=0, random_max=0.2) - + chaoxing.rate_limiter.limit_rate(random_time=True, random_min=0, random_max=0.2) + # 获取当前章节的所有任务点 job_info = None jobs, job_info = chaoxing.get_job_list(course, point) @@ -419,11 +424,11 @@ def process_chapter(chaoxing: Chaoxing, course:dict[str, Any], point:dict[str, A if not jobs: pass - job_results:list[StudyResult]=[] + job_results: list[StudyResult] = [] for job in jobs: result = process_job(chaoxing, course, job, job_info, speed) job_results.append(result) - + for result in job_results: if result.is_failure(): return ChapterResult.ERROR @@ -431,11 +436,10 @@ def process_chapter(chaoxing: Chaoxing, course:dict[str, Any], point:dict[str, A return ChapterResult.SUCCESS - -def process_course(chaoxing: Chaoxing, course:dict[str, Any], config: dict): +def process_course(chaoxing: Chaoxing, course: dict[str, Any], config: dict): """处理单个课程""" logger.info(f"开始学习课程: {course['title']}") - + # 获取当前课程的所有章节 point_list = chaoxing.get_course_point( course["courseId"], course["clazzId"], course["cpi"] @@ -446,7 +450,7 @@ def process_course(chaoxing: Chaoxing, course:dict[str, Any], config: dict): _old_format_sizeof = tqdm.format_sizeof tqdm.format_sizeof = format_time - tasks=[] + tasks = [] for i, point in enumerate(point_list["points"]): task = ChapterTask(point=point, index=i, course=course) @@ -454,9 +458,9 @@ def process_course(chaoxing: Chaoxing, course:dict[str, Any], config: dict): p = JobProcessor(chaoxing, tasks, config) p.run() - tqdm.format_sizeof = _old_format_sizeof + def filter_courses(all_course, course_list): """过滤要学习的课程""" if not course_list: @@ -479,11 +483,11 @@ def filter_courses(all_course, course_list): if course["courseId"] in course_list and course["courseId"] not in course_ids: course_task.append(course) course_ids.append(course["courseId"]) - + # 如果没有指定课程,则学习所有课程 if not course_task: course_task = all_course - + return course_task @@ -504,34 +508,34 @@ def main(): try: # 初始化配置 common_config, tiku_config, notification_config, config_path = init_config() - + # 强制播放按照配置文件调节 common_config["speed"] = min(2.0, max(1.0, common_config.get("speed", 1.0))) common_config["notopen_action"] = common_config.get("notopen_action", "retry") - + # 初始化超星实例 chaoxing = init_chaoxing(common_config, tiku_config, config_path=config_path) - + # 设置外部通知 notification = Notification() notification.config_set(notification_config) notification = notification.get_notification_from_config() notification.init_notification() - + # 检查当前登录状态 _login_state = chaoxing.login(login_with_cookies=common_config.get("use_cookies", False)) if not _login_state["status"]: raise LoginError(_login_state["msg"]) - + # 获取所有的课程列表 all_course = chaoxing.get_course_list() - + # 过滤要学习的课程 course_task = filter_courses(all_course, common_config.get("course_list")) - + # 开始学习 logger.info(f"课程列表过滤完毕, 当前课程任务数量: {len(course_task)}") - + _old_format_sizeof = tqdm.format_sizeof tqdm.format_sizeof = format_time @@ -550,10 +554,10 @@ def main(): p.run() tqdm.format_sizeof = _old_format_sizeof - + logger.info("所有课程学习任务已完成") notification.send("chaoxing : 所有课程学习任务已完成") - + except SystemExit as e: if e.code != 0: logger.error(f"错误: 程序异常退出, 返回码: {e.code}") From 53bf7634dc9b21a28a2c72d1a95da289febc1a82 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 15:56:55 +0800 Subject: [PATCH 11/17] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E4=BA=86?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/__init__.py | 2 +- api/answer.py | 145 +++++++++++++++++++++++-------------------- api/answer_check.py | 3 +- api/base.py | 25 +++++--- api/captcha.py | 1 + api/cipher.py | 2 +- api/cxsecret_font.py | 20 +++--- api/decode.py | 93 +++++++++++++-------------- api/font_decoder.py | 14 ++--- api/live.py | 16 ++--- api/logger.py | 1 + api/notification.py | 4 +- api/process.py | 14 ++--- main.py | 96 ++++++++++++++-------------- 14 files changed, 233 insertions(+), 203 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index a8039929..605adbc2 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- def formatted_output(_status, _text, _data): - return {"status": _status, "msg": _text, "data": _data} \ No newline at end of file + return {"status": _status, "msg": _text, "data": _data} diff --git a/api/answer.py b/api/answer.py index 144d7264..e78a03a0 100644 --- a/api/answer.py +++ b/api/answer.py @@ -23,7 +23,9 @@ # 关闭警告 disable_warnings(exceptions.InsecureRequestWarning) -__all__ = ["CacheDAO", "Tiku", "TikuFallback", "TikuYanxi", "TikuGo", "TikuLike", "TikuAdapter", "AI", "SiliconFlow", "TikuManual"] +__all__ = ["CacheDAO", "Tiku", "TikuFallback", "TikuYanxi", "TikuGo", "TikuLike", "TikuAdapter", "AI", "SiliconFlow", + "TikuManual"] + class CacheDAO: """ @@ -57,7 +59,7 @@ def _read_cache(self) -> dict: end = text.rfind('}') if start != -1 and end != -1 and start < end: try: - return json.loads(text[start:end+1]) + return json.loads(text[start:end + 1]) except Exception: pass except Exception: @@ -80,7 +82,7 @@ def _read_cache(self) -> dict: end = text.rfind('}') if start != -1 and end != -1 and start < end: try: - return json.loads(text[start:end+1]) + return json.loads(text[start:end + 1]) except Exception: pass except Exception: @@ -137,11 +139,12 @@ def add_cache(self, question: str, answer: str) -> None: class Tiku(ABC): CONFIG_PATH = os.path.join(os.getcwd(), "config.ini") - DISABLE = False # 停用标志 - SUBMIT = False # 提交标志 - COVER_RATE = 0.8 # 覆盖率 + DISABLE = False # 停用标志 + SUBMIT = False # 提交标志 + COVER_RATE = 0.8 # 覆盖率 true_list = None false_list = None + def __init__(self, config_path: Optional[str] = None) -> None: self._name = None self._api = None @@ -192,7 +195,7 @@ def _init_tiku(self): # 仅用于题库初始化, 例如配置token, 交由自定义题库完成 pass - def config_set(self,config): + def config_set(self, config): self._conf = config def _get_conf(self): @@ -207,16 +210,18 @@ def _get_conf(self): logger.info("未找到tiku配置, 已忽略题库功能") self.DISABLE = True return None - + @property def _is_manual_mode(self) -> bool: return ( - getattr(self, 'is_manual', False) or - self.__class__.__name__ == 'TikuManual' or - (self.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self, 'providers', []))) + getattr(self, 'is_manual', False) or + self.__class__.__name__ == 'TikuManual' or + (self.__class__.__name__ == 'TikuFallback' and any( + getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in + getattr(self, 'providers', []))) ) - def query(self,q_info:dict) -> Optional[str]: + def query(self, q_info: dict) -> Optional[str]: if self.DISABLE: return None @@ -282,7 +287,8 @@ def query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Option logger.error(f"{self.name} _query_all 返回结果格式异常,期望列表") sub_results = [None] * len(pending_indices) elif len(sub_results) != len(pending_indices): - logger.error(f"{self.name} _query_all 返回结果长度不匹配,期望 {len(pending_indices)},实际 {len(sub_results)}") + logger.error( + f"{self.name} _query_all 返回结果长度不匹配,期望 {len(pending_indices)},实际 {len(sub_results)}") # 补齐或截断 sub_results 防止错位 sub_results = list(sub_results) + [None] * (len(pending_indices) - len(sub_results)) sub_results = sub_results[:len(pending_indices)] @@ -302,10 +308,8 @@ def query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Option return results - - @abstractmethod - def _query(self, q_info:dict) -> Optional[str]: + def _query(self, q_info: dict) -> Optional[str]: """ 查询接口, 交由自定义题库实现 """ @@ -327,7 +331,6 @@ def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optio results.append(None) return results - @staticmethod def get_tiku_from_config(config: Optional[dict] = None, config_path: Optional[str] = None): """ @@ -400,7 +403,7 @@ def judgement_select(self, answer: str) -> bool: return False # 对响应的答案作处理 answer = answer.strip().lower() - + # 内置的高频通用判断词规整 if answer in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y']: return True @@ -414,8 +417,9 @@ def judgement_select(self, answer: str) -> bool: return False else: # 无法判断, 随机选择 - logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误, 请自行判断并加入配置文件重启脚本, 本次将会随机选择选项') - return random.choice([True,False]) + logger.error( + f'无法判断答案 -> {answer} 对应的是正确还是错误, 请自行判断并加入配置文件重启脚本, 本次将会随机选择选项') + return random.choice([True, False]) def get_submit_params(self): """ @@ -460,7 +464,7 @@ def _init_tiku(self): else: logger.info(f"多题库回退已启用,查询顺序: {', '.join([p.__class__.__name__ for p in self.providers])}") - def _query(self, q_info:dict) -> Optional[str]: + def _query(self, q_info: dict) -> Optional[str]: for provider in self.providers: try: answer = provider._query(q_info) @@ -503,7 +507,8 @@ def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optio continue if len(sub_results) != len(pending_indices): - logger.error(f"{provider.name} 批量查询返回结果长度({len(sub_results)})与请求题目数({len(pending_indices)})不匹配,跳过该题库以防答案错位") + logger.error( + f"{provider.name} 批量查询返回结果长度({len(sub_results)})与请求题目数({len(pending_indices)})不匹配,跳过该题库以防答案错位") continue next_pending_indices = [] @@ -535,14 +540,14 @@ def __init__(self, config_path: Optional[str] = None) -> None: self.name = '言溪题库' self.api = 'https://tk.enncy.cn/query' self._token = None - self._token_index = 0 # token队列计数器 - self._times = 100 # 查询次数剩余, 初始化为100, 查询后校对修正 + self._token_index = 0 # token队列计数器 + self._times = 100 # 查询次数剩余, 初始化为100, 查询后校对修正 - def _query(self,q_info:dict): + def _query(self, q_info: dict): res = requests.get( self.api, params={ - 'question':q_info['title'], + 'question': q_info['title'], 'token': self._token, # 'type':q_info['type'], #修复478题目类型与答案类型不符(不想写后处理了) # 没用,就算有type和options,言溪题库还是可能返回类型不符,问了客服,type仅用于收集 @@ -559,9 +564,10 @@ def _query(self,q_info:dict): self.load_token() # 重新查询 return self._query(q_info) - logger.error(f'{self.name}查询失败:\n\t剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n\t消息:{res_json["message"]}') + logger.error( + f'{self.name}查询失败:\n\t剩余查询数{res_json["data"].get("times", f"{self._times}(仅参考)")}:\n\t消息:{res_json["message"]}') return None - self._times = res_json["data"].get("times",self._times) + self._times = res_json["data"].get("times", self._times) return res_json['data']['answer'].strip() else: logger.error(f'{self.name}查询失败:\n{res.text}') @@ -578,6 +584,7 @@ def load_token(self): def _init_tiku(self): self.load_token() + class TikuGo(Tiku): # GO题(网课小工具题库)实现 def __init__(self, config_path: Optional[str] = None) -> None: @@ -747,12 +754,13 @@ def _init_tiku(self): except (TypeError, ValueError): logger.warning(f'{self.name}配置 go_retry_backoff 无效,使用默认值 {self._retry_backoff}') + class TikuLike(Tiku): # LIKE知识库实现 参考 https://www.datam.site/ def __init__(self, config_path: Optional[str] = None) -> None: super().__init__(config_path) self.name = 'LIKE知识库' - self.ver = '2.0.0' #对应官网API版本 + self.ver = '2.0.0' # 对应官网API版本 self.query_api = 'https://app.datam.site/api/v1/query' self.models_api = 'https://app.datam.site/api/v1/query/models' self.balance_api = 'https://app.datam.site/api/v1/balance' @@ -768,11 +776,11 @@ def __init__(self, config_path: Optional[str] = None) -> None: self._count = 0 self._headers = {"Content-Type": "application/json"} - def _query(self, q_info:dict = None): + def _query(self, q_info: dict = None): if not q_info: logger.error("当前无题目信息,请检查") return "" - + q_info_map = {"single": "【单选题】", "multiple": "【多选题】", "completion": "【填空题】", "judgement": "【判断题】"} q_info_prefix = q_info_map.get(q_info['type'], "【其他类型题目】") options = ', '.join(q_info['options']) if isinstance(q_info['options'], list) else q_info['options'] @@ -783,7 +791,7 @@ def _query(self, q_info:dict = None): # 随机选择一个token进行查询 token = random.choice(self._tokens) - + # 检查该token是否有余额 if self._balance.get(token, 0) <= 0: logger.error(f'{self.name}当前Token查询次数不足: ...{token[-5:]}') @@ -797,7 +805,7 @@ def _query(self, q_info:dict = None): ans = None try_times = 0 - + # 尝试查询,直到成功或达到重试次数 while not ans and self._retry and try_times < self._retry_times: ans = self._query_single(token, question) @@ -808,14 +816,14 @@ def _query(self, q_info:dict = None): break elif try_times < self._retry_times: logger.warning(f'使用Token ...{token[-5:]} 查询失败,进行第 {try_times + 1} 次重试...') - + # 10次查询后更新余额 self._count = (self._count + 1) % 10 if self._count == 0: self.update_times() return ans - + def _query_single(self, token: str = "", query: str = "") -> str: """ 查询单个问题的答案 @@ -831,15 +839,15 @@ def _query_single(self, token: str = "", query: str = "") -> str: if not token: logger.error(f'{self.name}查询失败: 未提供有效的token') return None - + if not query: logger.error(f'{self.name}查询失败: 查询内容为空') return None - + # 设置请求头 temp_headers = self._headers.copy() temp_headers['Authorization'] = f'Bearer {token}' - + # 准备请求数据 request_data = { 'query': query, @@ -847,7 +855,7 @@ def _query_single(self, token: str = "", query: str = "") -> str: 'search': self._search, 'vision': self._vision } - + # 发送API请求 try: res = requests.post( @@ -885,9 +893,9 @@ def _query_single(self, token: str = "", query: str = "") -> str: logger.error(f'{self.name}访问被拒绝: 可能是Token权限不足') else: logger.error(f'{self.name}查询失败: 状态码 {res.status_code}, 响应内容: \n{res.text}') - + return None - + def _parse_response(self, response): """ 解析API响应 @@ -906,7 +914,7 @@ def _parse_response(self, response): except Exception as e: logger.error(f'{self.name}响应解析异常: {e}') return None - + # 记录响应消息 msg = res_json.get('message', '') if msg: @@ -916,25 +924,25 @@ def _parse_response(self, response): if not results or not isinstance(results, dict): logger.error(f'{self.name}查询结果格式错误: API返回结果中results字段格式不正确') return None - + output = results.get('output', None) if output is None or not isinstance(output, dict): logger.error(f'{self.name}查询结果中output字段格式错误或不存在') return None - + q_type = output.get('questionType', None) if q_type is None: logger.error(f'{self.name}查询结果中questionType字段不存在') return None - + answer = output.get('answer', None) if answer is None: logger.error(f'{self.name}查询结果中answer字段不存在') return None - + # 根据题目类型提取答案 return self._extract_answer_by_type(q_type, answer) - + def _extract_answer_by_type(self, q_type: str, answer: dict) -> str: """ 根据题目类型提取答案 @@ -949,7 +957,7 @@ def _extract_answer_by_type(self, q_type: str, answer: dict) -> str: if not isinstance(answer, dict): logger.error(f'{self.name}答案格式错误: 不是有效的字典格式') return None - + if q_type == "CHOICE": selected_options = answer.get('selectedOptions', None) if selected_options is not None: @@ -990,14 +998,14 @@ def _extract_answer_by_type(self, q_type: str, answer: dict) -> str: return str(otherText) else: logger.error(f'{self.name}未知题目类型{q_type}且缺少otherText字段') - + return None - - def get_api_balance(self, token:str = ""): + + def get_api_balance(self, token: str = ""): if not token: logger.error(f'{self.name}获取余额失败: 未提供有效的token') return 0 - + temp_headers = self._headers.copy() temp_headers['Authorization'] = f'Bearer {token}' try: @@ -1033,7 +1041,8 @@ def update_times(self) -> None: for token in self._tokens: balance = self.get_api_balance(token) self._balance[token] = balance - logger.info(f"当前LIKE知识库Token: ...{token[-5:]} 的剩余查询次数为: {balance} (仅供参考, 实际次数以查询结果为准)") + logger.info( + f"当前LIKE知识库Token: ...{token[-5:]} 的剩余查询次数为: {balance} (仅供参考, 实际次数以查询结果为准)") def load_tokens(self) -> None: tokens_str = self._conf.get('tokens') @@ -1066,6 +1075,7 @@ def _init_tiku(self) -> None: logger.error(f'{self.name}初始化失败: 未加载任何有效的Token') self.DISABLE = True + class TikuAdapter(Tiku): # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter def __init__(self, config_path: Optional[str] = None) -> None: @@ -1114,6 +1124,7 @@ def _init_tiku(self): # self.load_token() self.api = self._conf['url'] + class AI(Tiku): # AI大模型答题实现 def __init__(self, config_path: Optional[str] = None) -> None: @@ -1124,8 +1135,8 @@ def __init__(self, config_path: Optional[str] = None) -> None: def _is_deepseek_v4(self) -> bool: return ( - 'api.deepseek.com' in (self.endpoint or '').lower() - and (self.model or '').lower().startswith('deepseek-v4') + 'api.deepseek.com' in (self.endpoint or '').lower() + and (self.model or '').lower().startswith('deepseek-v4') ) def _completion_kwargs(self, **kwargs): @@ -1156,9 +1167,9 @@ def remove_md_json_wrapper(md_str): if self.http_proxy: proxy = self.http_proxy httpx_client = httpx.Client(proxy=proxy) - client = OpenAI(http_client=httpx_client, base_url = self.endpoint,api_key = self.key) + client = OpenAI(http_client=httpx_client, base_url=self.endpoint, api_key=self.key) else: - client = OpenAI(base_url = self.endpoint,api_key = self.key) + client = OpenAI(base_url=self.endpoint, api_key=self.key) # 去除选项字母,防止大模型直接输出字母而非内容 options_list = q_info['options'].split('\n') cleaned_options = [re.sub(r"^[A-Z]\s*", "", option) for option in options_list] @@ -1168,7 +1179,7 @@ def remove_md_json_wrapper(md_str): self.last_request_time = time.time() if q_info['type'] == "single": completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1182,7 +1193,7 @@ def remove_md_json_wrapper(md_str): )) elif q_info['type'] == 'multiple': completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1196,7 +1207,7 @@ def remove_md_json_wrapper(md_str): )) elif q_info['type'] == 'completion': completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1210,7 +1221,7 @@ def remove_md_json_wrapper(md_str): )) elif q_info['type'] == 'judgement': completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1224,7 +1235,7 @@ def remove_md_json_wrapper(md_str): )) else: completion = client.chat.completions.create(**self._completion_kwargs( - model = self.model, + model=self.model, messages=[ { "role": "system", @@ -1295,6 +1306,7 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): """硅基流动大模型答题实现.""" + def __init__(self, config_path: Optional[str] = None): """初始化硅基流动大模型题库.""" super().__init__(config_path) @@ -1391,7 +1403,6 @@ def _init_tiku(self): self.model_name = self._conf.get('siliconflow_model', 'deepseek-ai/DeepSeek-V3') - self.min_interval = int(self._conf.get('min_interval_seconds', 3)) def check_llm_connection(self) -> bool: @@ -1471,7 +1482,7 @@ def _init_tiku(self): self.default_mode = self._conf.get('manual_mode_default', 'batch').strip().lower() if self.default_mode not in ['batch', 'single']: self.default_mode = 'batch' - + self.separator = self._conf.get('manual_mode_separator', ';') if self.separator.lower() in ['\\n', 'newline', '换行']: self.separator = '\n' @@ -1515,7 +1526,7 @@ def _query_all(self, q_list: list[dict], query_delay: float = 0.0) -> list[Optio self._safe_close_tqdm_bars() with self._manual_lock: - print(f"\n{'='*20} 手动输入题库 (共 {len(q_list)} 题) {'='*20}") + print(f"\n{'=' * 20} 手动输入题库 (共 {len(q_list)} 题) {'=' * 20}") if self.default_mode == 'batch': ans_list = self._batch_query_flow(q_list) else: @@ -1790,7 +1801,7 @@ def _print_batch_questions(self, q_list: list[dict]) -> None: def _print_batch_instructions(self, sep_desc: str) -> None: """打印批量输入的使用引导说明.""" - print("\n" + "="*50) + print("\n" + "=" * 50) print("请依次输入每道题的答案。") print(f"格式要求:当前配置要求使用【{sep_desc}】分割各题的答案。") print("如果是多选题,答案中的多个选项直接连着写即可(例如:AB 或 AC)。") @@ -1799,7 +1810,7 @@ def _print_batch_instructions(self, sep_desc: str) -> None: print("粘贴多行时,每行会被解析为对应一题的答案。") else: print(f"示例输入: A{self.separator} B{self.separator} 正确{self.separator} 答案1, 答案2{self.separator} 错") - print("="*50) + print("=" * 50) def _split_batch_answers(self, raw_input: str, expected_len: int) -> list[str]: """根据配置的分割符将批量的答案进行分拆和补齐.""" diff --git a/api/answer_check.py b/api/answer_check.py index e73061e4..13b810a8 100644 --- a/api/answer_check.py +++ b/api/answer_check.py @@ -28,7 +28,8 @@ def check_judgement(answer, true_list, false_list): val = str(answer).strip().lower() if val in ['true', 't', '1', '对', '正确', '√', '是', 'yes', 'y'] or val in [x.lower() for x in true_list]: return 1 - elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确'] or val in [x.lower() for x in false_list]: + elif val in ['false', 'f', '0', '错', '错误', '×', '否', 'no', 'n', '不对', '不正确'] or val in [x.lower() for x in + false_list]: return 0 else: return -1 diff --git a/api/base.py b/api/base.py index 3b7b81e7..e89684cb 100644 --- a/api/base.py +++ b/api/base.py @@ -156,6 +156,7 @@ def multi_cut(answer: str, origin_html_content="", logger=logger): else: return res + def clean_res(res): cleaned_res = [] if isinstance(res, str): @@ -166,6 +167,7 @@ def clean_res(res): cleaned_res.append(cleaned.strip()) return cleaned_res + def normalize_text(text: str) -> str: if not isinstance(text, str): text = str(text) @@ -182,9 +184,11 @@ def normalize_text(text: str) -> str: normalized = re.sub(r'[,。!?;:,.!?;:()()\[\]【】"“”‘’\-_/\\|]', '', normalized) return normalized.lower() + def get_option_text(option: str) -> str: return re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', option).strip() + def best_option_by_similarity(target: str, options: list, threshold: float = 0.8) -> str: if not target or not options: return "" @@ -209,10 +213,12 @@ def best_option_by_similarity(target: str, options: list, threshold: float = 0.8 return best_letter return "" + def is_subsequence(a, o): iter_o = iter(o.lower()) return all(c in iter_o for c in a.lower()) + def random_answer(options: str, q_type: str) -> str: answer = "" if not options: @@ -411,7 +417,6 @@ def get_activity_list(self, course: dict) -> list[dict]: return data["data"]["activeList"] - def pre_sign(self, course: dict, activity_id): s = SessionManager.get_session() params = { @@ -434,7 +439,6 @@ def pre_sign(self, course: dict, activity_id): return resp_txt - def sign_in_normal(self, course: dict, activity_id, name="", obj_id="aaa", lat=-1, lon=-1, type_=SignType.NORMAL): s = SessionManager.get_session() params = { @@ -467,7 +471,6 @@ def sign_in_normal(self, course: dict, activity_id, name="", obj_id="aaa", lat=- # TOD0: Implement triangulation for location signs return resp_txt - def get_course_point(self, _courseid, _clazzid, _cpi): _session = SessionManager.get_session() _url = f"https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentcourse?courseid={_courseid}&clazzid={_clazzid}&cpi={_cpi}&ut=s" @@ -779,7 +782,8 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, ) time.sleep(random.uniform(2, 4)) refreshed_meta = self._recover_after_forbidden(_session, _job, _type) - if refreshed_meta and refreshed_meta.get("dtoken") and refreshed_meta.get("duration") is not None: + if refreshed_meta and refreshed_meta.get("dtoken") and refreshed_meta.get( + "duration") is not None: _dtoken = refreshed_meta["dtoken"] duration = int(refreshed_meta["duration"]) refreshed_play_time = refreshed_meta.get("playTime") @@ -936,7 +940,8 @@ def fetch_response_with_retry(): logger.error("题库 query_all 返回的数据格式异常,期望列表。将采用随机答案答题") answers = [None] * total_questions elif len(answers) != total_questions: - logger.error(f"题库返回的答案数量({len(answers)})与题目数量({total_questions})不匹配,正在补齐或截断以防错位!") + logger.error( + f"题库返回的答案数量({len(answers)})与题目数量({total_questions})不匹配,正在补齐或截断以防错位!") answers = list(answers) + [None] * (total_questions - len(answers)) answers = answers[:total_questions] @@ -962,7 +967,7 @@ def fetch_response_with_retry(): ): answer += o[:1] matched = True - break # 找到匹配项后立即停止,防止重复添加 + break # 找到匹配项后立即停止,防止重复添加 if not matched: best_letter = best_option_by_similarity(_a, options_list, threshold=0.8) if best_letter: @@ -1007,9 +1012,11 @@ def fetch_response_with_retry(): logger.info(f"章节检测题库覆盖率: {cover_rate:.0f}%") # 提交模式 现在与题库绑定,留空直接提交, 1保存但不提交 is_manual_mode = ( - getattr(self.tiku, 'is_manual', False) or - self.tiku.__class__.__name__ == 'TikuManual' or - (self.tiku.__class__.__name__ == 'TikuFallback' and any(getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in getattr(self.tiku, 'providers', []))) + getattr(self.tiku, 'is_manual', False) or + self.tiku.__class__.__name__ == 'TikuManual' or + (self.tiku.__class__.__name__ == 'TikuFallback' and any( + getattr(p, 'is_manual', False) or p.__class__.__name__ == 'TikuManual' for p in + getattr(self.tiku, 'providers', []))) ) if self.tiku.get_submit_params() == "1": questions["pyFlag"] = "1" diff --git a/api/captcha.py b/api/captcha.py index 15655a3f..8878530c 100644 --- a/api/captcha.py +++ b/api/captcha.py @@ -21,6 +21,7 @@ try: from ddddocr import DdddOcr + HAS_DDDDOCR = True except ImportError: DdddOcr = None diff --git a/api/cipher.py b/api/cipher.py index aa489349..9aa5ca12 100644 --- a/api/cipher.py +++ b/api/cipher.py @@ -7,7 +7,7 @@ def pkcs7_unpadding(string): - return string[0 : -ord(string[-1])] + return string[0: -ord(string[-1])] def pkcs7_padding(s, block_size=16): diff --git a/api/cxsecret_font.py b/api/cxsecret_font.py index aefae865..1445d1c5 100644 --- a/api/cxsecret_font.py +++ b/api/cxsecret_font.py @@ -44,7 +44,7 @@ def resource_path(relative_path: str) -> str: except Exception: # 非打包环境,使用当前目录 base_path = os.path.abspath(".") - + return os.path.join(base_path, relative_path) @@ -66,7 +66,7 @@ def __init__(self, file_path: str = "resource/font_map_table.json"): """ self.char_map: Dict[str, str] = {} # unicode -> hash self.hash_map: Dict[str, str] = {} # hash -> unicode - + full_path = resource_path(file_path) try: with open(full_path, "r", encoding="utf-8") as fp: @@ -122,10 +122,10 @@ def hash_glyph(glyph: Glyph) -> str: """ if glyph.numberOfContours <= 0: return "" - + pos_data = [] last_index = 0 - + for i in range(glyph.numberOfContours): end_point = glyph.endPtsOfContours[i] for j in range(last_index, end_point + 1): @@ -133,7 +133,7 @@ def hash_glyph(glyph: Glyph) -> str: flag = glyph.flags[j] & 0x01 pos_data.append(f"{x}{y}{flag}") last_index = end_point + 1 - + pos_bin = "".join(pos_data) return hashlib.md5(pos_bin.encode()).hexdigest() @@ -152,7 +152,7 @@ def font2map(font_data: Union[IO, Path, str]) -> Dict[str, str]: ValueError: 当无法解析字体数据时 """ font_hashmap = {} - + # 处理Base64编码的字体数据 if isinstance(font_data, str) and font_data.startswith("data:application/font-ttf;charset=utf-8;base64,"): try: @@ -186,11 +186,11 @@ def decrypt(dst_fontmap: Dict[str, str], encrypted_text: str) -> str: 解密后的文本 """ result = [] - + for char in encrypted_text: # 构造Unicode字符名称 (如 "uni4E00") char_code = f"uni{ord(char):X}" - + # 查找字符在目标字体中的哈希值 if char_code in dst_fontmap: dst_hash = dst_fontmap[char_code] @@ -204,10 +204,10 @@ def decrypt(dst_fontmap: Dict[str, str], encrypted_text: str) -> str: continue except (ValueError, IndexError): pass - + # 如果无法解密,则保留原字符 result.append(char) - + # 替换解密后的康熙部首 decrypted_text = "".join(result).translate(KX_RADICALS_TAB) return decrypted_text diff --git a/api/decode.py b/api/decode.py index 3ba2b727..c1d20a7b 100644 --- a/api/decode.py +++ b/api/decode.py @@ -29,12 +29,12 @@ def decode_course_list(html_text: str) -> List[Dict[str, str]]: soup = BeautifulSoup(html_text, "lxml") raw_courses = soup.select("div.course") course_list = [] - + for course in raw_courses: # 跳过未开放课程 if course.select_one("a.not-open-tip") or course.select_one("div.not-open-tip"): continue - + course_detail = { "id": course.attrs["id"], "info": course.attrs["info"], @@ -47,7 +47,7 @@ def decode_course_list(html_text: str) -> List[Dict[str, str]]: "teacher": course.select_one("p.color3").attrs["title"] } course_list.append(course_detail) - + return course_list @@ -65,17 +65,17 @@ def decode_course_folder(html_text: str) -> List[Dict[str, str]]: soup = BeautifulSoup(html_text, "lxml") raw_courses = soup.select("ul.file-list>li") course_folder_list = [] - + for course in raw_courses: if not course.attrs.get("fileid"): continue - + course_folder_detail = { "id": course.attrs["fileid"], "rename": course.select_one("input.rename-input").attrs["value"] } course_folder_list.append(course_folder_detail) - + return course_folder_list @@ -102,9 +102,9 @@ def decode_course_point(html_text: str) -> Dict[str, Any]: for point in points: if point.get("need_unlock", False): course_point["hasLocked"] = True - + course_point["points"].extend(points) - + return course_point @@ -120,15 +120,15 @@ def _extract_points_from_chapter(chapter_unit) -> List[Dict[str, Any]]: """ point_list = [] raw_points = chapter_unit.find_all("li") - + for raw_point in raw_points: point = raw_point.div if "id" not in point.attrs: continue - + point_id = re.findall(r"^cur(\d{1,20})$", point.attrs["id"])[0] point_title = point.select_one("a.clicktitle").text.replace("\n", "").strip() - + # 提取任务数量 job_count = 1 # 默认为1 need_unlock = False @@ -136,12 +136,12 @@ def _extract_points_from_chapter(chapter_unit) -> List[Dict[str, Any]]: job_count = point.select_one("input.knowledgeJobCount").attrs["value"] elif point.select_one("span.bntHoverTips") and "解锁" in point.select_one("span.bntHoverTips").text: need_unlock = True - + # 判断是否已完成 is_finished = False if point.select_one("span.bntHoverTips") and "已完成" in point.select_one("span.bntHoverTips").text: is_finished = True - + point_detail = { "id": point_id, "title": point_title, @@ -150,7 +150,7 @@ def _extract_points_from_chapter(chapter_unit) -> List[Dict[str, Any]]: "need_unlock": need_unlock } point_list.append(point_detail) - + return point_list @@ -165,7 +165,7 @@ def decode_course_card(html_text: str) -> Tuple[List[Dict[str, Any]], Dict[str, 任务点列表和任务信息的元组 """ logger.trace("开始解码任务点列表...") - + # 检查章节是否未开放 if "章节未开放" in html_text: return [], {"notOpen": True} @@ -204,7 +204,7 @@ def _extract_job_info(cards_data: Dict[str, Any]) -> Dict[str, Any]: defaults = cards_data.get("defaults", {}) if not defaults: return {} - + return { "ktoken": defaults.get("ktoken", ""), "mtEnc": defaults.get("mtEnc", ""), @@ -228,7 +228,7 @@ def _process_attachment_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any 处理后的任务列表 """ job_list = [] - + for index, card in enumerate(cards): # 跳过已通过的任务 if card.get("isPassed", False): @@ -254,17 +254,17 @@ def _process_attachment_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any property_data = card.get("property", {}) prop_type = property_data.get("type", "").lower() resource_type = property_data.get("resourceType", "").lower() - + # 直播任务特征:包含liveId、streamName等字段, # 或类型标识包含live(因为live和video有点类似,怕超星又搞出什么幺蛾子就加了一些关键字识别) is_live = ( - "live" in card_type - or "live" in prop_type - or "live" in resource_type - or "livestream" in card_type - or property_data.get("liveId") is not None - or property_data.get("streamName") is not None - or property_data.get("vdoid") is not None + "live" in card_type + or "live" in prop_type + or "live" in resource_type + or "livestream" in card_type + or property_data.get("liveId") is not None + or property_data.get("streamName") is not None + or property_data.get("vdoid") is not None ) # 根据任务类型处理 @@ -311,11 +311,13 @@ def _process_live_task(card: Dict[str, Any]) -> Optional[Dict[str, Any]]: except Exception as e: logger.error(f"解析直播任务失败: {str(e)}, 任务数据: {str(card)[:200]}") return None + + def _process_read_task(card: Dict[str, Any]) -> Optional[Dict[str, Any]]: """处理阅读类型任务""" if not (card.get("type") == "read" and not card.get("property", {}).get("read", False)): return None - + return { "title": card.get("property", {}).get("title", ""), "type": "read", @@ -389,27 +391,27 @@ def decode_questions_info(html_content: str) -> Dict[str, Any]: """ soup = BeautifulSoup(html_content, "lxml") form_data = _extract_form_data(soup) - + # 检查是否存在字体加密 has_font_encryption = bool(soup.find("style", id="cxSecretStyle")) font_decoder = None - + if has_font_encryption: font_decoder = FontDecoder(html_content) else: logger.warning("未找到字体文件,可能是未加密的题目不进行解密") - + # 处理所有问题 questions = [] for div_tag in soup.find("form").find_all("div", class_="singleQuesId"): question = _process_question(div_tag, font_decoder) if question: questions.append(question) - + # 更新表单数据 form_data["questions"] = questions form_data["answerwqbid"] = ",".join([q["id"] for q in questions]) + "," - + return form_data @@ -452,11 +454,11 @@ def _process_question(div_tag, font_decoder=None) -> Dict[str, Any]: question_id = div_tag.attrs.get("data", "") q_type_code = div_tag.find("div", class_="TiMu").attrs.get("data", "") q_type = _get_question_type(q_type_code) - + # 提取题目内容和选项 title_div = div_tag.find("div", class_="Zy_TItle") options_list = div_tag.find("ul").find_all("li") if div_tag.find("ul") else [] - + # 解析题目和选项 q_title = _extract_title(title_div, font_decoder) q_options = [] @@ -465,7 +467,7 @@ def _process_question(div_tag, font_decoder=None) -> Dict[str, Any]: # 排序选项 q_options.sort() q_options = '\n'.join(q_options) - + return { "id": question_id, "title": q_title, @@ -481,16 +483,16 @@ def _process_question(div_tag, font_decoder=None) -> Dict[str, Any]: def _get_question_type(type_code: str) -> str: """根据题型代码返回题型名称""" type_map = { - "0": "single", # 单选题 - "1": "multiple", # 多选题 + "0": "single", # 单选题 + "1": "multiple", # 多选题 "2": "completion", # 填空题 - "3": "judgement", # 判断题 - "4": "shortanswer", # 简答题 + "3": "judgement", # 判断题 + "4": "shortanswer", # 简答题 } - + if type_code in type_map: return type_map[type_code] - + logger.info(f"未知题型代码 -> {type_code}") return "unknown" @@ -499,7 +501,7 @@ def _extract_title(element, font_decoder=None) -> str: """提取标题内容,支持解码加密字体""" if not element: return "" - + # 收集元素中的所有文本和图片 content = [] for item in element.descendants: @@ -508,21 +510,22 @@ def _extract_title(element, font_decoder=None) -> str: elif item.name == "img": img_url = item.get("src", "") content.append(f'') - + raw_content = "".join(content) cleaned_content = raw_content.replace("\r", "").replace("\t", "").replace("\n", "") - + # 如果有字体解码器,进行解码 if font_decoder: return font_decoder.decode(cleaned_content) - + return cleaned_content + def _extract_choices(element, font_decoder=None) -> str: """提取选项内容,支持解码加密字体""" if not element: return "" - + # 提取aria-label属性值作为选项,解决#474 choice = element.get("aria-label") or element.get_text() if not choice: diff --git a/api/font_decoder.py b/api/font_decoder.py index 8a9b938a..1915c9e6 100644 --- a/api/font_decoder.py +++ b/api/font_decoder.py @@ -13,11 +13,11 @@ class FontDecoder: 用于解码超星平台使用特殊字体加密的内容。 """ - + # 正则表达式常量 FONT_BASE64_PATTERN = r"base64,([\w\W]+?)\'" FONT_DATA_URL_PREFIX = "data:application/font-ttf;charset=utf-8;base64," - + def __init__(self, html_content: Optional[str] = None): """初始化字体解码器。 @@ -26,10 +26,10 @@ def __init__(self, html_content: Optional[str] = None): """ self.html_content = html_content self.__font_map: Optional[Dict] = None - + if html_content: self.__init_font_map(html_content) - + def __init_font_map(self, html_content: str) -> None: """从HTML内容中提取字体信息并初始化字体映射。 @@ -43,7 +43,7 @@ def __init_font_map(self, html_content: str) -> None: logger.trace(f"lxml parser not available, falling back to html.parser: {e}") soup = BeautifulSoup(html_content, "html.parser") style_tag = soup.find("style", id="cxSecretStyle") - + if not style_tag or not style_tag.text: raise FontDecodeError("未找到加密字体样式标签") @@ -57,7 +57,7 @@ def __init_font_map(self, html_content: str) -> None: except Exception as e: logger.warning(f"初始化字体映射失败: {e}") self.__font_map = None - + def decode(self, target_str: str) -> str: """解码加密字符串。 @@ -74,7 +74,7 @@ def decode(self, target_str: str) -> str: raise FontDecodeError("字体映射未初始化,无法解码") return cxfont.decrypt(self.__font_map, target_str) - + def set_html_content(self, html_content: str) -> None: """设置新的HTML内容并重新初始化字体映射。 diff --git a/api/live.py b/api/live.py index 59abaea8..581d36d3 100644 --- a/api/live.py +++ b/api/live.py @@ -24,14 +24,14 @@ def do_finish(self): stream_name = self.attachment.get("property", {}).get("streamName") vdoid = self.attachment.get("property", {}).get("vdoid") user_id = self.defaults.get("userid") - + if not all([stream_name, vdoid, user_id]): logger.error("缺少直播必要参数,无法提交时长") return False - + # 构造时长记录请求URL(超星直播时长记录接口) - url = f"https://zhibo.chaoxing.com/saveTimePc?streamName={stream_name}&vdoid={vdoid}&userId={user_id}&isStart=0&t={int(time.time()*1000)}&courseId={self.course_id}" - + url = f"https://zhibo.chaoxing.com/saveTimePc?streamName={stream_name}&vdoid={vdoid}&userId={user_id}&isStart=0&t={int(time.time() * 1000)}&courseId={self.course_id}" + # 发送请求记录时长 session = SessionManager.get_session() try: @@ -43,20 +43,20 @@ def do_finish(self): logger.error(f"提交直播时长失败: {str(e)}") return False - def get_status(self) -> dict|None: + def get_status(self) -> dict | None: """获取直播状态(总时长等信息)""" live_id = self.attachment.get("property", {}).get("liveId") user_id = self.defaults.get("userid") clazz_id = self.defaults.get("clazzId") knowledge_id = self.defaults.get("knowledgeid") - + if not all([live_id, user_id, clazz_id, knowledge_id]): logger.error("缺少直播状态查询必要参数") return None - + # 构造直播状态请求URL status_url = f"https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid={live_id}&userid={user_id}&clazzid={clazz_id}&knowledgeid={knowledge_id}&courseid={self.course_id}&jobid={self.attachment.get('property', {}).get('_jobid', '')}&ut=s" - + # 发送请求并解析状态(包含总时长) session = SessionManager.get_session() try: diff --git a/api/logger.py b/api/logger.py index a2948dc3..42728ae5 100644 --- a/api/logger.py +++ b/api/logger.py @@ -32,6 +32,7 @@ def tqdm_sink(msg): tqdm.write(msg.rstrip(), file=tqdm_stream) tqdm_stream.flush() + logger.remove() logger.add(tqdm_sink, colorize=True, enqueue=True) logger.add("chaoxing.log", rotation="10 MB", level="TRACE") diff --git a/api/notification.py b/api/notification.py index 85247ff7..f68c95d9 100644 --- a/api/notification.py +++ b/api/notification.py @@ -275,6 +275,7 @@ def _send(self, message: str) -> None: except ValueError as e: logger.error(f"Bark返回数据解析失败: {e}") + class Telegram(NotificationService): """ 通过Telegram发送通知 @@ -316,5 +317,6 @@ def _send(self, message: str) -> None: except ValueError as e: logger.error(f"Telegram返回数据解析失败: {e}") + # 为了向后兼容,保留原来的Notification类 -Notification = DefaultNotification \ No newline at end of file +Notification = DefaultNotification diff --git a/api/process.py b/api/process.py index c4df1bbf..bd866c33 100644 --- a/api/process.py +++ b/api/process.py @@ -16,7 +16,7 @@ def sec2time(seconds: int) -> str: hours = int(seconds / 3600) minutes = int(seconds % 3600 / 60) secs = int(seconds % 60) - + if hours > 0: return f"{hours}:{minutes:02}:{secs:02}" if seconds > 0: @@ -24,8 +24,8 @@ def sec2time(seconds: int) -> str: return "--:--" -def show_progress(task_name: str, start_position: int, duration: int, - total_length: int, speed: float) -> None: +def show_progress(task_name: str, start_position: int, duration: int, + total_length: int, speed: float) -> None: """ 显示任务进度条,模拟任务进度。 @@ -41,22 +41,22 @@ def show_progress(task_name: str, start_position: int, duration: int, """ start_time = time.time() expected_end_time = start_time + (duration / speed) - + while time.time() < expected_end_time: # 计算当前进度 current_position = start_position + int((time.time() - start_time) * speed) percent_complete = min(int(current_position / total_length * 100), 100) - + # 生成进度条 bar_length = 40 filled_length = int(percent_complete * bar_length // 100) progress_bar = ("#" * filled_length).ljust(bar_length, " ") - + # 格式化输出进度信息 progress_text = ( f"\r当前任务: {task_name} |{progress_bar}| {percent_complete}% " f"{sec2time(current_position)}/{sec2time(total_length)}" ) - + print(progress_text, end="", flush=True) time.sleep(gc.THRESHOLD) diff --git a/main.py b/main.py index ac908efc..b17e8fbd 100644 --- a/main.py +++ b/main.py @@ -21,14 +21,17 @@ from queue import PriorityQueue, ShutDown except ImportError: from queue import PriorityQueue + + class ShutDown(Exception): pass + class ChapterResult(enum.Enum): - SUCCESS=0, - ERROR=1, - NOT_OPEN=2, - PENDING=3 + SUCCESS = 0, + ERROR = 1, + NOT_OPEN = 2, + PENDING = 3 def log_error(func): @@ -81,7 +84,7 @@ def parse_args(): help="启用调试模式, 输出DEBUG级别日志", ) parser.add_argument( - "-a", "--notopen-action", type=str, default="retry", + "-a", "--notopen-action", type=str, default="retry", choices=["retry", "ask", "continue"], help="遇到关闭任务点时的行为: retry-重试, ask-询问, continue-继续" ) @@ -103,17 +106,18 @@ def load_config_from_file(config_path): """从配置文件加载设置""" config = configparser.ConfigParser() config.read(config_path, encoding="utf8") - + common_config: dict[str, Any] = {} tiku_config: dict[str, Any] = {} notification_config: dict[str, Any] = {} - + # 检查并读取common节 if config.has_section("common"): common_config = dict(config.items("common")) # 处理course_list,将字符串转换为列表 if "course_list" in common_config and common_config["course_list"]: - common_config["course_list"] = [item.strip() for item in common_config["course_list"].split(",") if item.strip()] + common_config["course_list"] = [item.strip() for item in common_config["course_list"].split(",") if + item.strip()] # 处理speed,将字符串转换为浮点数 if "speed" in common_config: common_config["speed"] = float(common_config["speed"]) @@ -144,7 +148,7 @@ def load_config_from_file(config_path): # 检查并读取notification节 if config.has_section("notification"): notification_config = dict(config.items("notification")) - + return common_config, tiku_config, notification_config @@ -174,26 +178,25 @@ def init_config(): return common_config, tiku_config, notification_config, args.config - def init_chaoxing(common_config, tiku_config, config_path=None): """初始化超星实例""" username = common_config.get("username", "") password = common_config.get("password", "") use_cookies = common_config.get("use_cookies", False) - + # 如果没有提供用户名密码,从命令行获取 if (not username or not password) and not use_cookies: username = input("请输入你的手机号, 按回车确认\n手机号:") password = input("请输入你的密码, 按回车确认\n密码:") - + account = Account(username, password) - + # 设置题库 tiku = Tiku.get_tiku_from_config(tiku_config, config_path=config_path) # 载入题库 tiku.init_tiku() # 初始化题库 - + # 获取查询延迟设置 - + # 检查大模型连接(如果使用的是大模型题库) # 根据配置文件中的 provider 判断是否为大模型题库 provider = tiku_config.get('provider', '') @@ -211,12 +214,13 @@ def init_chaoxing(common_config, tiku_config, config_path=None): logger.info('用户选择继续运行...') query_delay = tiku_config.get("delay", 0) - + # 实例化超星API chaoxing = Chaoxing(account=account, tiku=tiku, query_delay=query_delay) - + return chaoxing + def process_job(chaoxing: Chaoxing, course: dict, job: dict, job_info: dict, speed: float) -> StudyResult: """处理单个任务点""" # 视频任务 @@ -257,14 +261,14 @@ def process_job(chaoxing: Chaoxing, course: dict, job: dict, job_info: dict, spe "clazzId": course.get("clazzId"), "knowledgeid": job_info.get("knowledgeid") } - + # 创建直播对象 live = Live( attachment=job, defaults=defaults, course_id=course.get("courseId") ) - + # 启动直播处理线程 thread = threading.Thread( target=LiveProcessor.run_live, @@ -296,12 +300,13 @@ def __lt__(self, other): return NotImplemented return self.index < other.index + class JobProcessor: def __init__(self, chaoxing: Chaoxing, tasks: list[ChapterTask], config: dict[str, Any]): """初始化任务处理器.""" if "jobs" not in config or not config["jobs"]: config["jobs"] = 4 - + self.chaoxing = chaoxing self.speed = config["speed"] self.max_tries = 5 @@ -331,7 +336,6 @@ def run(self): if hasattr(self.task_queue, "shutdown"): self.task_queue.shutdown() - @log_error def worker_thread(self): while True: @@ -360,7 +364,7 @@ def worker_thread(self): logger.error( "章节未开启: {} - {} 可能由于上一章节的章节检测未完成, 也可能由于该章节因为时效已关闭," "请手动检查完成并提交再重试。或者在配置中配置(自动跳过关闭章节/开启题库并启用提交)" - , task.course["title"], task.point["title"]) + , task.course["title"], task.point["title"]) self.task_queue.task_done() continue @@ -369,7 +373,8 @@ def worker_thread(self): case ChapterResult.ERROR: task.tries += 1 - logger.warning("重试任务 {} - {} ({}/{} 次尝试)", task.course["title"], task.point["title"], task.tries, + logger.warning("重试任务 {} - {} ({}/{} 次尝试)", task.course["title"], task.point["title"], + task.tries, self.max_tries) if task.tries >= self.max_tries: logger.error("任务重试次数达到上限: {} - {}", task.course["title"], task.point["title"]) @@ -397,16 +402,16 @@ def retry_thread(self): pass -def process_chapter(chaoxing: Chaoxing, course:dict[str, Any], point:dict[str, Any], speed:float) -> ChapterResult: +def process_chapter(chaoxing: Chaoxing, course: dict[str, Any], point: dict[str, Any], speed: float) -> ChapterResult: """处理单个章节""" logger.info(f'当前章节: {point["title"]}') if point["has_finished"]: logger.info(f'章节:{point["title"]} 已完成所有任务点') return ChapterResult.SUCCESS - + # 随机等待,避免请求过快 - chaoxing.rate_limiter.limit_rate(random_time=True,random_min=0, random_max=0.2) - + chaoxing.rate_limiter.limit_rate(random_time=True, random_min=0, random_max=0.2) + # 获取当前章节的所有任务点 job_info = None jobs, job_info = chaoxing.get_job_list(course, point) @@ -419,11 +424,11 @@ def process_chapter(chaoxing: Chaoxing, course:dict[str, Any], point:dict[str, A if not jobs: pass - job_results:list[StudyResult]=[] + job_results: list[StudyResult] = [] for job in jobs: result = process_job(chaoxing, course, job, job_info, speed) job_results.append(result) - + for result in job_results: if result.is_failure(): return ChapterResult.ERROR @@ -431,11 +436,10 @@ def process_chapter(chaoxing: Chaoxing, course:dict[str, Any], point:dict[str, A return ChapterResult.SUCCESS - -def process_course(chaoxing: Chaoxing, course:dict[str, Any], config: dict): +def process_course(chaoxing: Chaoxing, course: dict[str, Any], config: dict): """处理单个课程""" logger.info(f"开始学习课程: {course['title']}") - + # 获取当前课程的所有章节 point_list = chaoxing.get_course_point( course["courseId"], course["clazzId"], course["cpi"] @@ -446,7 +450,7 @@ def process_course(chaoxing: Chaoxing, course:dict[str, Any], config: dict): _old_format_sizeof = tqdm.format_sizeof tqdm.format_sizeof = format_time - tasks=[] + tasks = [] for i, point in enumerate(point_list["points"]): task = ChapterTask(point=point, index=i, course=course) @@ -454,9 +458,9 @@ def process_course(chaoxing: Chaoxing, course:dict[str, Any], config: dict): p = JobProcessor(chaoxing, tasks, config) p.run() - tqdm.format_sizeof = _old_format_sizeof + def filter_courses(all_course, course_list): """过滤要学习的课程""" if not course_list: @@ -479,11 +483,11 @@ def filter_courses(all_course, course_list): if course["courseId"] in course_list and course["courseId"] not in course_ids: course_task.append(course) course_ids.append(course["courseId"]) - + # 如果没有指定课程,则学习所有课程 if not course_task: course_task = all_course - + return course_task @@ -504,34 +508,34 @@ def main(): try: # 初始化配置 common_config, tiku_config, notification_config, config_path = init_config() - + # 强制播放按照配置文件调节 common_config["speed"] = min(2.0, max(1.0, common_config.get("speed", 1.0))) common_config["notopen_action"] = common_config.get("notopen_action", "retry") - + # 初始化超星实例 chaoxing = init_chaoxing(common_config, tiku_config, config_path=config_path) - + # 设置外部通知 notification = Notification() notification.config_set(notification_config) notification = notification.get_notification_from_config() notification.init_notification() - + # 检查当前登录状态 _login_state = chaoxing.login(login_with_cookies=common_config.get("use_cookies", False)) if not _login_state["status"]: raise LoginError(_login_state["msg"]) - + # 获取所有的课程列表 all_course = chaoxing.get_course_list() - + # 过滤要学习的课程 course_task = filter_courses(all_course, common_config.get("course_list")) - + # 开始学习 logger.info(f"课程列表过滤完毕, 当前课程任务数量: {len(course_task)}") - + _old_format_sizeof = tqdm.format_sizeof tqdm.format_sizeof = format_time @@ -550,10 +554,10 @@ def main(): p.run() tqdm.format_sizeof = _old_format_sizeof - + logger.info("所有课程学习任务已完成") notification.send("chaoxing : 所有课程学习任务已完成") - + except SystemExit as e: if e.code != 0: logger.error(f"错误: 程序异常退出, 返回码: {e.code}") From 43a64a184823f345d0e642ca10690891718b0d92 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 16:06:17 +0800 Subject: [PATCH 12/17] refactor --- api/answer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/answer.py b/api/answer.py index e78a03a0..40f7f6b6 100644 --- a/api/answer.py +++ b/api/answer.py @@ -146,6 +146,12 @@ class Tiku(ABC): false_list = None def __init__(self, config_path: Optional[str] = None) -> None: + """ + 初始化题库基类。 + + Args: + config_path: 配置文件路径,若为 None 则使用默认的 CONFIG_PATH。 + """ self._name = None self._api = None self._conf = None From 84d0364dd8697e6d0a9c8f0c13fb63918f2b08f1 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 16:14:30 +0800 Subject: [PATCH 13/17] refactor --- api/answer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/answer.py b/api/answer.py index 40f7f6b6..fb9dae3b 100644 --- a/api/answer.py +++ b/api/answer.py @@ -542,6 +542,7 @@ def check_llm_connection(self) -> bool: class TikuYanxi(Tiku): # 言溪题库实现 def __init__(self, config_path: Optional[str] = None) -> None: + """初始化言溪题库实例.""" super().__init__(config_path) self.name = '言溪题库' self.api = 'https://tk.enncy.cn/query' @@ -1311,6 +1312,7 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): + """硅基流动大模型答题实现.""" def __init__(self, config_path: Optional[str] = None): From d45fce6d2aeaf081d7fb4b03371ad8deaf52f147 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 16:21:20 +0800 Subject: [PATCH 14/17] refactor --- api/answer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/answer.py b/api/answer.py index fb9dae3b..e6e5e293 100644 --- a/api/answer.py +++ b/api/answer.py @@ -595,6 +595,7 @@ def _init_tiku(self): class TikuGo(Tiku): # GO题(网课小工具题库)实现 def __init__(self, config_path: Optional[str] = None) -> None: + """初始化GO题实例.""" super().__init__(config_path) self.name = 'GO题(网课小工具题库)' self.api = 'https://q.icodef.com/wyn-nb?v=4' @@ -765,6 +766,7 @@ def _init_tiku(self): class TikuLike(Tiku): # LIKE知识库实现 参考 https://www.datam.site/ def __init__(self, config_path: Optional[str] = None) -> None: + """初始化LIKE知识库实例""" super().__init__(config_path) self.name = 'LIKE知识库' self.ver = '2.0.0' # 对应官网API版本 @@ -1086,6 +1088,7 @@ def _init_tiku(self) -> None: class TikuAdapter(Tiku): # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter def __init__(self, config_path: Optional[str] = None) -> None: + """初始化TikuAdapter题库实例""" super().__init__(config_path) self.name = 'TikuAdapter题库' self.api = '' @@ -1135,6 +1138,7 @@ def _init_tiku(self): class AI(Tiku): # AI大模型答题实现 def __init__(self, config_path: Optional[str] = None) -> None: + """初始化AI大模型答题实现""" super().__init__(config_path) self.name = 'AI大模型答题' self.last_request_time = None @@ -1474,6 +1478,7 @@ class TikuManual(Tiku): is_manual = True def __init__(self, config_path: Optional[str] = None) -> None: + """初始化手动题库实例""" super().__init__(config_path) self.name = '手动输入题库' self.default_mode = 'batch' @@ -1861,6 +1866,7 @@ def _parse_and_validate_batch(self, q_list: list[dict], answers: list[str]) -> t class DummyTiku(Tiku): def __init__(self, config_path: Optional[str] = None) -> None: + """初始化空题库""" super().__init__(config_path) self.name = '空/禁用题库' self.DISABLE = True From 99093295c44a529f064886593b3b601716c752d5 Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 16:29:50 +0800 Subject: [PATCH 15/17] refactor --- api/answer.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/api/answer.py b/api/answer.py index e6e5e293..8e76dd60 100644 --- a/api/answer.py +++ b/api/answer.py @@ -766,7 +766,7 @@ def _init_tiku(self): class TikuLike(Tiku): # LIKE知识库实现 参考 https://www.datam.site/ def __init__(self, config_path: Optional[str] = None) -> None: - """初始化LIKE知识库实例""" + """初始化LIKE知识库实例.""" super().__init__(config_path) self.name = 'LIKE知识库' self.ver = '2.0.0' # 对应官网API版本 @@ -1088,7 +1088,7 @@ def _init_tiku(self) -> None: class TikuAdapter(Tiku): # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter def __init__(self, config_path: Optional[str] = None) -> None: - """初始化TikuAdapter题库实例""" + """初始化TikuAdapter题库实例.""" super().__init__(config_path) self.name = 'TikuAdapter题库' self.api = '' @@ -1138,7 +1138,7 @@ def _init_tiku(self): class AI(Tiku): # AI大模型答题实现 def __init__(self, config_path: Optional[str] = None) -> None: - """初始化AI大模型答题实现""" + """初始化AI大模型答题实现.""" super().__init__(config_path) self.name = 'AI大模型答题' self.last_request_time = None @@ -1316,7 +1316,6 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): - """硅基流动大模型答题实现.""" def __init__(self, config_path: Optional[str] = None): @@ -1478,7 +1477,7 @@ class TikuManual(Tiku): is_manual = True def __init__(self, config_path: Optional[str] = None) -> None: - """初始化手动题库实例""" + """初始化手动题库实例.""" super().__init__(config_path) self.name = '手动输入题库' self.default_mode = 'batch' @@ -1866,7 +1865,7 @@ def _parse_and_validate_batch(self, q_list: list[dict], answers: list[str]) -> t class DummyTiku(Tiku): def __init__(self, config_path: Optional[str] = None) -> None: - """初始化空题库""" + """初始化空题库.""" super().__init__(config_path) self.name = '空/禁用题库' self.DISABLE = True From a5faddf50f5747774dbe53bdbca83239a54a0abd Mon Sep 17 00:00:00 2001 From: Zropk Date: Tue, 2 Jun 2026 16:32:19 +0800 Subject: [PATCH 16/17] refactor --- api/answer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/answer.py b/api/answer.py index 8e76dd60..79537678 100644 --- a/api/answer.py +++ b/api/answer.py @@ -1316,7 +1316,6 @@ def check_llm_connection(self) -> bool: class SiliconFlow(Tiku): - """硅基流动大模型答题实现.""" def __init__(self, config_path: Optional[str] = None): """初始化硅基流动大模型题库.""" From 7abf7f0d53ab52558c31562cb83ede76bf2cd4ad Mon Sep 17 00:00:00 2001 From: Zropk Date: Mon, 8 Jun 2026 18:26:47 +0800 Subject: [PATCH 17/17] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=88=90=E5=8A=9F=E5=90=8E=E8=8E=B7=E5=8F=96=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=94=A8=E6=88=B7=E5=90=8D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/base.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/api/base.py b/api/base.py index e89684cb..3d58b60b 100644 --- a/api/base.py +++ b/api/base.py @@ -299,6 +299,12 @@ def login(self, login_with_cookies=False): return self.login(login_with_cookies=False) return {"status": False, "msg": "cookies 已失效,请更新 cookies 或提供账号密码"} logger.info("登录成功...") + try: + realname = self.get_name() + if realname: + logger.info(f"当前登录用户: {realname}") + except Exception as e: + logger.debug(f"获取当前登录用户名失败: {e}") return {"status": True, "msg": "登录成功"} _session = requests.Session() @@ -320,10 +326,29 @@ def login(self, login_with_cookies=False): save_cookies(_session) SessionManager.update_cookies() logger.info("登录成功...") + try: + realname = self.get_name() + if realname: + logger.info(f"当前登录用户: {realname}") + except Exception as e: + logger.debug(f"获取当前登录用户名失败: {e}") return {"status": True, "msg": "登录成功"} else: return {"status": False, "msg": str(resp.json()["msg2"])} + @staticmethod + def get_name() -> str: + _session = SessionManager.get_session() + try: + resp = _session.get("https://passport2.chaoxing.com/mooc/accountManage", timeout=10) + if resp.status_code == 200: + match = re.search(r'id="messageName"\s+value="([^"]*)"', resp.text) + if match: + return match.group(1).strip() + except Exception as e: + logger.debug(f"获取用户名失败: {e}") + return "" + def _validate_cookie_session(self) -> bool: session = SessionManager.get_instance()._session if not session.cookies.get("_uid"):