├── README.md ├── api ├── logger.py ├── __init__.py ├── cookies.py ├── README.md ├── exceptions.py ├── config.py ├── cipher.py ├── process.py ├── answer_check.py ├── font_decoder.py ├── captcha.py ├── cxsecret_font.py ├── notification.py ├── decode.py ├── answer.py └── base.py ├── requirements.txt ├── resource └── README.md ├── Dockerfile ├── pyproject.toml ├── config_template.ini ├── .github └── workflows │ └── main.yml ├── app.py ├── .gitignore └── main.py /README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/logger.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | logger.add("chaoxing.log", rotation="10 MB", level="TRACE") 4 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | def formatted_output(_status, _text, _data): 3 | return {"status": _status, "msg": _text, "data": _data} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pyaes 3 | beautifulsoup4 4 | lxml 5 | argparse 6 | loguru 7 | celery 8 | flask 9 | fonttools 10 | openai 11 | ddddocr==1.5.6 -------------------------------------------------------------------------------- /resource/README.md: -------------------------------------------------------------------------------- 1 | # Resource 文件夹 2 | 3 | 此文件夹包含项目所需的资源文件: 4 | 5 | - `font_map_table.json`:字体映射表,包含字体字符与其对应哈希值的映射关系,用于字体渲染和处理。 6 | 7 | 这些映射关系被用于将字符转换为对应的唯一标识符,支持多种语言和符号的显示。 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY . /app 6 | 7 | RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 8 | 9 | # 创建配置文件目录并提供默认配置 10 | RUN mkdir -p /config && \ 11 | cp config_template.ini /config/config.ini 12 | 13 | # 定义卷,用户可以挂载自己的配置文件 14 | VOLUME /config 15 | 16 | # 使用配置文件启动应用 17 | ENTRYPOINT ["python3", "main.py", "-c", "/config/config.ini"] 18 | -------------------------------------------------------------------------------- /api/cookies.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | import pickle 4 | from api.config import GlobalConst as gc 5 | 6 | 7 | def save_cookies(_session): 8 | with open(gc.COOKIES_PATH, "wb") as f: 9 | pickle.dump(_session.cookies, f) 10 | 11 | 12 | def use_cookies(): 13 | if os.path.exists(gc.COOKIES_PATH): 14 | with open(gc.COOKIES_PATH, "rb") as f: 15 | _cookies = pickle.load(f) 16 | return _cookies 17 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | ## 模块说明 2 | 3 | - `__init__.py`: 提供格式化输出辅助函数 4 | - `base.py`: 提供核心功能,包含主要的Chaoxing类和学习功能 5 | - `answer.py`: 提供多种题库接口和答题功能 6 | - `answer_check.py`: 答案检查和验证 7 | - `cipher.py`: AES加密解密功能 8 | - `config.py`: 全局配置常量 9 | - `cookies.py`: Cookie管理 10 | - `cxsecret_font.py`: 超星字体解析 11 | - `decode.py`: 解析超星页面数据 12 | - `exceptions.py`: 自定义异常类 13 | - `font_decoder.py`: 字体解码器 14 | - `logger.py`: 日志功能 15 | - `notification.py`: 通知功能 16 | - `process.py`: 进度显示工具 17 | - `captcha.py`: 验证码识别模块 -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "chaoxing" 3 | version = "3.1.3" 4 | description = "超星学习通/超星尔雅/泛雅超星全自动无人值守完成任务点" 5 | readme = "README.md" 6 | license = { file = "LICENSE" } 7 | requires-python = ">=3.10,<4.0" 8 | dependencies = [ 9 | "argparse>=1.4.0", 10 | "beautifulsoup4>=4.13.3", 11 | "celery>=5.4.0", 12 | "flask>=3.1.0", 13 | "fonttools>=4.56.0", 14 | "loguru>=0.7.3", 15 | "lxml>=5.3.1", 16 | "openai>=1.66.2", 17 | "pyaes>=1.6.1", 18 | "requests>=2.32.3", 19 | ] 20 | -------------------------------------------------------------------------------- /config_template.ini: -------------------------------------------------------------------------------- 1 | [common] 2 | ; 手机号账号(必填) 3 | username = xxx 4 | 5 | ; 登录密码(必填) 6 | password = xxx 7 | 8 | ; 要学习的课程ID列表, 逗号隔开(选填,不需要则留空) 9 | course_list = xxx,xxx,xxx 10 | 11 | ; 视频播放倍速(默认1,最大2) 12 | speed = 1 13 | [tiku] 14 | ; 可选项 : 15 | ; 1. TikuYanxi(言溪题库 https://tk.enncy.cn/) 16 | provider=TikuYanxi 17 | ; 是否直接提交答题,填写false表示答完题后不提交而是保存,随后你可以自行前往学习通修改或提交 18 | ; 填写true表示直接提交,不保证正确率!不正确的填写会被视为false 19 | ; 对于那些需要解锁的章节,你必须要提交章节检测才能继续下一章节的学习,自行决定是否开启 20 | submit=false 21 | ; 用于言溪题库的TOKEN,同样使用英文逗号隔开多个,会按顺序去使用 22 | tokens= 23 | ; 用于判断判断题对应的选项,不要留有空格,不要留有引号,逗号为英文逗号 24 | true_list=正确,对,√,是 25 | false_list=错误,错,×,否,不对,不正确 26 | -------------------------------------------------------------------------------- /api/exceptions.py: -------------------------------------------------------------------------------- 1 | try: 2 | from requests.exceptions import JSONDecodeError 3 | except: # noqa: E722 4 | from json import JSONDecodeError 5 | 6 | 7 | class LoginError(Exception): 8 | def __init__(self, *args: object): 9 | super().__init__(*args) 10 | 11 | 12 | class InputFormatError(Exception): 13 | def __init__(self, *args: object): 14 | super().__init__(*args) 15 | 16 | 17 | class MaxRollBackExceeded(Exception): 18 | def __init__(self, *args: object): 19 | super().__init__(*args) 20 | 21 | 22 | class MaxRetryExceeded(Exception): 23 | def __init__(self, *args: object): 24 | super().__init__(*args) 25 | 26 | 27 | class FontDecodeError(Exception): 28 | def __init__(self, *args: object): 29 | super().__init__(*args) 30 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: 刷课 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | #是否开始每天8点定时刷课,若要开启请把下面两行注释去掉 7 | #schedule: 8 | # - cron: "0 8 * * *" 9 | 10 | jobs: 11 | Start: 12 | runs-on: ubuntu-latest # 在最新版本的 Ubuntu 操作系统环境下运行 13 | steps: # 要执行的步骤 14 | - name: 拷贝代码 15 | uses: actions/checkout@v3 # 用于将github代码仓库的代码拷贝到工作目录中 16 | 17 | - name: 设置python环境 18 | uses: actions/setup-python@v2 # 用于设置 Python 环境,它允许你指定要在工作环境中使用的 Python 版本 19 | with: 20 | python-version: '3.9' # 选择要用的Python版本 21 | 22 | - name: 安装依赖包 23 | run: | # 安装依赖包 24 | pip install -r ./requirements.txt 25 | 26 | 27 | - name: Run main.py 28 | run: python main.py -u 账号 -p 密码 -l 256869211 #课程id,默认为心理课的 29 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from celery import Celery, Task 2 | from flask import Flask 3 | 4 | 5 | def celery_init_app(app: Flask) -> Celery: 6 | class FlaskTask(Task): 7 | def __call__(self, *args: object, **kwargs: object) -> object: 8 | with app.app_context(): 9 | return self.run(*args, **kwargs) 10 | 11 | celery_app = Celery(app.name, task_cls=FlaskTask) 12 | celery_app.config_from_object(app.config["CELERY"]) 13 | celery_app.set_default() 14 | app.extensions["celery"] = celery_app 15 | return celery_app 16 | 17 | 18 | if __name__ == "__main__": 19 | app = Flask(__name__) 20 | app.config.from_mapping( 21 | CELERY=dict( 22 | broker_url="db+sqlite:///celeryresults.sqlite3", 23 | result_backend="sqlite:///celeryresults.sqlite3", 24 | task_ignore_result=True, 25 | ), 26 | ) 27 | celery_app = celery_init_app(app) 28 | -------------------------------------------------------------------------------- /api/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | class GlobalConst: 3 | AESKey = "u2oh6Vu^HWe4_AES" 4 | HEADERS = { 5 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", 6 | "Sec-Ch-Ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"', 7 | } 8 | COOKIES_PATH = "cookies.txt" 9 | VIDEO_HEADERS = { 10 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", 11 | "Referer": "https://mooc1.chaoxing.com/ananas/modules/video/index.html?v=2023-1110-1610", 12 | "Host": "mooc1.chaoxing.com", 13 | } 14 | AUDIO_HEADERS = { 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", 16 | "Referer": "https://mooc1.chaoxing.com/ananas/modules/audio/index_new.html?v=2023-0428-1705", 17 | "Host": "mooc1.chaoxing.com", 18 | } 19 | THRESHOLD = 3 20 | -------------------------------------------------------------------------------- /api/cipher.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import base64 3 | import pyaes 4 | from api.config import GlobalConst as gc 5 | 6 | 7 | def pkcs7_unpadding(string): 8 | return string[0 : -ord(string[-1])] 9 | 10 | 11 | def pkcs7_padding(s, block_size=16): 12 | bs = block_size 13 | return s + (bs - len(s) % bs) * chr(bs - len(s) % bs).encode() 14 | 15 | 16 | def split_to_data_blocks(byte_str, block_size=16): 17 | length = len(byte_str) 18 | j, y = divmod(length, block_size) 19 | blocks = [] 20 | shenyu = j * block_size 21 | for i in range(j): 22 | start = i * block_size 23 | end = (i + 1) * block_size 24 | blocks.append(byte_str[start:end]) 25 | stext = byte_str[shenyu:] 26 | if stext: 27 | blocks.append(stext) 28 | return blocks 29 | 30 | 31 | class AESCipher: 32 | def __init__(self): 33 | self.key = str(gc.AESKey).encode("utf8") 34 | self.iv = str(gc.AESKey).encode("utf8") 35 | 36 | def encrypt(self, plaintext: str): 37 | ciphertext = b"" 38 | cbc = pyaes.AESModeOfOperationCBC(self.key, self.iv) 39 | plaintext = plaintext.encode("utf-8") 40 | blocks = split_to_data_blocks(pkcs7_padding(plaintext)) 41 | for b in blocks: 42 | ciphertext = ciphertext + cbc.encrypt(b) 43 | base64_text = base64.b64encode(ciphertext).decode("utf8") 44 | return base64_text 45 | 46 | # def decrypt(self, ciphertext: str): 47 | # cbc = pyaes.AESModeOfOperationCBC(self.key, self.iv) 48 | # ciphertext.encode('utf8') 49 | # ciphertext = base64.b64decode(ciphertext) 50 | # ptext = b"" 51 | # for b in split_to_data_blocks(ciphertext): 52 | # ptext = ptext + cbc.decrypt(b) 53 | # return pkcs7_unpadding(ptext.decode()) 54 | -------------------------------------------------------------------------------- /api/process.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Union 3 | from api.config import GlobalConst as gc 4 | 5 | 6 | def sec2time(seconds: int) -> str: 7 | """ 8 | 将秒数转换为时分秒格式的字符串。 9 | 10 | Args: 11 | seconds: 要转换的秒数 12 | 13 | Returns: 14 | 格式化的时间字符串,格式为 "h:mm:ss" 或 "mm:ss",如果秒数为0则返回"--:--" 15 | """ 16 | hours = int(seconds / 3600) 17 | minutes = int(seconds % 3600 / 60) 18 | secs = int(seconds % 60) 19 | 20 | if hours > 0: 21 | return f"{hours}:{minutes:02}:{secs:02}" 22 | if seconds > 0: 23 | return f"{minutes:02}:{secs:02}" 24 | return "--:--" 25 | 26 | 27 | def show_progress(task_name: str, start_position: int, duration: int, 28 | total_length: int, speed: float) -> None: 29 | """ 30 | 显示任务进度条,模拟任务进度。 31 | 32 | Args: 33 | task_name: 当前执行的任务名称 34 | start_position: 起始位置(以秒为单位) 35 | duration: 任务持续时间(以秒为单位) 36 | total_length: 任务总长度(以秒为单位) 37 | speed: 任务执行速度 38 | 39 | Returns: 40 | None 41 | """ 42 | start_time = time.time() 43 | expected_end_time = start_time + (duration / speed) 44 | 45 | while time.time() < expected_end_time: 46 | # 计算当前进度 47 | current_position = start_position + int((time.time() - start_time) * speed) 48 | percent_complete = min(int(current_position / total_length * 100), 100) 49 | 50 | # 生成进度条 51 | bar_length = 40 52 | filled_length = int(percent_complete * bar_length // 100) 53 | progress_bar = ("#" * filled_length).ljust(bar_length, " ") 54 | 55 | # 格式化输出进度信息 56 | progress_text = ( 57 | f"\r当前任务: {task_name} |{progress_bar}| {percent_complete}% " 58 | f"{sec2time(current_position)}/{sec2time(total_length)}" 59 | ) 60 | 61 | print(progress_text, end="", flush=True) 62 | time.sleep(gc.THRESHOLD) 63 | -------------------------------------------------------------------------------- /api/answer_check.py: -------------------------------------------------------------------------------- 1 | def check_single(answer): 2 | _t = cut(answer) 3 | if _t is not None and len(_t) == 1: 4 | return True 5 | else: 6 | return False 7 | 8 | 9 | def check_multiple(answer): 10 | _t = cut(answer) 11 | if _t is not None and len(_t) > 0: 12 | return True 13 | return False 14 | 15 | 16 | def check_judgement(answer, true_list, false_list): 17 | if answer in true_list: 18 | return 1 19 | elif answer in false_list: 20 | return 0 21 | else: 22 | return -1 23 | 24 | 25 | def check_completion(answer): 26 | if len(answer) > 0: 27 | return True 28 | else: 29 | return False 30 | 31 | 32 | def check_answer(answer, type, tiku): # 只会写小杯代码,这里用个tiku感觉怪怪的,但先这么写着 33 | if type == 'single': 34 | if check_single(answer) and check_judgement(answer, tiku.true_list, tiku.false_list) == -1: 35 | return True 36 | elif type == 'multiple': 37 | if check_multiple(answer) and check_judgement(answer, tiku.true_list, tiku.false_list) == -1: 38 | return True 39 | elif type == 'completion': 40 | if check_completion(answer): 41 | return True 42 | elif type == 'judgement': 43 | if check_judgement(answer, tiku.true_list, tiku.false_list) != -1: 44 | return True 45 | else: # 未知类型不匹配 46 | return True 47 | return False 48 | 49 | 50 | def cut(answer): 51 | # cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 52 | # ',' 在常规被正确划分的, 选项中出现, 导致 multi_cut 无法正确划分选项 #391 53 | # IndexError: Cannot choose from an empty sequence #391 54 | # 同时为了避免没有考虑到的 case, 应该先按照 '\n' 匹配, 匹配不到再按照其他字符匹配 55 | cut_char = [ 56 | "\n", 57 | ",", 58 | ",", 59 | "|", 60 | "\r", 61 | "\t", 62 | "#", 63 | "*", 64 | "-", 65 | "_", 66 | "+", 67 | "@", 68 | "~", 69 | "/", 70 | "\\", 71 | ".", 72 | "&", 73 | " ", 74 | "、", 75 | ] # 多选答案切割符 76 | res = [] 77 | for char in cut_char: 78 | res = [ 79 | opt for opt in answer.split(char) if opt.strip() 80 | ] # Filter empty strings 81 | if len(res) > 0: 82 | return res 83 | return None 84 | -------------------------------------------------------------------------------- /api/font_decoder.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | import re 3 | from typing import Dict, Optional 4 | 5 | import api.cxsecret_font as cxfont 6 | from api.exceptions import FontDecodeError 7 | from api.logger import logger 8 | 9 | 10 | class FontDecoder: 11 | """超星加密字体解码器。 12 | 13 | 用于解码超星平台使用特殊字体加密的内容。 14 | """ 15 | 16 | # 正则表达式常量 17 | FONT_BASE64_PATTERN = r"base64,([\w\W]+?)\'" 18 | FONT_DATA_URL_PREFIX = "data:application/font-ttf;charset=utf-8;base64," 19 | 20 | def __init__(self, html_content: Optional[str] = None): 21 | """初始化字体解码器。 22 | 23 | Args: 24 | html_content: 包含加密字体信息的HTML内容 25 | """ 26 | self.html_content = html_content 27 | self.__font_map: Optional[Dict] = None 28 | 29 | if html_content: 30 | self.__init_font_map(html_content) 31 | 32 | def __init_font_map(self, html_content: str) -> None: 33 | """从HTML内容中提取字体信息并初始化字体映射。 34 | 35 | Args: 36 | html_content: 包含加密字体信息的HTML内容 37 | """ 38 | try: 39 | soup = BeautifulSoup(html_content, "lxml") 40 | style_tag = soup.find("style", id="cxSecretStyle") 41 | 42 | if not style_tag or not style_tag.text: 43 | raise FontDecodeError("未找到加密字体样式标签") 44 | 45 | match = re.search(self.FONT_BASE64_PATTERN, style_tag.text) 46 | if not match: 47 | raise FontDecodeError("无法从样式标签中提取字体数据") 48 | 49 | font_base64 = match.group(1) 50 | font_data_url = self.FONT_DATA_URL_PREFIX + font_base64 51 | self.__font_map = cxfont.font2map(font_data_url) 52 | except Exception as e: 53 | logger.warning(f"初始化字体映射失败: {e}") 54 | self.__font_map = None 55 | 56 | def decode(self, target_str: str) -> str: 57 | """解码加密字符串。 58 | 59 | Args: 60 | target_str: 需要解码的加密字符串 61 | 62 | Returns: 63 | 解码后的字符串 64 | 65 | Raises: 66 | ValueError: 当字体映射未初始化时抛出 67 | """ 68 | if not self.__font_map: 69 | raise FontDecodeError("字体映射未初始化,无法解码") 70 | 71 | return cxfont.decrypt(self.__font_map, target_str) 72 | 73 | def set_html_content(self, html_content: str) -> None: 74 | """设置新的HTML内容并重新初始化字体映射。 75 | 76 | Args: 77 | html_content: 包含加密字体信息的HTML内容 78 | """ 79 | self.html_content = html_content 80 | self.__init_font_map(html_content) 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | logs/ 131 | saves/ 132 | build/ 133 | dist/ 134 | *.spec 135 | 136 | # python-uv.lock 137 | # just like Pipfile.lock, 138 | uv.lock 139 | 140 | # poetry 141 | poetry.lock 142 | 143 | # Custom files 144 | .cookies.txt 145 | cookies.txt 146 | .config.ini 147 | config.ini 148 | chaoxing.log 149 | config*.ini 150 | .chaoxing.log 151 | ./config.ini 152 | ./chaoxing.log 153 | ./cookies.txt 154 | .idea/ 155 | .vscode/ 156 | cache.json -------------------------------------------------------------------------------- /api/captcha.py: -------------------------------------------------------------------------------- 1 | """ 2 | Captcha API for Chaoxing 3 | 4 | 本模块用于通过CX验证码,提供包括验证码获取、识别、验证等接口。 5 | 使用了开源的验证码识别库[DdddOcr](https://github.com/sml2h3/ddddocr) 6 | 7 | Author: skreon 8 | Email: 1340554713@qq.com 9 | Date: 2025-06-05 10 | Version: 1.0.0 11 | """ 12 | 13 | __author__ = "skreon 1340554713@qq.com" 14 | __version__ = "1.0.0" 15 | 16 | from random import randint 17 | from typing import Optional 18 | from requests import session 19 | from ddddocr import DdddOcr 20 | 21 | 22 | def ocr_init() -> DdddOcr: 23 | """ 24 | 初始化OCR对象 25 | 26 | Returns: DdddOcr对象 27 | """ 28 | return DdddOcr(show_ad=False) 29 | 30 | 31 | class CxCaptcha: 32 | """ 33 | CxCaptcha 类用于处理学习任务中出现的验证码 34 | 35 | 该类提供了获取、识别和提交验证码的方法,使用 requests 库进行 HTTP 请求, 36 | 并利用 DdddOcr 进行验证码识别。 37 | 38 | Attributes: 39 | host (str): 超星平台的主机地址。 40 | api (dict): 包含获取和提交验证码的 API 路径。 41 | user_agent (str): 用户代理字符串。 42 | cookies (str): 会话 cookies。 43 | s (requests.Session): 用于管理会话的请求对象。 44 | """ 45 | 46 | host = 'https://mooc1.chaoxing.com' 47 | api = { 48 | 'get': '/processVerifyPng.ac', 49 | 'submit': '/html/processVerify.ac' 50 | } 51 | 52 | def __init__(self, user_agent: str, cookies: str, ocr: Optional[DdddOcr] = None): 53 | """ 54 | 初始化 CxCaptcha 实例。 55 | 56 | Args: 57 | user_agent (str): 用户代理字符串。 58 | cookies (str): 会话 cookies。 59 | ocr (DdddOcr, optional): 已初始化的 DdddOcr 对象。默认为 None。据DdddOcr官方说明,每次初始化和初始化后的首次识别速度都非常慢,所以推荐传入一个现成的DdddOcr对象实现复用。 60 | """ 61 | 62 | self.user_agent = user_agent 63 | self.cookies = cookies 64 | self.s = session() 65 | self.s.headers.update({ 66 | 'User-Agent': self.user_agent, 67 | 'Cookie': self.cookies, 68 | 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8' 69 | }) 70 | self.s.verify = False 71 | 72 | self.ocr = ocr if ocr else ocr_init() 73 | 74 | def getCaptcha(self) -> Optional[bytes]: 75 | """ 76 | 获取验证码图片。 77 | 78 | Returns: 79 | Optional[bytes]: 返回验证码图片的二进制数据,如果获取失败则返回 None。 80 | """ 81 | api = self.host + self.api['get'] 82 | random_t = randint(0, 2147483647) 83 | 84 | res = self.s.get(api, params={'t': random_t}) 85 | if res.status_code == 200 and res.headers['Content-Type'] == 'image/png': 86 | return res.content 87 | else: 88 | # 提供的Cookies或UA存在问题,导致未能正常获取验证码内容 89 | return None 90 | 91 | def submitCaptcha(self, cap_token: str) -> bool: 92 | """ 93 | 提交验证码以完成验证。 94 | 95 | Args: 96 | cap_token (str): 验证码 token。 97 | 98 | Returns: 99 | bool: 如果提交成功并重定向,则返回 True;否则返回 False。 100 | """ 101 | api = self.host + self.api['submit'] 102 | params = { 103 | 'ucode': cap_token, 104 | 'app': 0 105 | } 106 | res = self.s.get(api, params=params) 107 | if res.status_code == 302: 108 | return True 109 | else: 110 | return False 111 | 112 | def recognition(self, img: bytes) -> str: 113 | """ 114 | 使用 DdddOcr 对验证码图片进行识别。 115 | 116 | Args: 117 | img (bytes): 验证码图片的二进制数据。 118 | 119 | Returns: 120 | str: 返回识别出的验证码字符串。 121 | """ 122 | res = self.ocr.classification(img) 123 | return res 124 | 125 | def try_pass(self) -> bool: 126 | """ 127 | 尝试通过验证码验证流程。 128 | 129 | 该方法会自动获取验证码、识别并提交。 130 | 131 | Returns: 132 | bool: 如果验证码成功通过验证,则返回 True;否则返回 False。 133 | """ 134 | cap_img = self.getCaptcha() 135 | if not cap_img: 136 | return False 137 | cap_token = self.recognition(cap_img) 138 | return self.submitCaptcha(cap_token) 139 | -------------------------------------------------------------------------------- /api/cxsecret_font.py: -------------------------------------------------------------------------------- 1 | ## 2 | # @Author: SocialSisterYi 3 | # @Edit: Samueli924 4 | # @Reference: https://github.com/SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy 5 | # 6 | 7 | import base64 8 | import hashlib 9 | import json 10 | import os 11 | import sys 12 | from io import BytesIO 13 | from pathlib import Path 14 | from typing import Dict, IO, Optional, Union 15 | 16 | from fontTools.ttLib.tables._g_l_y_f import Glyph, table__g_l_y_f 17 | from fontTools.ttLib.ttFont import TTFont 18 | from api.exceptions import FontDecodeError 19 | from api.logger import logger 20 | 21 | 22 | # 康熙部首替换表 23 | KX_RADICALS_TAB = str.maketrans( 24 | # 康熙部首 25 | "⼀⼁⼂⼃⼄⼅⼆⼇⼈⼉⼊⼋⼌⼍⼎⼏⼐⼑⼒⼓⼔⼕⼖⼗⼘⼙⼚⼛⼜⼝⼞⼟⼠⼡⼢⼣⼤⼥⼦⼧⼨⼩⼪⼫⼬⼭⼮⼯⼰⼱⼲⼳⼴⼵⼶⼷⼸⼹⼺⼻⼼⼽⼾⼿⽀⽁⽂⽃⽄⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿⾀⾁⾂⾃⾄⾅⾆⾇⾈⾉⾊⾋⾌⾍⾎⾏⾐⾑⾒⾓⾔⾕⾖⾗⾘⾙⾚⾛⾜⾝⾞⾟⾠⾡⾢⾣⾤⾥⾦⾧⾨⾩⾪⾫⾬⾭⾮⾯⾰⾱⾲⾳⾴⾵⾶⾷⾸⾹⾺⾻⾼髙⾽⾾⾿⿀⿁⿂⿃⿄⿅⿆⿇⿈⿉⿊⿋⿌⿍⿎⿏⿐⿑⿒⿓⿔⿕⺠⻬⻩⻢⻜⻅⺟⻓", 26 | # 对应汉字 27 | "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角言谷豆豕豸貝赤走足身車辛辰辵邑酉采里金長門阜隶隹雨青非面革韋韭音頁風飛食首香馬骨高高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠民齐黄马飞见母长", 28 | ) 29 | 30 | 31 | def resource_path(relative_path: str) -> str: 32 | """ 33 | 获取资源文件的路径,兼容PyInstaller打包后的环境 34 | 35 | Args: 36 | relative_path: 相对路径 37 | 38 | Returns: 39 | 资源文件的绝对路径 40 | """ 41 | try: 42 | # PyInstaller创建临时文件夹,定位路径 43 | base_path = sys._MEIPASS 44 | except Exception: 45 | # 非打包环境,使用当前目录 46 | base_path = os.path.abspath(".") 47 | 48 | return os.path.join(base_path, relative_path) 49 | 50 | 51 | class FontHashDAO: 52 | """ 53 | 字体哈希数据访问对象,负责管理字体哈希映射表 54 | """ 55 | 56 | def __init__(self, file_path: str = "resource/font_map_table.json"): 57 | """ 58 | 初始化字体哈希数据访问对象 59 | 60 | Args: 61 | file_path: 字体映射表JSON文件路径,相对于资源目录 62 | 63 | Raises: 64 | FileNotFoundError: 当字体映射表文件不存在时 65 | json.JSONDecodeError: 当字体映射表JSON格式错误时 66 | """ 67 | self.char_map: Dict[str, str] = {} # unicode -> hash 68 | self.hash_map: Dict[str, str] = {} # hash -> unicode 69 | 70 | full_path = resource_path(file_path) 71 | try: 72 | with open(full_path, "r", encoding="utf-8") as fp: 73 | self.char_map = json.load(fp) 74 | self.hash_map = {hash_val: char for char, hash_val in self.char_map.items()} 75 | except (FileNotFoundError, json.JSONDecodeError) as e: 76 | raise FontDecodeError(f"加载字体映射表失败: {full_path} - {e}") from e 77 | 78 | def find_char(self, font_hash: str) -> Optional[str]: 79 | """ 80 | 通过字体哈希值查找对应的Unicode字符编码 81 | 82 | Args: 83 | font_hash: 字体哈希值 84 | 85 | Returns: 86 | 对应的Unicode字符编码,如果未找到则返回None 87 | """ 88 | return self.hash_map.get(font_hash) 89 | 90 | def find_hash(self, char: str) -> Optional[str]: 91 | """ 92 | 通过Unicode字符编码查找对应的字体哈希值 93 | 94 | Args: 95 | char: Unicode字符编码 (如 "uni4E00") 96 | 97 | Returns: 98 | 对应的字体哈希值,如果未找到则返回None 99 | """ 100 | return self.char_map.get(char) 101 | 102 | 103 | # 初始化字体哈希DAO单例 104 | try: 105 | fonthash_dao = FontHashDAO() 106 | except Exception as e: 107 | logger.warning(f"初始化字体哈希数据失败 - {e}") 108 | fonthash_dao = FontHashDAO.__new__(FontHashDAO) 109 | fonthash_dao.char_map = {} 110 | fonthash_dao.hash_map = {} 111 | 112 | 113 | def hash_glyph(glyph: Glyph) -> str: 114 | """ 115 | 计算TTF字体字形的哈希值 116 | 117 | Args: 118 | glyph: TTF字体字形对象 119 | 120 | Returns: 121 | 字形的MD5哈希值 122 | """ 123 | if glyph.numberOfContours <= 0: 124 | return "" 125 | 126 | pos_data = [] 127 | last_index = 0 128 | 129 | for i in range(glyph.numberOfContours): 130 | end_point = glyph.endPtsOfContours[i] 131 | for j in range(last_index, end_point + 1): 132 | x, y = glyph.coordinates[j] 133 | flag = glyph.flags[j] & 0x01 134 | pos_data.append(f"{x}{y}{flag}") 135 | last_index = end_point + 1 136 | 137 | pos_bin = "".join(pos_data) 138 | return hashlib.md5(pos_bin.encode()).hexdigest() 139 | 140 | 141 | def font2map(font_data: Union[IO, Path, str]) -> Dict[str, str]: 142 | """ 143 | 从字体文件或Base64编码的字体数据中提取字形哈希映射表 144 | 145 | Args: 146 | font_data: 字体文件路径、文件对象或Base64编码的字体数据 147 | 148 | Returns: 149 | 字形名称到哈希值的映射字典 ({"uni4E00": "hash值", ...}) 150 | 151 | Raises: 152 | ValueError: 当无法解析字体数据时 153 | """ 154 | font_hashmap = {} 155 | 156 | # 处理Base64编码的字体数据 157 | if isinstance(font_data, str) and font_data.startswith("data:application/font-ttf;charset=utf-8;base64,"): 158 | try: 159 | font_data = BytesIO(base64.b64decode(font_data[47:])) 160 | except Exception as e: 161 | raise FontDecodeError(f"无法解码Base64字体数据: {e}") from e 162 | 163 | try: 164 | with TTFont(font_data, lazy=False) as font_file: 165 | table: table__g_l_y_f = font_file["glyf"] 166 | for name in table.glyphOrder: 167 | if name.startswith("uni"): 168 | glyph_hash = hash_glyph(table.glyphs[name]) 169 | if glyph_hash: 170 | font_hashmap[name] = glyph_hash 171 | except Exception as e: 172 | raise FontDecodeError(f"无法解析字体文件: {e}") from e 173 | 174 | return font_hashmap 175 | 176 | 177 | def decrypt(dst_fontmap: Dict[str, str], encrypted_text: str) -> str: 178 | """ 179 | 解密超星学习通加密字体的文本 180 | 181 | Args: 182 | dst_fontmap: 目标字体的字形哈希映射表 183 | encrypted_text: 加密的文本 184 | 185 | Returns: 186 | 解密后的文本 187 | """ 188 | result = [] 189 | 190 | for char in encrypted_text: 191 | # 构造Unicode字符名称 (如 "uni4E00") 192 | char_code = f"uni{ord(char):X}" 193 | 194 | # 查找字符在目标字体中的哈希值 195 | if char_code in dst_fontmap: 196 | dst_hash = dst_fontmap[char_code] 197 | # 通过哈希值找回原始字符 198 | original_char_code = fonthash_dao.find_char(dst_hash) 199 | if original_char_code: 200 | # 将Unicode编码转换为字符 201 | try: 202 | original_char = chr(int(original_char_code[3:], 16)) 203 | result.append(original_char) 204 | continue 205 | except (ValueError, IndexError): 206 | pass 207 | 208 | # 如果无法解密,则保留原字符 209 | result.append(char) 210 | 211 | # 替换解密后的康熙部首 212 | decrypted_text = "".join(result).translate(KX_RADICALS_TAB) 213 | return decrypted_text 214 | -------------------------------------------------------------------------------- /api/notification.py: -------------------------------------------------------------------------------- 1 | """ 2 | 通知服务模块,用于向外部服务发送通知消息。 3 | 支持多种通知服务,如ServerChan、Qmsg和Bark。 4 | """ 5 | 6 | import configparser 7 | import requests 8 | from abc import ABC, abstractmethod 9 | from typing import Dict, Optional, Any 10 | from api.logger import logger 11 | 12 | 13 | class NotificationService(ABC): 14 | """ 15 | 通知服务基类,定义通知服务的公共接口和实现。 16 | 所有具体的通知服务类应继承此类并实现必要的方法。 17 | """ 18 | 19 | CONFIG_PATH = "config.ini" 20 | 21 | def __init__(self): 22 | """初始化通知服务""" 23 | self.name = self.__class__.__name__ 24 | self.url = "" 25 | self._conf = None 26 | self.disabled = False 27 | 28 | def config_set(self, config: Dict[str, str]) -> None: 29 | """ 30 | 设置通知服务的配置 31 | 32 | Args: 33 | config: 包含配置参数的字典 34 | """ 35 | self._conf = config 36 | 37 | def _load_config_from_file(self) -> Optional[Dict[str, str]]: 38 | """ 39 | 从配置文件中加载通知服务的配置 40 | 41 | Returns: 42 | 成功返回配置字典,失败返回None 43 | """ 44 | try: 45 | config = configparser.ConfigParser() 46 | config.read(self.CONFIG_PATH, encoding="utf8") 47 | return config['notification'] 48 | except (KeyError, FileNotFoundError): 49 | logger.info("未找到notification配置,已忽略外部通知功能") 50 | self.disabled = True 51 | return None 52 | 53 | def init_notification(self) -> None: 54 | """初始化通知服务,加载配置并进行必要的设置""" 55 | if not self._conf: 56 | self._conf = self._load_config_from_file() 57 | 58 | if not self.disabled and self._conf: 59 | self._init_service() 60 | 61 | @abstractmethod 62 | def _init_service(self) -> None: 63 | """ 64 | 初始化特定的通知服务,由子类实现 65 | """ 66 | pass 67 | 68 | @abstractmethod 69 | def _send(self, message: str) -> None: 70 | """ 71 | 发送通知消息,由子类实现 72 | 73 | Args: 74 | message: 要发送的消息内容 75 | """ 76 | pass 77 | 78 | def send(self, message: str) -> None: 79 | """ 80 | 发送通知消息的公共接口 81 | 82 | Args: 83 | message: 要发送的消息内容 84 | """ 85 | if not self.disabled: 86 | self._send(message) 87 | 88 | 89 | class NotificationFactory: 90 | """ 91 | 通知服务工厂类,用于创建和获取通知服务实例 92 | """ 93 | 94 | @staticmethod 95 | def create_service(config: Optional[Dict[str, str]] = None) -> NotificationService: 96 | """ 97 | 根据配置创建通知服务实例 98 | 99 | Args: 100 | config: 通知服务的配置,如果为None则从配置文件加载 101 | 102 | Returns: 103 | 通知服务实例 104 | """ 105 | service = DefaultNotification() 106 | 107 | if config: 108 | service.config_set(config) 109 | 110 | # 尝试获取具体的通知服务 111 | service = service.get_notification_from_config() 112 | service.init_notification() 113 | 114 | return service 115 | 116 | 117 | class DefaultNotification(NotificationService): 118 | """ 119 | 默认通知服务,当未配置任何通知服务时使用 120 | """ 121 | 122 | def _init_service(self) -> None: 123 | pass 124 | 125 | def _send(self, message: str) -> None: 126 | pass 127 | 128 | def get_notification_from_config(self) -> NotificationService: 129 | """ 130 | 根据配置创建具体的通知服务实例 131 | 132 | Returns: 133 | 通知服务实例 134 | """ 135 | if not self._conf: 136 | self._conf = self._load_config_from_file() 137 | 138 | if self.disabled: 139 | return self 140 | 141 | try: 142 | provider_name = self._conf['provider'] 143 | if not provider_name: 144 | raise KeyError("未指定通知服务提供商") 145 | 146 | # 获取对应的通知服务类 147 | provider_class = globals().get(provider_name) 148 | if not provider_class: 149 | logger.error(f"未找到名为 {provider_name} 的通知服务提供商") 150 | self.disabled = True 151 | return self 152 | 153 | # 创建通知服务实例 154 | service = provider_class() 155 | service.config_set(self._conf) 156 | return service 157 | 158 | except KeyError: 159 | self.disabled = True 160 | logger.info("未找到外部通知配置,已忽略外部通知功能") 161 | return self 162 | 163 | 164 | class ServerChan(NotificationService): 165 | """ 166 | Server酱通知服务 167 | """ 168 | 169 | def _init_service(self) -> None: 170 | """初始化Server酱服务""" 171 | if not self._conf or not self._conf.get('url'): 172 | self.disabled = True 173 | logger.info("未找到Server酱url配置,已忽略该通知服务") 174 | return 175 | 176 | self.url = self._conf['url'] 177 | logger.info(f"已初始化Server酱通知服务,URL: {self.url}") 178 | 179 | def _send(self, message: str) -> None: 180 | """ 181 | 通过Server酱发送通知 182 | 183 | Args: 184 | message: 要发送的消息内容 185 | """ 186 | params = { 187 | 'text': message, # 兼容两个版本的Server酱 188 | 'desp': message, 189 | } 190 | headers = { 191 | 'Content-Type': 'application/json;charset=utf-8' 192 | } 193 | 194 | try: 195 | response = requests.post(self.url, json=params, headers=headers) 196 | response.raise_for_status() 197 | result = response.json() 198 | logger.info(f"Server酱通知发送成功: {result}") 199 | except requests.RequestException as e: 200 | logger.error(f"Server酱通知发送失败: {e}") 201 | except ValueError as e: 202 | logger.error(f"Server酱返回数据解析失败: {e}") 203 | 204 | 205 | class Qmsg(NotificationService): 206 | """ 207 | Qmsg酱通知服务 208 | """ 209 | 210 | def _init_service(self) -> None: 211 | """初始化Qmsg酱服务""" 212 | if not self._conf or not self._conf.get('url'): 213 | self.disabled = True 214 | logger.info("未找到Qmsg酱url配置,已忽略该通知服务") 215 | return 216 | 217 | self.url = self._conf['url'] 218 | logger.info(f"已初始化Qmsg酱通知服务,URL: {self.url}") 219 | 220 | def _send(self, message: str) -> None: 221 | """ 222 | 通过Qmsg酱发送通知 223 | 224 | Args: 225 | message: 要发送的消息内容 226 | """ 227 | params = {'msg': message} 228 | headers = {'Content-Type': 'application/json;charset=utf-8'} 229 | 230 | try: 231 | response = requests.post(self.url, params=params, headers=headers) 232 | response.raise_for_status() 233 | result = response.json() 234 | logger.info(f"Qmsg酱通知发送成功: {result}") 235 | except requests.RequestException as e: 236 | logger.error(f"Qmsg酱通知发送失败: {e}") 237 | except ValueError as e: 238 | logger.error(f"Qmsg酱返回数据解析失败: {e}") 239 | 240 | 241 | class Bark(NotificationService): 242 | """ 243 | Bark通知服务 244 | """ 245 | 246 | def _init_service(self) -> None: 247 | """初始化Bark服务""" 248 | if not self._conf or not self._conf.get('url'): 249 | self.disabled = True 250 | logger.info("未找到Bark的url配置,已忽略该通知服务") 251 | return 252 | 253 | self.url = self._conf['url'] 254 | logger.info(f"已初始化Bark通知服务,URL: {self.url}") 255 | 256 | def _send(self, message: str) -> None: 257 | """ 258 | 通过Bark发送通知 259 | 260 | Args: 261 | message: 要发送的消息内容 262 | """ 263 | params = {'body': message} 264 | 265 | try: 266 | response = requests.post(self.url, params=params) 267 | response.raise_for_status() 268 | result = response.json() 269 | logger.info(f"Bark通知发送成功: {result}") 270 | except requests.RequestException as e: 271 | logger.error(f"Bark通知发送失败: {e}") 272 | except ValueError as e: 273 | logger.error(f"Bark返回数据解析失败: {e}") 274 | 275 | 276 | # 为了向后兼容,保留原来的Notification类 277 | Notification = DefaultNotification -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import argparse 3 | import configparser 4 | import random 5 | import time 6 | import sys 7 | import os 8 | import traceback 9 | from urllib3 import disable_warnings, exceptions 10 | 11 | from api.logger import logger 12 | from api.base import Chaoxing, Account 13 | from api.exceptions import LoginError, InputFormatError, MaxRollBackExceeded 14 | from api.answer import Tiku 15 | from api.notification import Notification 16 | 17 | # 关闭警告 18 | disable_warnings(exceptions.InsecureRequestWarning) 19 | 20 | 21 | def parse_args(): 22 | """解析命令行参数""" 23 | parser = argparse.ArgumentParser( 24 | description="Samueli924/chaoxing", 25 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 26 | ) 27 | 28 | parser.add_argument( 29 | "-c", "--config", type=str, default=None, help="使用配置文件运行程序" 30 | ) 31 | parser.add_argument("-u", "--username", type=str, default=None, help="手机号账号") 32 | parser.add_argument("-p", "--password", type=str, default=None, help="登录密码") 33 | parser.add_argument( 34 | "-l", "--list", type=str, default=None, help="要学习的课程ID列表, 以 , 分隔" 35 | ) 36 | parser.add_argument( 37 | "-s", "--speed", type=float, default=1.0, help="视频播放倍速 (默认1, 最大2)" 38 | ) 39 | parser.add_argument( 40 | "-v", 41 | "--verbose", 42 | "--debug", 43 | action="store_true", 44 | help="启用调试模式, 输出DEBUG级别日志", 45 | ) 46 | parser.add_argument( 47 | "-a", "--notopen-action", type=str, default="retry", 48 | choices=["retry", "ask", "continue"], 49 | help="遇到关闭任务点时的行为: retry-重试, ask-询问, continue-继续" 50 | ) 51 | 52 | # 在解析之前捕获 -h 的行为 53 | if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"}: 54 | parser.print_help() 55 | sys.exit(0) 56 | 57 | return parser.parse_args() 58 | 59 | 60 | def load_config_from_file(config_path): 61 | """从配置文件加载设置""" 62 | config = configparser.ConfigParser() 63 | config.read(config_path, encoding="utf8") 64 | 65 | common_config = {} 66 | tiku_config = {} 67 | notification_config = {} 68 | 69 | # 检查并读取common节 70 | if config.has_section("common"): 71 | common_config = dict(config.items("common")) 72 | # 处理course_list,将字符串转换为列表 73 | if "course_list" in common_config and common_config["course_list"]: 74 | common_config["course_list"] = common_config["course_list"].split(",") 75 | # 处理speed,将字符串转换为浮点数 76 | if "speed" in common_config: 77 | common_config["speed"] = float(common_config["speed"]) 78 | # 处理notopen_action,设置默认值为retry 79 | if "notopen_action" not in common_config: 80 | common_config["notopen_action"] = "retry" 81 | 82 | # 检查并读取tiku节 83 | if config.has_section("tiku"): 84 | tiku_config = dict(config.items("tiku")) 85 | # 处理数值类型转换 86 | for key in ["delay", "cover_rate"]: 87 | if key in tiku_config: 88 | tiku_config[key] = float(tiku_config[key]) 89 | 90 | # 检查并读取notification节 91 | if config.has_section("notification"): 92 | notification_config = dict(config.items("notification")) 93 | 94 | return common_config, tiku_config, notification_config 95 | 96 | 97 | def build_config_from_args(args): 98 | """从命令行参数构建配置""" 99 | common_config = { 100 | "username": args.username, 101 | "password": args.password, 102 | "course_list": args.list.split(",") if args.list else None, 103 | "speed": args.speed if args.speed else 1.0, 104 | "notopen_action": args.notopen_action if args.notopen_action else "retry" 105 | } 106 | return common_config, {}, {} 107 | 108 | 109 | def init_config(): 110 | """初始化配置""" 111 | args = parse_args() 112 | 113 | if args.config: 114 | return load_config_from_file(args.config) 115 | else: 116 | return build_config_from_args(args) 117 | 118 | 119 | class RollBackManager: 120 | """课程回滚管理器,避免无限回滚""" 121 | def __init__(self): 122 | self.rollback_times = 0 123 | self.rollback_id = "" 124 | 125 | def add_times(self, id: str): 126 | """增加回滚次数""" 127 | if id == self.rollback_id and self.rollback_times == 3: 128 | raise MaxRollBackExceeded("回滚次数已达3次, 请手动检查学习通任务点完成情况") 129 | else: 130 | self.rollback_times += 1 131 | 132 | def new_job(self, id: str): 133 | """设置新任务,重置回滚次数""" 134 | if id != self.rollback_id: 135 | self.rollback_id = id 136 | self.rollback_times = 0 137 | 138 | 139 | def init_chaoxing(common_config, tiku_config): 140 | """初始化超星实例""" 141 | username = common_config.get("username", "") 142 | password = common_config.get("password", "") 143 | 144 | # 如果没有提供用户名密码,从命令行获取 145 | if not username or not password: 146 | username = input("请输入你的手机号, 按回车确认\n手机号:") 147 | password = input("请输入你的密码, 按回车确认\n密码:") 148 | 149 | account = Account(username, password) 150 | 151 | # 设置题库 152 | tiku = Tiku() 153 | tiku.config_set(tiku_config) # 载入配置 154 | tiku = tiku.get_tiku_from_config() # 载入题库 155 | tiku.init_tiku() # 初始化题库 156 | 157 | # 获取查询延迟设置 158 | query_delay = tiku_config.get("delay", 0) 159 | 160 | # 实例化超星API 161 | chaoxing = Chaoxing(account=account, tiku=tiku, query_delay=query_delay) 162 | 163 | return chaoxing 164 | 165 | 166 | def handle_not_open_chapter(notopen_action, point, tiku, RB, auto_skip_notopen=False): 167 | """处理未开放章节""" 168 | if notopen_action == "retry": 169 | # 默认处理方式:重试 170 | # 针对题库启用情况 171 | if not tiku or tiku.DISABLE or not tiku.SUBMIT: 172 | # 未启用题库或未开启题库提交, 章节检测未完成会导致无法开始下一章, 直接退出 173 | logger.error( 174 | "章节未开启, 可能由于上一章节的章节检测未完成, 也可能由于该章节因为时效已关闭," 175 | "请手动检查完成并提交再重试。或者在配置中配置(自动跳过关闭章节/开启题库并启用提交)" 176 | ) 177 | return -1 # 退出标记 178 | RB.add_times(point["id"]) 179 | return 0 # 重试上一章节 180 | 181 | elif notopen_action == "ask": 182 | # 询问模式 - 判断是否需要询问 183 | if not auto_skip_notopen: 184 | user_choice = input(f"章节 {point['title']} 未开放,是否继续检查后续章节?(y/n): ") 185 | if user_choice.lower() != 'y': 186 | # 用户选择停止 187 | logger.info("根据用户选择停止检查后续章节") 188 | return -1 # 退出标记 189 | # 用户选择继续,设置自动跳过标志 190 | logger.info("用户选择继续检查后续章节,将自动跳过连续的未开放章节") 191 | return 1, True # 继续下一章节, 设置自动跳过 192 | else: 193 | logger.info(f"章节 {point['title']} 未开放,自动跳过") 194 | return 1, auto_skip_notopen # 继续下一章节, 保持自动跳过状态 195 | 196 | else: # notopen_action == "continue" 197 | # 继续模式,直接跳过当前章节 198 | logger.info(f"章节 {point['title']} 未开放,根据配置跳过此章节") 199 | return 1 # 继续下一章节 200 | 201 | 202 | def process_job(chaoxing, course, job, job_info, speed): 203 | """处理单个任务点""" 204 | # 视频任务 205 | if job["type"] == "video": 206 | logger.trace(f"识别到视频任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") 207 | # 超星的接口没有返回当前任务是否为Audio音频任务 208 | video_result = chaoxing.study_video( 209 | course, job, job_info, _speed=speed, _type="Video" 210 | ) 211 | if chaoxing.StudyResult.is_failure(video_result): 212 | logger.warning("当前任务非视频任务, 正在尝试音频任务解码") 213 | video_result = chaoxing.study_video( 214 | course, job, job_info, _speed=speed, _type="Audio") 215 | if chaoxing.StudyResult.is_failure(video_result): 216 | logger.warning( 217 | f"出现异常任务 -> 任务章节: {course['title']} 任务ID: {job['jobid']}, 已跳过" 218 | ) 219 | # 文档任务 220 | elif job["type"] == "document": 221 | logger.trace(f"识别到文档任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") 222 | chaoxing.study_document(course, job) 223 | # 测验任务 224 | elif job["type"] == "workid": 225 | logger.trace(f"识别到章节检测任务, 任务章节: {course['title']}") 226 | chaoxing.study_work(course, job, job_info) 227 | # 阅读任务 228 | elif job["type"] == "read": 229 | logger.trace(f"识别到阅读任务, 任务章节: {course['title']}") 230 | chaoxing.strdy_read(course, job, job_info) 231 | 232 | 233 | def process_chapter(chaoxing, course, point, RB, notopen_action, speed, auto_skip_notopen=False): 234 | """处理单个章节""" 235 | logger.info(f'当前章节: {point["title"]}') 236 | 237 | if point["has_finished"]: 238 | logger.info(f'章节:{point["title"]} 已完成所有任务点') 239 | return 1, auto_skip_notopen # 继续下一章节 240 | 241 | # 随机等待,避免请求过快 242 | sleep_duration = random.uniform(1, 3) 243 | logger.debug(f"本次随机等待时间: {sleep_duration:.3f}s") 244 | time.sleep(sleep_duration) 245 | 246 | # 获取当前章节的所有任务点 247 | jobs = [] 248 | job_info = None 249 | jobs, job_info = chaoxing.get_job_list( 250 | course["clazzId"], course["courseId"], course["cpi"], point["id"] 251 | ) 252 | 253 | # 发现未开放章节, 根据配置处理 254 | try: 255 | if job_info.get("notOpen", False): 256 | result = handle_not_open_chapter( 257 | notopen_action, point, chaoxing.tiku, RB, auto_skip_notopen 258 | ) 259 | 260 | if isinstance(result, tuple): 261 | return result # 返回继续标志和更新后的auto_skip_notopen 262 | else: 263 | return result, auto_skip_notopen 264 | 265 | # 遇到开放的章节,重置自动跳过状态 266 | auto_skip_notopen = False 267 | RB.new_job(point["id"]) 268 | 269 | except MaxRollBackExceeded: 270 | logger.error("回滚次数已达3次, 请手动检查学习通任务点完成情况") 271 | # 跳过该课程 272 | return -1, auto_skip_notopen # 退出标记 273 | 274 | chaoxing.rollback_times = RB.rollback_times 275 | 276 | # 可能存在章节无任何内容的情况 277 | if not jobs: 278 | if RB.rollback_times > 0: 279 | logger.trace(f"回滚中 尝试空页面任务, 任务章节: {course['title']}") 280 | chaoxing.study_emptypage(course, point) 281 | return 1, auto_skip_notopen # 继续下一章节 282 | 283 | # 遍历所有任务点 284 | for job in jobs: 285 | process_job(chaoxing, course, job, job_info, speed) 286 | 287 | return 1, auto_skip_notopen # 继续下一章节 288 | 289 | 290 | def process_course(chaoxing, course, notopen_action, speed): 291 | """处理单个课程""" 292 | logger.info(f"开始学习课程: {course['title']}") 293 | 294 | # 获取当前课程的所有章节 295 | point_list = chaoxing.get_course_point( 296 | course["courseId"], course["clazzId"], course["cpi"] 297 | ) 298 | 299 | # 为了支持课程任务回滚, 采用下标方式遍历任务点 300 | __point_index = 0 301 | # 记录用户是否选择继续跳过连续的未开放任务点 302 | auto_skip_notopen = False 303 | # 初始化回滚管理器 304 | RB = RollBackManager() 305 | 306 | while __point_index < len(point_list["points"]): 307 | point = point_list["points"][__point_index] 308 | logger.debug(f"当前章节 __point_index: {__point_index}") 309 | 310 | result, auto_skip_notopen = process_chapter( 311 | chaoxing, course, point, RB, notopen_action, speed, auto_skip_notopen 312 | ) 313 | 314 | if result == -1: # 退出当前课程 315 | break 316 | elif result == 0: # 重试前一章节 317 | __point_index -= 1 # 默认第一个任务总是开放的 318 | else: # 继续下一章节 319 | __point_index += 1 320 | 321 | 322 | def filter_courses(all_course, course_list): 323 | """过滤要学习的课程""" 324 | if not course_list: 325 | # 手动输入要学习的课程ID列表 326 | print("*" * 10 + "课程列表" + "*" * 10) 327 | for course in all_course: 328 | print(f"ID: {course['courseId']} 课程名: {course['title']}") 329 | print("*" * 28) 330 | try: 331 | course_list = input( 332 | "请输入想要学习的课程列表,以逗号分隔,例: 2151141,189191,198198\n" 333 | ).split(",") 334 | except Exception as e: 335 | raise InputFormatError("输入格式错误") from e 336 | 337 | # 筛选需要学习的课程 338 | course_task = [] 339 | for course in all_course: 340 | if course["courseId"] in course_list: 341 | course_task.append(course) 342 | 343 | # 如果没有指定课程,则学习所有课程 344 | if not course_task: 345 | course_task = all_course 346 | 347 | return course_task 348 | 349 | 350 | def main(): 351 | """主程序入口""" 352 | try: 353 | # 初始化配置 354 | common_config, tiku_config, notification_config = init_config() 355 | 356 | # 规范化播放速度 357 | speed = min(2.0, max(1.0, common_config.get("speed", 1.0))) 358 | notopen_action = common_config.get("notopen_action", "retry") 359 | 360 | # 初始化超星实例 361 | chaoxing = init_chaoxing(common_config, tiku_config) 362 | 363 | # 设置外部通知 364 | notification = Notification() 365 | notification.config_set(notification_config) 366 | notification = notification.get_notification_from_config() 367 | notification.init_notification() 368 | 369 | # 检查当前登录状态 370 | _login_state = chaoxing.login() 371 | if not _login_state["status"]: 372 | raise LoginError(_login_state["msg"]) 373 | 374 | # 获取所有的课程列表 375 | all_course = chaoxing.get_course_list() 376 | 377 | # 过滤要学习的课程 378 | course_task = filter_courses(all_course, common_config.get("course_list")) 379 | 380 | # 开始学习 381 | logger.info(f"课程列表过滤完毕, 当前课程任务数量: {len(course_task)}") 382 | for course in course_task: 383 | process_course(chaoxing, course, notopen_action, speed) 384 | 385 | logger.info("所有课程学习任务已完成") 386 | notification.send("chaoxing : 所有课程学习任务已完成") 387 | 388 | except SystemExit as e: 389 | if e.code != 0: 390 | logger.error(f"错误: 程序异常退出, 返回码: {e.code}") 391 | sys.exit(e.code) 392 | except KeyboardInterrupt as e: 393 | logger.error(f"错误: 程序被用户手动中断, {e}") 394 | except BaseException as e: 395 | logger.error(f"错误: {type(e).__name__}: {e}") 396 | logger.error(traceback.format_exc()) 397 | try: 398 | notification.send(f"chaoxing : 出现错误 {type(e).__name__}: {e}\n{traceback.format_exc()}") 399 | except Exception: 400 | pass # 如果通知发送失败,忽略异常 401 | raise e 402 | 403 | 404 | if __name__ == "__main__": 405 | main() 406 | -------------------------------------------------------------------------------- /api/decode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 超星学习通数据解析模块 4 | 5 | 该模块负责解析超星学习通平台的课程、章节、任务点等各种数据, 6 | 并转换为程序内部使用的结构化数据格式。 7 | """ 8 | import re 9 | import json 10 | from typing import List, Dict, Tuple, Any, Optional 11 | from bs4 import BeautifulSoup, NavigableString 12 | from api.logger import logger 13 | from api.font_decoder import FontDecoder 14 | 15 | 16 | def decode_course_list(html_text: str) -> List[Dict[str, str]]: 17 | """ 18 | 解析课程列表页面,提取课程信息 19 | 20 | Args: 21 | html_text: 课程列表页面的HTML内容 22 | 23 | Returns: 24 | 课程信息列表,每个课程包含id、title、teacher等信息 25 | """ 26 | logger.trace("开始解码课程列表...") 27 | soup = BeautifulSoup(html_text, "lxml") 28 | raw_courses = soup.select("div.course") 29 | course_list = [] 30 | 31 | for course in raw_courses: 32 | # 跳过未开放课程 33 | if course.select_one("a.not-open-tip") or course.select_one("div.not-open-tip"): 34 | continue 35 | 36 | course_detail = { 37 | "id": course.attrs["id"], 38 | "info": course.attrs["info"], 39 | "roleid": course.attrs["roleid"], 40 | "clazzId": course.select_one("input.clazzId").attrs["value"], 41 | "courseId": course.select_one("input.courseId").attrs["value"], 42 | "cpi": re.findall(r"cpi=(.*?)&", course.select_one("a").attrs["href"])[0], 43 | "title": course.select_one("span.course-name").attrs["title"], 44 | "desc": course.select_one("p.margint10").attrs["title"] if course.select_one("p.margint10") else "", 45 | "teacher": course.select_one("p.color3").attrs["title"] 46 | } 47 | course_list.append(course_detail) 48 | 49 | return course_list 50 | 51 | 52 | def decode_course_folder(html_text: str) -> List[Dict[str, str]]: 53 | """ 54 | 解析二级课程列表页面,提取文件夹信息 55 | 56 | Args: 57 | html_text: 二级课程列表页面的HTML内容 58 | 59 | Returns: 60 | 课程文件夹信息列表 61 | """ 62 | logger.trace("开始解码二级课程列表...") 63 | soup = BeautifulSoup(html_text, "lxml") 64 | raw_courses = soup.select("ul.file-list>li") 65 | course_folder_list = [] 66 | 67 | for course in raw_courses: 68 | if not course.attrs.get("fileid"): 69 | continue 70 | 71 | course_folder_detail = { 72 | "id": course.attrs["fileid"], 73 | "rename": course.select_one("input.rename-input").attrs["value"] 74 | } 75 | course_folder_list.append(course_folder_detail) 76 | 77 | return course_folder_list 78 | 79 | 80 | def decode_course_point(html_text: str) -> Dict[str, Any]: 81 | """ 82 | 解析章节列表页面,提取章节点信息 83 | 84 | Args: 85 | html_text: 章节列表页面的HTML内容 86 | 87 | Returns: 88 | 章节信息字典,包含是否锁定状态和章节点列表 89 | """ 90 | logger.trace("开始解码章节列表...") 91 | soup = BeautifulSoup(html_text, "lxml") 92 | course_point = { 93 | "hasLocked": False, # 用于判断该课程任务是否是需要解锁 94 | "points": [], 95 | } 96 | 97 | for chapter_unit in soup.find_all("div", class_="chapter_unit"): 98 | points = _extract_points_from_chapter(chapter_unit) 99 | # 检查是否有锁定内容 100 | for point in points: 101 | if point.get("need_unlock", False): 102 | course_point["hasLocked"] = True 103 | 104 | course_point["points"].extend(points) 105 | 106 | return course_point 107 | 108 | 109 | def _extract_points_from_chapter(chapter_unit) -> List[Dict[str, Any]]: 110 | """ 111 | 从章节单元中提取章节点信息 112 | 113 | Args: 114 | chapter_unit: BeautifulSoup对象,表示一个章节单元 115 | 116 | Returns: 117 | 章节点信息列表 118 | """ 119 | point_list = [] 120 | raw_points = chapter_unit.find_all("li") 121 | 122 | for raw_point in raw_points: 123 | point = raw_point.div 124 | if "id" not in point.attrs: 125 | continue 126 | 127 | point_id = re.findall(r"^cur(\d{1,20})$", point.attrs["id"])[0] 128 | point_title = point.select_one("a.clicktitle").text.replace("\n", "").strip() 129 | 130 | # 提取任务数量 131 | job_count = 1 # 默认为1 132 | need_unlock = False 133 | if point.select_one("input.knowledgeJobCount"): 134 | job_count = point.select_one("input.knowledgeJobCount").attrs["value"] 135 | elif point.select_one("span.bntHoverTips") and "解锁" in point.select_one("span.bntHoverTips").text: 136 | need_unlock = True 137 | 138 | # 判断是否已完成 139 | is_finished = False 140 | if point.select_one("span.bntHoverTips") and "已完成" in point.select_one("span.bntHoverTips").text: 141 | is_finished = True 142 | 143 | point_detail = { 144 | "id": point_id, 145 | "title": point_title, 146 | "jobCount": job_count, 147 | "has_finished": is_finished, 148 | "need_unlock": need_unlock 149 | } 150 | point_list.append(point_detail) 151 | 152 | return point_list 153 | 154 | 155 | def decode_course_card(html_text: str) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: 156 | """ 157 | 解析任务点列表页面,提取任务点信息 158 | 159 | Args: 160 | html_text: 任务点列表页面的HTML内容 161 | 162 | Returns: 163 | 任务点列表和任务信息的元组 164 | """ 165 | logger.trace("开始解码任务点列表...") 166 | job_list = [] 167 | 168 | # 检查章节是否未开放 169 | if "章节未开放" in html_text: 170 | return [], {"notOpen": True} 171 | 172 | # 提取mArg参数 173 | temp = re.findall(r"mArg=\{(.*?)\};", html_text.replace(" ", "")) 174 | if not temp: 175 | return [], {} 176 | 177 | # 解析JSON数据 178 | cards_data = json.loads("{" + temp[0] + "}") 179 | if not cards_data: 180 | return [], {} 181 | 182 | # 提取任务信息 183 | job_info = _extract_job_info(cards_data) 184 | 185 | # 处理所有附件任务 186 | cards = cards_data.get("attachments", []) 187 | job_list = _process_attachment_cards(cards) 188 | 189 | return job_list, job_info 190 | 191 | 192 | def _extract_job_info(cards_data: Dict[str, Any]) -> Dict[str, Any]: 193 | """ 194 | 从卡片数据中提取任务基本信息 195 | 196 | Args: 197 | cards_data: 卡片数据字典 198 | 199 | Returns: 200 | 任务基本信息字典 201 | """ 202 | defaults = cards_data.get("defaults", {}) 203 | if not defaults: 204 | return {} 205 | 206 | return { 207 | "ktoken": defaults.get("ktoken", ""), 208 | "mtEnc": defaults.get("mtEnc", ""), 209 | "reportTimeInterval": defaults.get("reportTimeInterval", 60), 210 | "defenc": defaults.get("defenc", ""), 211 | "cardid": defaults.get("cardid", ""), 212 | "cpi": defaults.get("cpi", ""), 213 | "qnenc": defaults.get("qnenc", ""), 214 | "knowledgeid": defaults.get("knowledgeid", "") 215 | } 216 | 217 | 218 | def _process_attachment_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 219 | """ 220 | 处理所有附件任务卡片 221 | 222 | Args: 223 | cards: 附件任务卡片列表 224 | 225 | Returns: 226 | 处理后的任务列表 227 | """ 228 | job_list = [] 229 | 230 | for card in cards: 231 | # 跳过已通过的任务 232 | if card.get("isPassed", False): 233 | continue 234 | 235 | # 处理不同类型的任务 236 | if card.get("job", False) == False: 237 | # 处理阅读类型任务 238 | read_job = _process_read_task(card) 239 | if read_job: 240 | job_list.append(read_job) 241 | continue 242 | 243 | # 根据任务类型处理 244 | card_type = card.get("type", "") 245 | if card_type == "video": 246 | video_job = _process_video_task(card) 247 | if video_job: 248 | job_list.append(video_job) 249 | elif card_type == "document": 250 | doc_job = _process_document_task(card) 251 | if doc_job: 252 | job_list.append(doc_job) 253 | elif card_type == "workid": 254 | work_job = _process_work_task(card) 255 | if work_job: 256 | job_list.append(work_job) 257 | 258 | return job_list 259 | 260 | 261 | def _process_read_task(card: Dict[str, Any]) -> Optional[Dict[str, Any]]: 262 | """处理阅读类型任务""" 263 | if not (card.get("type") == "read" and not card.get("property", {}).get("read", False)): 264 | return None 265 | 266 | return { 267 | "title": card.get("property", {}).get("title", ""), 268 | "type": "read", 269 | "id": card.get("property", {}).get("id", ""), 270 | "jobid": card.get("jobid", ""), 271 | "jtoken": card.get("jtoken", ""), 272 | "mid": card.get("mid", ""), 273 | "otherinfo": card.get("otherInfo", ""), 274 | "enc": card.get("enc", ""), 275 | "aid": card.get("aid", "") 276 | } 277 | 278 | 279 | def _process_video_task(card: Dict[str, Any]) -> Optional[Dict[str, Any]]: 280 | """处理视频类型任务""" 281 | try: 282 | return { 283 | "type": "video", 284 | "jobid": card.get("jobid", ""), 285 | "name": card.get("property", {}).get("name", ""), 286 | "otherinfo": card.get("otherInfo", ""), 287 | "mid": card["mid"], # 必须字段,如果不存在会抛出异常 288 | "objectid": card.get("objectId", ""), 289 | "aid": card.get("aid", "") 290 | } 291 | except KeyError: 292 | logger.warning("出现转码失败视频,已跳过...") 293 | return None 294 | 295 | 296 | def _process_document_task(card: Dict[str, Any]) -> Dict[str, Any]: 297 | """处理文档类型任务""" 298 | return { 299 | "type": "document", 300 | "jobid": card.get("jobid", ""), 301 | "otherinfo": card.get("otherInfo", ""), 302 | "jtoken": card.get("jtoken", ""), 303 | "mid": card.get("mid", ""), 304 | "enc": card.get("enc", ""), 305 | "aid": card.get("aid", ""), 306 | "objectid": card.get("property", {}).get("objectid", "") 307 | } 308 | 309 | 310 | def _process_work_task(card: Dict[str, Any]) -> Dict[str, Any]: 311 | """处理作业类型任务""" 312 | return { 313 | "type": "workid", 314 | "jobid": card.get("jobid", ""), 315 | "otherinfo": card.get("otherInfo", ""), 316 | "mid": card.get("mid", ""), 317 | "enc": card.get("enc", ""), 318 | "aid": card.get("aid", "") 319 | } 320 | 321 | 322 | def decode_questions_info(html_content: str) -> Dict[str, Any]: 323 | """ 324 | 解析题目信息,提取表单数据和问题列表 325 | 326 | Args: 327 | html_content: 题目页面HTML内容 328 | 329 | Returns: 330 | 包含表单数据和问题列表的字典 331 | """ 332 | soup = BeautifulSoup(html_content, "lxml") 333 | form_data = _extract_form_data(soup) 334 | 335 | # 检查是否存在字体加密 336 | has_font_encryption = bool(soup.find("style", id="cxSecretStyle")) 337 | font_decoder = None 338 | 339 | if has_font_encryption: 340 | font_decoder = FontDecoder(html_content) 341 | else: 342 | logger.warning("未找到字体文件,可能是未加密的题目不进行解密") 343 | 344 | # 处理所有问题 345 | questions = [] 346 | for div_tag in soup.find("form").find_all("div", class_="singleQuesId"): 347 | question = _process_question(div_tag, font_decoder) 348 | if question: 349 | questions.append(question) 350 | 351 | # 更新表单数据 352 | form_data["questions"] = questions 353 | form_data["answerwqbid"] = ",".join([q["id"] for q in questions]) + "," 354 | 355 | return form_data 356 | 357 | 358 | def _extract_form_data(soup: BeautifulSoup) -> Dict[str, Any]: 359 | """从BeautifulSoup对象中提取表单数据""" 360 | form_data = {} 361 | form_tag = soup.find("form") 362 | 363 | if not form_tag: 364 | return form_data 365 | 366 | # 提取所有非答案字段的input 367 | for input_tag in form_tag.find_all("input"): 368 | if "name" not in input_tag.attrs or "answer" in input_tag.attrs["name"]: 369 | continue 370 | form_data[input_tag.attrs["name"]] = input_tag.attrs.get("value", "") 371 | 372 | return form_data 373 | 374 | 375 | def _process_question(div_tag, font_decoder=None) -> Dict[str, Any]: 376 | """处理单个问题""" 377 | # 提取问题ID和题目类型 378 | question_id = div_tag.attrs.get("data", "") 379 | q_type_code = div_tag.find("div", class_="TiMu").attrs.get("data", "") 380 | q_type = _get_question_type(q_type_code) 381 | 382 | # 提取题目内容和选项 383 | title_div = div_tag.find("div", class_="Zy_TItle") 384 | options_list = div_tag.find("ul").find_all("li") if div_tag.find("ul") else [] 385 | 386 | # 解析题目和选项 387 | q_title = _extract_title(title_div, font_decoder) 388 | q_options = [] 389 | for li in options_list: 390 | q_options.append(_extract_choices(li, font_decoder)) 391 | # 排序选项 392 | q_options.sort() 393 | q_options = '\n'.join(q_options) 394 | 395 | return { 396 | "id": question_id, 397 | "title": q_title, 398 | "options": q_options, 399 | "type": q_type, 400 | "answerField": { 401 | f"answer{question_id}": "", 402 | f"answertype{question_id}": q_type_code, 403 | }, 404 | } 405 | 406 | 407 | def _get_question_type(type_code: str) -> str: 408 | """根据题型代码返回题型名称""" 409 | type_map = { 410 | "0": "single", # 单选题 411 | "1": "multiple", # 多选题 412 | "2": "completion", # 填空题 413 | "3": "judgement", # 判断题 414 | "4": "shortanswer", # 简答题 415 | } 416 | 417 | if type_code in type_map: 418 | return type_map[type_code] 419 | 420 | logger.info(f"未知题型代码 -> {type_code}") 421 | return "unknown" 422 | 423 | 424 | def _extract_title(element, font_decoder=None) -> str: 425 | """提取标题内容,支持解码加密字体""" 426 | if not element: 427 | return "" 428 | 429 | # 收集元素中的所有文本和图片 430 | content = [] 431 | for item in element.descendants: 432 | if isinstance(item, NavigableString): 433 | content.append(item.string or "") 434 | elif item.name == "img": 435 | img_url = item.get("src", "") 436 | content.append(f'') 437 | 438 | raw_content = "".join(content) 439 | cleaned_content = raw_content.replace("\r", "").replace("\t", "").replace("\n", "") 440 | 441 | # 如果有字体解码器,进行解码 442 | if font_decoder: 443 | return font_decoder.decode(cleaned_content) 444 | 445 | return cleaned_content 446 | 447 | def _extract_choices(element, font_decoder=None) -> str: 448 | """提取选项内容,支持解码加密字体""" 449 | if not element: 450 | return "" 451 | 452 | # 提取aria-label属性值作为选项,解决#474 453 | choice = element.get('aria-label') 454 | 455 | cleaned_content = choice.replace("\r", "").replace("\t", "").replace("\n", "") 456 | 457 | # 如果有字体解码器,进行解码 458 | if font_decoder: 459 | return font_decoder.decode(cleaned_content) 460 | 461 | return cleaned_content -------------------------------------------------------------------------------- /api/answer.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import random 4 | import re 5 | import time 6 | from pathlib import Path 7 | from re import sub 8 | 9 | import httpx 10 | import requests 11 | from openai import OpenAI 12 | from urllib3 import disable_warnings, exceptions 13 | 14 | from api.answer_check import * 15 | from api.logger import logger 16 | 17 | # 关闭警告 18 | disable_warnings(exceptions.InsecureRequestWarning) 19 | 20 | class CacheDAO: 21 | """ 22 | @Author: SocialSisterYi 23 | @Reference: https://github.com/SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy 24 | """ 25 | DEFAULT_CACHE_FILE = "cache.json" 26 | 27 | def __init__(self, file: str = DEFAULT_CACHE_FILE): 28 | self.cache_file = Path(file) 29 | if not self.cache_file.is_file(): 30 | self._write_cache({}) 31 | 32 | def _read_cache(self) -> dict: 33 | try: 34 | with self.cache_file.open("r", encoding="utf8") as fp: 35 | return json.load(fp) 36 | except (FileNotFoundError, json.JSONDecodeError): 37 | return {} 38 | 39 | def _write_cache(self, data: dict) -> None: 40 | try: 41 | with self.cache_file.open("w", encoding="utf8") as fp: 42 | json.dump(data, fp, ensure_ascii=False, indent=4) 43 | except IOError as e: 44 | logger.error(f"Failed to write cache: {e}") 45 | 46 | def get_cache(self, question: str): 47 | data = self._read_cache() 48 | return data.get(question) 49 | 50 | def add_cache(self, question: str, answer: str) -> None: 51 | data = self._read_cache() 52 | data[question] = answer 53 | self._write_cache(data) 54 | 55 | 56 | class Tiku: 57 | CONFIG_PATH = "config.ini" # 默认配置文件路径 58 | DISABLE = False # 停用标志 59 | SUBMIT = False # 提交标志 60 | COVER_RATE = 0.8 # 覆盖率 61 | true_list = [] 62 | false_list = [] 63 | def __init__(self) -> None: 64 | self._name = None 65 | self._api = None 66 | self._conf = None 67 | 68 | @property 69 | def name(self): 70 | return self._name 71 | 72 | @name.setter 73 | def name(self, value): 74 | self._name = value 75 | 76 | @property 77 | def api(self): 78 | return self._api 79 | 80 | @api.setter 81 | def api(self, value): 82 | self._api = value 83 | 84 | @property 85 | def token(self): 86 | return self._token 87 | 88 | @token.setter 89 | def token(self,value): 90 | self._token = value 91 | 92 | def init_tiku(self): 93 | # 仅用于题库初始化, 应该在题库载入后作初始化调用, 随后才可以使用题库 94 | # 尝试根据配置文件设置提交模式 95 | if not self._conf: 96 | self.config_set(self._get_conf()) 97 | if not self.DISABLE: 98 | # 设置提交模式 99 | self.SUBMIT = True if self._conf['submit'] == 'true' else False 100 | self.COVER_RATE = float(self._conf['cover_rate']) 101 | self.true_list = self._conf['true_list'].split(',') 102 | self.false_list = self._conf['false_list'].split(',') 103 | # 调用自定义题库初始化 104 | self._init_tiku() 105 | 106 | def _init_tiku(self): 107 | # 仅用于题库初始化, 例如配置token, 交由自定义题库完成 108 | pass 109 | 110 | def config_set(self,config): 111 | self._conf = config 112 | 113 | def _get_conf(self): 114 | """ 115 | 从默认配置文件查询配置, 如果未能查到, 停用题库 116 | """ 117 | try: 118 | config = configparser.ConfigParser() 119 | config.read(self.CONFIG_PATH, encoding="utf8") 120 | return config['tiku'] 121 | except (KeyError, FileNotFoundError): 122 | logger.info("未找到tiku配置, 已忽略题库功能") 123 | self.DISABLE = True 124 | return None 125 | def query(self,q_info:dict): 126 | if self.DISABLE: 127 | return None 128 | 129 | # 预处理, 去除【单选题】这样与标题无关的字段 130 | logger.debug(f"原始标题:{q_info['title']}") 131 | q_info['title'] = sub(r'^\d+', '', q_info['title']) 132 | q_info['title'] = sub(r'(\d+\.\d+分)$', '', q_info['title']) 133 | logger.debug(f"处理后标题:{q_info['title']}") 134 | 135 | # 先过缓存 136 | cache_dao = CacheDAO() 137 | answer = cache_dao.get_cache(q_info['title']) 138 | if answer: 139 | logger.info(f"从缓存中获取答案:{q_info['title']} -> {answer}") 140 | return answer.strip() 141 | else: 142 | answer = self._query(q_info) 143 | if answer: 144 | answer = answer.strip() 145 | cache_dao.add_cache(q_info['title'], answer) 146 | logger.info(f"从{self.name}获取答案:{q_info['title']} -> {answer}") 147 | if check_answer(answer, q_info['type'], self): 148 | return answer 149 | else: 150 | logger.info(f"从{self.name}获取到的答案类型与题目类型不符,已舍弃") 151 | return None 152 | 153 | logger.error(f"从{self.name}获取答案失败:{q_info['title']}") 154 | return None 155 | 156 | def _query(self,q_info:dict): 157 | """ 158 | 查询接口, 交由自定义题库实现 159 | """ 160 | pass 161 | 162 | def get_tiku_from_config(self): 163 | """ 164 | 从配置文件加载题库, 这个配置可以是用户提供, 可以是默认配置文件 165 | """ 166 | if not self._conf: 167 | # 尝试从默认配置文件加载 168 | self.config_set(self._get_conf()) 169 | if self.DISABLE: 170 | return self 171 | try: 172 | cls_name = self._conf['provider'] 173 | if not cls_name: 174 | raise KeyError 175 | except KeyError: 176 | self.DISABLE = True 177 | logger.error("未找到题库配置, 已忽略题库功能") 178 | return self 179 | new_cls = globals()[cls_name]() 180 | new_cls.config_set(self._conf) 181 | return new_cls 182 | 183 | def judgement_select(self, answer: str) -> bool: 184 | """ 185 | 这是一个专用的方法, 要求配置维护两个选项列表, 一份用于正确选项, 一份用于错误选项, 以应对题库对判断题答案响应的各种可能的情况 186 | 它的作用是将获取到的答案answer与可能的选项列对比并返回对应的布尔值 187 | """ 188 | if self.DISABLE: 189 | return False 190 | # 对响应的答案作处理 191 | answer = answer.strip() 192 | if answer in self.true_list: 193 | return True 194 | elif answer in self.false_list: 195 | return False 196 | else: 197 | # 无法判断, 随机选择 198 | logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误, 请自行判断并加入配置文件重启脚本, 本次将会随机选择选项') 199 | return random.choice([True,False]) 200 | 201 | def get_submit_params(self): 202 | """ 203 | 这是一个专用方法, 用于根据当前设置的提交模式, 响应对应的答题提交API中的pyFlag值 204 | """ 205 | # 留空直接提交, 1保存但不提交 206 | if self.SUBMIT: 207 | return "" 208 | else: 209 | return "1" 210 | 211 | # 按照以下模板实现更多题库 212 | 213 | class TikuYanxi(Tiku): 214 | # 言溪题库实现 215 | def __init__(self) -> None: 216 | super().__init__() 217 | self.name = '言溪题库' 218 | self.api = 'https://tk.enncy.cn/query' 219 | self._token = None 220 | self._token_index = 0 # token队列计数器 221 | self._times = 100 # 查询次数剩余, 初始化为100, 查询后校对修正 222 | 223 | def _query(self,q_info:dict): 224 | res = requests.get( 225 | self.api, 226 | params={ 227 | 'question':q_info['title'], 228 | 'token': self._token, 229 | # 'type':q_info['type'], #修复478题目类型与答案类型不符(不想写后处理了) 230 | # 没用,就算有type和options,言溪题库还是可能返回类型不符,问了客服,type仅用于收集 231 | }, 232 | verify=False 233 | ) 234 | if res.status_code == 200: 235 | res_json = res.json() 236 | if not res_json['code']: 237 | # 如果是因为TOKEN次数到期, 则更换token 238 | if self._times == 0 or '次数不足' in res_json['data']['answer']: 239 | logger.info(f'TOKEN查询次数不足, 将会更换并重新搜题') 240 | self._token_index += 1 241 | self.load_token() 242 | # 重新查询 243 | return self._query(q_info) 244 | logger.error(f'{self.name}查询失败:\n\t剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n\t消息:{res_json["message"]}') 245 | return None 246 | self._times = res_json["data"].get("times",self._times) 247 | return res_json['data']['answer'].strip() 248 | else: 249 | logger.error(f'{self.name}查询失败:\n{res.text}') 250 | return None 251 | 252 | def load_token(self): 253 | token_list = self._conf['tokens'].split(',') 254 | if self._token_index == len(token_list): 255 | # TOKEN 用完 256 | logger.error('TOKEN用完, 请自行更换再重启脚本') 257 | raise PermissionError(f'{self.name} TOKEN 已用完, 请更换') 258 | self._token = token_list[self._token_index] 259 | 260 | def _init_tiku(self): 261 | self.load_token() 262 | 263 | class TikuLike(Tiku): 264 | # Like知识库实现 265 | def __init__(self) -> None: 266 | super().__init__() 267 | self.name = 'Like知识库' 268 | self.ver = '1.0.8' #对应官网API版本 269 | self.query_api = 'https://api.datam.site/search' 270 | self.balance_api = 'https://api.datam.site/balance' 271 | self.homepage = 'https://www.datam.site' 272 | self._model = None 273 | self._token = None 274 | self._times = -1 275 | self._search = False 276 | self._count = 0 277 | 278 | def _query(self,q_info:dict): 279 | q_info_map = {"single":"【单选题】","multiple":"【多选题】","completion":"【填空题】","judgement":"【判断题】"} 280 | api_params_map = {0:"others",1:"choose",2:"fills",3:"judge"} 281 | q_info_prefix = q_info_map.get(q_info['type'],"【其他类型题目】") 282 | option_map = {"A": 0, "B": 1, "C": 2, "D": 3, "E": 4, "F": 5, "G": 6, "H": 7, 'a': 0, "b": 1, "c": 2, "d": 3, 283 | "e": 4, "f": 5, "g": 6, "h": 7} 284 | options = ', '.join(q_info['options']) if isinstance(q_info['options'], list) else q_info['options'] 285 | question = f"{q_info_prefix}{q_info['title']}\n{options}" 286 | ret = "" 287 | ans = "" 288 | res = requests.post( 289 | self.query_api, 290 | json={ 291 | 'query': question, 292 | 'token': self._token, 293 | 'model': self._model if self._model else '', 294 | 'search': self._search 295 | }, 296 | verify=False 297 | ) 298 | 299 | if res.status_code == 200: 300 | res_json = res.json() 301 | q_type = res_json['data'].get('type', 0) 302 | params = api_params_map.get(q_type, "") 303 | tans = res_json['data'].get(params, "") 304 | ans = "" 305 | match q_type: 306 | case 1: 307 | for i in tans: 308 | ans = ans + q_info['options'][option_map[i]] + '\n' 309 | case 2: 310 | for i in tans: 311 | ans = ans + i + '\n' 312 | case 3: 313 | ans = "正确" if tans == 1 else "错误" 314 | case 0: 315 | ans = tans 316 | else: 317 | logger.error(f'{self.name}查询失败:\n{res.text}') 318 | return None 319 | 320 | ret += str(ans) 321 | 322 | self._times -= 1 323 | 324 | #10次查询后更新实际次数 325 | self._count = (self._count+1) % 10 326 | 327 | if self._count == 0: 328 | self.update_times() 329 | 330 | return ret 331 | 332 | def update_times(self): 333 | res = requests.post( 334 | self.balance_api, 335 | json={ 336 | 'token': self._token, 337 | }, 338 | verify=False 339 | ) 340 | if res.status_code == 200: 341 | res_json = res.json() 342 | self._times = res_json["data"].get("balance",self._times) 343 | logger.info(f"当前LIKE知识库Token剩余查询次数为: {self._times}") 344 | else: 345 | logger.error('TOKEN出现错误,请检查后再试') 346 | 347 | def load_token(self): 348 | token = self._conf['tokens'].split(',')[-1] if ',' in self._conf['tokens'] else self._conf['tokens'] 349 | self._token = token 350 | 351 | def load_config(self): 352 | self._search = self._conf['likeapi_search'] 353 | self._model = self._conf['likeapi_model'] 354 | var_params = {"likeapi_search": self._search, "likeapi_model": self._model} 355 | config_params = {"likeapi_search": False, "likeapi_model": None} 356 | 357 | for k,v in config_params.items(): 358 | if k in self._conf: 359 | var_params[k] = self._conf[k] 360 | else: 361 | var_params[k] = v 362 | 363 | def _init_tiku(self): 364 | self.load_token() 365 | self.load_config() 366 | self.update_times() 367 | 368 | class TikuAdapter(Tiku): 369 | # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter 370 | def __init__(self) -> None: 371 | super().__init__() 372 | self.name = 'TikuAdapter题库' 373 | self.api = '' 374 | 375 | def _query(self, q_info: dict): 376 | # 判断题目类型 377 | if q_info['type'] == "single": 378 | type = 0 379 | elif q_info['type'] == 'multiple': 380 | type = 1 381 | elif q_info['type'] == 'completion': 382 | type = 2 383 | elif q_info['type'] == 'judgement': 384 | type = 3 385 | else: 386 | type = 4 387 | 388 | options = q_info['options'] 389 | res = requests.post( 390 | self.api, 391 | json={ 392 | 'question': q_info['title'], 393 | 'options': [sub(r'^[A-Za-z]\.?、?\s?', '', option) for option in options.split('\n')], 394 | 'type': type 395 | }, 396 | verify=False 397 | ) 398 | if res.status_code == 200: 399 | res_json = res.json() 400 | # if bool(res_json['plat']): 401 | # plat无论搜没搜到答案都返回0 402 | # 这个参数是tikuadapter用来设定自定义的平台类型 403 | if not len(res_json['answer']['bestAnswer']): 404 | logger.error("查询失败, 返回:" + res.text) 405 | return None 406 | sep = "\n" 407 | return sep.join(res_json['answer']['bestAnswer']).strip() 408 | # else: 409 | # logger.error(f'{self.name}查询失败:\n{res.text}') 410 | return None 411 | 412 | def _init_tiku(self): 413 | # self.load_token() 414 | self.api = self._conf['url'] 415 | 416 | class AI(Tiku): 417 | # AI大模型答题实现 418 | def __init__(self) -> None: 419 | super().__init__() 420 | self.name = 'AI大模型答题' 421 | self.last_request_time = None 422 | 423 | def _query(self, q_info: dict): 424 | def remove_md_json_wrapper(md_str): 425 | # 使用正则表达式匹配Markdown代码块并提取内容 426 | pattern = r'^\s*```(?:json)?\s*(.*?)\s*```\s*$' 427 | match = re.search(pattern, md_str, re.DOTALL) 428 | return match.group(1).strip() if match else md_str.strip() 429 | 430 | if self.http_proxy: 431 | proxy = self.http_proxy 432 | httpx_client = httpx.Client(proxy=proxy) 433 | client = OpenAI(http_client=httpx_client, base_url = self.endpoint,api_key = self.key) 434 | else: 435 | client = OpenAI(base_url = self.endpoint,api_key = self.key) 436 | # 去除选项字母,防止大模型直接输出字母而非内容 437 | options_list = q_info['options'].split('\n') 438 | cleaned_options = [re.sub(r"^[A-Z]\s*", "", option) for option in options_list] 439 | options = "\n".join(cleaned_options) 440 | # 判断题目类型 441 | if q_info['type'] == "single": 442 | completion = client.chat.completions.create( 443 | model = self.model, 444 | messages=[ 445 | { 446 | "role": "system", 447 | "content": "本题为单选题,你只能选择一个选项,请根据题目和选项回答问题,以json格式输出正确的选项内容,示例回答:{\"Answer\": [\"答案\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 448 | }, 449 | { 450 | "role": "user", 451 | "content": f"题目:{q_info['title']}\n选项:{options}" 452 | } 453 | ] 454 | ) 455 | elif q_info['type'] == 'multiple': 456 | completion = client.chat.completions.create( 457 | model = self.model, 458 | messages=[ 459 | { 460 | "role": "system", 461 | "content": "本题为多选题,你必须选择两个或以上选项,请根据题目和选项回答问题,以json格式输出正确的选项内容,示例回答:{\"Answer\": [\"答案1\",\n\"答案2\",\n\"答案3\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 462 | }, 463 | { 464 | "role": "user", 465 | "content": f"题目:{q_info['title']}\n选项:{options}" 466 | } 467 | ] 468 | ) 469 | elif q_info['type'] == 'completion': 470 | completion = client.chat.completions.create( 471 | model = self.model, 472 | messages=[ 473 | { 474 | "role": "system", 475 | "content": "本题为填空题,你必须根据语境和相关知识填入合适的内容,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"答案\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 476 | }, 477 | { 478 | "role": "user", 479 | "content": f"题目:{q_info['title']}" 480 | } 481 | ] 482 | ) 483 | elif q_info['type'] == 'judgement': 484 | completion = client.chat.completions.create( 485 | model = self.model, 486 | messages=[ 487 | { 488 | "role": "system", 489 | "content": "本题为判断题,你只能回答正确或者错误,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"正确\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 490 | }, 491 | { 492 | "role": "user", 493 | "content": f"题目:{q_info['title']}" 494 | } 495 | ] 496 | ) 497 | else: 498 | completion = client.chat.completions.create( 499 | model = self.model, 500 | messages=[ 501 | { 502 | "role": "system", 503 | "content": "本题为简答题,你必须根据语境和相关知识填入合适的内容,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"这是我的答案\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 504 | }, 505 | { 506 | "role": "user", 507 | "content": f"题目:{q_info['title']}" 508 | } 509 | ] 510 | ) 511 | 512 | try: 513 | if self.last_request_time: 514 | interval_time = time.time() - self.last_request_time 515 | if interval_time < self.min_interval_seconds: 516 | sleep_time = self.min_interval_seconds - interval_time 517 | logger.debug(f"API请求间隔过短, 等待 {sleep_time} 秒") 518 | time.sleep(sleep_time) 519 | self.last_request_time = time.time() 520 | response = json.loads(remove_md_json_wrapper(completion.choices[0].message.content)) 521 | sep = "\n" 522 | return sep.join(response['Answer']).strip() 523 | except: 524 | logger.error("无法解析大模型输出内容") 525 | return None 526 | 527 | def _init_tiku(self): 528 | self.endpoint = self._conf['endpoint'] 529 | self.key = self._conf['key'] 530 | self.model = self._conf['model'] 531 | self.http_proxy = self._conf['http_proxy'] 532 | self.min_interval_seconds = int(self._conf['min_interval_seconds']) 533 | class SiliconFlow(Tiku): 534 | """硅基流动大模型答题实现""" 535 | def __init__(self): 536 | super().__init__() 537 | self.name = '硅基流动大模型' 538 | self.last_request_time = None 539 | 540 | def _query(self, q_info: dict): 541 | def remove_md_json_wrapper(md_str): 542 | # 解析可能存在的JSON包装 543 | pattern = r'^\s*```(?:json)?\s*(.*?)\s*```\s*$' 544 | match = re.search(pattern, md_str, re.DOTALL) 545 | return match.group(1).strip() if match else md_str.strip() 546 | 547 | # 构造请求头 548 | headers = { 549 | "Authorization": f"Bearer {self.api_key}", 550 | "Content-Type": "application/json" 551 | } 552 | 553 | # 构造系统提示词 554 | system_prompt = "" 555 | if q_info['type'] == "single": 556 | system_prompt = "本题为单选题,请根据题目和选项选择唯一正确答案,输出的是选项的具体内容,而不是内容前的ABCD,并以JSON格式输出:示例回答:{\"Answer\": [\"正确选项内容\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 557 | elif q_info['type'] == 'multiple': 558 | system_prompt = "本题为多选题,请选择所有正确选项,输出的是选项的具体内容,而不是内容前的ABCD,以JSON格式输出:示例回答:{\"Answer\": [\"选项1\",\"选项2\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 559 | elif q_info['type'] == 'completion': 560 | system_prompt = "本题为填空题,请直接给出填空内容,以JSON格式输出:示例回答:{\"Answer\": [\"答案文本\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 561 | elif q_info['type'] == 'judgement': 562 | system_prompt = "本题为判断题,请回答'正确'或'错误',以JSON格式输出:示例回答:{\"Answer\": [\"正确\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" 563 | 564 | # 构造请求体 565 | payload = { 566 | "model": self.model_name, 567 | "messages": [ 568 | { 569 | "role": "system", 570 | "content": system_prompt 571 | }, 572 | { 573 | "role": "user", 574 | "content": f"题目:{q_info['title']}\n选项:{q_info['options']}" 575 | } 576 | ], 577 | "stream": False, 578 | 579 | "max_tokens": 4096, 580 | 581 | "temperature": 0.7, 582 | "top_p": 0.7, 583 | "response_format": {"type": "text"} 584 | } 585 | 586 | # 处理请求间隔 587 | if self.last_request_time: 588 | interval = time.time() - self.last_request_time 589 | if interval < self.min_interval: 590 | time.sleep(self.min_interval - interval) 591 | 592 | try: 593 | response = requests.post( 594 | self.api_endpoint, 595 | headers=headers, 596 | json=payload, 597 | timeout=30 598 | ) 599 | self.last_request_time = time.time() 600 | 601 | if response.status_code == 200: 602 | result = response.json() 603 | content = result['choices'][0]['message']['content'] 604 | parsed = json.loads(remove_md_json_wrapper(content)) 605 | return "\n".join(parsed['Answer']).strip() 606 | else: 607 | logger.error(f"API请求失败:{response.status_code} {response.text}") 608 | return None 609 | 610 | except Exception as e: 611 | logger.error(f"硅基流动API异常:{e}") 612 | return None 613 | 614 | def _init_tiku(self): 615 | # 从配置文件读取参数 616 | self.api_endpoint = self._conf.get('siliconflow_endpoint', 'https://api.siliconflow.cn/v1/chat/completions') 617 | self.api_key = self._conf['siliconflow_key'] 618 | 619 | self.model_name = self._conf.get('siliconflow_model', 'deepseek-ai/DeepSeek-V3') 620 | 621 | 622 | self.min_interval = int(self._conf.get('min_interval_seconds', 3)) 623 | -------------------------------------------------------------------------------- /api/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from enum import Enum 3 | from hashlib import md5 4 | 5 | import requests 6 | from requests.adapters import HTTPAdapter 7 | 8 | from api.answer import * 9 | from api.cipher import AESCipher 10 | from api.config import GlobalConst as gc 11 | from api.cookies import save_cookies, use_cookies 12 | from api.decode import ( 13 | decode_course_list, 14 | decode_course_point, 15 | decode_course_card, 16 | decode_course_folder, 17 | decode_questions_info, 18 | ) 19 | from api.process import show_progress 20 | from api.exceptions import MaxRetryExceeded 21 | 22 | 23 | def get_timestamp(): 24 | return str(int(time.time() * 1000)) 25 | 26 | 27 | def get_random_seconds(): 28 | return random.randint(30, 90) 29 | 30 | 31 | def init_session(isVideo: bool = False, isAudio: bool = False): 32 | _session = requests.session() 33 | _session.verify = False 34 | _session.mount("http://", HTTPAdapter(max_retries=3)) 35 | _session.mount("https://", HTTPAdapter(max_retries=3)) 36 | if isVideo: 37 | _session.headers = gc.VIDEO_HEADERS 38 | elif isAudio: 39 | _session.headers = gc.AUDIO_HEADERS 40 | else: 41 | _session.headers = gc.HEADERS 42 | _session.cookies.update(use_cookies()) 43 | return _session 44 | 45 | 46 | class Account: 47 | username = None 48 | password = None 49 | last_login = None 50 | isSuccess = None 51 | 52 | def __init__(self, _username, _password): 53 | self.username = _username 54 | self.password = _password 55 | 56 | 57 | class Chaoxing: 58 | class StudyResult(Enum): 59 | SUCCESS = 0 60 | FORBIDDEN = 1 # 403 61 | ERROR = 2 62 | TIMEOUT = 3 63 | 64 | @staticmethod 65 | def is_success(result): 66 | return result == Chaoxing.StudyResult.SUCCESS 67 | 68 | @staticmethod 69 | def is_failure(result): 70 | return result != Chaoxing.StudyResult.SUCCESS 71 | 72 | def __init__(self, account: Account = None, tiku: Tiku = None,**kwargs): 73 | self.account = account 74 | self.cipher = AESCipher() 75 | self.tiku = tiku 76 | self.kwargs = kwargs 77 | self.rollback_times = 0 78 | 79 | def login(self): 80 | _session = requests.session() 81 | _session.verify = False 82 | _url = "https://passport2.chaoxing.com/fanyalogin" 83 | _data = { 84 | "fid": "-1", 85 | "uname": self.cipher.encrypt(self.account.username), 86 | "password": self.cipher.encrypt(self.account.password), 87 | "refer": "https%3A%2F%2Fi.chaoxing.com", 88 | "t": True, 89 | "forbidotherlogin": 0, 90 | "validate": "", 91 | "doubleFactorLogin": 0, 92 | "independentId": 0, 93 | } 94 | logger.trace("正在尝试登录...") 95 | resp = _session.post(_url, headers=gc.HEADERS, data=_data) 96 | if resp and resp.json()["status"] == True: 97 | save_cookies(_session) 98 | logger.info("登录成功...") 99 | return {"status": True, "msg": "登录成功"} 100 | else: 101 | return {"status": False, "msg": str(resp.json()["msg2"])} 102 | 103 | def get_fid(self): 104 | _session = init_session() 105 | return _session.cookies.get("fid") 106 | 107 | def get_uid(self): 108 | _session = init_session() 109 | return _session.cookies.get("_uid") 110 | 111 | def get_course_list(self): 112 | _session = init_session() 113 | _url = "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata" 114 | _data = {"courseType": 1, "courseFolderId": 0, "query": "", "superstarClass": 0} 115 | logger.trace("正在读取所有的课程列表...") 116 | # 接口突然抽风, 增加headers 117 | _headers = { 118 | "Host": "mooc2-ans.chaoxing.com", 119 | "sec-ch-ua-platform": '"Windows"', 120 | "X-Requested-With": "XMLHttpRequest", 121 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", 122 | "Accept": "text/html, */*; q=0.01", 123 | "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', 124 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 125 | "sec-ch-ua-mobile": "?0", 126 | "Origin": "https://mooc2-ans.chaoxing.com", 127 | "Sec-Fetch-Site": "same-origin", 128 | "Sec-Fetch-Mode": "cors", 129 | "Sec-Fetch-Dest": "empty", 130 | "Referer": "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction?moocDomain=https://mooc1-1.chaoxing.com/mooc-ans", 131 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,ja;q=0.5", 132 | } 133 | _resp = _session.post(_url, headers=_headers, data=_data) 134 | # logger.trace(f"原始课程列表内容:\n{_resp.text}") 135 | logger.info("课程列表读取完毕...") 136 | course_list = decode_course_list(_resp.text) 137 | 138 | _interaction_url = "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction" 139 | _interaction_resp = _session.get(_interaction_url) 140 | course_folder = decode_course_folder(_interaction_resp.text) 141 | for folder in course_folder: 142 | _data = { 143 | "courseType": 1, 144 | "courseFolderId": folder["id"], 145 | "query": "", 146 | "superstarClass": 0, 147 | } 148 | _resp = _session.post(_url, data=_data) 149 | course_list += decode_course_list(_resp.text) 150 | return course_list 151 | 152 | def get_course_point(self, _courseid, _clazzid, _cpi): 153 | _session = init_session() 154 | _url = f"https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentcourse?courseid={_courseid}&clazzid={_clazzid}&cpi={_cpi}&ut=s" 155 | logger.trace("开始读取课程所有章节...") 156 | _resp = _session.get(_url) 157 | # logger.trace(f"原始章节列表内容:\n{_resp.text}") 158 | logger.info("课程章节读取成功...") 159 | return decode_course_point(_resp.text) 160 | 161 | def get_job_list(self, _clazzid, _courseid, _cpi, _knowledgeid): 162 | _session = init_session() 163 | job_list = [] 164 | job_info = {} 165 | for _possible_num in [ 166 | "0", 167 | "1", 168 | "2", 169 | "3", 170 | "4", 171 | "5", 172 | "6", 173 | ]: # 学习界面任务卡片数, 很少有3个的, 但是对于章节解锁任务点少一个都不行, 可以从API /mooc-ans/mycourse/studentstudyAjax获取值, 或者干脆直接加, 但二者都会造成额外的请求 174 | _url = f"https://mooc1.chaoxing.com/mooc-ans/knowledge/cards?clazzid={_clazzid}&courseid={_courseid}&knowledgeid={_knowledgeid}&num={_possible_num}&ut=s&cpi={_cpi}&v=20160407-3&mooc2=1" 175 | logger.trace("开始读取章节所有任务点...") 176 | _resp = _session.get(_url) 177 | _job_list, _job_info = decode_course_card(_resp.text) 178 | if _job_info.get("notOpen", False): 179 | # 直接返回, 节省一次请求 180 | logger.info("该章节未开放") 181 | return [], _job_info 182 | job_list += _job_list 183 | job_info.update(_job_info) 184 | # if _job_list and len(_job_list) != 0: 185 | # break 186 | # logger.trace(f"原始任务点列表内容:\n{_resp.text}") 187 | logger.info("章节任务点读取成功...") 188 | return job_list, job_info 189 | 190 | def get_enc(self, clazzId, jobid, objectId, playingTime, duration, userid): 191 | return md5( 192 | f"[{clazzId}][{userid}][{jobid}][{objectId}][{playingTime * 1000}][d_yHJ!$pdA~5][{duration * 1000}][0_{duration}]".encode() 193 | ).hexdigest() 194 | 195 | def video_progress_log( 196 | self, 197 | _session, 198 | _course, 199 | _job, 200 | _job_info, 201 | _dtoken, 202 | _duration, 203 | _playingTime, 204 | _type: str = "Video", 205 | ): 206 | if "courseId" in _job["otherinfo"]: 207 | _mid_text = f"otherInfo={_job['otherinfo']}&" 208 | else: 209 | _mid_text = f"otherInfo={_job['otherinfo']}&courseId={_course['courseId']}&" 210 | _success = False 211 | for _possible_rt in ["0.9", "1"]: 212 | _url = ( 213 | f"https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" 214 | f"{_course['cpi']}/" 215 | f"{_dtoken}?" 216 | f"clazzId={_course['clazzId']}&" 217 | f"playingTime={_playingTime}&" 218 | f"duration={_duration}&" 219 | f"clipTime=0_{_duration}&" 220 | f"objectId={_job['objectid']}&" 221 | f"{_mid_text}" 222 | f"jobid={_job['jobid']}&" 223 | f"userid={self.get_uid()}&" 224 | f"isdrag=3&" 225 | f"view=pc&" 226 | f"enc={self.get_enc(_course['clazzId'], _job['jobid'], _job['objectid'], _playingTime, _duration, self.get_uid())}&" 227 | f"rt={_possible_rt}&" 228 | f"dtype={_type}&" 229 | f"_t={get_timestamp()}" 230 | ) 231 | resp = _session.get(_url) 232 | if resp.status_code == 200: 233 | _success = True 234 | break # 如果返回为200正常, 则跳出循环 235 | elif resp.status_code == 403: 236 | continue # 如果出现403无权限报错, 则继续尝试不同的rt参数 237 | if _success: 238 | return resp.json(), 200 239 | else: 240 | # 若出现两个rt参数都返回403的情况, 则跳过当前任务 241 | logger.warning("出现403报错, 尝试修复无效, 正在跳过当前任务点...") 242 | return {"isPassed": False}, 403 # 返回一个字典和当前状态 243 | def study_video( 244 | self, _course, _job, _job_info, _speed: float = 1.0, _type: str = "Video" 245 | ) -> StudyResult: 246 | if _type == "Video": 247 | _session = init_session(isVideo=True) 248 | else: 249 | _session = init_session(isAudio=True) 250 | _session.headers.update() 251 | _info_url = f"https://mooc1.chaoxing.com/ananas/status/{_job['objectid']}?k={self.get_fid()}&flag=normal" 252 | _video_info = _session.get(_info_url).json() 253 | if _video_info["status"] == "success": 254 | _dtoken = _video_info["dtoken"] 255 | _duration = _video_info["duration"] 256 | _crc = _video_info["crc"] 257 | _key = _video_info["key"] 258 | _isPassed = False 259 | _isFinished = False 260 | _playingTime = 0 261 | logger.info(f"开始任务: {_job['name']}, 总时长: {_duration}秒") 262 | state = 200 263 | while not _isFinished: 264 | if _isFinished: 265 | _playingTime = _duration 266 | _isPassed, state = self.video_progress_log( 267 | _session, 268 | _course, 269 | _job, 270 | _job_info, 271 | _dtoken, 272 | _duration, 273 | _playingTime, 274 | _type, 275 | ) 276 | if not _isPassed or (_isPassed and _isPassed["isPassed"]): 277 | break 278 | if _isPassed and not _isPassed["isPassed"] and state == 403: 279 | return self.StudyResult.FORBIDDEN 280 | _wait_time = get_random_seconds() 281 | if _playingTime + _wait_time >= int(_duration): 282 | _wait_time = int(_duration) - _playingTime 283 | _isPassed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, _duration, _duration, _type) 284 | if _isPassed['isPassed']: 285 | _isFinished = True 286 | # 播放进度条 287 | show_progress(_job["name"], _playingTime, _wait_time, _duration, _speed) 288 | _playingTime += _wait_time 289 | print("\r", end="", flush=True) 290 | logger.info(f"任务完成: {_job['name']}") 291 | return self.StudyResult.SUCCESS 292 | else: 293 | return self.StudyResult.ERROR 294 | def study_document(self, _course, _job) -> StudyResult: 295 | """ 296 | Study a document in Chaoxing platform. 297 | 298 | This method makes a GET request to fetch document information for a given course and job. 299 | 300 | Args: 301 | _course (dict): Dictionary containing course information with keys: 302 | - courseId: ID of the course 303 | - clazzId: ID of the class 304 | _job (dict): Dictionary containing job information with keys: 305 | - jobid: ID of the job 306 | - otherinfo: String containing node information 307 | - jtoken: Authentication token for the job 308 | 309 | Returns: 310 | requests.Response: Response object from the GET request 311 | 312 | Note: 313 | This method requires the following helper functions: 314 | - init_session(): To initialize a new session 315 | - get_timestamp(): To get current timestamp 316 | - re module for regular expression matching 317 | """ 318 | _session = init_session() 319 | _url = f"https://mooc1.chaoxing.com/ananas/job/document?jobid={_job['jobid']}&knowledgeid={re.findall(r'nodeId_(.*?)-', _job['otherinfo'])[0]}&courseid={_course['courseId']}&clazzid={_course['clazzId']}&jtoken={_job['jtoken']}&_dc={get_timestamp()}" 320 | _resp = _session.get(_url) 321 | if _resp.status_code != 200: 322 | return self.StudyResult.ERROR 323 | else: 324 | return self.StudyResult.SUCCESS 325 | 326 | def study_work(self, _course, _job, _job_info) -> StudyResult: 327 | if self.tiku.DISABLE or not self.tiku: 328 | return self.StudyResult.SUCCESS 329 | _ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码, 帮助修复#391错误 330 | 331 | def random_answer(options: str) -> str: 332 | answer = "" 333 | if not options: 334 | return answer 335 | 336 | if q["type"] == "multiple": 337 | logger.debug(f"当前选项列表[cut前] -> {options}") 338 | _op_list = multi_cut(options) 339 | logger.debug(f"当前选项列表[cut后] -> {_op_list}") 340 | 341 | if not _op_list: 342 | logger.error( 343 | "选项为空, 未能正确提取题目选项信息! 请反馈并提供以上信息" 344 | ) 345 | return answer 346 | 347 | available_options = len(_op_list) 348 | select_count = 0 349 | 350 | # 根据可用选项数量调整可能选择的选项数 351 | if available_options <= 1: 352 | select_count = available_options 353 | else: 354 | max_possible = min(4, available_options) 355 | min_possible = min(2, available_options) 356 | 357 | weights_map = { 358 | 2: [1.0], 359 | 3: [0.3, 0.7], 360 | 4: [0.1, 0.5, 0.4], 361 | 5: [0.1, 0.4, 0.3, 0.2], 362 | } 363 | 364 | weights = weights_map.get(max_possible, [0.3, 0.4, 0.3]) 365 | possible_counts = list(range(min_possible, max_possible + 1)) 366 | 367 | weights = weights[:len(possible_counts)] 368 | 369 | weights_sum = sum(weights) 370 | if weights_sum > 0: 371 | weights = [w/weights_sum for w in weights] 372 | 373 | select_count = random.choices(possible_counts, weights=weights, k=1)[0] 374 | 375 | selected_options = random.sample(_op_list, select_count) if select_count > 0 else [] 376 | 377 | for option in selected_options: 378 | answer += option[:1] # 取首字为答案,例如A或B 379 | 380 | answer = "".join(sorted(answer)) 381 | elif q["type"] == "single": 382 | answer = random.choice(options.split("\n"))[ 383 | :1 384 | ] # 取首字为答案, 例如A或B 385 | # 判断题处理 386 | elif q["type"] == "judgement": 387 | # answer = self.tiku.jugement_select(_answer) 388 | answer = "true" if random.choice([True, False]) else "false" 389 | logger.info(f"随机选择 -> {answer}") 390 | return answer 391 | 392 | def multi_cut(answer: str): 393 | """ 394 | 将多选题答案字符串按特定字符进行切割, 并返回切割后的答案列表 395 | 396 | 参数: 397 | answer(str): 多选题答案字符串. 398 | 399 | 返回: 400 | list[str]: 切割后的答案列表, 如果无法切割, 则返回默认的选项列表None 401 | 402 | 注意: 403 | 如果无法从网页中提取题目信息, 将记录警告日志并返回None 404 | """ 405 | # cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 406 | # ',' 在常规被正确划分的, 选项中出现, 导致 multi_cut 无法正确划分选项 #391 407 | # IndexError: Cannot choose from an empty sequence #391 408 | # 同时为了避免没有考虑到的 case, 应该先按照 '\n' 匹配, 匹配不到再按照其他字符匹配 409 | cut_char = [ 410 | "\n", 411 | ",", 412 | ",", 413 | "|", 414 | "\r", 415 | "\t", 416 | "#", 417 | "*", 418 | "-", 419 | "_", 420 | "+", 421 | "@", 422 | "~", 423 | "/", 424 | "\\", 425 | ".", 426 | "&", 427 | " ", 428 | "、", 429 | ] # 多选答案切割符 430 | res = cut(answer) 431 | if res is None: 432 | logger.warning( 433 | f"未能从网页中提取题目信息, 以下为相关信息:\n\t{answer}\n\n{_ORIGIN_HTML_CONTENT}\n" 434 | ) # 尝试输出网页内容和选项信息 435 | logger.warning("未能正确提取题目选项信息! 请反馈并提供以上信息") 436 | return None 437 | else: 438 | return res 439 | 440 | def clean_res(res): 441 | cleaned_res = [] 442 | if isinstance(res, str): 443 | res = [res] 444 | for c in res: 445 | cleaned_res.append(re.sub(r'^[A-Za-z]|[.,!?;:,。!?;:]', '', c)) 446 | 447 | return cleaned_res 448 | 449 | def is_subsequence(a, o): 450 | iter_o = iter(o) 451 | return all(c in iter_o for c in a) 452 | 453 | def with_retry(max_retries=3, delay=1): 454 | def decorator(func): 455 | def wrapper(*args, **kwargs): 456 | retries = 0 457 | while retries < max_retries: 458 | try: 459 | _resp = func(*args, **kwargs) 460 | 461 | # 未创建完成该测验则不进行答题,目前遇到的情况是未创建完成等同于没题目 462 | if '教师未创建完成该测验' in _resp.text: 463 | raise PermissionError("教师未创建完成该测验") 464 | 465 | questions = decode_questions_info(_resp.text) 466 | 467 | if _resp.status_code == 200 and questions.get("questions"): 468 | return (_resp, questions) 469 | 470 | logger.warning(f"无效响应 (Code: {getattr(_resp, 'status_code', 'Unknown')}), 重试中... ({retries+1}/{max_retries})") 471 | 472 | except requests.exceptions.RequestException as e: 473 | logger.warning(f"请求失败: {str(e)[:50]}, 重试中... ({retries+1}/{max_retries})") 474 | retries += 1 475 | time.sleep(delay * (2 ** retries)) 476 | raise MaxRetryExceeded(f"超过最大重试次数 ({max_retries})") 477 | return wrapper 478 | return decorator 479 | 480 | # 学习通这里根据参数差异能重定向至两个不同接口, 需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle 481 | _session = init_session() 482 | headers = { 483 | "Host": "mooc1.chaoxing.com", 484 | "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', 485 | "sec-ch-ua-mobile": "?0", 486 | "sec-ch-ua-platform": '"Windows"', 487 | "Upgrade-Insecure-Requests": "1", 488 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", 489 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", 490 | "Sec-Fetch-Site": "same-origin", 491 | "Sec-Fetch-Mode": "navigate", 492 | "Sec-Fetch-Dest": "iframe", 493 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,ja;q=0.5", 494 | } 495 | cookies = _session.cookies.get_dict() 496 | 497 | _url = "https://mooc1.chaoxing.com/mooc-ans/api/work" 498 | 499 | @with_retry(max_retries=3, delay=1) 500 | def fetch_response(): 501 | return requests.get( 502 | _url, 503 | headers=headers, 504 | cookies=cookies, 505 | verify=False, 506 | params={ 507 | "api": "1", 508 | "workId": _job["jobid"].replace("work-", ""), 509 | "jobid": _job["jobid"], 510 | "originJobId": _job["jobid"], 511 | "needRedirect": "true", 512 | "skipHeader": "true", 513 | "knowledgeid": str(_job_info["knowledgeid"]), 514 | "ktoken": _job_info["ktoken"], 515 | "cpi": _job_info["cpi"], 516 | "ut": "s", 517 | "clazzId": _course["clazzId"], 518 | "type": "", 519 | "enc": _job["enc"], 520 | "mooc2": "1", 521 | "courseid": _course["courseId"], 522 | } 523 | ) 524 | 525 | final_resp = {} 526 | questions = {} 527 | 528 | try: 529 | final_resp, questions = fetch_response() 530 | except Exception as e: 531 | logger.error(f"请求失败: {e}") 532 | return self.StudyResult.ERROR 533 | 534 | _ORIGIN_HTML_CONTENT = final_resp.text # 用于配合输出网页源码, 帮助修复#391错误 535 | 536 | # 搜题 537 | total_questions = len(questions["questions"]) 538 | found_answers = 0 539 | for q in questions["questions"]: 540 | logger.debug(f"当前题目信息 -> {q}") 541 | # 添加搜题延迟 #428 - 默认0s延迟 542 | query_delay = self.kwargs.get("query_delay",0) 543 | time.sleep(query_delay) 544 | res = self.tiku.query(q) 545 | answer = "" 546 | if not res: 547 | # 随机答题 548 | answer = random_answer(q["options"]) 549 | q[f'answerSource{q["id"]}'] = "random" 550 | else: 551 | # 根据响应结果选择答案 552 | if q["type"] == "multiple": 553 | # 多选处理 554 | options_list = multi_cut(q["options"]) 555 | res_list = multi_cut(res) 556 | if res_list is not None and options_list is not None: 557 | for _a in clean_res(res_list): 558 | for o in options_list: 559 | if ( 560 | is_subsequence(_a, o) # 去掉各种符号和前面ABCD的答案应当是选项的子序列 561 | ): 562 | answer += o[:1] 563 | # 对答案进行排序, 否则会提交失败 564 | answer = "".join(sorted(answer)) 565 | # else 如果分割失败那么就直接到下面去随机选 566 | elif q["type"] == "single": 567 | # 单选也进行切割,主要是防止返回的答案有异常字符 568 | options_list = multi_cut(q["options"]) 569 | if options_list is not None: 570 | t_res = clean_res(res) 571 | for o in options_list: 572 | if is_subsequence(t_res[0], o): 573 | answer = o[:1] 574 | break 575 | elif q["type"] == "judgement": 576 | answer = "true" if self.tiku.judgement_select(res) else "false" 577 | elif q["type"] == "completion": 578 | if isinstance(res,list): 579 | answer = "".join(answer) 580 | elif isinstance(res,str): 581 | answer = res 582 | else: 583 | # 其他类型直接使用答案 (目前仅知有简答题,待补充处理) 584 | answer = res 585 | 586 | if not answer: # 检查 answer 是否为空 587 | logger.warning(f"找到答案但答案未能匹配 -> {res}\t随机选择答案") 588 | answer = random_answer(q["options"]) # 如果为空,则随机选择答案 589 | q[f'answerSource{q["id"]}'] = "random" 590 | else: 591 | logger.info(f"成功获取到答案:{answer}") 592 | q[f'answerSource{q["id"]}'] = "cover" 593 | found_answers += 1 594 | # 填充答案 595 | q["answerField"][f'answer{q["id"]}'] = answer 596 | logger.info(f'{q["title"]} 填写答案为 {answer}') 597 | cover_rate = (found_answers / total_questions) * 100 598 | logger.info(f"章节检测题库覆盖率: {cover_rate:.0f}%") 599 | # 提交模式 现在与题库绑定,留空直接提交, 1保存但不提交 600 | if self.tiku.get_submit_params() == "1": 601 | questions["pyFlag"] = "1" 602 | elif cover_rate >= self.tiku.COVER_RATE*100 or self.rollback_times >= 1: 603 | questions["pyFlag"] = "" 604 | else: 605 | questions["pyFlag"] = "1" 606 | logger.info(f"章节检测题库覆盖率低于{self.tiku.COVER_RATE*100:.0f}%,不予提交") 607 | # 组建提交表单 608 | if questions["pyFlag"] == "1": 609 | for q in questions["questions"]: 610 | questions.update( 611 | { 612 | f'answer{q["id"]}': 613 | q["answerField"][f'answer{q["id"]}'] if q[f'answerSource{q["id"]}'] == "cover" else '', 614 | f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'], 615 | } 616 | ) 617 | else: 618 | for q in questions["questions"]: 619 | questions.update( 620 | { 621 | f'answer{q["id"]}': q["answerField"][f'answer{q["id"]}'], 622 | f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'], 623 | } 624 | ) 625 | 626 | del questions["questions"] 627 | 628 | res = _session.post( 629 | "https://mooc1.chaoxing.com/mooc-ans/work/addStudentWorkNew", 630 | data=questions, 631 | headers={ 632 | "Host": "mooc1.chaoxing.com", 633 | "sec-ch-ua-platform": '"Windows"', 634 | "X-Requested-With": "XMLHttpRequest", 635 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", 636 | "Accept": "application/json, text/javascript, */*; q=0.01", 637 | "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', 638 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 639 | "sec-ch-ua-mobile": "?0", 640 | "Origin": "https://mooc1.chaoxing.com", 641 | "Sec-Fetch-Site": "same-origin", 642 | "Sec-Fetch-Mode": "cors", 643 | "Sec-Fetch-Dest": "empty", 644 | # "Referer": "https://mooc1.chaoxing.com/mooc-ans/work/doHomeWorkNew?courseId=246831735&workAnswerId=52680423&workId=37778125&api=1&knowledgeid=913820156&classId=107515845&oldWorkId=07647c38d8de4c648a9277c5bed7075a&jobid=work-07647c38d8de4c648a9277c5bed7075a&type=&isphone=false&submit=false&enc=1d826aab06d44a1198fc983ed3d243b1&cpi=338350298&mooc2=1&skipHeader=true&originJobId=work-07647c38d8de4c648a9277c5bed7075a", 645 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,ja;q=0.5", 646 | }, 647 | ) 648 | if res.status_code == 200: 649 | res_json = res.json() 650 | if res_json["status"]: 651 | logger.info(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题成功 -> {res_json["msg"]}') 652 | else: 653 | logger.error(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题失败 -> {res_json["msg"]}') 654 | return self.StudyResult.ERROR 655 | else: 656 | logger.error(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题失败 -> {res.text}') 657 | return self.StudyResult.ERROR 658 | return self.StudyResult.SUCCESS 659 | 660 | def strdy_read(self, _course, _job, _job_info) -> StudyResult: 661 | """ 662 | 阅读任务学习, 仅完成任务点, 并不增长时长 663 | """ 664 | _session = init_session() 665 | _resp = _session.get( 666 | url="https://mooc1.chaoxing.com/ananas/job/readv2", 667 | params={ 668 | "jobid": _job["jobid"], 669 | "knowledgeid": _job_info["knowledgeid"], 670 | "jtoken": _job["jtoken"], 671 | "courseid": _course["courseId"], 672 | "clazzid": _course["clazzId"], 673 | }, 674 | ) 675 | if _resp.status_code != 200: 676 | logger.error(f"阅读任务学习失败 -> [{_resp.status_code}]{_resp.text}") 677 | return self.StudyResult.ERROR 678 | else: 679 | _resp_json = _resp.json() 680 | logger.info(f"阅读任务学习 -> {_resp_json['msg']}") 681 | return self.StudyResult.SUCCESS 682 | 683 | def study_emptypage(self, _course, _chapterId): 684 | _session = init_session() 685 | # &cpi=0&verificationcode=&mooc2=1µTopicId=0&editorPreview=0 686 | _resp = _session.get( 687 | url="https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudyAjax", 688 | params={ 689 | "courseId": _course["courseId"], 690 | "clazzid": _course["clazzId"], 691 | "chapterId": _chapterId['id'], 692 | "cpi": 0, 693 | "verificationcode": "", 694 | "mooc2": 1, 695 | "microTopicId": 0, 696 | "editorPreview": 0, 697 | }, 698 | ) 699 | if _resp.status_code != 200: 700 | logger.error(f"空页面任务失败 -> [{_resp.status_code}]{_chapterId['title']}") 701 | return self.StudyResult.ERROR 702 | else: 703 | logger.info(f"空页面任务完成 -> {_chapterId['title']}") 704 | return self.StudyResult.SUCCESS 705 | --------------------------------------------------------------------------------