From 2fe32757f1d5babc24b1153e73e959b46c9577c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=98=A5=E5=AD=90?= <2638526782@qq.com> Date: Sat, 30 Nov 2024 20:31:33 +0800 Subject: [PATCH 01/21] Update main.yml --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b85dd62..05f07a6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: 刷课 on: push: - branches: [ actions ] + branches: [ main ] jobs: run_demo_actions: @@ -22,4 +22,4 @@ jobs: - name: Run main.py - run: python main.py -u 15092321932 -p 9854264yjn -l 244403509 + run: python main.py -u 学习通账号 -p 学习通密码 -l 244403509 #课程id,默认为心理课的 From 2a402b796ce55f0f7fd6a2fc746da1cabf4bd155 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Sun, 1 Dec 2024 20:06:59 +0800 Subject: [PATCH 02/21] Update main.yml --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 54253e1..bb02eb9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,7 +5,7 @@ on: branches: [ main ] jobs: - run_demo_actions: + 刷课: runs-on: ubuntu-latest # 在最新版本的 Ubuntu 操作系统环境下运行 steps: # 要执行的步骤 - name: 拷贝代码 From 69ed23283e881e5bf955bae9baae0c5abb219c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=98=A5=E5=AD=90?= <2638526782@qq.com> Date: Mon, 2 Dec 2024 11:38:49 +0800 Subject: [PATCH 03/21] Update main.yml --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bfb107a..6e8d66c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,7 +5,7 @@ on: branches: [ main ] jobs: - 刷课: + Start: runs-on: ubuntu-latest # 在最新版本的 Ubuntu 操作系统环境下运行 steps: # 要执行的步骤 - name: 拷贝代码 From dfec67917e1b928335899322087ae02eab77c403 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Fri, 6 Dec 2024 11:03:27 +0800 Subject: [PATCH 04/21] Update README.md --- README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 26e42b8..e0c7375 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ -学习通刷课脚本 \ No newline at end of file +
+

+ + Logo + +

学习通云端刷课脚本

+ +

+ 利用Github actions|自动化刷课|无人值守|云端刷课 +
+ 🌎 小白使用说明  |   + 📦️ exe仓库  |   +
+
+

+

+ + +# ❤️快速开始 +Fork本仓库
+开启GitHub Actions +生成静态文件 +``` +hexo ge +``` +本地预览 +``` +hexo s +``` From c10cba07636413096e9a6bed9b4cdcf0014433b4 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Fri, 6 Dec 2024 11:11:01 +0800 Subject: [PATCH 05/21] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/main.yml | 5 +++-- README.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6e8d66c..4e9a318 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,8 +1,9 @@ name: 刷课 on: - push: - branches: [ main ] + schedule: + - cron: "0 8 * * *" + #每天8点开始刷课 jobs: Start: diff --git a/README.md b/README.md index e0c7375..b6c0fdb 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 利用Github actions|自动化刷课|无人值守|云端刷课
🌎 小白使用说明  |   - 📦️ exe仓库  |   + 📦️ EXE版本仓库  

From 075983d5d506369ba5597f1c65aedd05a0edf83b Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Fri, 6 Dec 2024 11:16:08 +0800 Subject: [PATCH 06/21] Update main.yml --- .github/workflows/main.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4e9a318..bcbdb85 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,10 +1,11 @@ name: 刷课 on: + push: + branches: [ main ] schedule: - - cron: "0 8 * * *" - #每天8点开始刷课 - + - cron: "0 8 * * *" #每天8点开始刷课 + jobs: Start: runs-on: ubuntu-latest # 在最新版本的 Ubuntu 操作系统环境下运行 From 89c511061b7890c6e80e598a8ddcc1d6b296e4cf Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Fri, 6 Dec 2024 11:22:48 +0800 Subject: [PATCH 07/21] Update main.yml --- .github/workflows/main.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bcbdb85..ff73827 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,8 +3,9 @@ name: 刷课 on: push: branches: [ main ] - schedule: - - cron: "0 8 * * *" #每天8点开始刷课 + #是否开始每天8点定时刷课,若要开启请把下面两行注释去掉 + #schedule: + # - cron: "0 8 * * *" jobs: Start: From 2ca0088efc572681158048fc70f1b813ace33da3 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Fri, 6 Dec 2024 11:25:16 +0800 Subject: [PATCH 08/21] Update README.md --- README.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b6c0fdb..6d65776 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,8 @@ # ❤️快速开始 Fork本仓库
-开启GitHub Actions -生成静态文件 -``` -hexo ge -``` -本地预览 -``` -hexo s -``` +开启GitHub Actions
+修改.gothub/workflows/main.yml(具体注释在文件内) + +# 严禁用于商业用途! + From 9593508fe25fb8b50e1844f7e5794bb113122bc5 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Fri, 6 Dec 2024 11:26:15 +0800 Subject: [PATCH 09/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d65776..9c7c9a0 100644 --- a/README.md +++ b/README.md @@ -21,5 +21,5 @@ Fork本仓库
开启GitHub Actions
修改.gothub/workflows/main.yml(具体注释在文件内) -# 严禁用于商业用途! +# 🈲严禁用于商业用途! From 6c034fc3fe1bdd0acfc04817fc56727a5c618b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=98=A5=E5=AD=90?= <2638526782@qq.com> Date: Thu, 26 Dec 2024 15:54:09 +0800 Subject: [PATCH 10/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9c7c9a0..272ac12 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ # ❤️快速开始 Fork本仓库
开启GitHub Actions
-修改.gothub/workflows/main.yml(具体注释在文件内) +修改.github/workflows/main.yml(具体注释在文件内) # 🈲严禁用于商业用途! From da9c8a7ec20705e089322611fbf439343c0c8a24 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Sun, 6 Apr 2025 17:15:57 +0800 Subject: [PATCH 11/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 272ac12..30550ce 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- Logo + Logo

学习通云端刷课脚本

From 312d56ae69f7d2f92a94a777ce5e4c843e10847f Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Sun, 6 Apr 2025 17:18:58 +0800 Subject: [PATCH 12/21] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +- api/answer.py | 300 ++++++++++++++++++++-- api/base.py | 591 ++++++++++++++++++++++++++++++------------- api/cipher.py | 10 +- api/config.py | 8 +- api/cookies.py | 6 +- api/cxsecret_font.py | 3 +- api/decode.py | 205 +++++++++------ api/exceptions.py | 2 +- api/font_decoder.py | 15 +- api/logger.py | 2 +- api/notification.py | 116 +++++++++ api/process.py | 15 +- app.py | 4 +- config_template.ini | 44 +++- main.py | 290 +++++++++++++++------ pyproject.toml | 18 ++ requirements.txt | 3 +- 18 files changed, 1262 insertions(+), 378 deletions(-) create mode 100644 api/notification.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index fea5fc2..9147193 100644 --- a/.gitignore +++ b/.gitignore @@ -133,15 +133,21 @@ build/ dist/ *.spec +# python-uv.lock +# just like Pipfile.lock, +uv.lock + # Custom files .cookies.txt cookies.txt .config.ini config.ini chaoxing.log +config*.ini .chaoxing.log ./config.ini ./chaoxing.log ./cookies.txt .idea/ -cache.json +.vscode/ +cache.json \ No newline at end of file diff --git a/api/answer.py b/api/answer.py index 8273647..e09ca93 100644 --- a/api/answer.py +++ b/api/answer.py @@ -5,6 +5,10 @@ from api.logger import logger import random from urllib3 import disable_warnings,exceptions +from openai import OpenAI +import httpx +from re import sub +import time # 关闭警告 disable_warnings(exceptions.InsecureRequestWarning) @@ -37,6 +41,7 @@ class Tiku: CONFIG_PATH = "config.ini" # 默认配置文件路径 DISABLE = False # 停用标志 SUBMIT = False # 提交标志 + COVER_RATE = 0.8 # 覆盖率 def __init__(self) -> None: self._name = None @@ -68,18 +73,19 @@ def token(self,value): self._token = value def init_tiku(self): - # 仅用于题库初始化,应该在题库载入后作初始化调用,随后才可以使用题库 + # 仅用于题库初始化, 应该在题库载入后作初始化调用, 随后才可以使用题库 # 尝试根据配置文件设置提交模式 if not self._conf: self.config_set(self._get_conf()) if not self.DISABLE: # 设置提交模式 self.SUBMIT = True if self._conf['submit'] == 'true' else False + self.COVER_RATE = self._conf['cover_rate'] # 调用自定义题库初始化 self._init_tiku() def _init_tiku(self): - # 仅用于题库初始化,例如配置token,交由自定义题库完成 + # 仅用于题库初始化, 例如配置token, 交由自定义题库完成 pass def config_set(self,config): @@ -87,14 +93,14 @@ def config_set(self,config): def _get_conf(self): """ - 从默认配置文件查询配置,如果未能查到,停用题库 + 从默认配置文件查询配置, 如果未能查到, 停用题库 """ try: config = configparser.ConfigParser() config.read(self.CONFIG_PATH, encoding="utf8") return config['tiku'] - except KeyError or FileNotFoundError: - logger.info("未找到tiku配置,已忽略题库功能") + except (KeyError, FileNotFoundError): + logger.info("未找到tiku配置, 已忽略题库功能") self.DISABLE = True return None @@ -102,9 +108,13 @@ def query(self,q_info:dict): if self.DISABLE: return None - # 预处理,去除【单选题】这样与标题无关的字段 + # 预处理, 去除【单选题】这样与标题无关的字段 # 此处需要改进!!! - q_info['title'] = q_info['title'][6:] # 暂时直接用裁切解决 + logger.debug(f"原始标题:{q_info['title']}") + q_info['title'] = sub(r'^\d+', '', q_info['title']) + q_info['title'] = sub(r'^(?:【.*?】)+', '', q_info['title']) + q_info['title'] = sub(r'(\d+\.\d+分)$', '', q_info['title']) + logger.debug(f"处理后标题:{q_info['title']}") # 先过缓存 cache_dao = CacheDAO() @@ -121,15 +131,16 @@ def query(self,q_info:dict): return answer logger.error(f"从{self.name}获取答案失败:{q_info['title']}") return None + def _query(self,q_info:dict): """ - 查询接口,交由自定义题库实现 + 查询接口, 交由自定义题库实现 """ pass def get_tiku_from_config(self): """ - 从配置文件加载题库,这个配置可以是用户提供,可以是默认配置文件 + 从配置文件加载题库, 这个配置可以是用户提供, 可以是默认配置文件 """ if not self._conf: # 尝试从默认配置文件加载 @@ -141,7 +152,8 @@ def get_tiku_from_config(self): if not cls_name: raise KeyError except KeyError: - logger.error("未找到题库配置,已忽略题库功能") + self.DISABLE = True + logger.error("未找到题库配置, 已忽略题库功能") return self new_cls = globals()[cls_name]() new_cls.config_set(self._conf) @@ -149,7 +161,7 @@ def get_tiku_from_config(self): def jugement_select(self,answer:str) -> bool: """ - 这是一个专用的方法,要求配置维护两个选项列表,一份用于正确选项,一份用于错误选项,以应对题库对判断题答案响应的各种可能的情况 + 这是一个专用的方法, 要求配置维护两个选项列表, 一份用于正确选项, 一份用于错误选项, 以应对题库对判断题答案响应的各种可能的情况 它的作用是将获取到的答案answer与可能的选项列对比并返回对应的布尔值 """ if self.DISABLE: @@ -163,15 +175,15 @@ def jugement_select(self,answer:str) -> bool: elif answer in false_list: return False else: - # 无法判断,随机选择 - logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误,请自行判断并加入配置文件重启脚本,本次将会随机选择选项') + # 无法判断, 随机选择 + logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误, 请自行判断并加入配置文件重启脚本, 本次将会随机选择选项') return random.choice([True,False]) def get_submit_params(self): """ - 这是一个专用方法,用于根据当前设置的提交模式,响应对应的答题提交API中的pyFlag值 + 这是一个专用方法, 用于根据当前设置的提交模式, 响应对应的答题提交API中的pyFlag值 """ - # 留空直接提交,1保存但不提交 + # 留空直接提交, 1保存但不提交 if self.SUBMIT: return "" else: @@ -187,7 +199,7 @@ def __init__(self) -> None: self.api = 'https://tk.enncy.cn/query' self._token = None self._token_index = 0 # token队列计数器 - self._times = 100 # 查询次数剩余,初始化为100,查询后校对修正 + self._times = 100 # 查询次数剩余, 初始化为100, 查询后校对修正 def _query(self,q_info:dict): res = requests.get( @@ -201,14 +213,14 @@ def _query(self,q_info:dict): if res.status_code == 200: res_json = res.json() if not res_json['code']: - # 如果是因为TOKEN次数到期,则更换token + # 如果是因为TOKEN次数到期, 则更换token if self._times == 0 or '次数不足' in res_json['data']['answer']: - logger.info(f'TOKEN查询次数不足,将会更换并重新搜题') + logger.info(f'TOKEN查询次数不足, 将会更换并重新搜题') self._token_index += 1 self.load_token() # 重新查询 return self._query(q_info) - logger.error(f'{self.name}查询失败:\n剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n消息:{res_json["message"]}') + logger.error(f'{self.name}查询失败:\n\t剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n\t消息:{res_json["message"]}') return None self._times = res_json["data"].get("times",self._times) return res_json['data']['answer'].strip() @@ -220,10 +232,256 @@ def load_token(self): token_list = self._conf['tokens'].split(',') if self._token_index == len(token_list): # TOKEN 用完 - logger.error('TOKEN用完,请自行更换再重启脚本') - raise Exception(f'{self.name} TOKEN 已用完,请更换') + logger.error('TOKEN用完, 请自行更换再重启脚本') + raise Exception(f'{self.name} TOKEN 已用完, 请更换') self._token = token_list[self._token_index] def _init_tiku(self): self.load_token() +class TikuLike(Tiku): + # Like知识库实现 + def __init__(self) -> None: + super().__init__() + self.name = 'Like知识库' + self.ver = '1.0.8' #对应官网API版本 + self.query_api = 'https://api.datam.site/search' + self.balance_api = 'https://api.datam.site/balance' + self.homepage = 'https://www.datam.site' + self._model = None + self._token = None + self._times = -1 + self._search = False + self._count = 0 + + def _query(self,q_info:dict): + q_info_map = {"single":"【单选题】","multiple":"【多选题】","completion":"【填空题】","judgement":"【判断题】"} + api_params_map = {0:"others",1:"choose",2:"fills",3:"judge"} + q_info_prefix = q_info_map.get(q_info['type'],"【其他类型题目】") + options = ', '.join(q_info['options']) if isinstance(q_info['options'], list) else q_info['options'] + question = "{}{}\n{}".format(q_info_prefix,q_info['title'],options) + ret = "" + ans = "" + res = requests.post( + self.query_api, + json={ + 'query': question, + 'token': self._token, + 'model': self._model if self._model else '', + 'search': self._search + }, + verify=False + ) + + if res.status_code == 200: + res_json = res.json() + q_type = res_json['data'].get('type',0) + params = api_params_map.get(q_type,"") + ans = res_json['data'].get(params,"") + if q_type == 3: + ans = "正确" if ans ==1 else "错误" + else: + logger.error(f'{self.name}查询失败:\n{res.text}') + return None + + ret += str(ans) + + self._times -= 1 + + #10次查询后更新实际次数 + self._count = (self._count+1) % 10 + + if self._count == 0: + self.update_times() + + return ret + + def update_times(self): + res = requests.post( + self.balance_api, + json={ + 'token': self._token, + }, + verify=False + ) + if res.status_code == 200: + res_json = res.json() + self._times = res_json["data"].get("balance",self._times) + logger.info("当前LIKE知识库Token剩余查询次数为: {}".format(str(self._times))) + else: + logger.error('TOKEN出现错误,请检查后再试') + + def load_token(self): + token = self._conf['tokens'].split(',')[-1] if ',' in self._conf['tokens'] else self._conf['tokens'] + self._token = token + + def load_config(self): + var_params = {"likeapi_search":self._search,"likeapi_model":self._model} + config_params = {"likeapi_search":False, "likeapi_model":None} + + for k,v in config_params.items(): + if k in self._conf: + var_params[k] = self._conf[k] + else: + var_params[k] = v + + def _init_tiku(self): + self.load_token() + self.load_config() + self.update_times() + +class TikuAdapter(Tiku): + # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter + def __init__(self) -> None: + super().__init__() + self.name = 'TikuAdapter题库' + self.api = '' + + def _query(self, q_info: dict): + # 判断题目类型 + if q_info['type'] == "single": + type = 0 + elif q_info['type'] == 'multiple': + type = 1 + elif q_info['type'] == 'completion': + type = 2 + elif q_info['type'] == 'judgement': + type = 3 + else: + type = 4 + + options = q_info['options'] + res = requests.post( + self.api, + json={ + 'question': q_info['title'], + 'options': [sub(r'^[A-Za-z]\.?、?\s?', '', option) for option in options.split('\n')], + 'type': type + }, + verify=False + ) + if res.status_code == 200: + res_json = res.json() + # if bool(res_json['plat']): + # plat无论搜没搜到答案都返回0 + # 这个参数是tikuadapter用来设定自定义的平台类型 + if not len(res_json['answer']['bestAnswer']): + logger.error("查询失败, 返回:" + res.text) + return None + sep = "\n" + return sep.join(res_json['answer']['bestAnswer']).strip() + # else: + # logger.error(f'{self.name}查询失败:\n{res.text}') + return None + + def _init_tiku(self): + # self.load_token() + self.api = self._conf['url'] + +class AI(Tiku): + # AI大模型答题实现 + def __init__(self) -> None: + super().__init__() + self.name = 'AI大模型答题' + self.last_request_time = None + + def _query(self, q_info: dict): + if self.http_proxy: + proxy = self.http_proxy + httpx_client = httpx.Client(proxy=proxy) + client = OpenAI(http_client=httpx_client, base_url = self.endpoint,api_key = self.key) + else: + client = OpenAI(base_url = self.endpoint,api_key = self.key) + # 判断题目类型 + if q_info['type'] == "single": + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为单选题,你只能选择一个选项,请根据题目和选项回答问题,以json格式输出正确的选项内容,特别注意回答的内容需要去除选项内容前的字母,示例回答:{\"Answer\": [\"答案\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}\n选项:{q_info['options']}" + } + ] + ) + elif q_info['type'] == 'multiple': + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为多选题,你必须选择两个或以上选项,请根据题目和选项回答问题,以json格式输出正确的选项内容,特别注意回答的内容需要去除选项内容前的字母,示例回答:{\"Answer\": [\"答案1\",\n\"答案2\",\n\"答案3\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}\n选项:{q_info['options']}" + } + ] + ) + elif q_info['type'] == 'completion': + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为填空题,你必须根据语境和相关知识填入合适的内容,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"答案\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}" + } + ] + ) + elif q_info['type'] == 'judgement': + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为判断题,你只能回答正确或者错误,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"正确\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}" + } + ] + ) + else: + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为简答题,你必须根据语境和相关知识填入合适的内容,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"这是我的答案\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}" + } + ] + ) + + try: + if self.last_request_time: + interval_time = time.time() - self.last_request_time + if interval_time < self.min_interval_seconds: + sleep_time = self.min_interval_seconds - interval_time + logger.debug(f"API请求间隔过短, 等待 {sleep_time} 秒") + time.sleep(sleep_time) + self.last_request_time = time.time() + response = json.loads(completion.choices[0].message.content) + sep = "\n" + return sep.join(response['Answer']).strip() + except: + logger.error("无法解析大模型输出内容") + return None + + def _init_tiku(self): + self.endpoint = self._conf['endpoint'] + self.key = self._conf['key'] + self.model = self._conf['model'] + self.http_proxy = self._conf['http_proxy'] + self.min_interval_seconds = int(self._conf['min_interval_seconds']) diff --git a/api/base.py b/api/base.py index 6734caf..2a2d2d2 100644 --- a/api/base.py +++ b/api/base.py @@ -11,13 +11,15 @@ from api.cookies import save_cookies, use_cookies from api.process import show_progress from api.config import GlobalConst as gc -from api.decode import (decode_course_list, - decode_course_point, - decode_course_card, - decode_course_folder, - decode_questions_info - ) +from api.decode import ( + decode_course_list, + decode_course_point, + decode_course_card, + decode_course_folder, + decode_questions_info, +) from api.answer import * +from enum import Enum def get_timestamp(): return str(int(time.time() * 1000)) @@ -30,8 +32,8 @@ def get_random_seconds(): def init_session(isVideo: bool = False, isAudio: bool = False): _session = requests.session() _session.verify = False - _session.mount('http://', HTTPAdapter(max_retries=3)) - _session.mount('https://', HTTPAdapter(max_retries=3)) + _session.mount("http://", HTTPAdapter(max_retries=3)) + _session.mount("https://", HTTPAdapter(max_retries=3)) if isVideo: _session.headers = gc.VIDEO_HEADERS elif isAudio: @@ -47,32 +49,49 @@ class Account: password = None last_login = None isSuccess = None + def __init__(self, _username, _password): self.username = _username self.password = _password class Chaoxing: - def __init__(self, account: Account = None,tiku:Tiku=None): + class StudyResult(Enum): + SUCCESS = 0 + FORBIDDEN = 1 # 403 + ERROR = 2 + TIMEOUT = 3 + + @staticmethod + def is_success(result): + return result == Chaoxing.StudyResult.SUCCESS + + @staticmethod + def is_failure(result): + return result != Chaoxing.StudyResult.SUCCESS + + def __init__(self, account: Account = None, tiku: Tiku = None,**kwargs): self.account = account self.cipher = AESCipher() self.tiku = tiku + self.kwargs = kwargs + self.rollback_times = 0 def login(self): _session = requests.session() _session.verify = False _url = "https://passport2.chaoxing.com/fanyalogin" - _data = {"fid": "-1", - "uname": self.cipher.encrypt(self.account.username), - "password": self.cipher.encrypt(self.account.password), - "refer": "https%3A%2F%2Fi.chaoxing.com", - "t": True, - "forbidotherlogin": 0, - "validate": "", - "doubleFactorLogin": 0, - "independentId": 0, - } - + _data = { + "fid": "-1", + "uname": self.cipher.encrypt(self.account.username), + "password": self.cipher.encrypt(self.account.password), + "refer": "https%3A%2F%2Fi.chaoxing.com", + "t": True, + "forbidotherlogin": 0, + "validate": "", + "doubleFactorLogin": 0, + "independentId": 0, + } logger.trace("正在尝试登录...") resp = _session.post(_url, headers=gc.HEADERS, data=_data) if resp and resp.json()["status"] == True: @@ -93,21 +112,16 @@ def get_uid(self): def get_course_list(self): _session = init_session() _url = "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata" - _data = { - "courseType": 1, - "courseFolderId": 0, - "query": "", - "superstarClass": 0 - } + _data = {"courseType": 1, "courseFolderId": 0, "query": "", "superstarClass": 0} logger.trace("正在读取所有的课程列表...") - # 接口突然抽风,增加headers + # 接口突然抽风, 增加headers _headers = { "Host": "mooc2-ans.chaoxing.com", - "sec-ch-ua-platform": "\"Windows\"", + "sec-ch-ua-platform": '"Windows"', "X-Requested-With": "XMLHttpRequest", "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", "Accept": "text/html, */*; q=0.01", - "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", + "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "sec-ch-ua-mobile": "?0", "Origin": "https://mooc2-ans.chaoxing.com", @@ -115,9 +129,9 @@ def get_course_list(self): "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", "Referer": "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction?moocDomain=https://mooc1-1.chaoxing.com/mooc-ans", - "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" + "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", } - _resp = _session.post(_url,headers=_headers,data=_data) + _resp = _session.post(_url, headers=_headers, data=_data) # logger.trace(f"原始课程列表内容:\n{_resp.text}") logger.info("课程列表读取完毕...") course_list = decode_course_list(_resp.text) @@ -130,7 +144,7 @@ def get_course_list(self): "courseType": 1, "courseFolderId": folder["id"], "query": "", - "superstarClass": 0 + "superstarClass": 0, } _resp = _session.post(_url, data=_data) course_list += decode_course_list(_resp.text) @@ -149,13 +163,17 @@ def get_job_list(self, _clazzid, _courseid, _cpi, _knowledgeid): _session = init_session() job_list = [] job_info = {} - for _possible_num in ["0", "1","2"]: # 学习界面任务卡片数,很少有3个的,但是对于章节解锁任务点少一个都不行,可以从API /mooc-ans/mycourse/studentstudyAjax获取值,或者干脆直接加,但二者都会造成额外的请求 + for _possible_num in [ + "0", + "1", + "2", + ]: # 学习界面任务卡片数, 很少有3个的, 但是对于章节解锁任务点少一个都不行, 可以从API /mooc-ans/mycourse/studentstudyAjax获取值, 或者干脆直接加, 但二者都会造成额外的请求 _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" logger.trace("开始读取章节所有任务点...") _resp = _session.get(_url) _job_list, _job_info = decode_course_card(_resp.text) - if _job_info.get('notOpen',False): - # 直接返回,节省一次请求 + if _job_info.get("notOpen", False): + # 直接返回, 节省一次请求 logger.info("该章节未开放") return [], _job_info job_list += _job_list @@ -168,47 +186,60 @@ def get_job_list(self, _clazzid, _courseid, _cpi, _knowledgeid): def get_enc(self, clazzId, jobid, objectId, playingTime, duration, userid): return md5( - f"[{clazzId}][{userid}][{jobid}][{objectId}][{playingTime * 1000}][d_yHJ!$pdA~5][{duration * 1000}][0_{duration}]" - .encode()).hexdigest() - - def video_progress_log(self, _session, _course, _job, _job_info, _dtoken, _duration, _playingTime, _type: str = "Video"): - if "courseId" in _job['otherinfo']: + f"[{clazzId}][{userid}][{jobid}][{objectId}][{playingTime * 1000}][d_yHJ!$pdA~5][{duration * 1000}][0_{duration}]".encode() + ).hexdigest() + + def video_progress_log( + self, + _session, + _course, + _job, + _job_info, + _dtoken, + _duration, + _playingTime, + _type: str = "Video", + ): + if "courseId" in _job["otherinfo"]: _mid_text = f"otherInfo={_job['otherinfo']}&" else: _mid_text = f"otherInfo={_job['otherinfo']}&courseId={_course['courseId']}&" _success = False for _possible_rt in ["0.9", "1"]: - _url = (f"https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" - f"{_course['cpi']}/" - f"{_dtoken}?" - f"clazzId={_course['clazzId']}&" - f"playingTime={_playingTime}&" - f"duration={_duration}&" - f"clipTime=0_{_duration}&" - f"objectId={_job['objectid']}&" - f"{_mid_text}" - f"jobid={_job['jobid']}&" - f"userid={self.get_uid()}&" - f"isdrag=3&" - f"view=pc&" - f"enc={self.get_enc(_course['clazzId'], _job['jobid'], _job['objectid'], _playingTime, _duration, self.get_uid())}&" - f"rt={_possible_rt}&" - f"dtype={_type}&" - f"_t={get_timestamp()}") + _url = ( + f"https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" + f"{_course['cpi']}/" + f"{_dtoken}?" + f"clazzId={_course['clazzId']}&" + f"playingTime={_playingTime}&" + f"duration={_duration}&" + f"clipTime=0_{_duration}&" + f"objectId={_job['objectid']}&" + f"{_mid_text}" + f"jobid={_job['jobid']}&" + f"userid={self.get_uid()}&" + f"isdrag=3&" + f"view=pc&" + f"enc={self.get_enc(_course['clazzId'], _job['jobid'], _job['objectid'], _playingTime, _duration, self.get_uid())}&" + f"rt={_possible_rt}&" + f"dtype={_type}&" + f"_t={get_timestamp()}" + ) resp = _session.get(_url) if resp.status_code == 200: _success = True - break # 如果返回为200正常,则跳出循环 + break # 如果返回为200正常, 则跳出循环 elif resp.status_code == 403: - continue # 如果出现403无权限报错,则继续尝试不同的rt参数 + continue # 如果出现403无权限报错, 则继续尝试不同的rt参数 if _success: - return resp.json() + return resp.json(), 200 else: - # 若出现两个rt参数都返回403的情况,则跳过当前任务 - logger.warning("出现403报错,尝试修复无效,正在跳过当前任务点...") - return False - - def study_video(self, _course, _job, _job_info, _speed: float = 1.0, _type: str = "Video"): + # 若出现两个rt参数都返回403的情况, 则跳过当前任务 + logger.warning("出现403报错, 尝试修复无效, 正在跳过当前任务点...") + return {"isPassed": False}, 403 # 返回一个字典和当前状态 + def study_video( + self, _course, _job, _job_info, _speed: float = 1.0, _type: str = "Video" + ) -> StudyResult: if _type == "Video": _session = init_session(isVideo=True) else: @@ -225,203 +256,419 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, _type: str _isFinished = False _playingTime = 0 logger.info(f"开始任务: {_job['name']}, 总时长: {_duration}秒") + state = 200 while not _isFinished: if _isFinished: _playingTime = _duration - _isPassed = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, _duration, _playingTime, _type) + _isPassed, state = self.video_progress_log( + _session, + _course, + _job, + _job_info, + _dtoken, + _duration, + _playingTime, + _type, + ) if not _isPassed or (_isPassed and _isPassed["isPassed"]): break + if _isPassed and not _isPassed["isPassed"] and state == 403: + return self.StudyResult.FORBIDDEN _wait_time = get_random_seconds() if _playingTime + _wait_time >= int(_duration): _wait_time = int(_duration) - _playingTime - _isFinished = True + _isPassed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, _duration, _duration, _type) + if _isPassed['isPassed']: + _isFinished = True # 播放进度条 - show_progress(_job['name'], _playingTime, _wait_time, _duration, _speed) + show_progress(_job["name"], _playingTime, _wait_time, _duration, _speed) _playingTime += _wait_time print("\r", end="", flush=True) logger.info(f"任务完成: {_job['name']}") - - def study_document(self, _course, _job): + return self.StudyResult.SUCCESS + else: + return self.StudyResult.ERROR + def study_document(self, _course, _job) -> StudyResult: + """ + Study a document in Chaoxing platform. + + This method makes a GET request to fetch document information for a given course and job. + + Args: + _course (dict): Dictionary containing course information with keys: + - courseId: ID of the course + - clazzId: ID of the class + _job (dict): Dictionary containing job information with keys: + - jobid: ID of the job + - otherinfo: String containing node information + - jtoken: Authentication token for the job + + Returns: + requests.Response: Response object from the GET request + + Note: + This method requires the following helper functions: + - init_session(): To initialize a new session + - get_timestamp(): To get current timestamp + - re module for regular expression matching + """ _session = init_session() _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()}" _resp = _session.get(_url) + if _resp.status_code != 200: + return self.StudyResult.ERROR + else: + return self.StudyResult.SUCCESS - def study_work(self, _course, _job,_job_info) -> None: + def study_work(self, _course, _job, _job_info) -> StudyResult: if self.tiku.DISABLE or not self.tiku: - return None - _ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码,帮助修复#391错误 + return self.StudyResult.SUCCESS + _ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码, 帮助修复#391错误 - def random_answer(options:str) -> str: - answer = '' + def random_answer(options: str) -> str: + answer = "" if not options: return answer - - if q['type'] == "multiple": + + if q["type"] == "multiple": + logger.debug(f"当前选项列表[cut前] -> {options}") _op_list = multi_cut(options) - for i in range(random.choices([2,3,4],weights=[0.1,0.5,0.4],k=1)[0]): # 此处表示随机多选答案几率:2个 10%,3个 50% ,4个 40% + logger.debug(f"当前选项列表[cut后] -> {_op_list}") + + if not _op_list: + logger.error( + "选项为空, 未能正确提取题目选项信息! 请反馈并提供以上信息" + ) + return answer + + for i in range( + random.choices([2, 3, 4], weights=[0.1, 0.5, 0.4], k=1)[0] + ): # 此处表示随机多选答案几率:2个 10%, 3个 50%, 4个 40% _choice = random.choice(_op_list) _op_list.remove(_choice) - answer+=_choice[:1] # 取首字为答案,例如A或B - # 对答案进行排序,否则会提交失败 + answer += _choice[:1] # 取首字为答案, 例如A或B + # 对答案进行排序, 否则会提交失败 answer = "".join(sorted(answer)) - elif q['type'] == "single": - answer = random.choice(options.split('\n'))[:1] # 取首字为答案,例如A或B + elif q["type"] == "single": + answer = random.choice(options.split("\n"))[ + :1 + ] # 取首字为答案, 例如A或B # 判断题处理 - elif q['type'] == "judgement": + elif q["type"] == "judgement": # answer = self.tiku.jugement_select(_answer) - answer = "true" if random.choice([True,False]) else "false" - logger.info(f'随机选择 -> {answer}') + answer = "true" if random.choice([True, False]) else "false" + logger.info(f"随机选择 -> {answer}") return answer - - def multi_cut(answer:str) -> list[str]: - cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 + + def multi_cut(answer: str) -> list[str]: + """ + 将多选题答案字符串按特定字符进行切割, 并返回切割后的答案列表. + + 参数: + answer (str): 多选题答案字符串. + + 返回: + list[str]: 切割后的答案列表, 如果无法切割, 则返回默认的选项列表 ['A', 'B', 'C', 'D']. + + 注意: + 如果无法从网页中提取题目信息, 将记录警告日志并返回默认选项列表. + """ + # cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 + # ',' 在常规被正确划分的, 选项中出现, 导致 multi_cut 无法正确划分选项 #391 + # IndexError: Cannot choose from an empty sequence #391 + # 同时为了避免没有考虑到的 case, 应该先按照 '\n' 匹配, 匹配不到再按照其他字符匹配 + cut_char = [ + "\n", + ",", + ",", + "|", + "\r", + "\t", + "#", + "*", + "-", + "_", + "+", + "@", + "~", + "/", + "\\", + ".", + "&", + " ", + "、", + ] # 多选答案切割符 res = [] for char in cut_char: - res = answer.split(char) - if len(res)>1: + res = [ + opt for opt in answer.split(char) if opt.strip() + ] # Filter empty strings + if len(res) > 1: return res - logger.warning(f"未能从网页中提取题目信息,以下为相关信息:\n{answer}\n\n{_ORIGIN_HTML_CONTENT}\n") # 尝试输出网页内容和选项信息 - logger.warning("未能正确提取题目选项信息!请反馈并提供以上信息。") - return ['A','B','C','D'] # 默认多选题为4个选项 - - - # 学习通这里根据参数差异能重定向至两个不同接口,需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle + logger.warning( + f"未能从网页中提取题目信息, 以下为相关信息:\n\t{answer}\n\n{_ORIGIN_HTML_CONTENT}\n" + ) # 尝试输出网页内容和选项信息 + logger.warning("未能正确提取题目选项信息! 请反馈并提供以上信息") + return ["A", "B", "C", "D"] # 默认多选题为4个选项 + + def clean_res(res): + cleaned_res = [] + if isinstance(res, str): + res = [res] + for c in res: + cleaned_res.append(re.sub(r'^[A-Za-z]|[.,!?;:,。!?;:]', '', c)) + + return cleaned_res + + def is_subsequence(a, o): + iter_o = iter(o) + return all(c in iter_o for c in a) + + def with_retry(max_retries=3, delay=1): + def decorator(func): + def wrapper(*args, **kwargs): + retries = 0 + while retries < max_retries: + try: + _resp = func(*args, **kwargs) + + # 未创建完成该测验则不进行答题,目前遇到的情况是未创建完成等同于没题目 + if '教师未创建完成该测验' in _resp.text: + raise Exception(f"教师未创建完成该测验") + + questions = decode_questions_info(_resp.text) + + if _resp.status_code == 200 and questions.get("questions"): + return (_resp, questions) + + logger.warning(f"无效响应 (Code: {getattr(_resp, 'status_code', 'Unknown')}), 重试中... ({retries+1}/{max_retries})") + + except requests.exceptions.RequestException as e: + logger.warning(f"请求失败: {str(e)[:50]}, 重试中... ({retries+1}/{max_retries})") + retries += 1 + time.sleep(delay * (2 ** retries)) + raise Exception(f"超过最大重试次数 ({max_retries})") + return wrapper + return decorator + + # 学习通这里根据参数差异能重定向至两个不同接口, 需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle _session = init_session() - headers={ + headers = { "Host": "mooc1.chaoxing.com", - "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", + "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": "\"Windows\"", + "sec-ch-ua-platform": '"Windows"', "Upgrade-Insecure-Requests": "1", "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", "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", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "iframe", - "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" + "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", } cookies = _session.cookies.get_dict() - - _url = "https://mooc1.chaoxing.com/mooc-ans/api/work" - _resp = requests.get( - _url, - headers=headers, - cookies=cookies, - verify=False, - params = { - "api": "1", - "workId": _job['jobid'].replace("work-",""), - "jobid": _job['jobid'], - "originJobId": _job['jobid'], - "needRedirect": "true", - "skipHeader": "true", - "knowledgeid": str(_job_info['knowledgeid']), - 'ktoken': _job_info['ktoken'], - "cpi": _job_info['cpi'], - "ut": "s", - "clazzId": _course['clazzId'], - "type": "", - "enc": _job['enc'], - "mooc2": "1", - "courseid": _course['courseId'] - } - ) - _ORIGIN_HTML_CONTENT = _resp.text # 用于配合输出网页源码,帮助修复#391错误 - questions = decode_questions_info(_resp.text) # 加载题目信息 + _url = "https://mooc1.chaoxing.com/mooc-ans/api/work" + + @with_retry(max_retries=3, delay=1) + def fetch_response(): + return requests.get( + _url, + headers=headers, + cookies=cookies, + verify=False, + params={ + "api": "1", + "workId": _job["jobid"].replace("work-", ""), + "jobid": _job["jobid"], + "originJobId": _job["jobid"], + "needRedirect": "true", + "skipHeader": "true", + "knowledgeid": str(_job_info["knowledgeid"]), + "ktoken": _job_info["ktoken"], + "cpi": _job_info["cpi"], + "ut": "s", + "clazzId": _course["clazzId"], + "type": "", + "enc": _job["enc"], + "mooc2": "1", + "courseid": _course["courseId"], + } + ) + + final_resp = {} + questions = {} + + try: + final_resp, questions = fetch_response() + except Exception as e: + logger.error(f"请求失败: {e}") + return self.StudyResult.ERROR + + _ORIGIN_HTML_CONTENT = final_resp.text # 用于配合输出网页源码, 帮助修复#391错误 # 搜题 - for q in questions['questions']: + total_questions = len(questions["questions"]) + found_answers = 0 + for q in questions["questions"]: + logger.debug(f"当前题目信息 -> {q}") + # 添加搜题延迟 #428 - 默认0s延迟 + query_delay = self.kwargs.get("query_delay",0) + time.sleep(query_delay) res = self.tiku.query(q) - answer = '' + answer = "" if not res: # 随机答题 - answer = random_answer(q['options']) + answer = random_answer(q["options"]) + q[f'answerSource{q["id"]}'] = "random" else: # 根据响应结果选择答案 - options_list = multi_cut(q['options']) - if q['type'] == "multiple": + if q["type"] == "multiple": # 多选处理 - for _a in multi_cut(res): + options_list = multi_cut(q["options"]) + for _a in clean_res(multi_cut(res)): for o in options_list: - if _a.upper() in o: # 题库返回的答案可能包含选项,如A,B,C,全部转成大写与学习通一致 + if ( + is_subsequence(_a, o) # 去掉各种符号和前面ABCD的答案应当是选项的子序列 + ): answer += o[:1] - # 对答案进行排序,否则会提交失败 + # 对答案进行排序, 否则会提交失败 answer = "".join(sorted(answer)) - elif q['type'] == 'judgement': - answer = 'true' if self.tiku.jugement_select(res) else 'false' - else: + elif q["type"] == "single": + # 单选也进行切割,主要是防止返回的答案有异常字符 + options_list = multi_cut(q["options"]) + t_res = clean_res(res) for o in options_list: - if res in o: + if is_subsequence(t_res[0], o): answer = o[:1] break - # 如果未能匹配,依然随机答题 - answer = answer if answer else random_answer(q['options']) + elif q["type"] == "judgement": + answer = "true" if self.tiku.jugement_select(res) else "false" + elif q["type"] == "completion": + if isinstance(res,list): + answer = "".join(answer) + elif isinstance(res,str): + answer = res + else: + # 其他类型直接使用答案 (目前仅知有简答题,待补充处理) + answer = res + + if not answer: # 检查 answer 是否为空 + logger.warning(f"找到答案但答案未能匹配 -> {res}\t随机选择答案") + answer = random_answer(q["options"]) # 如果为空,则随机选择答案 + q[f'answerSource{q["id"]}'] = "random" + else: + logger.info(f"成功获取到答案:{answer}") + q[f'answerSource{q["id"]}'] = "cover" + found_answers += 1 # 填充答案 - q['answerField'][f'answer{q["id"]}'] = answer + q["answerField"][f'answer{q["id"]}'] = answer logger.info(f'{q["title"]} 填写答案为 {answer}') - - # 提交模式 现在与题库绑定 - questions['pyFlag'] = self.tiku.get_submit_params() - + cover_rate = (found_answers / total_questions) * 100 + logger.info(f"章节检测题库覆盖率: {cover_rate:.0f}%") + # 提交模式 现在与题库绑定,留空直接提交, 1保存但不提交 + if self.tiku.get_submit_params() == "1": + questions["pyFlag"] = "1" + elif cover_rate >= self.tiku.COVER_RATE*100 or self.rollback_times >= 1: + questions["pyFlag"] = "" + else: + questions["pyFlag"] = "1" + logger.info(f"章节检测题库覆盖率低于{self.tiku.COVER_RATE*100:.0f}%,不予提交") # 组建提交表单 - for q in questions["questions"]: - questions.update({ - f'answer{q["id"]}':q['answerField'][f'answer{q["id"]}'], - f'answertype{q["id"]}':q['answerField'][f'answertype{q["id"]}'] - }) - + if questions["pyFlag"] == "1": + for q in questions["questions"]: + questions.update( + { + f'answer{q["id"]}': + q["answerField"][f'answer{q["id"]}'] if q[f'answerSource{q["id"]}'] == "cover" else '', + f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'], + } + ) + else: + for q in questions["questions"]: + questions.update( + { + f'answer{q["id"]}': q["answerField"][f'answer{q["id"]}'], + f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'], + } + ) del questions["questions"] res = _session.post( - 'https://mooc1.chaoxing.com/mooc-ans/work/addStudentWorkNew', + "https://mooc1.chaoxing.com/mooc-ans/work/addStudentWorkNew", data=questions, - headers= { + headers={ "Host": "mooc1.chaoxing.com", - "sec-ch-ua-platform": "\"Windows\"", + "sec-ch-ua-platform": '"Windows"', "X-Requested-With": "XMLHttpRequest", "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", "Accept": "application/json, text/javascript, */*; q=0.01", - "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", + "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "sec-ch-ua-mobile": "?0", "Origin": "https://mooc1.chaoxing.com", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", - #"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", - "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" - } + # "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", + "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", + }, ) if res.status_code == 200: res_json = res.json() - if res_json['status']: - logger.info(f'提交答题成功 -> {res_json["msg"]}') + if res_json["status"]: + logger.info(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题成功 -> {res_json["msg"]}') else: - logger.error(f'提交答题失败 -> {res_json["msg"]}') + logger.error(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题失败 -> {res_json["msg"]}') + return self.StudyResult.ERROR else: - logger.error(f"提交答题失败 -> {res.text}") + logger.error(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题失败 -> {res.text}') + return self.StudyResult.ERROR + return self.StudyResult.SUCCESS - def strdy_read(self, _course, _job,_job_info) -> None: + def strdy_read(self, _course, _job, _job_info) -> StudyResult: """ - 阅读任务学习,仅完成任务点,并不增长时长 + 阅读任务学习, 仅完成任务点, 并不增长时长 """ _session = init_session() _resp = _session.get( url="https://mooc1.chaoxing.com/ananas/job/readv2", params={ - 'jobid': _job['jobid'], - 'knowledgeid':_job_info['knowledgeid'], - 'jtoken': _job['jtoken'], - 'courseid': _course['courseId'], - 'clazzid': _course['clazzId'] - } + "jobid": _job["jobid"], + "knowledgeid": _job_info["knowledgeid"], + "jtoken": _job["jtoken"], + "courseid": _course["courseId"], + "clazzid": _course["clazzId"], + }, ) if _resp.status_code != 200: logger.error(f"阅读任务学习失败 -> [{_resp.status_code}]{_resp.text}") + return self.StudyResult.ERROR else: _resp_json = _resp.json() logger.info(f"阅读任务学习 -> {_resp_json['msg']}") + return self.StudyResult.SUCCESS - + def study_emptypage(self, _course, _chapterId): + _session = init_session() + # &cpi=0&verificationcode=&mooc2=1µTopicId=0&editorPreview=0 + _resp = _session.get( + url="https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudyAjax", + params={ + "courseId": _course["courseId"], + "clazzid": _course["clazzId"], + "chapterId": _chapterId['id'], + "cpi": 0, + "verificationcode": "", + "mooc2": 1, + "microTopicId": 0, + "editorPreview": 0, + }, + ) + if _resp.status_code != 200: + logger.error(f"空页面任务失败 -> [{_resp.status_code}]{_chapterId['title']}") + return self.StudyResult.ERROR + else: + logger.info(f"空页面任务完成 -> {_chapterId['title']}") + return self.StudyResult.SUCCESS diff --git a/api/cipher.py b/api/cipher.py index e06e7cd..efe31ac 100644 --- a/api/cipher.py +++ b/api/cipher.py @@ -6,7 +6,7 @@ def pkcs7_unpadding(string): - return string[0:-ord(string[-1])] + return string[0 : -ord(string[-1])] def pkcs7_padding(s, block_size=16): @@ -29,15 +29,15 @@ def split_to_data_blocks(byte_str, block_size=16): return blocks -class AESCipher(): +class AESCipher: def __init__(self): self.key = str(gc.AESKey).encode("utf8") self.iv = str(gc.AESKey).encode("utf8") def encrypt(self, plaintext: str): - ciphertext = b'' + ciphertext = b"" cbc = pyaes.AESModeOfOperationCBC(self.key, self.iv) - plaintext = plaintext.encode('utf-8') + plaintext = plaintext.encode("utf-8") blocks = split_to_data_blocks(pkcs7_padding(plaintext)) for b in blocks: ciphertext = ciphertext + cbc.encrypt(b) @@ -51,4 +51,4 @@ def encrypt(self, plaintext: str): # ptext = b"" # for b in split_to_data_blocks(ciphertext): # ptext = ptext + cbc.decrypt(b) - # return pkcs7_unpadding(ptext.decode()) \ No newline at end of file + # return pkcs7_unpadding(ptext.decode()) diff --git a/api/config.py b/api/config.py index bee24b8..bc0a956 100644 --- a/api/config.py +++ b/api/config.py @@ -3,17 +3,17 @@ class GlobalConst: AESKey = "u2oh6Vu^HWe4_AES" HEADERS = { "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", - "Sec-Ch-Ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"' + "Sec-Ch-Ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"', } COOKIES_PATH = "cookies.txt" VIDEO_HEADERS = { "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", "Referer": "https://mooc1.chaoxing.com/ananas/modules/video/index.html?v=2023-1110-1610", - "Host": "mooc1.chaoxing.com" + "Host": "mooc1.chaoxing.com", } AUDIO_HEADERS = { "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", "Referer": "https://mooc1.chaoxing.com/ananas/modules/audio/index_new.html?v=2023-0428-1705", - "Host": "mooc1.chaoxing.com" + "Host": "mooc1.chaoxing.com", } - THRESHOLD = 3 \ No newline at end of file + THRESHOLD = 3 diff --git a/api/cookies.py b/api/cookies.py index 1158d8e..ce99d99 100644 --- a/api/cookies.py +++ b/api/cookies.py @@ -5,12 +5,12 @@ def save_cookies(_session): - with open(gc.COOKIES_PATH, 'wb') as f: + with open(gc.COOKIES_PATH, "wb") as f: pickle.dump(_session.cookies, f) def use_cookies(): if os.path.exists(gc.COOKIES_PATH): - with open(gc.COOKIES_PATH, 'rb') as f: + with open(gc.COOKIES_PATH, "rb") as f: _cookies = pickle.load(f) - return _cookies \ No newline at end of file + return _cookies diff --git a/api/cxsecret_font.py b/api/cxsecret_font.py index f44fb5b..d66fb85 100644 --- a/api/cxsecret_font.py +++ b/api/cxsecret_font.py @@ -1,7 +1,7 @@ ## # @Author: SocialSisterYi # @Reference: https://github.com/SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy -# +# import base64 import hashlib @@ -24,6 +24,7 @@ class FontHashDAO: """原始字体hashmap DAO""" + char_map: Dict[str, str] # unicode -> hsah hash_map: Dict[str, str] # hash -> unicode diff --git a/api/decode.py b/api/decode.py index 7fe7463..59f763e 100644 --- a/api/decode.py +++ b/api/decode.py @@ -1,34 +1,46 @@ # -*- coding: utf-8 -*- import re import json -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, NavigableString from api.logger import logger from api.font_decoder import FontDecoder + def decode_course_list(_text): logger.trace("开始解码课程列表...") _soup = BeautifulSoup(_text, "lxml") _raw_courses = _soup.select("div.course") _course_list = list() for course in _raw_courses: - if not course.select_one("a.not-open-tip") and not course.select_one("div.not-open-tip"): + if not course.select_one("a.not-open-tip") and not course.select_one( + "div.not-open-tip" + ): _course_detail = {} _course_detail["id"] = course.attrs["id"] _course_detail["info"] = course.attrs["info"] _course_detail["roleid"] = course.attrs["roleid"] - _course_detail["clazzId"] = course.select_one("input.clazzId").attrs["value"] - _course_detail["courseId"] = course.select_one("input.courseId").attrs["value"] - _course_detail["cpi"] = re.findall(r"cpi=(.*?)&", course.select_one("a").attrs["href"])[0] - _course_detail["title"] = course.select_one("span.course-name").attrs["title"] + _course_detail["clazzId"] = course.select_one("input.clazzId").attrs[ + "value" + ] + _course_detail["courseId"] = course.select_one("input.courseId").attrs[ + "value" + ] + _course_detail["cpi"] = re.findall( + r"cpi=(.*?)&", course.select_one("a").attrs["href"] + )[0] + _course_detail["title"] = course.select_one("span.course-name").attrs[ + "title" + ] if course.select_one("p.margint10") is None: - _course_detail["desc"] = '' + _course_detail["desc"] = "" else: _course_detail["desc"] = course.select_one("p.margint10").attrs["title"] _course_detail["teacher"] = course.select_one("p.color3").attrs["title"] _course_list.append(_course_detail) return _course_list + def decode_course_folder(_text): logger.trace("开始解码二级课程列表...") _soup = BeautifulSoup(_text, "lxml") @@ -38,39 +50,49 @@ def decode_course_folder(_text): if course.attrs["fileid"]: _course_folder_detail = {} _course_folder_detail["id"] = course.attrs["fileid"] - _course_folder_detail["rename"] = course.select_one("input.rename-input").attrs["value"] + _course_folder_detail["rename"] = course.select_one( + "input.rename-input" + ).attrs["value"] _course_folder_list.append(_course_folder_detail) return _course_folder_list + def decode_course_point(_text): logger.trace("开始解码章节列表...") _soup = BeautifulSoup(_text, "lxml") _course_point = { - "hasLocked": False, # 用于判断该课程任务是否是需要解锁 - "points": [] + "hasLocked": False, # 用于判断该课程任务是否是需要解锁 + "points": [], } - - - for _chapter_unit in _soup.find_all("div",class_="chapter_unit") : + + for _chapter_unit in _soup.find_all("div", class_="chapter_unit"): _point_list = [] _raw_points = _chapter_unit.find_all("li") for _point in _raw_points: _point = _point.div - if (not "id" in _point.attrs): + if not "id" in _point.attrs: continue _point_detail = {} _point_detail["id"] = re.findall(r"^cur(\d{1,20})$", _point.attrs["id"])[0] - _point_detail["title"] = _point.select_one("a.clicktitle").text.replace("\n",'').strip(' ') - _point_detail["jobCount"] = 1 # 默认为1 + _point_detail["title"] = ( + _point.select_one("a.clicktitle").text.replace("\n", "").strip(" ") + ) + _point_detail["jobCount"] = 1 # 默认为1 if _point.select_one("input.knowledgeJobCount"): - _point_detail["jobCount"] = _point.select_one("input.knowledgeJobCount").attrs["value"] + _point_detail["jobCount"] = _point.select_one( + "input.knowledgeJobCount" + ).attrs["value"] else: # 判断是不是因为需要解锁 - if '解锁' in _point.select_one("span.bntHoverTips").text: + if "解锁" in _point.select_one("span.bntHoverTips").text: _course_point["hasLocked"] = True - + if "已完成" in _point.select_one("span.bntHoverTips").text: + _point_detail["has_finished"] = True + else: + _point_detail["has_finished"] = False + _point_list.append(_point_detail) - _course_point["points"]+=_point_list + _course_point["points"] += _point_list return _course_point @@ -79,27 +101,27 @@ def decode_course_card(_text: str): _job_info = {} _job_list = [] # 对于未开放章节检测 - if '章节未开放' in _text: - _job_info['notOpen'] = True - return [],_job_info - + if "章节未开放" in _text: + _job_info["notOpen"] = True + return [], _job_info + _temp = re.findall(r"mArg=\{(.*?)\};", _text.replace(" ", "")) if _temp: _temp = _temp[0] else: - return [],{} + return [], {} _cards = json.loads("{" + _temp + "}") - + if _cards: _job_info = {} _job_info["ktoken"] = _cards["defaults"]["ktoken"] _job_info["mtEnc"] = _cards["defaults"]["mtEnc"] - _job_info["reportTimeInterval"] = _cards["defaults"]["reportTimeInterval"] # 60 + _job_info["reportTimeInterval"] = _cards["defaults"]["reportTimeInterval"] # 60 _job_info["defenc"] = _cards["defaults"]["defenc"] _job_info["cardid"] = _cards["defaults"]["cardid"] _job_info["cpi"] = _cards["defaults"]["cpi"] _job_info["qnenc"] = _cards["defaults"]["qnenc"] - _job_info['knowledgeid'] = _cards["defaults"]["knowledgeid"] + _job_info["knowledgeid"] = _cards["defaults"]["knowledgeid"] _cards = _cards["attachments"] _job_list = [] for _card in _cards: @@ -108,24 +130,26 @@ def decode_course_card(_text: str): continue # 不属于任务点的任务 if "job" not in _card or _card["job"] is False: - if _card.get('type') and _card['type'] == "read": + if _card.get("type") and _card["type"] == "read": # 发现有在视频任务下掺杂阅读任务,不完成可能会导致无法开启下一章节 - if _card['property'].get('read',False): + if _card["property"].get("read", False): # 已阅读,跳过 continue _job = {} - _job['title'] = _card['property']['title'] + _job["title"] = _card["property"]["title"] _job["type"] = "read" - _job['id'] = _card['property']['id'] + _job["id"] = _card["property"]["id"] _job["jobid"] = _card["jobid"] _job["jtoken"] = _card["jtoken"] - _job['mid'] = _card['mid'] - _job['otherinfo'] = _card["otherInfo"] - _job['enc'] = _card["enc"] - _job['aid'] = _card["aid"] + _job["mid"] = _card["mid"] + _job["otherinfo"] = _card["otherInfo"] + _job["enc"] = _card["enc"] + _job["aid"] = _card["aid"] _job_list.append(_job) continue # 视频任务 + if not "type" in _card: + continue if _card["type"] == "video": _job = {} _job["type"] = "video" @@ -165,67 +189,92 @@ def decode_course_card(_text: str): _job["aid"] = _card["aid"] _job_list.append(_job) continue - + if _card["type"] == "vote": # 调查问卷 同上 continue return _job_list, _job_info - + def decode_questions_info(html_content) -> dict: def replace_rtn(text): - return text.replace('\r', '').replace('\t', '').replace('\n', '') + return text.replace("\r", "").replace("\t", "").replace("\n", "") + + def extract_content(div): + text = [] + for element in div.descendants: + if isinstance(element, NavigableString): + text.append(element.string) + elif element.name == "img": + img_url = element.get("src", "") + text.append(f'') + return "".join(text) soup = BeautifulSoup(html_content, "lxml") form_data = {} form_tag = soup.find("form") - fd = FontDecoder(html_content) # 加载字体 - # 抽取表单信息 for input_tag in form_tag.find_all("input"): - if 'name' not in input_tag.attrs or 'answer' in input_tag.attrs["name"]: + if "name" not in input_tag.attrs or "answer" in input_tag.attrs["name"]: continue - form_data.update({ - input_tag.attrs["name"]: input_tag.attrs.get("value",'') - }) - - form_data['questions'] = [] - for div_tag in form_tag.find_all("div",class_="singleQuesId"): # 目前来说无论是单选还是多选的题class都是这个 - q_title = replace_rtn(fd.decode(div_tag.find("div", class_="Zy_TItle").text)) - q_options = '' - for li_tag in div_tag.find("ul").find_all("li"): - q_options += replace_rtn(fd.decode(li_tag.text))+'\n' - q_options=q_options[:-1] # 去除尾部'\n' + form_data.update({input_tag.attrs["name"]: input_tag.attrs.get("value", "")}) + + form_data["questions"] = [] + + if soup.find("style", id="cxSecretStyle") is None: # 未找到字体文件,目前只有可能是空或者无中文内容。 + logger.warning("未找到字体文件,可能是未加密的题目不进行解密") + else: + fd = FontDecoder(html_content) # 加载字体 + + for div_tag in form_tag.find_all( + "div", class_="singleQuesId"): # 目前来说无论是单选还是多选的题class都是这个 + q_title = "" + q_options = "" + if 'fd' in locals(): + q_title = replace_rtn(fd.decode(extract_content(div_tag.find("div", class_="Zy_TItle")))) + for li_tag in div_tag.find("ul").find_all("li"): + q_options += replace_rtn( + fd.decode(extract_content(li_tag))) + "\n" + else: + q_title = replace_rtn(extract_content(div_tag.find("div", class_="Zy_TItle"))) + for li_tag in div_tag.find("ul").find_all("li"): + q_options += replace_rtn( + extract_content(li_tag)) + "\n" + + print(q_title, q_options, sep="\n") + q_options = q_options[:-1] # 去除尾部'\n' # 尝试使用 data 属性来判断题型 - q_type_code = div_tag.find('div',class_='TiMu').attrs['data'] - q_type = '' + q_type_code = div_tag.find("div", class_="TiMu").attrs["data"] + q_type = "" # 此处可能需要完善更多题型的判断 - if q_type_code == '0': - q_type = 'single' - elif q_type_code == '1': - q_type = 'multiple' - elif q_type_code == '2': - q_type = 'completion' - elif q_type_code == '3': - q_type = 'judgement' + if q_type_code == "0": + q_type = "single" + elif q_type_code == "1": + q_type = "multiple" + elif q_type_code == "2": + q_type = "completion" + elif q_type_code == "3": + q_type = "judgement" + elif q_type_code == "4": + q_type = "shortanswer" else: - logger.info("未知题型代码 -> "+q_type_code) - q_type = 'unknown' # 避免出现未定义取值错误 - - form_data["questions"].append({ - 'id': div_tag.attrs["data"], - 'title':q_title, # 题目 - 'options':q_options, # 选项 可提供给题库作为辅助 - 'type': q_type, # 题型 可提供给题库作为辅助 - 'answerField':{ - 'answer'+div_tag.attrs["data"]:'', # 答案填入处 - 'answertype'+div_tag.attrs["data"]:q_type_code + logger.info("未知题型代码 -> " + q_type_code) + q_type = "unknown" # 避免出现未定义取值错误 + + form_data["questions"].append( + { + "id": div_tag.attrs["data"], + "title": q_title, # 题目 + "options": q_options, # 选项 可提供给题库作为辅助 + "type": q_type, # 题型 可提供给题库作为辅助 + "answerField": { + "answer" + div_tag.attrs["data"]: "", # 答案填入处 + "answertype" + div_tag.attrs["data"]: q_type_code, + }, } - }) + ) # 处理答题信息 - form_data['answerwqbid'] = ",".join([q['id'] for q in form_data['questions']])+"," + form_data["answerwqbid"] = ",".join([q["id"] for q in form_data["questions"]]) + "," return form_data - - diff --git a/api/exceptions.py b/api/exceptions.py index 913e71e..57e00b5 100644 --- a/api/exceptions.py +++ b/api/exceptions.py @@ -13,7 +13,7 @@ class FormatError(Exception): def __init__(self, *args: object): super().__init__(*args) + class MaxRollBackError(Exception): def __init__(self, *args: object): super().__init__(*args) - \ No newline at end of file diff --git a/api/font_decoder.py b/api/font_decoder.py index 1742c04..5d0ca4a 100644 --- a/api/font_decoder.py +++ b/api/font_decoder.py @@ -4,19 +4,20 @@ class FontDecoder: - def __init__(self,html_content:str=None): + def __init__(self, html_content: str = None): self.html_content = html_content # self.__isNeedDecode = True self.__font_hash_map = None self.__decode_init(html_content) - + def __decode_init(self, html_content): if html_content: soup = BeautifulSoup(html_content, "lxml") - style_tag = soup.find("style",id="cxSecretStyle") - match = re.search(r'base64,([\w\W]+?)\'', style_tag.text) - self.__font_hash_map = cxfont.font2map('data:application/font-ttf;charset=utf-8;base64,'+match.group(1)) + style_tag = soup.find("style", id="cxSecretStyle") + match = re.search(r"base64,([\w\W]+?)\'", style_tag.text) + self.__font_hash_map = cxfont.font2map( + "data:application/font-ttf;charset=utf-8;base64," + match.group(1) + ) - def decode(self,target_str:str) -> str: + def decode(self, target_str: str) -> str: return cxfont.decrypt(self.__font_hash_map, target_str) - diff --git a/api/logger.py b/api/logger.py index 2caa568..ba1e61d 100644 --- a/api/logger.py +++ b/api/logger.py @@ -1,3 +1,3 @@ from loguru import logger -logger.add("chaoxing.log", rotation="10 MB", level="TRACE") \ No newline at end of file +logger.add("chaoxing.log", rotation="10 MB", level="TRACE") diff --git a/api/notification.py b/api/notification.py new file mode 100644 index 0000000..e73c706 --- /dev/null +++ b/api/notification.py @@ -0,0 +1,116 @@ + +import configparser +import requests +from api.logger import logger + +#仿制answer.py写的外部通知 + +class Notification: + + CONFIG_PATH = "config.ini" + DISABLE = False + + def __init__(self): + self._name = None + self._conf = None + + def config_set(self, config): + self._conf = config + + def _init_notification(self): + # 仅用于外部通知初始化, 例如配置token, 则交由具体外部通知完成 + pass + + def _get_conf(self): + """ + 从默认配置文件查询配置, 如果未能查到, 停用外部通知功能 + """ + try: + config = configparser.ConfigParser() + config.read(self.CONFIG_PATH, encoding="utf8") + return config['notification'] + except (KeyError, FileNotFoundError): + logger.info("未找到notification配置, 已忽略外部通知功能") + self.DISABLE = True + return None + + def get_notification_from_config(self): + if not self._conf: + # 尝试从默认配置文件加载 + self.config_set(self._get_conf()) + if self.DISABLE: + return self + try: + cls_name = self._conf['provider'] + if not cls_name: + raise KeyError + except KeyError: + self.DISABLE = True + logger.info("未找到外部通知配置, 已忽略外部通知功能") + return self + new_cls = globals()[cls_name]() + new_cls.config_set(self._conf) + return new_cls + def init_notification(self): + if not self._conf: + self.config_set(self._get_conf()) + if not self.DISABLE: + # 调用自定义外部通知初始化 + self._init_notification() + def _send(self,*args, **kwargs): + pass + def send(self, *args, **kwargs): + if not self.DISABLE: + self._send(*args, **kwargs) + return None + + +class ServerChan(Notification): + def __init__(self): + super().__init__() + self.name = 'ServerChan' + self.url = '' + + def _send(self, text): + params = { + # serverChan有两个版本,一版本参数是text,一个是desp,干脆直接这么写,不区分 + 'text': text, + 'desp': text, + } + headers = { + 'Content-Type': 'application/json;charset=utf-8' + } + response = requests.post(self.url, json=params, headers=headers) + result = response.json() + if response.status_code != 200: + logger.error(f"Server酱发送通知失败{result}") + else: + logger.info("Server酱发送通知成功") + return None + + def _init_notification(self): + self.url = self._conf['url'] + +class Qmsg(Notification): + def __init__(self): + super().__init__() + self.name = 'Qmsg' + self.url = '' + + def _send(self, msg): + params = { + 'msg': msg, + } + headers = { + 'Content-Type': 'application/json;charset=utf-8' + } + response = requests.post(self.url, params=params, headers=headers) + result = response.json() + if response.status_code != 200: + logger.error(f"Qmsg酱发送通知失败{result}") + else: + logger.info("Qmsg酱发送通知成功") + return None + + def _init_notification(self): + self.url = self._conf['url'] \ No newline at end of file diff --git a/api/process.py b/api/process.py index 7e5d9cb..309e574 100644 --- a/api/process.py +++ b/api/process.py @@ -1,15 +1,16 @@ import time from api.config import GlobalConst as gc + def sec2time(sec: int): h = int(sec / 3600) m = int(sec % 3600 / 60) s = int(sec % 60) if h != 0: - return f'{h}:{m:02}:{s:02}' + return f"{h}:{m:02}:{s:02}" if sec != 0: - return f'{m:02}:{s:02}' - return '--:--' + return f"{m:02}:{s:02}" + return "--:--" def show_progress(name: str, start: int, span: int, total: int, _speed: float): @@ -20,5 +21,9 @@ def show_progress(name: str, start: int, span: int, total: int, _speed: float): length = int(percent * 40 // 100) progress = ("#" * length).ljust(40, " ") # remain = (total - current) - print(f"\r当前任务: {name} |{progress}| {percent}% {sec2time(current)}/{sec2time(total)}", end="", flush=True) - time.sleep(gc.THRESHOLD) \ No newline at end of file + print( + f"\r当前任务: {name} |{progress}| {percent}% {sec2time(current)}/{sec2time(total)}", + end="", + flush=True, + ) + time.sleep(gc.THRESHOLD) diff --git a/app.py b/app.py index 486af05..ba72eb4 100644 --- a/app.py +++ b/app.py @@ -15,7 +15,7 @@ def __call__(self, *args: object, **kwargs: object) -> object: return celery_app -if __name__ == '__main__': +if __name__ == "__main__": app = Flask(__name__) app.config.from_mapping( CELERY=dict( @@ -24,4 +24,4 @@ def __call__(self, *args: object, **kwargs: object) -> object: task_ignore_result=True, ), ) - celery_app = celery_init_app(app) \ No newline at end of file + celery_app = celery_init_app(app) diff --git a/config_template.ini b/config_template.ini index 1c482ce..709d5f4 100644 --- a/config_template.ini +++ b/config_template.ini @@ -10,16 +10,56 @@ course_list = xxx,xxx,xxx ; 视频播放倍速(默认1,最大2) speed = 1 + +; 遇到关闭任务点时的行为: retry-重试(默认), ask-询问, continue-继续 +notopen_action = retry [tiku] ; 可选项 : ; 1. TikuYanxi(言溪题库 https://tk.enncy.cn/) +; 2. TikuLike(LIKE知识库 https://www.datam.site/) +; 3. TikuAdapter(开源项目 https://github.com/DokiDoki1103/tikuAdapter) +; 4. AI(需自行寻找兼容openai格式的API Endpoint和Key) provider=TikuYanxi -; 是否直接提交答题,填写false表示答完题后不提交而是保存,随后你可以自行前往学习通修改或提交 -; 填写true表示直接提交,不保证正确率!不正确的填写会被视为false +; 是否提交答题,填写false表示答完题后不提交而是保存搜到的题目,随后你可以自行前往学习通修改或提交 +; 填写true表示达到最低题库覆盖率提交,没达到只保存搜到的题目,进入下一章节,不保证正确率!不正确的填写会被视为false +; 题库覆盖率-搜到的题目占总题目的比例 ; 对于那些需要解锁的章节,你必须要提交章节检测才能继续下一章节的学习,自行决定是否开启 +; 选择提交答题但题库覆盖率不达标时,若是需要解锁的章节,保存后会回滚重新答题且忽略搜到率提交 submit=false +; 最低题库覆盖率 +cover_rate=0.9 +; 搜索多个题目时间隔的时间,单位秒 +delay=1.0 ; 用于言溪题库的TOKEN,同样使用英文逗号隔开多个,会按顺序去使用 +; 或用于LIKE知识库的TOKEN,在使用LIKE知识库时仅会调用最后一个TOKEN,请注意! tokens= +; 下面是用于LIKE知识库模型的专属配置,其他题库无需关注下列选项 +; likeapi_search=true表示启用模型的联网搜索功能,false则不启用联网搜索 +; likeapi_model=deepseek-v3表示使用deepseek-v3模型,其他模型请自行查询 +; 支持模型列表:https://www.datam.site/doc/api_doc.html#%E6%A8%A1%E5%9E%8B%E6%94%AF%E6%8C%81%E5%88%97%E8%A1%A8 +likeapi_search=false +likeapi_model=deepseek-v3 +; 用于TikuAdapter题库的url +url= +; 用于AI大模型答题的API Endpoint和Key +; 请注意API Endpoint可能需要带上/v1路径,例如: https://example.com/v1 +; min_interval_seconds 为 API 请求的最小间隔时间,单位秒, 0表示不限制 +endpoint= +key= +model= +min_interval_seconds=0 +; 可选配置请求大模型时使用的代理,填写示例:http://examples.com +http_proxy= ; 用于判断判断题对应的选项,不要留有空格,不要留有引号,逗号为英文逗号 true_list=正确,对,√,是 false_list=错误,错,×,否,不对,不正确 +[notification] +provider=ServerChan +; 可选项 : +; 1. ServerChan(Server酱 多平台推送 https://sct.ftqq.com/) +; 2. Qmsg(Qmsg酱 qq推送 https://qmsg.zendee.cn/) +; 外部通知服务的提供方 +url= +; Server酱或Qmsg酱的url,以下为例子,需要自己将key填入*号位置 +; https://sctapi.ftqq.com/****************.send Server酱 +; https://qmsg.zendee.cn/send/**************** Qmsg酱 \ No newline at end of file diff --git a/main.py b/main.py index d97cbf1..5f3fc1a 100644 --- a/main.py +++ b/main.py @@ -1,21 +1,26 @@ # -*- coding: utf-8 -*- import argparse import configparser +import random + from api.logger import logger from api.base import Chaoxing, Account -from api.exceptions import LoginError, FormatError, JSONDecodeError,MaxRollBackError +from api.exceptions import LoginError, FormatError, JSONDecodeError, MaxRollBackError from api.answer import Tiku -from urllib3 import disable_warnings,exceptions +from urllib3 import disable_warnings, exceptions +import time +import sys import os +from api.notification import Notification -# # 定义全局变量,用于存储配置文件路径 +# # 定义全局变量, 用于存储配置文件路径 # textPath = './resource/BookID.txt' # # 获取文本 -> 用于查看学习过的课程ID # def getText(): -# try: +# try: # if not os.path.exists(textPath): -# with open(textPath, 'x') as file: pass +# with open(textPath, 'x') as file: pass # return [] # with open(textPath, 'r', encoding='utf-8') as file: content = file.read().split(',') # content = {int(item.strip()) for item in content if item.strip()} @@ -25,68 +30,146 @@ # # 追加文本 -> 用于记录学习过的课程ID # def appendText(text): # if not os.path.exists(textPath): return -# with open(textPath, 'a', encoding='utf-8') as file: file.write(f'{text}, ') - +# with open(textPath, 'a', encoding='utf-8') as file: file.write(f'{text}, ') + # 关闭警告 disable_warnings(exceptions.InsecureRequestWarning) + def init_config(): - parser = argparse.ArgumentParser(description='Samueli924/chaoxing') # 命令行传参 - parser.add_argument("-c", "--config", type=str, default=None, help="使用配置文件运行程序") + parser = argparse.ArgumentParser( + description="Samueli924/chaoxing", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "-c", "--config", type=str, default=None, help="使用配置文件运行程序" + ) parser.add_argument("-u", "--username", type=str, default=None, help="手机号账号") parser.add_argument("-p", "--password", type=str, default=None, help="登录密码") - parser.add_argument("-l", "--list", type=str, default=None, help="要学习的课程ID列表") - parser.add_argument("-s", "--speed", type=float, default=1.0, help="视频播放倍速(默认1,最大2)") + parser.add_argument( + "-l", "--list", type=str, default=None, help="要学习的课程ID列表, 以 , 分隔" + ) + parser.add_argument( + "-s", "--speed", type=float, default=1.0, help="视频播放倍速 (默认1, 最大2)" + ) + parser.add_argument( + "-v", + "--verbose", + "--debug", + action="store_true", + help="启用调试模式, 输出DEBUG级别日志", + ) + parser.add_argument( + "-a", "--notopen-action", type=str, default="retry", + choices=["retry", "ask", "continue"], + help="遇到关闭任务点时的行为: retry-重试, ask-询问, continue-继续" + ) + + # 在解析之前捕获 -h 的行为 + if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"}: + parser.print_help() + # 返回一个 SystemExit 异常, 用于退出程序 + raise SystemExit + + # 提前检查 -h 和 --help 并退出 args = parser.parse_args() + if args.config: config = configparser.ConfigParser() config.read(args.config, encoding="utf8") - return (config.get("common", "username"), - config.get("common", "password"), - str(config.get("common", "course_list")).split(",") if config.get("common", "course_list") else None, - int(config.get("common", "speed")), - config['tiku'] - ) + common_config = {} + tiku_config = {} + notification_config = {} + # 检查并读取common节 + if config.has_section("common"): + common_config = dict(config.items("common")) + # 处理course_list,将字符串转换为列表 + if "course_list" in common_config and common_config["course_list"]: + common_config["course_list"] = common_config["course_list"].split(",") + # 处理speed,将字符串转换为浮点数 + if "speed" in common_config: + common_config["speed"] = float(common_config["speed"]) + # 处理notopen_action,设置默认值为retry + if "notopen_action" not in common_config: + common_config["notopen_action"] = "retry" + + # 检查并读取tiku节 + if config.has_section("tiku"): + tiku_config = dict(config.items("tiku")) + # 处理delay,将字符串转换为整数 + if "delay" in tiku_config: + tiku_config["delay"] = float(tiku_config["delay"]) + # 处理delay,将字符串转换为小数 + if "cover_rate" in tiku_config: + tiku_config["cover_rate"] = float(tiku_config["cover_rate"]) + + # 检查并读取notification节 + if config.has_section("notification"): + notification_config = dict(config.items("notification")) + return common_config, tiku_config, notification_config else: - return (args.username, args.password, args.list.split(",") if args.list else None, int(args.speed) if args.speed else 1,None) + build_params = {'common':{},"tiku":{}} + build_params['common']['username'] = args.username + build_params['common']['password'] = args.password + build_params['common']['course_list'] = args.list.split(",") if args.list else None + build_params['common']['speed'] = args.speed if args.speed else 1 + build_params['common']['notopen_action'] = args.notopen_action if args.notopen_action else "retry" + return build_params['common'],build_params['tiku'] + class RollBackManager: def __init__(self) -> None: self.rollback_times = 0 self.rollback_id = "" - def add_times(self,id:str) -> None: + def add_times(self, id: str) -> None: if id == self.rollback_id and self.rollback_times == 3: - raise MaxRollBackError("回滚次数已达3次,请手动检查学习通任务点完成情况") - elif id != self.rollback_id: - # 新job - self.rollback_id = id - self.rollback_times = 1 - else: + raise MaxRollBackError("回滚次数已达3次, 请手动检查学习通任务点完成情况") + # elif id != self.rollback_id: + # # 新job + # self.rollback_id = id + # self.rollback_times = 1 + else: self.rollback_times += 1 + def new_job(self, id: str) -> None: + if id != self.rollback_id: + self.rollback_id = id + self.rollback_times = 0 -if __name__ == '__main__': +if __name__ == "__main__": try: # 避免异常的无限回滚 RB = RollBackManager() # 初始化登录信息 - username, password, course_list, speed,tiku_config= init_config() + common_config, tiku_config, notification_config = init_config() + username = common_config.get("username","") + password = common_config.get("password","") + course_list = common_config.get("course_list",None) + speed = common_config.get("speed",1) + query_delay = tiku_config.get("delay",0) + notopen_action = common_config.get("notopen_action", "retry") # 获取未开放任务点处理方式 # 规范化播放速度的输入值 speed = min(2.0, max(1.0, speed)) if (not username) or (not password): - username = input("请输入你的手机号,按回车确认\n手机号:") - password = input("请输入你的密码,按回车确认\n密码:") + username = input("请输入你的手机号, 按回车确认\n手机号:") + password = input("请输入你的密码, 按回车确认\n密码:") account = Account(username, password) # 设置题库 tiku = Tiku() - tiku.config_set(tiku_config) # 载入配置 + tiku.config_set(tiku_config) # 载入配置 tiku = tiku.get_tiku_from_config() # 载入题库 - tiku.init_tiku() # 初始化题库 + tiku.init_tiku() # 初始化题库 + # 设置外部通知 + notification = Notification() + notification.config_set(notification_config) + notification = notification.get_notification_from_config() + notification.init_notification() # 实例化超星API - chaoxing = Chaoxing(account=account,tiku=tiku) - # 检查当前登录状态,并检查账号密码 + chaoxing = Chaoxing(account=account, tiku=tiku,query_delay = query_delay) + # 检查当前登录状态, 并检查账号密码 _login_state = chaoxing.login() if not _login_state["status"]: raise LoginError(_login_state["msg"]) @@ -100,7 +183,9 @@ def add_times(self,id:str) -> None: print(f"ID: {course['courseId']} 课程名: {course['title']}") print("*" * 28) try: - course_list = input("请输入想要学习的课程列表,以逗号分隔,例: 2151141,189191,198198\n").split(",") + course_list = input( + "请输入想要学习的课程列表,以逗号分隔,例: 2151141,189191,198198\n" + ).split(",") except Exception as e: raise FormatError("输入格式错误") from e # 筛选需要学习的课程 @@ -110,85 +195,142 @@ def add_times(self,id:str) -> None: if not course_task: course_task = all_course # 开始遍历要学习的课程列表 - logger.info(f"课程列表过滤完毕,当前课程任务数量: {len(course_task)}") + logger.info(f"课程列表过滤完毕, 当前课程任务数量: {len(course_task)}") for course in course_task: logger.info(f"开始学习课程: {course['title']}") # 获取当前课程的所有章节 - point_list = chaoxing.get_course_point(course["courseId"], course["clazzId"], course["cpi"]) + point_list = chaoxing.get_course_point( + course["courseId"], course["clazzId"], course["cpi"] + ) - # 为了支持课程任务回滚,采用下标方式遍历任务点 + # 为了支持课程任务回滚, 采用下标方式遍历任务点 __point_index = 0 + # 记录用户是否选择继续跳过连续的未开放任务点 + auto_skip_notopen = False while __point_index < len(point_list["points"]): point = point_list["points"][__point_index] logger.info(f'当前章节: {point["title"]}') + logger.debug(f"当前章节 __point_index: {__point_index}") # 触发参数: -v + if point["has_finished"]: + logger.info(f'章节:{point["title"]} 已完成所有任务点') + __point_index += 1 + continue + sleep_duration = random.uniform(1, 3) + logger.debug(f"本次随机等待时间: {sleep_duration}") + time.sleep(sleep_duration) # 避免请求过快导致异常, 所以引入随机sleep # 获取当前章节的所有任务点 jobs = [] job_info = None - jobs, job_info = chaoxing.get_job_list(course["clazzId"], course["courseId"], course["cpi"], point["id"]) - + jobs, job_info = chaoxing.get_job_list( + course["clazzId"], course["courseId"], course["cpi"], point["id"] + ) + # bookID = job_info["knowledgeid"] # 获取视频ID - - # 发现未开放章节,尝试回滚上一个任务重新完成一次 + + # 发现未开放章节, 根据配置处理 try: - if job_info.get('notOpen',False): - __point_index -= 1 # 默认第一个任务总是开放的 - # 针对题库启用情况 - if not tiku or tiku.DISABLE or not tiku.SUBMIT: - # 未启用题库或未开启题库提交,章节检测未完成会导致无法开始下一章,直接退出 - logger.error(f"章节未开启,可能由于上一章节的章节检测未完成,请手动完成并提交再重试,或者开启题库并启用提交") - break - RB.add_times(point["id"]) - continue + if job_info.get("notOpen", False): + # 根据配置选择处理方式 + if notopen_action == "retry": + # 默认处理方式:重试 + __point_index -= 1 # 默认第一个任务总是开放的 + # 针对题库启用情况 + if not tiku or tiku.DISABLE or not tiku.SUBMIT: + # 未启用题库或未开启题库提交, 章节检测未完成会导致无法开始下一章, 直接退出 + logger.error( + "章节未开启, 可能由于上一章节的章节检测未完成, 也可能由于该章节因为时效已关闭," + "请手动检查完成并提交再重试。或者在配置中配置(自动跳过关闭章节/开启题库并启用提交)" + ) + break + RB.add_times(point["id"]) + continue + elif notopen_action == "ask": + # 询问模式 - 判断是否需要询问 + if not auto_skip_notopen: + user_choice = input(f"章节 {point['title']} 未开放,是否继续检查后续章节?(y/n): ") + if user_choice.lower() != 'y': + # 用户选择停止 + logger.info("根据用户选择停止检查后续章节") + break + # 用户选择继续,设置自动跳过标志 + auto_skip_notopen = True + logger.info("用户选择继续检查后续章节,将自动跳过连续的未开放章节") + else: + logger.info(f"章节 {point['title']} 未开放,自动跳过") + # 无论是否自动跳过,都继续到下一章节 + __point_index += 1 + continue + else: # notopen_action == "continue" + # 继续模式,直接跳过当前章节 + logger.info(f"章节 {point['title']} 未开放,根据配置跳过此章节") + __point_index += 1 + continue + # 遇到开放的章节,重置自动跳过状态 + auto_skip_notopen = False + RB.new_job(point["id"]) except MaxRollBackError as e: - logger.error("回滚次数已达3次,请手动检查学习通任务点完成情况") - # 跳过该课程,继续下一课程 + logger.error("回滚次数已达3次, 请手动检查学习通任务点完成情况") + # 跳过该课程, 继续下一课程 break - - + chaoxing.rollback_times = RB.rollback_times # 可能存在章节无任何内容的情况 if not jobs: + if RB.rollback_times > 0: + logger.trace(f"回滚中 尝试空页面任务, 任务章节: {course['title']}") + chaoxing.study_emptypage(course, point) __point_index += 1 continue # 遍历所有任务点 for job in jobs: # 视频任务 if job["type"] == "video": - # TODO: 目前这个记录功能还不够完善,中途退出的课程ID也会被记录 + # TODO: 目前这个记录功能还不够完善, 中途退出的课程ID也会被记录 # TextBookID = getText() # 获取学习过的课程ID - # if TextBookID.count(bookID) > 0: - # logger.info(f"课程: {course['title']} 章节: {point['title']} 任务: {job['title']} 已学习过或在学习中,跳过") # 如果已经学习过该课程,则跳过 - # break # 如果已经学习过该课程,则跳过 + # if TextBookID.count(bookID) > 0: + # logger.info(f"课程: {course['title']} 章节: {point['title']} 任务: {job['title']} 已学习过或在学习中, 跳过") # 如果已经学习过该课程, 则跳过 + # break # 如果已经学习过该课程, 则跳过 # appendText(bookID) # 记录正在学习的课程ID - logger.trace(f"识别到视频任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") + logger.trace( + f"识别到视频任务, 任务章节: {course['title']} 任务ID: {job['jobid']}" + ) # 超星的接口没有返回当前任务是否为Audio音频任务 - isAudio = False - try: - chaoxing.study_video(course, job, job_info, _speed=speed, _type="Video") - except JSONDecodeError as e: - logger.warning("当前任务非视频任务,正在尝试音频任务解码") - isAudio = True - if isAudio: - try: - chaoxing.study_video(course, job, job_info, _speed=speed, _type="Audio") - except JSONDecodeError as e: - logger.warning(f"出现异常任务 -> 任务章节: {course['title']} 任务ID: {job['jobid']}, 已跳过") + video_result = chaoxing.study_video( + course, job, job_info, _speed=speed, _type="Video" + ) + if chaoxing.StudyResult.is_failure(video_result): + logger.warning("当前任务非视频任务, 正在尝试音频任务解码") + video_result = chaoxing.study_video( + course, job, job_info, _speed=speed, _type="Audio") + if chaoxing.StudyResult.is_failure(video_result): + logger.warning( + f"出现异常任务 -> 任务章节: {course['title']} 任务ID: {job['jobid']}, 已跳过" + ) # 文档任务 elif job["type"] == "document": - logger.trace(f"识别到文档任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") + logger.trace( + f"识别到文档任务, 任务章节: {course['title']} 任务ID: {job['jobid']}" + ) chaoxing.study_document(course, job) # 测验任务 elif job["type"] == "workid": logger.trace(f"识别到章节检测任务, 任务章节: {course['title']}") - chaoxing.study_work(course, job,job_info) + chaoxing.study_work(course, job, job_info) # 阅读任务 elif job["type"] == "read": logger.trace(f"识别到阅读任务, 任务章节: {course['title']}") - chaoxing.strdy_read(course, job,job_info) + chaoxing.strdy_read(course, job, job_info) __point_index += 1 logger.info("所有课程学习任务已完成") + notification.send( "chaoxing : 所有课程学习任务已完成") + except SystemExit as e: + if e.code == 0: # 正常退出 + sys.exit(0) + else: + raise except BaseException as e: import traceback logger.error(f"错误: {type(e).__name__}: {e}") logger.error(traceback.format_exc()) - raise e \ No newline at end of file + notification.send(f"chaoxing : 出现错误", f"{type(e).__name__}: {e}\n{traceback.format_exc()}") + raise e diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7b4ffc9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "chaoxing" +version = "3.1.1" +description = "超星学习通/超星尔雅/泛雅超星全自动无人值守完成任务点" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "argparse>=1.4.0", + "beautifulsoup4>=4.13.3", + "celery>=5.4.0", + "flask>=3.1.0", + "fonttools>=4.56.0", + "loguru>=0.7.3", + "lxml>=5.3.1", + "openai>=1.66.2", + "pyaes>=1.6.1", + "requests>=2.32.3", +] diff --git a/requirements.txt b/requirements.txt index 4575e6d..6c0f5f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ argparse loguru celery flask -fonttools \ No newline at end of file +fonttools +openai \ No newline at end of file From 19224345d1cfece68c800c44d0d00e5a7f4e8cad Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Sun, 6 Apr 2025 17:48:06 +0800 Subject: [PATCH 13/21] =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +- api/answer.py | 300 ++-------------------- api/base.py | 591 +++++++++++++------------------------------ api/cipher.py | 10 +- api/config.py | 8 +- api/cookies.py | 6 +- api/cxsecret_font.py | 3 +- api/decode.py | 205 ++++++--------- api/exceptions.py | 2 +- api/font_decoder.py | 15 +- api/logger.py | 2 +- api/notification.py | 116 --------- api/process.py | 15 +- app.py | 4 +- config_template.ini | 44 +--- main.py | 290 ++++++--------------- pyproject.toml | 18 -- requirements.txt | 3 +- 18 files changed, 378 insertions(+), 1262 deletions(-) delete mode 100644 api/notification.py delete mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 9147193..fea5fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -133,21 +133,15 @@ build/ dist/ *.spec -# python-uv.lock -# just like Pipfile.lock, -uv.lock - # Custom files .cookies.txt cookies.txt .config.ini config.ini chaoxing.log -config*.ini .chaoxing.log ./config.ini ./chaoxing.log ./cookies.txt .idea/ -.vscode/ -cache.json \ No newline at end of file +cache.json diff --git a/api/answer.py b/api/answer.py index e09ca93..8273647 100644 --- a/api/answer.py +++ b/api/answer.py @@ -5,10 +5,6 @@ from api.logger import logger import random from urllib3 import disable_warnings,exceptions -from openai import OpenAI -import httpx -from re import sub -import time # 关闭警告 disable_warnings(exceptions.InsecureRequestWarning) @@ -41,7 +37,6 @@ class Tiku: CONFIG_PATH = "config.ini" # 默认配置文件路径 DISABLE = False # 停用标志 SUBMIT = False # 提交标志 - COVER_RATE = 0.8 # 覆盖率 def __init__(self) -> None: self._name = None @@ -73,19 +68,18 @@ def token(self,value): self._token = value def init_tiku(self): - # 仅用于题库初始化, 应该在题库载入后作初始化调用, 随后才可以使用题库 + # 仅用于题库初始化,应该在题库载入后作初始化调用,随后才可以使用题库 # 尝试根据配置文件设置提交模式 if not self._conf: self.config_set(self._get_conf()) if not self.DISABLE: # 设置提交模式 self.SUBMIT = True if self._conf['submit'] == 'true' else False - self.COVER_RATE = self._conf['cover_rate'] # 调用自定义题库初始化 self._init_tiku() def _init_tiku(self): - # 仅用于题库初始化, 例如配置token, 交由自定义题库完成 + # 仅用于题库初始化,例如配置token,交由自定义题库完成 pass def config_set(self,config): @@ -93,14 +87,14 @@ def config_set(self,config): def _get_conf(self): """ - 从默认配置文件查询配置, 如果未能查到, 停用题库 + 从默认配置文件查询配置,如果未能查到,停用题库 """ try: config = configparser.ConfigParser() config.read(self.CONFIG_PATH, encoding="utf8") return config['tiku'] - except (KeyError, FileNotFoundError): - logger.info("未找到tiku配置, 已忽略题库功能") + except KeyError or FileNotFoundError: + logger.info("未找到tiku配置,已忽略题库功能") self.DISABLE = True return None @@ -108,13 +102,9 @@ def query(self,q_info:dict): if self.DISABLE: return None - # 预处理, 去除【单选题】这样与标题无关的字段 + # 预处理,去除【单选题】这样与标题无关的字段 # 此处需要改进!!! - logger.debug(f"原始标题:{q_info['title']}") - q_info['title'] = sub(r'^\d+', '', q_info['title']) - q_info['title'] = sub(r'^(?:【.*?】)+', '', q_info['title']) - q_info['title'] = sub(r'(\d+\.\d+分)$', '', q_info['title']) - logger.debug(f"处理后标题:{q_info['title']}") + q_info['title'] = q_info['title'][6:] # 暂时直接用裁切解决 # 先过缓存 cache_dao = CacheDAO() @@ -131,16 +121,15 @@ def query(self,q_info:dict): return answer logger.error(f"从{self.name}获取答案失败:{q_info['title']}") return None - def _query(self,q_info:dict): """ - 查询接口, 交由自定义题库实现 + 查询接口,交由自定义题库实现 """ pass def get_tiku_from_config(self): """ - 从配置文件加载题库, 这个配置可以是用户提供, 可以是默认配置文件 + 从配置文件加载题库,这个配置可以是用户提供,可以是默认配置文件 """ if not self._conf: # 尝试从默认配置文件加载 @@ -152,8 +141,7 @@ def get_tiku_from_config(self): if not cls_name: raise KeyError except KeyError: - self.DISABLE = True - logger.error("未找到题库配置, 已忽略题库功能") + logger.error("未找到题库配置,已忽略题库功能") return self new_cls = globals()[cls_name]() new_cls.config_set(self._conf) @@ -161,7 +149,7 @@ def get_tiku_from_config(self): def jugement_select(self,answer:str) -> bool: """ - 这是一个专用的方法, 要求配置维护两个选项列表, 一份用于正确选项, 一份用于错误选项, 以应对题库对判断题答案响应的各种可能的情况 + 这是一个专用的方法,要求配置维护两个选项列表,一份用于正确选项,一份用于错误选项,以应对题库对判断题答案响应的各种可能的情况 它的作用是将获取到的答案answer与可能的选项列对比并返回对应的布尔值 """ if self.DISABLE: @@ -175,15 +163,15 @@ def jugement_select(self,answer:str) -> bool: elif answer in false_list: return False else: - # 无法判断, 随机选择 - logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误, 请自行判断并加入配置文件重启脚本, 本次将会随机选择选项') + # 无法判断,随机选择 + logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误,请自行判断并加入配置文件重启脚本,本次将会随机选择选项') return random.choice([True,False]) def get_submit_params(self): """ - 这是一个专用方法, 用于根据当前设置的提交模式, 响应对应的答题提交API中的pyFlag值 + 这是一个专用方法,用于根据当前设置的提交模式,响应对应的答题提交API中的pyFlag值 """ - # 留空直接提交, 1保存但不提交 + # 留空直接提交,1保存但不提交 if self.SUBMIT: return "" else: @@ -199,7 +187,7 @@ def __init__(self) -> None: self.api = 'https://tk.enncy.cn/query' self._token = None self._token_index = 0 # token队列计数器 - self._times = 100 # 查询次数剩余, 初始化为100, 查询后校对修正 + self._times = 100 # 查询次数剩余,初始化为100,查询后校对修正 def _query(self,q_info:dict): res = requests.get( @@ -213,14 +201,14 @@ def _query(self,q_info:dict): if res.status_code == 200: res_json = res.json() if not res_json['code']: - # 如果是因为TOKEN次数到期, 则更换token + # 如果是因为TOKEN次数到期,则更换token if self._times == 0 or '次数不足' in res_json['data']['answer']: - logger.info(f'TOKEN查询次数不足, 将会更换并重新搜题') + logger.info(f'TOKEN查询次数不足,将会更换并重新搜题') self._token_index += 1 self.load_token() # 重新查询 return self._query(q_info) - logger.error(f'{self.name}查询失败:\n\t剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n\t消息:{res_json["message"]}') + logger.error(f'{self.name}查询失败:\n剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n消息:{res_json["message"]}') return None self._times = res_json["data"].get("times",self._times) return res_json['data']['answer'].strip() @@ -232,256 +220,10 @@ def load_token(self): token_list = self._conf['tokens'].split(',') if self._token_index == len(token_list): # TOKEN 用完 - logger.error('TOKEN用完, 请自行更换再重启脚本') - raise Exception(f'{self.name} TOKEN 已用完, 请更换') + logger.error('TOKEN用完,请自行更换再重启脚本') + raise Exception(f'{self.name} TOKEN 已用完,请更换') self._token = token_list[self._token_index] def _init_tiku(self): self.load_token() -class TikuLike(Tiku): - # Like知识库实现 - def __init__(self) -> None: - super().__init__() - self.name = 'Like知识库' - self.ver = '1.0.8' #对应官网API版本 - self.query_api = 'https://api.datam.site/search' - self.balance_api = 'https://api.datam.site/balance' - self.homepage = 'https://www.datam.site' - self._model = None - self._token = None - self._times = -1 - self._search = False - self._count = 0 - - def _query(self,q_info:dict): - q_info_map = {"single":"【单选题】","multiple":"【多选题】","completion":"【填空题】","judgement":"【判断题】"} - api_params_map = {0:"others",1:"choose",2:"fills",3:"judge"} - q_info_prefix = q_info_map.get(q_info['type'],"【其他类型题目】") - options = ', '.join(q_info['options']) if isinstance(q_info['options'], list) else q_info['options'] - question = "{}{}\n{}".format(q_info_prefix,q_info['title'],options) - ret = "" - ans = "" - res = requests.post( - self.query_api, - json={ - 'query': question, - 'token': self._token, - 'model': self._model if self._model else '', - 'search': self._search - }, - verify=False - ) - - if res.status_code == 200: - res_json = res.json() - q_type = res_json['data'].get('type',0) - params = api_params_map.get(q_type,"") - ans = res_json['data'].get(params,"") - if q_type == 3: - ans = "正确" if ans ==1 else "错误" - else: - logger.error(f'{self.name}查询失败:\n{res.text}') - return None - - ret += str(ans) - - self._times -= 1 - - #10次查询后更新实际次数 - self._count = (self._count+1) % 10 - - if self._count == 0: - self.update_times() - - return ret - - def update_times(self): - res = requests.post( - self.balance_api, - json={ - 'token': self._token, - }, - verify=False - ) - if res.status_code == 200: - res_json = res.json() - self._times = res_json["data"].get("balance",self._times) - logger.info("当前LIKE知识库Token剩余查询次数为: {}".format(str(self._times))) - else: - logger.error('TOKEN出现错误,请检查后再试') - - def load_token(self): - token = self._conf['tokens'].split(',')[-1] if ',' in self._conf['tokens'] else self._conf['tokens'] - self._token = token - - def load_config(self): - var_params = {"likeapi_search":self._search,"likeapi_model":self._model} - config_params = {"likeapi_search":False, "likeapi_model":None} - - for k,v in config_params.items(): - if k in self._conf: - var_params[k] = self._conf[k] - else: - var_params[k] = v - - def _init_tiku(self): - self.load_token() - self.load_config() - self.update_times() - -class TikuAdapter(Tiku): - # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter - def __init__(self) -> None: - super().__init__() - self.name = 'TikuAdapter题库' - self.api = '' - - def _query(self, q_info: dict): - # 判断题目类型 - if q_info['type'] == "single": - type = 0 - elif q_info['type'] == 'multiple': - type = 1 - elif q_info['type'] == 'completion': - type = 2 - elif q_info['type'] == 'judgement': - type = 3 - else: - type = 4 - - options = q_info['options'] - res = requests.post( - self.api, - json={ - 'question': q_info['title'], - 'options': [sub(r'^[A-Za-z]\.?、?\s?', '', option) for option in options.split('\n')], - 'type': type - }, - verify=False - ) - if res.status_code == 200: - res_json = res.json() - # if bool(res_json['plat']): - # plat无论搜没搜到答案都返回0 - # 这个参数是tikuadapter用来设定自定义的平台类型 - if not len(res_json['answer']['bestAnswer']): - logger.error("查询失败, 返回:" + res.text) - return None - sep = "\n" - return sep.join(res_json['answer']['bestAnswer']).strip() - # else: - # logger.error(f'{self.name}查询失败:\n{res.text}') - return None - - def _init_tiku(self): - # self.load_token() - self.api = self._conf['url'] - -class AI(Tiku): - # AI大模型答题实现 - def __init__(self) -> None: - super().__init__() - self.name = 'AI大模型答题' - self.last_request_time = None - - def _query(self, q_info: dict): - if self.http_proxy: - proxy = self.http_proxy - httpx_client = httpx.Client(proxy=proxy) - client = OpenAI(http_client=httpx_client, base_url = self.endpoint,api_key = self.key) - else: - client = OpenAI(base_url = self.endpoint,api_key = self.key) - # 判断题目类型 - if q_info['type'] == "single": - completion = client.chat.completions.create( - model = self.model, - messages=[ - { - "role": "system", - "content": "本题为单选题,你只能选择一个选项,请根据题目和选项回答问题,以json格式输出正确的选项内容,特别注意回答的内容需要去除选项内容前的字母,示例回答:{\"Answer\": [\"答案\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" - }, - { - "role": "user", - "content": f"题目:{q_info['title']}\n选项:{q_info['options']}" - } - ] - ) - elif q_info['type'] == 'multiple': - completion = client.chat.completions.create( - model = self.model, - messages=[ - { - "role": "system", - "content": "本题为多选题,你必须选择两个或以上选项,请根据题目和选项回答问题,以json格式输出正确的选项内容,特别注意回答的内容需要去除选项内容前的字母,示例回答:{\"Answer\": [\"答案1\",\n\"答案2\",\n\"答案3\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" - }, - { - "role": "user", - "content": f"题目:{q_info['title']}\n选项:{q_info['options']}" - } - ] - ) - elif q_info['type'] == 'completion': - completion = client.chat.completions.create( - model = self.model, - messages=[ - { - "role": "system", - "content": "本题为填空题,你必须根据语境和相关知识填入合适的内容,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"答案\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" - }, - { - "role": "user", - "content": f"题目:{q_info['title']}" - } - ] - ) - elif q_info['type'] == 'judgement': - completion = client.chat.completions.create( - model = self.model, - messages=[ - { - "role": "system", - "content": "本题为判断题,你只能回答正确或者错误,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"正确\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" - }, - { - "role": "user", - "content": f"题目:{q_info['title']}" - } - ] - ) - else: - completion = client.chat.completions.create( - model = self.model, - messages=[ - { - "role": "system", - "content": "本题为简答题,你必须根据语境和相关知识填入合适的内容,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"这是我的答案\"]}。除此之外不要输出任何多余的内容。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" - }, - { - "role": "user", - "content": f"题目:{q_info['title']}" - } - ] - ) - - try: - if self.last_request_time: - interval_time = time.time() - self.last_request_time - if interval_time < self.min_interval_seconds: - sleep_time = self.min_interval_seconds - interval_time - logger.debug(f"API请求间隔过短, 等待 {sleep_time} 秒") - time.sleep(sleep_time) - self.last_request_time = time.time() - response = json.loads(completion.choices[0].message.content) - sep = "\n" - return sep.join(response['Answer']).strip() - except: - logger.error("无法解析大模型输出内容") - return None - - def _init_tiku(self): - self.endpoint = self._conf['endpoint'] - self.key = self._conf['key'] - self.model = self._conf['model'] - self.http_proxy = self._conf['http_proxy'] - self.min_interval_seconds = int(self._conf['min_interval_seconds']) diff --git a/api/base.py b/api/base.py index 2a2d2d2..6734caf 100644 --- a/api/base.py +++ b/api/base.py @@ -11,15 +11,13 @@ from api.cookies import save_cookies, use_cookies from api.process import show_progress from api.config import GlobalConst as gc -from api.decode import ( - decode_course_list, - decode_course_point, - decode_course_card, - decode_course_folder, - decode_questions_info, -) +from api.decode import (decode_course_list, + decode_course_point, + decode_course_card, + decode_course_folder, + decode_questions_info + ) from api.answer import * -from enum import Enum def get_timestamp(): return str(int(time.time() * 1000)) @@ -32,8 +30,8 @@ def get_random_seconds(): def init_session(isVideo: bool = False, isAudio: bool = False): _session = requests.session() _session.verify = False - _session.mount("http://", HTTPAdapter(max_retries=3)) - _session.mount("https://", HTTPAdapter(max_retries=3)) + _session.mount('http://', HTTPAdapter(max_retries=3)) + _session.mount('https://', HTTPAdapter(max_retries=3)) if isVideo: _session.headers = gc.VIDEO_HEADERS elif isAudio: @@ -49,49 +47,32 @@ class Account: password = None last_login = None isSuccess = None - def __init__(self, _username, _password): self.username = _username self.password = _password class Chaoxing: - class StudyResult(Enum): - SUCCESS = 0 - FORBIDDEN = 1 # 403 - ERROR = 2 - TIMEOUT = 3 - - @staticmethod - def is_success(result): - return result == Chaoxing.StudyResult.SUCCESS - - @staticmethod - def is_failure(result): - return result != Chaoxing.StudyResult.SUCCESS - - def __init__(self, account: Account = None, tiku: Tiku = None,**kwargs): + def __init__(self, account: Account = None,tiku:Tiku=None): self.account = account self.cipher = AESCipher() self.tiku = tiku - self.kwargs = kwargs - self.rollback_times = 0 def login(self): _session = requests.session() _session.verify = False _url = "https://passport2.chaoxing.com/fanyalogin" - _data = { - "fid": "-1", - "uname": self.cipher.encrypt(self.account.username), - "password": self.cipher.encrypt(self.account.password), - "refer": "https%3A%2F%2Fi.chaoxing.com", - "t": True, - "forbidotherlogin": 0, - "validate": "", - "doubleFactorLogin": 0, - "independentId": 0, - } + _data = {"fid": "-1", + "uname": self.cipher.encrypt(self.account.username), + "password": self.cipher.encrypt(self.account.password), + "refer": "https%3A%2F%2Fi.chaoxing.com", + "t": True, + "forbidotherlogin": 0, + "validate": "", + "doubleFactorLogin": 0, + "independentId": 0, + } + logger.trace("正在尝试登录...") resp = _session.post(_url, headers=gc.HEADERS, data=_data) if resp and resp.json()["status"] == True: @@ -112,16 +93,21 @@ def get_uid(self): def get_course_list(self): _session = init_session() _url = "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata" - _data = {"courseType": 1, "courseFolderId": 0, "query": "", "superstarClass": 0} + _data = { + "courseType": 1, + "courseFolderId": 0, + "query": "", + "superstarClass": 0 + } logger.trace("正在读取所有的课程列表...") - # 接口突然抽风, 增加headers + # 接口突然抽风,增加headers _headers = { "Host": "mooc2-ans.chaoxing.com", - "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform": "\"Windows\"", "X-Requested-With": "XMLHttpRequest", "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", "Accept": "text/html, */*; q=0.01", - "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', + "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "sec-ch-ua-mobile": "?0", "Origin": "https://mooc2-ans.chaoxing.com", @@ -129,9 +115,9 @@ def get_course_list(self): "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", "Referer": "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction?moocDomain=https://mooc1-1.chaoxing.com/mooc-ans", - "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", + "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" } - _resp = _session.post(_url, headers=_headers, data=_data) + _resp = _session.post(_url,headers=_headers,data=_data) # logger.trace(f"原始课程列表内容:\n{_resp.text}") logger.info("课程列表读取完毕...") course_list = decode_course_list(_resp.text) @@ -144,7 +130,7 @@ def get_course_list(self): "courseType": 1, "courseFolderId": folder["id"], "query": "", - "superstarClass": 0, + "superstarClass": 0 } _resp = _session.post(_url, data=_data) course_list += decode_course_list(_resp.text) @@ -163,17 +149,13 @@ def get_job_list(self, _clazzid, _courseid, _cpi, _knowledgeid): _session = init_session() job_list = [] job_info = {} - for _possible_num in [ - "0", - "1", - "2", - ]: # 学习界面任务卡片数, 很少有3个的, 但是对于章节解锁任务点少一个都不行, 可以从API /mooc-ans/mycourse/studentstudyAjax获取值, 或者干脆直接加, 但二者都会造成额外的请求 + for _possible_num in ["0", "1","2"]: # 学习界面任务卡片数,很少有3个的,但是对于章节解锁任务点少一个都不行,可以从API /mooc-ans/mycourse/studentstudyAjax获取值,或者干脆直接加,但二者都会造成额外的请求 _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" logger.trace("开始读取章节所有任务点...") _resp = _session.get(_url) _job_list, _job_info = decode_course_card(_resp.text) - if _job_info.get("notOpen", False): - # 直接返回, 节省一次请求 + if _job_info.get('notOpen',False): + # 直接返回,节省一次请求 logger.info("该章节未开放") return [], _job_info job_list += _job_list @@ -186,60 +168,47 @@ def get_job_list(self, _clazzid, _courseid, _cpi, _knowledgeid): def get_enc(self, clazzId, jobid, objectId, playingTime, duration, userid): return md5( - f"[{clazzId}][{userid}][{jobid}][{objectId}][{playingTime * 1000}][d_yHJ!$pdA~5][{duration * 1000}][0_{duration}]".encode() - ).hexdigest() - - def video_progress_log( - self, - _session, - _course, - _job, - _job_info, - _dtoken, - _duration, - _playingTime, - _type: str = "Video", - ): - if "courseId" in _job["otherinfo"]: + f"[{clazzId}][{userid}][{jobid}][{objectId}][{playingTime * 1000}][d_yHJ!$pdA~5][{duration * 1000}][0_{duration}]" + .encode()).hexdigest() + + def video_progress_log(self, _session, _course, _job, _job_info, _dtoken, _duration, _playingTime, _type: str = "Video"): + if "courseId" in _job['otherinfo']: _mid_text = f"otherInfo={_job['otherinfo']}&" else: _mid_text = f"otherInfo={_job['otherinfo']}&courseId={_course['courseId']}&" _success = False for _possible_rt in ["0.9", "1"]: - _url = ( - f"https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" - f"{_course['cpi']}/" - f"{_dtoken}?" - f"clazzId={_course['clazzId']}&" - f"playingTime={_playingTime}&" - f"duration={_duration}&" - f"clipTime=0_{_duration}&" - f"objectId={_job['objectid']}&" - f"{_mid_text}" - f"jobid={_job['jobid']}&" - f"userid={self.get_uid()}&" - f"isdrag=3&" - f"view=pc&" - f"enc={self.get_enc(_course['clazzId'], _job['jobid'], _job['objectid'], _playingTime, _duration, self.get_uid())}&" - f"rt={_possible_rt}&" - f"dtype={_type}&" - f"_t={get_timestamp()}" - ) + _url = (f"https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" + f"{_course['cpi']}/" + f"{_dtoken}?" + f"clazzId={_course['clazzId']}&" + f"playingTime={_playingTime}&" + f"duration={_duration}&" + f"clipTime=0_{_duration}&" + f"objectId={_job['objectid']}&" + f"{_mid_text}" + f"jobid={_job['jobid']}&" + f"userid={self.get_uid()}&" + f"isdrag=3&" + f"view=pc&" + f"enc={self.get_enc(_course['clazzId'], _job['jobid'], _job['objectid'], _playingTime, _duration, self.get_uid())}&" + f"rt={_possible_rt}&" + f"dtype={_type}&" + f"_t={get_timestamp()}") resp = _session.get(_url) if resp.status_code == 200: _success = True - break # 如果返回为200正常, 则跳出循环 + break # 如果返回为200正常,则跳出循环 elif resp.status_code == 403: - continue # 如果出现403无权限报错, 则继续尝试不同的rt参数 + continue # 如果出现403无权限报错,则继续尝试不同的rt参数 if _success: - return resp.json(), 200 + return resp.json() else: - # 若出现两个rt参数都返回403的情况, 则跳过当前任务 - logger.warning("出现403报错, 尝试修复无效, 正在跳过当前任务点...") - return {"isPassed": False}, 403 # 返回一个字典和当前状态 - def study_video( - self, _course, _job, _job_info, _speed: float = 1.0, _type: str = "Video" - ) -> StudyResult: + # 若出现两个rt参数都返回403的情况,则跳过当前任务 + logger.warning("出现403报错,尝试修复无效,正在跳过当前任务点...") + return False + + def study_video(self, _course, _job, _job_info, _speed: float = 1.0, _type: str = "Video"): if _type == "Video": _session = init_session(isVideo=True) else: @@ -256,419 +225,203 @@ def study_video( _isFinished = False _playingTime = 0 logger.info(f"开始任务: {_job['name']}, 总时长: {_duration}秒") - state = 200 while not _isFinished: if _isFinished: _playingTime = _duration - _isPassed, state = self.video_progress_log( - _session, - _course, - _job, - _job_info, - _dtoken, - _duration, - _playingTime, - _type, - ) + _isPassed = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, _duration, _playingTime, _type) if not _isPassed or (_isPassed and _isPassed["isPassed"]): break - if _isPassed and not _isPassed["isPassed"] and state == 403: - return self.StudyResult.FORBIDDEN _wait_time = get_random_seconds() if _playingTime + _wait_time >= int(_duration): _wait_time = int(_duration) - _playingTime - _isPassed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, _duration, _duration, _type) - if _isPassed['isPassed']: - _isFinished = True + _isFinished = True # 播放进度条 - show_progress(_job["name"], _playingTime, _wait_time, _duration, _speed) + show_progress(_job['name'], _playingTime, _wait_time, _duration, _speed) _playingTime += _wait_time print("\r", end="", flush=True) logger.info(f"任务完成: {_job['name']}") - return self.StudyResult.SUCCESS - else: - return self.StudyResult.ERROR - def study_document(self, _course, _job) -> StudyResult: - """ - Study a document in Chaoxing platform. - - This method makes a GET request to fetch document information for a given course and job. - - Args: - _course (dict): Dictionary containing course information with keys: - - courseId: ID of the course - - clazzId: ID of the class - _job (dict): Dictionary containing job information with keys: - - jobid: ID of the job - - otherinfo: String containing node information - - jtoken: Authentication token for the job - - Returns: - requests.Response: Response object from the GET request - - Note: - This method requires the following helper functions: - - init_session(): To initialize a new session - - get_timestamp(): To get current timestamp - - re module for regular expression matching - """ + + def study_document(self, _course, _job): _session = init_session() _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()}" _resp = _session.get(_url) - if _resp.status_code != 200: - return self.StudyResult.ERROR - else: - return self.StudyResult.SUCCESS - def study_work(self, _course, _job, _job_info) -> StudyResult: + def study_work(self, _course, _job,_job_info) -> None: if self.tiku.DISABLE or not self.tiku: - return self.StudyResult.SUCCESS - _ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码, 帮助修复#391错误 + return None + _ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码,帮助修复#391错误 - def random_answer(options: str) -> str: - answer = "" + def random_answer(options:str) -> str: + answer = '' if not options: return answer - - if q["type"] == "multiple": - logger.debug(f"当前选项列表[cut前] -> {options}") + + if q['type'] == "multiple": _op_list = multi_cut(options) - logger.debug(f"当前选项列表[cut后] -> {_op_list}") - - if not _op_list: - logger.error( - "选项为空, 未能正确提取题目选项信息! 请反馈并提供以上信息" - ) - return answer - - for i in range( - random.choices([2, 3, 4], weights=[0.1, 0.5, 0.4], k=1)[0] - ): # 此处表示随机多选答案几率:2个 10%, 3个 50%, 4个 40% + for i in range(random.choices([2,3,4],weights=[0.1,0.5,0.4],k=1)[0]): # 此处表示随机多选答案几率:2个 10%,3个 50% ,4个 40% _choice = random.choice(_op_list) _op_list.remove(_choice) - answer += _choice[:1] # 取首字为答案, 例如A或B - # 对答案进行排序, 否则会提交失败 + answer+=_choice[:1] # 取首字为答案,例如A或B + # 对答案进行排序,否则会提交失败 answer = "".join(sorted(answer)) - elif q["type"] == "single": - answer = random.choice(options.split("\n"))[ - :1 - ] # 取首字为答案, 例如A或B + elif q['type'] == "single": + answer = random.choice(options.split('\n'))[:1] # 取首字为答案,例如A或B # 判断题处理 - elif q["type"] == "judgement": + elif q['type'] == "judgement": # answer = self.tiku.jugement_select(_answer) - answer = "true" if random.choice([True, False]) else "false" - logger.info(f"随机选择 -> {answer}") + answer = "true" if random.choice([True,False]) else "false" + logger.info(f'随机选择 -> {answer}') return answer - - def multi_cut(answer: str) -> list[str]: - """ - 将多选题答案字符串按特定字符进行切割, 并返回切割后的答案列表. - - 参数: - answer (str): 多选题答案字符串. - - 返回: - list[str]: 切割后的答案列表, 如果无法切割, 则返回默认的选项列表 ['A', 'B', 'C', 'D']. - - 注意: - 如果无法从网页中提取题目信息, 将记录警告日志并返回默认选项列表. - """ - # cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 - # ',' 在常规被正确划分的, 选项中出现, 导致 multi_cut 无法正确划分选项 #391 - # IndexError: Cannot choose from an empty sequence #391 - # 同时为了避免没有考虑到的 case, 应该先按照 '\n' 匹配, 匹配不到再按照其他字符匹配 - cut_char = [ - "\n", - ",", - ",", - "|", - "\r", - "\t", - "#", - "*", - "-", - "_", - "+", - "@", - "~", - "/", - "\\", - ".", - "&", - " ", - "、", - ] # 多选答案切割符 + + def multi_cut(answer:str) -> list[str]: + cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 res = [] for char in cut_char: - res = [ - opt for opt in answer.split(char) if opt.strip() - ] # Filter empty strings - if len(res) > 1: + res = answer.split(char) + if len(res)>1: return res - logger.warning( - f"未能从网页中提取题目信息, 以下为相关信息:\n\t{answer}\n\n{_ORIGIN_HTML_CONTENT}\n" - ) # 尝试输出网页内容和选项信息 - logger.warning("未能正确提取题目选项信息! 请反馈并提供以上信息") - return ["A", "B", "C", "D"] # 默认多选题为4个选项 - - def clean_res(res): - cleaned_res = [] - if isinstance(res, str): - res = [res] - for c in res: - cleaned_res.append(re.sub(r'^[A-Za-z]|[.,!?;:,。!?;:]', '', c)) - - return cleaned_res - - def is_subsequence(a, o): - iter_o = iter(o) - return all(c in iter_o for c in a) - - def with_retry(max_retries=3, delay=1): - def decorator(func): - def wrapper(*args, **kwargs): - retries = 0 - while retries < max_retries: - try: - _resp = func(*args, **kwargs) - - # 未创建完成该测验则不进行答题,目前遇到的情况是未创建完成等同于没题目 - if '教师未创建完成该测验' in _resp.text: - raise Exception(f"教师未创建完成该测验") - - questions = decode_questions_info(_resp.text) - - if _resp.status_code == 200 and questions.get("questions"): - return (_resp, questions) - - logger.warning(f"无效响应 (Code: {getattr(_resp, 'status_code', 'Unknown')}), 重试中... ({retries+1}/{max_retries})") - - except requests.exceptions.RequestException as e: - logger.warning(f"请求失败: {str(e)[:50]}, 重试中... ({retries+1}/{max_retries})") - retries += 1 - time.sleep(delay * (2 ** retries)) - raise Exception(f"超过最大重试次数 ({max_retries})") - return wrapper - return decorator - - # 学习通这里根据参数差异能重定向至两个不同接口, 需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle + logger.warning(f"未能从网页中提取题目信息,以下为相关信息:\n{answer}\n\n{_ORIGIN_HTML_CONTENT}\n") # 尝试输出网页内容和选项信息 + logger.warning("未能正确提取题目选项信息!请反馈并提供以上信息。") + return ['A','B','C','D'] # 默认多选题为4个选项 + + + # 学习通这里根据参数差异能重定向至两个不同接口,需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle _session = init_session() - headers = { + headers={ "Host": "mooc1.chaoxing.com", - "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', + "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform": "\"Windows\"", "Upgrade-Insecure-Requests": "1", "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", "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", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "iframe", - "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", + "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" } cookies = _session.cookies.get_dict() - _url = "https://mooc1.chaoxing.com/mooc-ans/api/work" - - @with_retry(max_retries=3, delay=1) - def fetch_response(): - return requests.get( - _url, - headers=headers, - cookies=cookies, - verify=False, - params={ - "api": "1", - "workId": _job["jobid"].replace("work-", ""), - "jobid": _job["jobid"], - "originJobId": _job["jobid"], - "needRedirect": "true", - "skipHeader": "true", - "knowledgeid": str(_job_info["knowledgeid"]), - "ktoken": _job_info["ktoken"], - "cpi": _job_info["cpi"], - "ut": "s", - "clazzId": _course["clazzId"], - "type": "", - "enc": _job["enc"], - "mooc2": "1", - "courseid": _course["courseId"], - } - ) - - final_resp = {} - questions = {} - - try: - final_resp, questions = fetch_response() - except Exception as e: - logger.error(f"请求失败: {e}") - return self.StudyResult.ERROR - - _ORIGIN_HTML_CONTENT = final_resp.text # 用于配合输出网页源码, 帮助修复#391错误 + + _url = "https://mooc1.chaoxing.com/mooc-ans/api/work" + _resp = requests.get( + _url, + headers=headers, + cookies=cookies, + verify=False, + params = { + "api": "1", + "workId": _job['jobid'].replace("work-",""), + "jobid": _job['jobid'], + "originJobId": _job['jobid'], + "needRedirect": "true", + "skipHeader": "true", + "knowledgeid": str(_job_info['knowledgeid']), + 'ktoken': _job_info['ktoken'], + "cpi": _job_info['cpi'], + "ut": "s", + "clazzId": _course['clazzId'], + "type": "", + "enc": _job['enc'], + "mooc2": "1", + "courseid": _course['courseId'] + } + ) + _ORIGIN_HTML_CONTENT = _resp.text # 用于配合输出网页源码,帮助修复#391错误 + questions = decode_questions_info(_resp.text) # 加载题目信息 # 搜题 - total_questions = len(questions["questions"]) - found_answers = 0 - for q in questions["questions"]: - logger.debug(f"当前题目信息 -> {q}") - # 添加搜题延迟 #428 - 默认0s延迟 - query_delay = self.kwargs.get("query_delay",0) - time.sleep(query_delay) + for q in questions['questions']: res = self.tiku.query(q) - answer = "" + answer = '' if not res: # 随机答题 - answer = random_answer(q["options"]) - q[f'answerSource{q["id"]}'] = "random" + answer = random_answer(q['options']) else: # 根据响应结果选择答案 - if q["type"] == "multiple": + options_list = multi_cut(q['options']) + if q['type'] == "multiple": # 多选处理 - options_list = multi_cut(q["options"]) - for _a in clean_res(multi_cut(res)): + for _a in multi_cut(res): for o in options_list: - if ( - is_subsequence(_a, o) # 去掉各种符号和前面ABCD的答案应当是选项的子序列 - ): + if _a.upper() in o: # 题库返回的答案可能包含选项,如A,B,C,全部转成大写与学习通一致 answer += o[:1] - # 对答案进行排序, 否则会提交失败 + # 对答案进行排序,否则会提交失败 answer = "".join(sorted(answer)) - elif q["type"] == "single": - # 单选也进行切割,主要是防止返回的答案有异常字符 - options_list = multi_cut(q["options"]) - t_res = clean_res(res) + elif q['type'] == 'judgement': + answer = 'true' if self.tiku.jugement_select(res) else 'false' + else: for o in options_list: - if is_subsequence(t_res[0], o): + if res in o: answer = o[:1] break - elif q["type"] == "judgement": - answer = "true" if self.tiku.jugement_select(res) else "false" - elif q["type"] == "completion": - if isinstance(res,list): - answer = "".join(answer) - elif isinstance(res,str): - answer = res - else: - # 其他类型直接使用答案 (目前仅知有简答题,待补充处理) - answer = res - - if not answer: # 检查 answer 是否为空 - logger.warning(f"找到答案但答案未能匹配 -> {res}\t随机选择答案") - answer = random_answer(q["options"]) # 如果为空,则随机选择答案 - q[f'answerSource{q["id"]}'] = "random" - else: - logger.info(f"成功获取到答案:{answer}") - q[f'answerSource{q["id"]}'] = "cover" - found_answers += 1 + # 如果未能匹配,依然随机答题 + answer = answer if answer else random_answer(q['options']) # 填充答案 - q["answerField"][f'answer{q["id"]}'] = answer + q['answerField'][f'answer{q["id"]}'] = answer logger.info(f'{q["title"]} 填写答案为 {answer}') - cover_rate = (found_answers / total_questions) * 100 - logger.info(f"章节检测题库覆盖率: {cover_rate:.0f}%") - # 提交模式 现在与题库绑定,留空直接提交, 1保存但不提交 - if self.tiku.get_submit_params() == "1": - questions["pyFlag"] = "1" - elif cover_rate >= self.tiku.COVER_RATE*100 or self.rollback_times >= 1: - questions["pyFlag"] = "" - else: - questions["pyFlag"] = "1" - logger.info(f"章节检测题库覆盖率低于{self.tiku.COVER_RATE*100:.0f}%,不予提交") + + # 提交模式 现在与题库绑定 + questions['pyFlag'] = self.tiku.get_submit_params() + # 组建提交表单 - if questions["pyFlag"] == "1": - for q in questions["questions"]: - questions.update( - { - f'answer{q["id"]}': - q["answerField"][f'answer{q["id"]}'] if q[f'answerSource{q["id"]}'] == "cover" else '', - f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'], - } - ) - else: - for q in questions["questions"]: - questions.update( - { - f'answer{q["id"]}': q["answerField"][f'answer{q["id"]}'], - f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'], - } - ) + for q in questions["questions"]: + questions.update({ + f'answer{q["id"]}':q['answerField'][f'answer{q["id"]}'], + f'answertype{q["id"]}':q['answerField'][f'answertype{q["id"]}'] + }) + del questions["questions"] res = _session.post( - "https://mooc1.chaoxing.com/mooc-ans/work/addStudentWorkNew", + 'https://mooc1.chaoxing.com/mooc-ans/work/addStudentWorkNew', data=questions, - headers={ + headers= { "Host": "mooc1.chaoxing.com", - "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform": "\"Windows\"", "X-Requested-With": "XMLHttpRequest", "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", "Accept": "application/json, text/javascript, */*; q=0.01", - "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', + "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "sec-ch-ua-mobile": "?0", "Origin": "https://mooc1.chaoxing.com", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", - # "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", - "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", - }, + #"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", + "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" + } ) if res.status_code == 200: res_json = res.json() - if res_json["status"]: - logger.info(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题成功 -> {res_json["msg"]}') + if res_json['status']: + logger.info(f'提交答题成功 -> {res_json["msg"]}') else: - logger.error(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题失败 -> {res_json["msg"]}') - return self.StudyResult.ERROR + logger.error(f'提交答题失败 -> {res_json["msg"]}') else: - logger.error(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题失败 -> {res.text}') - return self.StudyResult.ERROR - return self.StudyResult.SUCCESS + logger.error(f"提交答题失败 -> {res.text}") - def strdy_read(self, _course, _job, _job_info) -> StudyResult: + def strdy_read(self, _course, _job,_job_info) -> None: """ - 阅读任务学习, 仅完成任务点, 并不增长时长 + 阅读任务学习,仅完成任务点,并不增长时长 """ _session = init_session() _resp = _session.get( url="https://mooc1.chaoxing.com/ananas/job/readv2", params={ - "jobid": _job["jobid"], - "knowledgeid": _job_info["knowledgeid"], - "jtoken": _job["jtoken"], - "courseid": _course["courseId"], - "clazzid": _course["clazzId"], - }, + 'jobid': _job['jobid'], + 'knowledgeid':_job_info['knowledgeid'], + 'jtoken': _job['jtoken'], + 'courseid': _course['courseId'], + 'clazzid': _course['clazzId'] + } ) if _resp.status_code != 200: logger.error(f"阅读任务学习失败 -> [{_resp.status_code}]{_resp.text}") - return self.StudyResult.ERROR else: _resp_json = _resp.json() logger.info(f"阅读任务学习 -> {_resp_json['msg']}") - return self.StudyResult.SUCCESS - def study_emptypage(self, _course, _chapterId): - _session = init_session() - # &cpi=0&verificationcode=&mooc2=1µTopicId=0&editorPreview=0 - _resp = _session.get( - url="https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudyAjax", - params={ - "courseId": _course["courseId"], - "clazzid": _course["clazzId"], - "chapterId": _chapterId['id'], - "cpi": 0, - "verificationcode": "", - "mooc2": 1, - "microTopicId": 0, - "editorPreview": 0, - }, - ) - if _resp.status_code != 200: - logger.error(f"空页面任务失败 -> [{_resp.status_code}]{_chapterId['title']}") - return self.StudyResult.ERROR - else: - logger.info(f"空页面任务完成 -> {_chapterId['title']}") - return self.StudyResult.SUCCESS + diff --git a/api/cipher.py b/api/cipher.py index efe31ac..e06e7cd 100644 --- a/api/cipher.py +++ b/api/cipher.py @@ -6,7 +6,7 @@ def pkcs7_unpadding(string): - return string[0 : -ord(string[-1])] + return string[0:-ord(string[-1])] def pkcs7_padding(s, block_size=16): @@ -29,15 +29,15 @@ def split_to_data_blocks(byte_str, block_size=16): return blocks -class AESCipher: +class AESCipher(): def __init__(self): self.key = str(gc.AESKey).encode("utf8") self.iv = str(gc.AESKey).encode("utf8") def encrypt(self, plaintext: str): - ciphertext = b"" + ciphertext = b'' cbc = pyaes.AESModeOfOperationCBC(self.key, self.iv) - plaintext = plaintext.encode("utf-8") + plaintext = plaintext.encode('utf-8') blocks = split_to_data_blocks(pkcs7_padding(plaintext)) for b in blocks: ciphertext = ciphertext + cbc.encrypt(b) @@ -51,4 +51,4 @@ def encrypt(self, plaintext: str): # ptext = b"" # for b in split_to_data_blocks(ciphertext): # ptext = ptext + cbc.decrypt(b) - # return pkcs7_unpadding(ptext.decode()) + # return pkcs7_unpadding(ptext.decode()) \ No newline at end of file diff --git a/api/config.py b/api/config.py index bc0a956..bee24b8 100644 --- a/api/config.py +++ b/api/config.py @@ -3,17 +3,17 @@ class GlobalConst: AESKey = "u2oh6Vu^HWe4_AES" HEADERS = { "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", - "Sec-Ch-Ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"', + "Sec-Ch-Ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"' } COOKIES_PATH = "cookies.txt" VIDEO_HEADERS = { "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", "Referer": "https://mooc1.chaoxing.com/ananas/modules/video/index.html?v=2023-1110-1610", - "Host": "mooc1.chaoxing.com", + "Host": "mooc1.chaoxing.com" } AUDIO_HEADERS = { "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", "Referer": "https://mooc1.chaoxing.com/ananas/modules/audio/index_new.html?v=2023-0428-1705", - "Host": "mooc1.chaoxing.com", + "Host": "mooc1.chaoxing.com" } - THRESHOLD = 3 + THRESHOLD = 3 \ No newline at end of file diff --git a/api/cookies.py b/api/cookies.py index ce99d99..1158d8e 100644 --- a/api/cookies.py +++ b/api/cookies.py @@ -5,12 +5,12 @@ def save_cookies(_session): - with open(gc.COOKIES_PATH, "wb") as f: + with open(gc.COOKIES_PATH, 'wb') as f: pickle.dump(_session.cookies, f) def use_cookies(): if os.path.exists(gc.COOKIES_PATH): - with open(gc.COOKIES_PATH, "rb") as f: + with open(gc.COOKIES_PATH, 'rb') as f: _cookies = pickle.load(f) - return _cookies + return _cookies \ No newline at end of file diff --git a/api/cxsecret_font.py b/api/cxsecret_font.py index d66fb85..f44fb5b 100644 --- a/api/cxsecret_font.py +++ b/api/cxsecret_font.py @@ -1,7 +1,7 @@ ## # @Author: SocialSisterYi # @Reference: https://github.com/SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy -# +# import base64 import hashlib @@ -24,7 +24,6 @@ class FontHashDAO: """原始字体hashmap DAO""" - char_map: Dict[str, str] # unicode -> hsah hash_map: Dict[str, str] # hash -> unicode diff --git a/api/decode.py b/api/decode.py index 59f763e..7fe7463 100644 --- a/api/decode.py +++ b/api/decode.py @@ -1,46 +1,34 @@ # -*- coding: utf-8 -*- import re import json -from bs4 import BeautifulSoup, NavigableString +from bs4 import BeautifulSoup from api.logger import logger from api.font_decoder import FontDecoder - def decode_course_list(_text): logger.trace("开始解码课程列表...") _soup = BeautifulSoup(_text, "lxml") _raw_courses = _soup.select("div.course") _course_list = list() for course in _raw_courses: - if not course.select_one("a.not-open-tip") and not course.select_one( - "div.not-open-tip" - ): + if not course.select_one("a.not-open-tip") and not course.select_one("div.not-open-tip"): _course_detail = {} _course_detail["id"] = course.attrs["id"] _course_detail["info"] = course.attrs["info"] _course_detail["roleid"] = course.attrs["roleid"] - _course_detail["clazzId"] = course.select_one("input.clazzId").attrs[ - "value" - ] - _course_detail["courseId"] = course.select_one("input.courseId").attrs[ - "value" - ] - _course_detail["cpi"] = re.findall( - r"cpi=(.*?)&", course.select_one("a").attrs["href"] - )[0] - _course_detail["title"] = course.select_one("span.course-name").attrs[ - "title" - ] + _course_detail["clazzId"] = course.select_one("input.clazzId").attrs["value"] + _course_detail["courseId"] = course.select_one("input.courseId").attrs["value"] + _course_detail["cpi"] = re.findall(r"cpi=(.*?)&", course.select_one("a").attrs["href"])[0] + _course_detail["title"] = course.select_one("span.course-name").attrs["title"] if course.select_one("p.margint10") is None: - _course_detail["desc"] = "" + _course_detail["desc"] = '' else: _course_detail["desc"] = course.select_one("p.margint10").attrs["title"] _course_detail["teacher"] = course.select_one("p.color3").attrs["title"] _course_list.append(_course_detail) return _course_list - def decode_course_folder(_text): logger.trace("开始解码二级课程列表...") _soup = BeautifulSoup(_text, "lxml") @@ -50,49 +38,39 @@ def decode_course_folder(_text): if course.attrs["fileid"]: _course_folder_detail = {} _course_folder_detail["id"] = course.attrs["fileid"] - _course_folder_detail["rename"] = course.select_one( - "input.rename-input" - ).attrs["value"] + _course_folder_detail["rename"] = course.select_one("input.rename-input").attrs["value"] _course_folder_list.append(_course_folder_detail) return _course_folder_list - def decode_course_point(_text): logger.trace("开始解码章节列表...") _soup = BeautifulSoup(_text, "lxml") _course_point = { - "hasLocked": False, # 用于判断该课程任务是否是需要解锁 - "points": [], + "hasLocked": False, # 用于判断该课程任务是否是需要解锁 + "points": [] } - - for _chapter_unit in _soup.find_all("div", class_="chapter_unit"): + + + for _chapter_unit in _soup.find_all("div",class_="chapter_unit") : _point_list = [] _raw_points = _chapter_unit.find_all("li") for _point in _raw_points: _point = _point.div - if not "id" in _point.attrs: + if (not "id" in _point.attrs): continue _point_detail = {} _point_detail["id"] = re.findall(r"^cur(\d{1,20})$", _point.attrs["id"])[0] - _point_detail["title"] = ( - _point.select_one("a.clicktitle").text.replace("\n", "").strip(" ") - ) - _point_detail["jobCount"] = 1 # 默认为1 + _point_detail["title"] = _point.select_one("a.clicktitle").text.replace("\n",'').strip(' ') + _point_detail["jobCount"] = 1 # 默认为1 if _point.select_one("input.knowledgeJobCount"): - _point_detail["jobCount"] = _point.select_one( - "input.knowledgeJobCount" - ).attrs["value"] + _point_detail["jobCount"] = _point.select_one("input.knowledgeJobCount").attrs["value"] else: # 判断是不是因为需要解锁 - if "解锁" in _point.select_one("span.bntHoverTips").text: + if '解锁' in _point.select_one("span.bntHoverTips").text: _course_point["hasLocked"] = True - if "已完成" in _point.select_one("span.bntHoverTips").text: - _point_detail["has_finished"] = True - else: - _point_detail["has_finished"] = False - + _point_list.append(_point_detail) - _course_point["points"] += _point_list + _course_point["points"]+=_point_list return _course_point @@ -101,27 +79,27 @@ def decode_course_card(_text: str): _job_info = {} _job_list = [] # 对于未开放章节检测 - if "章节未开放" in _text: - _job_info["notOpen"] = True - return [], _job_info - + if '章节未开放' in _text: + _job_info['notOpen'] = True + return [],_job_info + _temp = re.findall(r"mArg=\{(.*?)\};", _text.replace(" ", "")) if _temp: _temp = _temp[0] else: - return [], {} + return [],{} _cards = json.loads("{" + _temp + "}") - + if _cards: _job_info = {} _job_info["ktoken"] = _cards["defaults"]["ktoken"] _job_info["mtEnc"] = _cards["defaults"]["mtEnc"] - _job_info["reportTimeInterval"] = _cards["defaults"]["reportTimeInterval"] # 60 + _job_info["reportTimeInterval"] = _cards["defaults"]["reportTimeInterval"] # 60 _job_info["defenc"] = _cards["defaults"]["defenc"] _job_info["cardid"] = _cards["defaults"]["cardid"] _job_info["cpi"] = _cards["defaults"]["cpi"] _job_info["qnenc"] = _cards["defaults"]["qnenc"] - _job_info["knowledgeid"] = _cards["defaults"]["knowledgeid"] + _job_info['knowledgeid'] = _cards["defaults"]["knowledgeid"] _cards = _cards["attachments"] _job_list = [] for _card in _cards: @@ -130,26 +108,24 @@ def decode_course_card(_text: str): continue # 不属于任务点的任务 if "job" not in _card or _card["job"] is False: - if _card.get("type") and _card["type"] == "read": + if _card.get('type') and _card['type'] == "read": # 发现有在视频任务下掺杂阅读任务,不完成可能会导致无法开启下一章节 - if _card["property"].get("read", False): + if _card['property'].get('read',False): # 已阅读,跳过 continue _job = {} - _job["title"] = _card["property"]["title"] + _job['title'] = _card['property']['title'] _job["type"] = "read" - _job["id"] = _card["property"]["id"] + _job['id'] = _card['property']['id'] _job["jobid"] = _card["jobid"] _job["jtoken"] = _card["jtoken"] - _job["mid"] = _card["mid"] - _job["otherinfo"] = _card["otherInfo"] - _job["enc"] = _card["enc"] - _job["aid"] = _card["aid"] + _job['mid'] = _card['mid'] + _job['otherinfo'] = _card["otherInfo"] + _job['enc'] = _card["enc"] + _job['aid'] = _card["aid"] _job_list.append(_job) continue # 视频任务 - if not "type" in _card: - continue if _card["type"] == "video": _job = {} _job["type"] = "video" @@ -189,92 +165,67 @@ def decode_course_card(_text: str): _job["aid"] = _card["aid"] _job_list.append(_job) continue - + if _card["type"] == "vote": # 调查问卷 同上 continue return _job_list, _job_info - + def decode_questions_info(html_content) -> dict: def replace_rtn(text): - return text.replace("\r", "").replace("\t", "").replace("\n", "") - - def extract_content(div): - text = [] - for element in div.descendants: - if isinstance(element, NavigableString): - text.append(element.string) - elif element.name == "img": - img_url = element.get("src", "") - text.append(f'') - return "".join(text) + return text.replace('\r', '').replace('\t', '').replace('\n', '') soup = BeautifulSoup(html_content, "lxml") form_data = {} form_tag = soup.find("form") + fd = FontDecoder(html_content) # 加载字体 + # 抽取表单信息 for input_tag in form_tag.find_all("input"): - if "name" not in input_tag.attrs or "answer" in input_tag.attrs["name"]: + if 'name' not in input_tag.attrs or 'answer' in input_tag.attrs["name"]: continue - form_data.update({input_tag.attrs["name"]: input_tag.attrs.get("value", "")}) - - form_data["questions"] = [] - - if soup.find("style", id="cxSecretStyle") is None: # 未找到字体文件,目前只有可能是空或者无中文内容。 - logger.warning("未找到字体文件,可能是未加密的题目不进行解密") - else: - fd = FontDecoder(html_content) # 加载字体 - - for div_tag in form_tag.find_all( - "div", class_="singleQuesId"): # 目前来说无论是单选还是多选的题class都是这个 - q_title = "" - q_options = "" - if 'fd' in locals(): - q_title = replace_rtn(fd.decode(extract_content(div_tag.find("div", class_="Zy_TItle")))) - for li_tag in div_tag.find("ul").find_all("li"): - q_options += replace_rtn( - fd.decode(extract_content(li_tag))) + "\n" - else: - q_title = replace_rtn(extract_content(div_tag.find("div", class_="Zy_TItle"))) - for li_tag in div_tag.find("ul").find_all("li"): - q_options += replace_rtn( - extract_content(li_tag)) + "\n" - - print(q_title, q_options, sep="\n") - q_options = q_options[:-1] # 去除尾部'\n' + form_data.update({ + input_tag.attrs["name"]: input_tag.attrs.get("value",'') + }) + + form_data['questions'] = [] + for div_tag in form_tag.find_all("div",class_="singleQuesId"): # 目前来说无论是单选还是多选的题class都是这个 + q_title = replace_rtn(fd.decode(div_tag.find("div", class_="Zy_TItle").text)) + q_options = '' + for li_tag in div_tag.find("ul").find_all("li"): + q_options += replace_rtn(fd.decode(li_tag.text))+'\n' + q_options=q_options[:-1] # 去除尾部'\n' # 尝试使用 data 属性来判断题型 - q_type_code = div_tag.find("div", class_="TiMu").attrs["data"] - q_type = "" + q_type_code = div_tag.find('div',class_='TiMu').attrs['data'] + q_type = '' # 此处可能需要完善更多题型的判断 - if q_type_code == "0": - q_type = "single" - elif q_type_code == "1": - q_type = "multiple" - elif q_type_code == "2": - q_type = "completion" - elif q_type_code == "3": - q_type = "judgement" - elif q_type_code == "4": - q_type = "shortanswer" + if q_type_code == '0': + q_type = 'single' + elif q_type_code == '1': + q_type = 'multiple' + elif q_type_code == '2': + q_type = 'completion' + elif q_type_code == '3': + q_type = 'judgement' else: - logger.info("未知题型代码 -> " + q_type_code) - q_type = "unknown" # 避免出现未定义取值错误 - - form_data["questions"].append( - { - "id": div_tag.attrs["data"], - "title": q_title, # 题目 - "options": q_options, # 选项 可提供给题库作为辅助 - "type": q_type, # 题型 可提供给题库作为辅助 - "answerField": { - "answer" + div_tag.attrs["data"]: "", # 答案填入处 - "answertype" + div_tag.attrs["data"]: q_type_code, - }, + logger.info("未知题型代码 -> "+q_type_code) + q_type = 'unknown' # 避免出现未定义取值错误 + + form_data["questions"].append({ + 'id': div_tag.attrs["data"], + 'title':q_title, # 题目 + 'options':q_options, # 选项 可提供给题库作为辅助 + 'type': q_type, # 题型 可提供给题库作为辅助 + 'answerField':{ + 'answer'+div_tag.attrs["data"]:'', # 答案填入处 + 'answertype'+div_tag.attrs["data"]:q_type_code } - ) + }) # 处理答题信息 - form_data["answerwqbid"] = ",".join([q["id"] for q in form_data["questions"]]) + "," + form_data['answerwqbid'] = ",".join([q['id'] for q in form_data['questions']])+"," return form_data + + diff --git a/api/exceptions.py b/api/exceptions.py index 57e00b5..913e71e 100644 --- a/api/exceptions.py +++ b/api/exceptions.py @@ -13,7 +13,7 @@ class FormatError(Exception): def __init__(self, *args: object): super().__init__(*args) - class MaxRollBackError(Exception): def __init__(self, *args: object): super().__init__(*args) + \ No newline at end of file diff --git a/api/font_decoder.py b/api/font_decoder.py index 5d0ca4a..1742c04 100644 --- a/api/font_decoder.py +++ b/api/font_decoder.py @@ -4,20 +4,19 @@ class FontDecoder: - def __init__(self, html_content: str = None): + def __init__(self,html_content:str=None): self.html_content = html_content # self.__isNeedDecode = True self.__font_hash_map = None self.__decode_init(html_content) - + def __decode_init(self, html_content): if html_content: soup = BeautifulSoup(html_content, "lxml") - style_tag = soup.find("style", id="cxSecretStyle") - match = re.search(r"base64,([\w\W]+?)\'", style_tag.text) - self.__font_hash_map = cxfont.font2map( - "data:application/font-ttf;charset=utf-8;base64," + match.group(1) - ) + style_tag = soup.find("style",id="cxSecretStyle") + match = re.search(r'base64,([\w\W]+?)\'', style_tag.text) + self.__font_hash_map = cxfont.font2map('data:application/font-ttf;charset=utf-8;base64,'+match.group(1)) - def decode(self, target_str: str) -> str: + def decode(self,target_str:str) -> str: return cxfont.decrypt(self.__font_hash_map, target_str) + diff --git a/api/logger.py b/api/logger.py index ba1e61d..2caa568 100644 --- a/api/logger.py +++ b/api/logger.py @@ -1,3 +1,3 @@ from loguru import logger -logger.add("chaoxing.log", rotation="10 MB", level="TRACE") +logger.add("chaoxing.log", rotation="10 MB", level="TRACE") \ No newline at end of file diff --git a/api/notification.py b/api/notification.py deleted file mode 100644 index e73c706..0000000 --- a/api/notification.py +++ /dev/null @@ -1,116 +0,0 @@ - -import configparser -import requests -from api.logger import logger - -#仿制answer.py写的外部通知 - -class Notification: - - CONFIG_PATH = "config.ini" - DISABLE = False - - def __init__(self): - self._name = None - self._conf = None - - def config_set(self, config): - self._conf = config - - def _init_notification(self): - # 仅用于外部通知初始化, 例如配置token, 则交由具体外部通知完成 - pass - - def _get_conf(self): - """ - 从默认配置文件查询配置, 如果未能查到, 停用外部通知功能 - """ - try: - config = configparser.ConfigParser() - config.read(self.CONFIG_PATH, encoding="utf8") - return config['notification'] - except (KeyError, FileNotFoundError): - logger.info("未找到notification配置, 已忽略外部通知功能") - self.DISABLE = True - return None - - def get_notification_from_config(self): - if not self._conf: - # 尝试从默认配置文件加载 - self.config_set(self._get_conf()) - if self.DISABLE: - return self - try: - cls_name = self._conf['provider'] - if not cls_name: - raise KeyError - except KeyError: - self.DISABLE = True - logger.info("未找到外部通知配置, 已忽略外部通知功能") - return self - new_cls = globals()[cls_name]() - new_cls.config_set(self._conf) - return new_cls - def init_notification(self): - if not self._conf: - self.config_set(self._get_conf()) - if not self.DISABLE: - # 调用自定义外部通知初始化 - self._init_notification() - def _send(self,*args, **kwargs): - pass - def send(self, *args, **kwargs): - if not self.DISABLE: - self._send(*args, **kwargs) - return None - - -class ServerChan(Notification): - def __init__(self): - super().__init__() - self.name = 'ServerChan' - self.url = '' - - def _send(self, text): - params = { - # serverChan有两个版本,一版本参数是text,一个是desp,干脆直接这么写,不区分 - 'text': text, - 'desp': text, - } - headers = { - 'Content-Type': 'application/json;charset=utf-8' - } - response = requests.post(self.url, json=params, headers=headers) - result = response.json() - if response.status_code != 200: - logger.error(f"Server酱发送通知失败{result}") - else: - logger.info("Server酱发送通知成功") - return None - - def _init_notification(self): - self.url = self._conf['url'] - -class Qmsg(Notification): - def __init__(self): - super().__init__() - self.name = 'Qmsg' - self.url = '' - - def _send(self, msg): - params = { - 'msg': msg, - } - headers = { - 'Content-Type': 'application/json;charset=utf-8' - } - response = requests.post(self.url, params=params, headers=headers) - result = response.json() - if response.status_code != 200: - logger.error(f"Qmsg酱发送通知失败{result}") - else: - logger.info("Qmsg酱发送通知成功") - return None - - def _init_notification(self): - self.url = self._conf['url'] \ No newline at end of file diff --git a/api/process.py b/api/process.py index 309e574..7e5d9cb 100644 --- a/api/process.py +++ b/api/process.py @@ -1,16 +1,15 @@ import time from api.config import GlobalConst as gc - def sec2time(sec: int): h = int(sec / 3600) m = int(sec % 3600 / 60) s = int(sec % 60) if h != 0: - return f"{h}:{m:02}:{s:02}" + return f'{h}:{m:02}:{s:02}' if sec != 0: - return f"{m:02}:{s:02}" - return "--:--" + return f'{m:02}:{s:02}' + return '--:--' def show_progress(name: str, start: int, span: int, total: int, _speed: float): @@ -21,9 +20,5 @@ def show_progress(name: str, start: int, span: int, total: int, _speed: float): length = int(percent * 40 // 100) progress = ("#" * length).ljust(40, " ") # remain = (total - current) - print( - f"\r当前任务: {name} |{progress}| {percent}% {sec2time(current)}/{sec2time(total)}", - end="", - flush=True, - ) - time.sleep(gc.THRESHOLD) + print(f"\r当前任务: {name} |{progress}| {percent}% {sec2time(current)}/{sec2time(total)}", end="", flush=True) + time.sleep(gc.THRESHOLD) \ No newline at end of file diff --git a/app.py b/app.py index ba72eb4..486af05 100644 --- a/app.py +++ b/app.py @@ -15,7 +15,7 @@ def __call__(self, *args: object, **kwargs: object) -> object: return celery_app -if __name__ == "__main__": +if __name__ == '__main__': app = Flask(__name__) app.config.from_mapping( CELERY=dict( @@ -24,4 +24,4 @@ def __call__(self, *args: object, **kwargs: object) -> object: task_ignore_result=True, ), ) - celery_app = celery_init_app(app) + celery_app = celery_init_app(app) \ No newline at end of file diff --git a/config_template.ini b/config_template.ini index 709d5f4..1c482ce 100644 --- a/config_template.ini +++ b/config_template.ini @@ -10,56 +10,16 @@ course_list = xxx,xxx,xxx ; 视频播放倍速(默认1,最大2) speed = 1 - -; 遇到关闭任务点时的行为: retry-重试(默认), ask-询问, continue-继续 -notopen_action = retry [tiku] ; 可选项 : ; 1. TikuYanxi(言溪题库 https://tk.enncy.cn/) -; 2. TikuLike(LIKE知识库 https://www.datam.site/) -; 3. TikuAdapter(开源项目 https://github.com/DokiDoki1103/tikuAdapter) -; 4. AI(需自行寻找兼容openai格式的API Endpoint和Key) provider=TikuYanxi -; 是否提交答题,填写false表示答完题后不提交而是保存搜到的题目,随后你可以自行前往学习通修改或提交 -; 填写true表示达到最低题库覆盖率提交,没达到只保存搜到的题目,进入下一章节,不保证正确率!不正确的填写会被视为false -; 题库覆盖率-搜到的题目占总题目的比例 +; 是否直接提交答题,填写false表示答完题后不提交而是保存,随后你可以自行前往学习通修改或提交 +; 填写true表示直接提交,不保证正确率!不正确的填写会被视为false ; 对于那些需要解锁的章节,你必须要提交章节检测才能继续下一章节的学习,自行决定是否开启 -; 选择提交答题但题库覆盖率不达标时,若是需要解锁的章节,保存后会回滚重新答题且忽略搜到率提交 submit=false -; 最低题库覆盖率 -cover_rate=0.9 -; 搜索多个题目时间隔的时间,单位秒 -delay=1.0 ; 用于言溪题库的TOKEN,同样使用英文逗号隔开多个,会按顺序去使用 -; 或用于LIKE知识库的TOKEN,在使用LIKE知识库时仅会调用最后一个TOKEN,请注意! tokens= -; 下面是用于LIKE知识库模型的专属配置,其他题库无需关注下列选项 -; likeapi_search=true表示启用模型的联网搜索功能,false则不启用联网搜索 -; likeapi_model=deepseek-v3表示使用deepseek-v3模型,其他模型请自行查询 -; 支持模型列表:https://www.datam.site/doc/api_doc.html#%E6%A8%A1%E5%9E%8B%E6%94%AF%E6%8C%81%E5%88%97%E8%A1%A8 -likeapi_search=false -likeapi_model=deepseek-v3 -; 用于TikuAdapter题库的url -url= -; 用于AI大模型答题的API Endpoint和Key -; 请注意API Endpoint可能需要带上/v1路径,例如: https://example.com/v1 -; min_interval_seconds 为 API 请求的最小间隔时间,单位秒, 0表示不限制 -endpoint= -key= -model= -min_interval_seconds=0 -; 可选配置请求大模型时使用的代理,填写示例:http://examples.com -http_proxy= ; 用于判断判断题对应的选项,不要留有空格,不要留有引号,逗号为英文逗号 true_list=正确,对,√,是 false_list=错误,错,×,否,不对,不正确 -[notification] -provider=ServerChan -; 可选项 : -; 1. ServerChan(Server酱 多平台推送 https://sct.ftqq.com/) -; 2. Qmsg(Qmsg酱 qq推送 https://qmsg.zendee.cn/) -; 外部通知服务的提供方 -url= -; Server酱或Qmsg酱的url,以下为例子,需要自己将key填入*号位置 -; https://sctapi.ftqq.com/****************.send Server酱 -; https://qmsg.zendee.cn/send/**************** Qmsg酱 \ No newline at end of file diff --git a/main.py b/main.py index 5f3fc1a..d97cbf1 100644 --- a/main.py +++ b/main.py @@ -1,26 +1,21 @@ # -*- coding: utf-8 -*- import argparse import configparser -import random - from api.logger import logger from api.base import Chaoxing, Account -from api.exceptions import LoginError, FormatError, JSONDecodeError, MaxRollBackError +from api.exceptions import LoginError, FormatError, JSONDecodeError,MaxRollBackError from api.answer import Tiku -from urllib3 import disable_warnings, exceptions -import time -import sys +from urllib3 import disable_warnings,exceptions import os -from api.notification import Notification -# # 定义全局变量, 用于存储配置文件路径 +# # 定义全局变量,用于存储配置文件路径 # textPath = './resource/BookID.txt' # # 获取文本 -> 用于查看学习过的课程ID # def getText(): -# try: +# try: # if not os.path.exists(textPath): -# with open(textPath, 'x') as file: pass +# with open(textPath, 'x') as file: pass # return [] # with open(textPath, 'r', encoding='utf-8') as file: content = file.read().split(',') # content = {int(item.strip()) for item in content if item.strip()} @@ -30,146 +25,68 @@ # # 追加文本 -> 用于记录学习过的课程ID # def appendText(text): # if not os.path.exists(textPath): return -# with open(textPath, 'a', encoding='utf-8') as file: file.write(f'{text}, ') - +# with open(textPath, 'a', encoding='utf-8') as file: file.write(f'{text}, ') + # 关闭警告 disable_warnings(exceptions.InsecureRequestWarning) - def init_config(): - parser = argparse.ArgumentParser( - description="Samueli924/chaoxing", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - - parser.add_argument( - "-c", "--config", type=str, default=None, help="使用配置文件运行程序" - ) + parser = argparse.ArgumentParser(description='Samueli924/chaoxing') # 命令行传参 + parser.add_argument("-c", "--config", type=str, default=None, help="使用配置文件运行程序") parser.add_argument("-u", "--username", type=str, default=None, help="手机号账号") parser.add_argument("-p", "--password", type=str, default=None, help="登录密码") - parser.add_argument( - "-l", "--list", type=str, default=None, help="要学习的课程ID列表, 以 , 分隔" - ) - parser.add_argument( - "-s", "--speed", type=float, default=1.0, help="视频播放倍速 (默认1, 最大2)" - ) - parser.add_argument( - "-v", - "--verbose", - "--debug", - action="store_true", - help="启用调试模式, 输出DEBUG级别日志", - ) - parser.add_argument( - "-a", "--notopen-action", type=str, default="retry", - choices=["retry", "ask", "continue"], - help="遇到关闭任务点时的行为: retry-重试, ask-询问, continue-继续" - ) - - # 在解析之前捕获 -h 的行为 - if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"}: - parser.print_help() - # 返回一个 SystemExit 异常, 用于退出程序 - raise SystemExit - - # 提前检查 -h 和 --help 并退出 + parser.add_argument("-l", "--list", type=str, default=None, help="要学习的课程ID列表") + parser.add_argument("-s", "--speed", type=float, default=1.0, help="视频播放倍速(默认1,最大2)") args = parser.parse_args() - if args.config: config = configparser.ConfigParser() config.read(args.config, encoding="utf8") - common_config = {} - tiku_config = {} - notification_config = {} - # 检查并读取common节 - if config.has_section("common"): - common_config = dict(config.items("common")) - # 处理course_list,将字符串转换为列表 - if "course_list" in common_config and common_config["course_list"]: - common_config["course_list"] = common_config["course_list"].split(",") - # 处理speed,将字符串转换为浮点数 - if "speed" in common_config: - common_config["speed"] = float(common_config["speed"]) - # 处理notopen_action,设置默认值为retry - if "notopen_action" not in common_config: - common_config["notopen_action"] = "retry" - - # 检查并读取tiku节 - if config.has_section("tiku"): - tiku_config = dict(config.items("tiku")) - # 处理delay,将字符串转换为整数 - if "delay" in tiku_config: - tiku_config["delay"] = float(tiku_config["delay"]) - # 处理delay,将字符串转换为小数 - if "cover_rate" in tiku_config: - tiku_config["cover_rate"] = float(tiku_config["cover_rate"]) - - # 检查并读取notification节 - if config.has_section("notification"): - notification_config = dict(config.items("notification")) - return common_config, tiku_config, notification_config + return (config.get("common", "username"), + config.get("common", "password"), + str(config.get("common", "course_list")).split(",") if config.get("common", "course_list") else None, + int(config.get("common", "speed")), + config['tiku'] + ) else: - build_params = {'common':{},"tiku":{}} - build_params['common']['username'] = args.username - build_params['common']['password'] = args.password - build_params['common']['course_list'] = args.list.split(",") if args.list else None - build_params['common']['speed'] = args.speed if args.speed else 1 - build_params['common']['notopen_action'] = args.notopen_action if args.notopen_action else "retry" - return build_params['common'],build_params['tiku'] - + return (args.username, args.password, args.list.split(",") if args.list else None, int(args.speed) if args.speed else 1,None) class RollBackManager: def __init__(self) -> None: self.rollback_times = 0 self.rollback_id = "" - def add_times(self, id: str) -> None: + def add_times(self,id:str) -> None: if id == self.rollback_id and self.rollback_times == 3: - raise MaxRollBackError("回滚次数已达3次, 请手动检查学习通任务点完成情况") - # elif id != self.rollback_id: - # # 新job - # self.rollback_id = id - # self.rollback_times = 1 - else: + raise MaxRollBackError("回滚次数已达3次,请手动检查学习通任务点完成情况") + elif id != self.rollback_id: + # 新job + self.rollback_id = id + self.rollback_times = 1 + else: self.rollback_times += 1 - def new_job(self, id: str) -> None: - if id != self.rollback_id: - self.rollback_id = id - self.rollback_times = 0 -if __name__ == "__main__": +if __name__ == '__main__': try: # 避免异常的无限回滚 RB = RollBackManager() # 初始化登录信息 - common_config, tiku_config, notification_config = init_config() - username = common_config.get("username","") - password = common_config.get("password","") - course_list = common_config.get("course_list",None) - speed = common_config.get("speed",1) - query_delay = tiku_config.get("delay",0) - notopen_action = common_config.get("notopen_action", "retry") # 获取未开放任务点处理方式 + username, password, course_list, speed,tiku_config= init_config() # 规范化播放速度的输入值 speed = min(2.0, max(1.0, speed)) if (not username) or (not password): - username = input("请输入你的手机号, 按回车确认\n手机号:") - password = input("请输入你的密码, 按回车确认\n密码:") + username = input("请输入你的手机号,按回车确认\n手机号:") + password = input("请输入你的密码,按回车确认\n密码:") account = Account(username, password) # 设置题库 tiku = Tiku() - tiku.config_set(tiku_config) # 载入配置 + tiku.config_set(tiku_config) # 载入配置 tiku = tiku.get_tiku_from_config() # 载入题库 - tiku.init_tiku() # 初始化题库 - # 设置外部通知 - notification = Notification() - notification.config_set(notification_config) - notification = notification.get_notification_from_config() - notification.init_notification() + tiku.init_tiku() # 初始化题库 # 实例化超星API - chaoxing = Chaoxing(account=account, tiku=tiku,query_delay = query_delay) - # 检查当前登录状态, 并检查账号密码 + chaoxing = Chaoxing(account=account,tiku=tiku) + # 检查当前登录状态,并检查账号密码 _login_state = chaoxing.login() if not _login_state["status"]: raise LoginError(_login_state["msg"]) @@ -183,9 +100,7 @@ def new_job(self, id: str) -> None: print(f"ID: {course['courseId']} 课程名: {course['title']}") print("*" * 28) try: - course_list = input( - "请输入想要学习的课程列表,以逗号分隔,例: 2151141,189191,198198\n" - ).split(",") + course_list = input("请输入想要学习的课程列表,以逗号分隔,例: 2151141,189191,198198\n").split(",") except Exception as e: raise FormatError("输入格式错误") from e # 筛选需要学习的课程 @@ -195,142 +110,85 @@ def new_job(self, id: str) -> None: if not course_task: course_task = all_course # 开始遍历要学习的课程列表 - logger.info(f"课程列表过滤完毕, 当前课程任务数量: {len(course_task)}") + logger.info(f"课程列表过滤完毕,当前课程任务数量: {len(course_task)}") for course in course_task: logger.info(f"开始学习课程: {course['title']}") # 获取当前课程的所有章节 - point_list = chaoxing.get_course_point( - course["courseId"], course["clazzId"], course["cpi"] - ) + point_list = chaoxing.get_course_point(course["courseId"], course["clazzId"], course["cpi"]) - # 为了支持课程任务回滚, 采用下标方式遍历任务点 + # 为了支持课程任务回滚,采用下标方式遍历任务点 __point_index = 0 - # 记录用户是否选择继续跳过连续的未开放任务点 - auto_skip_notopen = False while __point_index < len(point_list["points"]): point = point_list["points"][__point_index] logger.info(f'当前章节: {point["title"]}') - logger.debug(f"当前章节 __point_index: {__point_index}") # 触发参数: -v - if point["has_finished"]: - logger.info(f'章节:{point["title"]} 已完成所有任务点') - __point_index += 1 - continue - sleep_duration = random.uniform(1, 3) - logger.debug(f"本次随机等待时间: {sleep_duration}") - time.sleep(sleep_duration) # 避免请求过快导致异常, 所以引入随机sleep # 获取当前章节的所有任务点 jobs = [] job_info = None - jobs, job_info = chaoxing.get_job_list( - course["clazzId"], course["courseId"], course["cpi"], point["id"] - ) - + jobs, job_info = chaoxing.get_job_list(course["clazzId"], course["courseId"], course["cpi"], point["id"]) + # bookID = job_info["knowledgeid"] # 获取视频ID - - # 发现未开放章节, 根据配置处理 + + # 发现未开放章节,尝试回滚上一个任务重新完成一次 try: - if job_info.get("notOpen", False): - # 根据配置选择处理方式 - if notopen_action == "retry": - # 默认处理方式:重试 - __point_index -= 1 # 默认第一个任务总是开放的 - # 针对题库启用情况 - if not tiku or tiku.DISABLE or not tiku.SUBMIT: - # 未启用题库或未开启题库提交, 章节检测未完成会导致无法开始下一章, 直接退出 - logger.error( - "章节未开启, 可能由于上一章节的章节检测未完成, 也可能由于该章节因为时效已关闭," - "请手动检查完成并提交再重试。或者在配置中配置(自动跳过关闭章节/开启题库并启用提交)" - ) - break - RB.add_times(point["id"]) - continue - elif notopen_action == "ask": - # 询问模式 - 判断是否需要询问 - if not auto_skip_notopen: - user_choice = input(f"章节 {point['title']} 未开放,是否继续检查后续章节?(y/n): ") - if user_choice.lower() != 'y': - # 用户选择停止 - logger.info("根据用户选择停止检查后续章节") - break - # 用户选择继续,设置自动跳过标志 - auto_skip_notopen = True - logger.info("用户选择继续检查后续章节,将自动跳过连续的未开放章节") - else: - logger.info(f"章节 {point['title']} 未开放,自动跳过") - # 无论是否自动跳过,都继续到下一章节 - __point_index += 1 - continue - else: # notopen_action == "continue" - # 继续模式,直接跳过当前章节 - logger.info(f"章节 {point['title']} 未开放,根据配置跳过此章节") - __point_index += 1 - continue - # 遇到开放的章节,重置自动跳过状态 - auto_skip_notopen = False - RB.new_job(point["id"]) + if job_info.get('notOpen',False): + __point_index -= 1 # 默认第一个任务总是开放的 + # 针对题库启用情况 + if not tiku or tiku.DISABLE or not tiku.SUBMIT: + # 未启用题库或未开启题库提交,章节检测未完成会导致无法开始下一章,直接退出 + logger.error(f"章节未开启,可能由于上一章节的章节检测未完成,请手动完成并提交再重试,或者开启题库并启用提交") + break + RB.add_times(point["id"]) + continue except MaxRollBackError as e: - logger.error("回滚次数已达3次, 请手动检查学习通任务点完成情况") - # 跳过该课程, 继续下一课程 + logger.error("回滚次数已达3次,请手动检查学习通任务点完成情况") + # 跳过该课程,继续下一课程 break - chaoxing.rollback_times = RB.rollback_times + + # 可能存在章节无任何内容的情况 if not jobs: - if RB.rollback_times > 0: - logger.trace(f"回滚中 尝试空页面任务, 任务章节: {course['title']}") - chaoxing.study_emptypage(course, point) __point_index += 1 continue # 遍历所有任务点 for job in jobs: # 视频任务 if job["type"] == "video": - # TODO: 目前这个记录功能还不够完善, 中途退出的课程ID也会被记录 + # TODO: 目前这个记录功能还不够完善,中途退出的课程ID也会被记录 # TextBookID = getText() # 获取学习过的课程ID - # if TextBookID.count(bookID) > 0: - # logger.info(f"课程: {course['title']} 章节: {point['title']} 任务: {job['title']} 已学习过或在学习中, 跳过") # 如果已经学习过该课程, 则跳过 - # break # 如果已经学习过该课程, 则跳过 + # if TextBookID.count(bookID) > 0: + # logger.info(f"课程: {course['title']} 章节: {point['title']} 任务: {job['title']} 已学习过或在学习中,跳过") # 如果已经学习过该课程,则跳过 + # break # 如果已经学习过该课程,则跳过 # appendText(bookID) # 记录正在学习的课程ID - logger.trace( - f"识别到视频任务, 任务章节: {course['title']} 任务ID: {job['jobid']}" - ) + logger.trace(f"识别到视频任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") # 超星的接口没有返回当前任务是否为Audio音频任务 - video_result = chaoxing.study_video( - course, job, job_info, _speed=speed, _type="Video" - ) - if chaoxing.StudyResult.is_failure(video_result): - logger.warning("当前任务非视频任务, 正在尝试音频任务解码") - video_result = chaoxing.study_video( - course, job, job_info, _speed=speed, _type="Audio") - if chaoxing.StudyResult.is_failure(video_result): - logger.warning( - f"出现异常任务 -> 任务章节: {course['title']} 任务ID: {job['jobid']}, 已跳过" - ) + isAudio = False + try: + chaoxing.study_video(course, job, job_info, _speed=speed, _type="Video") + except JSONDecodeError as e: + logger.warning("当前任务非视频任务,正在尝试音频任务解码") + isAudio = True + if isAudio: + try: + chaoxing.study_video(course, job, job_info, _speed=speed, _type="Audio") + except JSONDecodeError as e: + logger.warning(f"出现异常任务 -> 任务章节: {course['title']} 任务ID: {job['jobid']}, 已跳过") # 文档任务 elif job["type"] == "document": - logger.trace( - f"识别到文档任务, 任务章节: {course['title']} 任务ID: {job['jobid']}" - ) + logger.trace(f"识别到文档任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") chaoxing.study_document(course, job) # 测验任务 elif job["type"] == "workid": logger.trace(f"识别到章节检测任务, 任务章节: {course['title']}") - chaoxing.study_work(course, job, job_info) + chaoxing.study_work(course, job,job_info) # 阅读任务 elif job["type"] == "read": logger.trace(f"识别到阅读任务, 任务章节: {course['title']}") - chaoxing.strdy_read(course, job, job_info) + chaoxing.strdy_read(course, job,job_info) __point_index += 1 logger.info("所有课程学习任务已完成") - notification.send( "chaoxing : 所有课程学习任务已完成") - except SystemExit as e: - if e.code == 0: # 正常退出 - sys.exit(0) - else: - raise except BaseException as e: import traceback logger.error(f"错误: {type(e).__name__}: {e}") logger.error(traceback.format_exc()) - notification.send(f"chaoxing : 出现错误", f"{type(e).__name__}: {e}\n{traceback.format_exc()}") - raise e + raise e \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 7b4ffc9..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "chaoxing" -version = "3.1.1" -description = "超星学习通/超星尔雅/泛雅超星全自动无人值守完成任务点" -readme = "README.md" -requires-python = ">=3.10" -dependencies = [ - "argparse>=1.4.0", - "beautifulsoup4>=4.13.3", - "celery>=5.4.0", - "flask>=3.1.0", - "fonttools>=4.56.0", - "loguru>=0.7.3", - "lxml>=5.3.1", - "openai>=1.66.2", - "pyaes>=1.6.1", - "requests>=2.32.3", -] diff --git a/requirements.txt b/requirements.txt index 6c0f5f4..4575e6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,4 @@ argparse loguru celery flask -fonttools -openai \ No newline at end of file +fonttools \ No newline at end of file From 9aa73afc8ff48f2b56788beb04b83471be4ea73f Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Sun, 27 Apr 2025 19:50:38 +0800 Subject: [PATCH 14/21] Update README.md --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 30550ce..0fdd356 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,14 @@

- +# ⚠️明文账号密码泄露风险警示 +**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!** +**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!** +**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!** +**可能会被某些别有用心的人拿去做坏事!!!** +**当然如果你自己觉着自己的学习通账号没啥价值,可以无视** +**当你fork本仓库后,默认你看过了这条警示,作者不对账号密码明文泄露导致的后果负责!!!** +>推荐将仓库可见性设置为私密,保护自己的账号密码 # ❤️快速开始 Fork本仓库
开启GitHub Actions
From 6682bf5eccaf1c87bd187d14fcedc1a1f3531a05 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Sun, 27 Apr 2025 19:51:52 +0800 Subject: [PATCH 15/21] Update README.md --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0fdd356..9669834 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@

# ⚠️明文账号密码泄露风险警示 -**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!** -**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!** -**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!** -**可能会被某些别有用心的人拿去做坏事!!!** -**当然如果你自己觉着自己的学习通账号没啥价值,可以无视** -**当你fork本仓库后,默认你看过了这条警示,作者不对账号密码明文泄露导致的后果负责!!!** +

**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!**

+

**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!**

+

**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!**

+

**可能会被某些别有用心的人拿去做坏事!!!**

+

**当然如果你自己觉着自己的学习通账号没啥价值,可以无视**

+

**当你fork本仓库后,默认你看过了这条警示,作者不对账号密码明文泄露导致的后果负责!!!**

>推荐将仓库可见性设置为私密,保护自己的账号密码 # ❤️快速开始 Fork本仓库
From 3b9a5434a794e94636ace1a6cb8206989dba5be4 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Sun, 27 Apr 2025 19:52:35 +0800 Subject: [PATCH 16/21] Update README.md --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9669834..99452a8 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,15 @@

# ⚠️明文账号密码泄露风险警示 -

**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!**

-

**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!**

-

**注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!**

-

**可能会被某些别有用心的人拿去做坏事!!!**

-

**当然如果你自己觉着自己的学习通账号没啥价值,可以无视**

-

**当你fork本仓库后,默认你看过了这条警示,作者不对账号密码明文泄露导致的后果负责!!!**

+

注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!

+

注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!

+

注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!

+

可能会被某些别有用心的人拿去做坏事!!!

+

当然如果你自己觉着自己的学习通账号没啥价值,可以无视

+

当你fork本仓库后,默认你看过了这条警示,作者不对账号密码明文泄露导致的后果负责!!!

>推荐将仓库可见性设置为私密,保护自己的账号密码 + + # ❤️快速开始 Fork本仓库
开启GitHub Actions
From c1a5017807ecfbe5b8799afcd74a777be9ca6536 Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Sun, 27 Apr 2025 19:52:59 +0800 Subject: [PATCH 17/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 99452a8..7fda42c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 利用Github actions|自动化刷课|无人值守|云端刷课
🌎 小白使用说明  |   - 📦️ EXE版本仓库   + 📦️ 源仓库  

From 7b8d01f546ec9748509ad6f658777e4776d1e79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=98=A5=E5=AD=90?= <2638526782@qq.com> Date: Thu, 8 May 2025 12:33:45 +0800 Subject: [PATCH 18/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7fda42c..56b4ae4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@

可能会被某些别有用心的人拿去做坏事!!!

当然如果你自己觉着自己的学习通账号没啥价值,可以无视

当你fork本仓库后,默认你看过了这条警示,作者不对账号密码明文泄露导致的后果负责!!!

->推荐将仓库可见性设置为私密,保护自己的账号密码 +**推荐将仓库可见性设置为私密,保护自己的账号密码** # ❤️快速开始 From ad318ec221064e3123234050e882569d6dedf5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=98=A5=E5=AD=90?= <2638526782@qq.com> Date: Thu, 8 May 2025 12:34:07 +0800 Subject: [PATCH 19/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 56b4ae4..9557af4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@

可能会被某些别有用心的人拿去做坏事!!!

当然如果你自己觉着自己的学习通账号没啥价值,可以无视

当你fork本仓库后,默认你看过了这条警示,作者不对账号密码明文泄露导致的后果负责!!!

-**推荐将仓库可见性设置为私密,保护自己的账号密码** +推荐将仓库可见性设置为私密,保护自己的账号密码 # ❤️快速开始 From 6608eb0a76a5900c0d858cc240354a436e90058f Mon Sep 17 00:00:00 2001 From: lispringing <2638526782@qq.com> Date: Tue, 16 Sep 2025 19:15:08 +0800 Subject: [PATCH 20/21] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 11 +- Dockerfile | 17 ++ README.md | 34 --- api/README.md | 17 ++ api/answer.py | 503 +++++++++++++++++++++++++++++---- api/answer_check.py | 83 ++++++ api/base.py | 653 ++++++++++++++++++++++++++++++------------- api/captcha.py | 138 +++++++++ api/cipher.py | 11 +- api/config.py | 8 +- api/cookies.py | 6 +- api/cxsecret_font.py | 218 ++++++++++++--- api/decode.py | 640 ++++++++++++++++++++++++++++-------------- api/exceptions.py | 16 +- api/font_decoder.py | 82 +++++- api/logger.py | 2 +- api/notification.py | 277 ++++++++++++++++++ api/process.py | 74 +++-- app.py | 4 +- main.py | 529 ++++++++++++++++++++++++----------- pyproject.toml | 19 ++ requirements.txt | 4 +- resource/README.md | 7 + 23 files changed, 2616 insertions(+), 737 deletions(-) create mode 100644 Dockerfile create mode 100644 api/README.md create mode 100644 api/answer_check.py create mode 100644 api/captcha.py create mode 100644 api/notification.py create mode 100644 pyproject.toml create mode 100644 resource/README.md diff --git a/.gitignore b/.gitignore index fea5fc2..33cbd54 100644 --- a/.gitignore +++ b/.gitignore @@ -133,15 +133,24 @@ build/ dist/ *.spec +# python-uv.lock +# just like Pipfile.lock, +uv.lock + +# poetry +poetry.lock + # Custom files .cookies.txt cookies.txt .config.ini config.ini chaoxing.log +config*.ini .chaoxing.log ./config.ini ./chaoxing.log ./cookies.txt .idea/ -cache.json +.vscode/ +cache.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..35f29bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.10-slim + +WORKDIR /app + +COPY . /app + +RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 创建配置文件目录并提供默认配置 +RUN mkdir -p /config && \ + cp config_template.ini /config/config.ini + +# 定义卷,用户可以挂载自己的配置文件 +VOLUME /config + +# 使用配置文件启动应用 +ENTRYPOINT ["python3", "main.py", "-c", "/config/config.ini"] diff --git a/README.md b/README.md index 9557af4..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,34 +0,0 @@ -
-

- - Logo - -

学习通云端刷课脚本

- -

- 利用Github actions|自动化刷课|无人值守|云端刷课 -
- 🌎 小白使用说明  |   - 📦️ 源仓库   -
-
-

-

- -# ⚠️明文账号密码泄露风险警示 -

注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!

-

注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!

-

注意!因为默认fork后的本仓库是公开的,你的学习通密码以及自己手机号是明文公开状态!!!

-

可能会被某些别有用心的人拿去做坏事!!!

-

当然如果你自己觉着自己的学习通账号没啥价值,可以无视

-

当你fork本仓库后,默认你看过了这条警示,作者不对账号密码明文泄露导致的后果负责!!!

-推荐将仓库可见性设置为私密,保护自己的账号密码 - - -# ❤️快速开始 -Fork本仓库
-开启GitHub Actions
-修改.github/workflows/main.yml(具体注释在文件内) - -# 🈲严禁用于商业用途! - diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..396918f --- /dev/null +++ b/api/README.md @@ -0,0 +1,17 @@ +## 模块说明 + +- `__init__.py`: 提供格式化输出辅助函数 +- `base.py`: 提供核心功能,包含主要的Chaoxing类和学习功能 +- `answer.py`: 提供多种题库接口和答题功能 +- `answer_check.py`: 答案检查和验证 +- `cipher.py`: AES加密解密功能 +- `config.py`: 全局配置常量 +- `cookies.py`: Cookie管理 +- `cxsecret_font.py`: 超星字体解析 +- `decode.py`: 解析超星页面数据 +- `exceptions.py`: 自定义异常类 +- `font_decoder.py`: 字体解码器 +- `logger.py`: 日志功能 +- `notification.py`: 通知功能 +- `process.py`: 进度显示工具 +- `captcha.py`: 验证码识别模块 \ No newline at end of file diff --git a/api/answer.py b/api/answer.py index 8273647..6f591cd 100644 --- a/api/answer.py +++ b/api/answer.py @@ -1,10 +1,19 @@ import configparser -import requests -from pathlib import Path import json -from api.logger import logger import random -from urllib3 import disable_warnings,exceptions +import re +import time +from pathlib import Path +from re import sub + +import httpx +import requests +from openai import OpenAI +from urllib3 import disable_warnings, exceptions + +from api.answer_check import * +from api.logger import logger + # 关闭警告 disable_warnings(exceptions.InsecureRequestWarning) @@ -13,31 +22,44 @@ class CacheDAO: @Author: SocialSisterYi @Reference: https://github.com/SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy """ - def __init__(self, file: str = "cache.json"): - self.cacheFile = Path(file) - if not self.cacheFile.is_file(): - self.cacheFile.open("w").write("{}") - self.fp = self.cacheFile.open("r+", encoding="utf8") - - def getCache(self, question: str): - self.fp.seek(0) - data = json.load(self.fp) - if isinstance(data, dict): - return data.get(question) - - def addCache(self, question: str, answer: str): - self.fp.seek(0) - data: dict = json.load(self.fp) + DEFAULT_CACHE_FILE = "cache.json" + + def __init__(self, file: str = DEFAULT_CACHE_FILE): + self.cache_file = Path(file) + if not self.cache_file.is_file(): + self._write_cache({}) + + def _read_cache(self) -> dict: + try: + with self.cache_file.open("r", encoding="utf8") as fp: + return json.load(fp) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + def _write_cache(self, data: dict) -> None: + try: + with self.cache_file.open("w", encoding="utf8") as fp: + json.dump(data, fp, ensure_ascii=False, indent=4) + except IOError as e: + logger.error(f"Failed to write cache: {e}") + + def get_cache(self, question: str): + data = self._read_cache() + return data.get(question) + + def add_cache(self, question: str, answer: str) -> None: + data = self._read_cache() data[question] = answer - self.fp.seek(0) - json.dump(data, self.fp, ensure_ascii=False, indent=4) + self._write_cache(data) class Tiku: CONFIG_PATH = "config.ini" # 默认配置文件路径 DISABLE = False # 停用标志 SUBMIT = False # 提交标志 - + COVER_RATE = 0.8 # 覆盖率 + true_list = [] + false_list = [] def __init__(self) -> None: self._name = None self._api = None @@ -68,18 +90,21 @@ def token(self,value): self._token = value def init_tiku(self): - # 仅用于题库初始化,应该在题库载入后作初始化调用,随后才可以使用题库 + # 仅用于题库初始化, 应该在题库载入后作初始化调用, 随后才可以使用题库 # 尝试根据配置文件设置提交模式 if not self._conf: self.config_set(self._get_conf()) if not self.DISABLE: # 设置提交模式 self.SUBMIT = True if self._conf['submit'] == 'true' else False + self.COVER_RATE = float(self._conf['cover_rate']) + self.true_list = self._conf['true_list'].split(',') + self.false_list = self._conf['false_list'].split(',') # 调用自定义题库初始化 self._init_tiku() def _init_tiku(self): - # 仅用于题库初始化,例如配置token,交由自定义题库完成 + # 仅用于题库初始化, 例如配置token, 交由自定义题库完成 pass def config_set(self,config): @@ -87,28 +112,29 @@ def config_set(self,config): def _get_conf(self): """ - 从默认配置文件查询配置,如果未能查到,停用题库 + 从默认配置文件查询配置, 如果未能查到, 停用题库 """ try: config = configparser.ConfigParser() config.read(self.CONFIG_PATH, encoding="utf8") return config['tiku'] - except KeyError or FileNotFoundError: - logger.info("未找到tiku配置,已忽略题库功能") + except (KeyError, FileNotFoundError): + logger.info("未找到tiku配置, 已忽略题库功能") self.DISABLE = True return None - def query(self,q_info:dict): if self.DISABLE: return None - # 预处理,去除【单选题】这样与标题无关的字段 - # 此处需要改进!!! - q_info['title'] = q_info['title'][6:] # 暂时直接用裁切解决 + # 预处理, 去除【单选题】这样与标题无关的字段 + logger.debug(f"原始标题:{q_info['title']}") + q_info['title'] = sub(r'^\d+', '', q_info['title']) + q_info['title'] = sub(r'(\d+\.\d+分)$', '', q_info['title']) + logger.debug(f"处理后标题:{q_info['title']}") # 先过缓存 cache_dao = CacheDAO() - answer = cache_dao.getCache(q_info['title']) + answer = cache_dao.get_cache(q_info['title']) if answer: logger.info(f"从缓存中获取答案:{q_info['title']} -> {answer}") return answer.strip() @@ -116,20 +142,26 @@ def query(self,q_info:dict): answer = self._query(q_info) if answer: answer = answer.strip() - cache_dao.addCache(q_info['title'], answer) + cache_dao.add_cache(q_info['title'], answer) logger.info(f"从{self.name}获取答案:{q_info['title']} -> {answer}") - return answer + if check_answer(answer, q_info['type'], self): + return answer + else: + logger.info(f"从{self.name}获取到的答案类型与题目类型不符,已舍弃") + return None + logger.error(f"从{self.name}获取答案失败:{q_info['title']}") return None + def _query(self,q_info:dict): """ - 查询接口,交由自定义题库实现 + 查询接口, 交由自定义题库实现 """ pass def get_tiku_from_config(self): """ - 从配置文件加载题库,这个配置可以是用户提供,可以是默认配置文件 + 从配置文件加载题库, 这个配置可以是用户提供, 可以是默认配置文件 """ if not self._conf: # 尝试从默认配置文件加载 @@ -141,37 +173,36 @@ def get_tiku_from_config(self): if not cls_name: raise KeyError except KeyError: - logger.error("未找到题库配置,已忽略题库功能") + self.DISABLE = True + logger.error("未找到题库配置, 已忽略题库功能") return self new_cls = globals()[cls_name]() new_cls.config_set(self._conf) return new_cls - - def jugement_select(self,answer:str) -> bool: + + def judgement_select(self, answer: str) -> bool: """ - 这是一个专用的方法,要求配置维护两个选项列表,一份用于正确选项,一份用于错误选项,以应对题库对判断题答案响应的各种可能的情况 + 这是一个专用的方法, 要求配置维护两个选项列表, 一份用于正确选项, 一份用于错误选项, 以应对题库对判断题答案响应的各种可能的情况 它的作用是将获取到的答案answer与可能的选项列对比并返回对应的布尔值 """ if self.DISABLE: return False - true_list = self._conf['true_list'].split(',') - false_list = self._conf['false_list'].split(',') # 对响应的答案作处理 answer = answer.strip() - if answer in true_list: + if answer in self.true_list: return True - elif answer in false_list: + elif answer in self.false_list: return False else: - # 无法判断,随机选择 - logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误,请自行判断并加入配置文件重启脚本,本次将会随机选择选项') + # 无法判断, 随机选择 + logger.error(f'无法判断答案 -> {answer} 对应的是正确还是错误, 请自行判断并加入配置文件重启脚本, 本次将会随机选择选项') return random.choice([True,False]) def get_submit_params(self): """ - 这是一个专用方法,用于根据当前设置的提交模式,响应对应的答题提交API中的pyFlag值 + 这是一个专用方法, 用于根据当前设置的提交模式, 响应对应的答题提交API中的pyFlag值 """ - # 留空直接提交,1保存但不提交 + # 留空直接提交, 1保存但不提交 if self.SUBMIT: return "" else: @@ -187,28 +218,30 @@ def __init__(self) -> None: self.api = 'https://tk.enncy.cn/query' self._token = None self._token_index = 0 # token队列计数器 - self._times = 100 # 查询次数剩余,初始化为100,查询后校对修正 + self._times = 100 # 查询次数剩余, 初始化为100, 查询后校对修正 def _query(self,q_info:dict): res = requests.get( self.api, params={ 'question':q_info['title'], - 'token':self._token + 'token': self._token, + # 'type':q_info['type'], #修复478题目类型与答案类型不符(不想写后处理了) + # 没用,就算有type和options,言溪题库还是可能返回类型不符,问了客服,type仅用于收集 }, verify=False ) if res.status_code == 200: res_json = res.json() if not res_json['code']: - # 如果是因为TOKEN次数到期,则更换token + # 如果是因为TOKEN次数到期, 则更换token if self._times == 0 or '次数不足' in res_json['data']['answer']: - logger.info(f'TOKEN查询次数不足,将会更换并重新搜题') + logger.info(f'TOKEN查询次数不足, 将会更换并重新搜题') self._token_index += 1 self.load_token() # 重新查询 return self._query(q_info) - logger.error(f'{self.name}查询失败:\n剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n消息:{res_json["message"]}') + logger.error(f'{self.name}查询失败:\n\t剩余查询数{res_json["data"].get("times",f"{self._times}(仅参考)")}:\n\t消息:{res_json["message"]}') return None self._times = res_json["data"].get("times",self._times) return res_json['data']['answer'].strip() @@ -220,10 +253,370 @@ def load_token(self): token_list = self._conf['tokens'].split(',') if self._token_index == len(token_list): # TOKEN 用完 - logger.error('TOKEN用完,请自行更换再重启脚本') - raise Exception(f'{self.name} TOKEN 已用完,请更换') + logger.error('TOKEN用完, 请自行更换再重启脚本') + raise PermissionError(f'{self.name} TOKEN 已用完, 请更换') self._token = token_list[self._token_index] def _init_tiku(self): self.load_token() +class TikuLike(Tiku): + # Like知识库实现 + def __init__(self) -> None: + super().__init__() + self.name = 'Like知识库' + self.ver = '1.0.8' #对应官网API版本 + self.query_api = 'https://api.datam.site/search' + self.balance_api = 'https://api.datam.site/balance' + self.homepage = 'https://www.datam.site' + self._model = None + self._token = None + self._times = -1 + self._search = False + self._count = 0 + + def _query(self,q_info:dict): + q_info_map = {"single":"【单选题】","multiple":"【多选题】","completion":"【填空题】","judgement":"【判断题】"} + api_params_map = {0:"others",1:"choose",2:"fills",3:"judge"} + q_info_prefix = q_info_map.get(q_info['type'],"【其他类型题目】") + 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, + "e": 4, "f": 5, "g": 6, "h": 7} + options = ', '.join(q_info['options']) if isinstance(q_info['options'], list) else q_info['options'] + question = f"{q_info_prefix}{q_info['title']}\n{options}" + ret = "" + ans = "" + res = requests.post( + self.query_api, + json={ + 'query': question, + 'token': self._token, + 'model': self._model if self._model else '', + 'search': self._search + }, + verify=False + ) + + if res.status_code == 200: + res_json = res.json() + q_type = res_json['data'].get('type', 0) + params = api_params_map.get(q_type, "") + tans = res_json['data'].get(params, "") + ans = "" + match q_type: + case 1: + for i in tans: + ans = ans + q_info['options'][option_map[i]] + '\n' + case 2: + for i in tans: + ans = ans + i + '\n' + case 3: + ans = "正确" if tans == 1 else "错误" + case 0: + ans = tans + else: + logger.error(f'{self.name}查询失败:\n{res.text}') + return None + + ret += str(ans) + + self._times -= 1 + + #10次查询后更新实际次数 + self._count = (self._count+1) % 10 + + if self._count == 0: + self.update_times() + + return ret + + def update_times(self): + res = requests.post( + self.balance_api, + json={ + 'token': self._token, + }, + verify=False + ) + if res.status_code == 200: + res_json = res.json() + self._times = res_json["data"].get("balance",self._times) + logger.info(f"当前LIKE知识库Token剩余查询次数为: {self._times}") + else: + logger.error('TOKEN出现错误,请检查后再试') + + def load_token(self): + token = self._conf['tokens'].split(',')[-1] if ',' in self._conf['tokens'] else self._conf['tokens'] + self._token = token + + def load_config(self): + self._search = self._conf['likeapi_search'] + self._model = self._conf['likeapi_model'] + var_params = {"likeapi_search": self._search, "likeapi_model": self._model} + config_params = {"likeapi_search": False, "likeapi_model": None} + + for k,v in config_params.items(): + if k in self._conf: + var_params[k] = self._conf[k] + else: + var_params[k] = v + + def _init_tiku(self): + self.load_token() + self.load_config() + self.update_times() + +class TikuAdapter(Tiku): + # TikuAdapter题库实现 https://github.com/DokiDoki1103/tikuAdapter + def __init__(self) -> None: + super().__init__() + self.name = 'TikuAdapter题库' + self.api = '' + + def _query(self, q_info: dict): + # 判断题目类型 + if q_info['type'] == "single": + type = 0 + elif q_info['type'] == 'multiple': + type = 1 + elif q_info['type'] == 'completion': + type = 2 + elif q_info['type'] == 'judgement': + type = 3 + else: + type = 4 + + options = q_info['options'] + res = requests.post( + self.api, + json={ + 'question': q_info['title'], + 'options': [sub(r'^[A-Za-z]\.?、?\s?', '', option) for option in options.split('\n')], + 'type': type + }, + verify=False + ) + if res.status_code == 200: + res_json = res.json() + # if bool(res_json['plat']): + # plat无论搜没搜到答案都返回0 + # 这个参数是tikuadapter用来设定自定义的平台类型 + if not len(res_json['answer']['bestAnswer']): + logger.error("查询失败, 返回:" + res.text) + return None + sep = "\n" + return sep.join(res_json['answer']['bestAnswer']).strip() + # else: + # logger.error(f'{self.name}查询失败:\n{res.text}') + return None + + def _init_tiku(self): + # self.load_token() + self.api = self._conf['url'] + +class AI(Tiku): + # AI大模型答题实现 + def __init__(self) -> None: + super().__init__() + self.name = 'AI大模型答题' + self.last_request_time = None + + def _query(self, q_info: dict): + def remove_md_json_wrapper(md_str): + # 使用正则表达式匹配Markdown代码块并提取内容 + pattern = r'^\s*```(?:json)?\s*(.*?)\s*```\s*$' + match = re.search(pattern, md_str, re.DOTALL) + return match.group(1).strip() if match else md_str.strip() + + if self.http_proxy: + proxy = self.http_proxy + httpx_client = httpx.Client(proxy=proxy) + client = OpenAI(http_client=httpx_client, base_url = self.endpoint,api_key = self.key) + else: + client = OpenAI(base_url = self.endpoint,api_key = self.key) + # 去除选项字母,防止大模型直接输出字母而非内容 + options_list = q_info['options'].split('\n') + cleaned_options = [re.sub(r"^[A-Z]\s*", "", option) for option in options_list] + options = "\n".join(cleaned_options) + # 判断题目类型 + if q_info['type'] == "single": + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为单选题,你只能选择一个选项,请根据题目和选项回答问题,以json格式输出正确的选项内容,示例回答:{\"Answer\": [\"答案\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}\n选项:{options}" + } + ] + ) + elif q_info['type'] == 'multiple': + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为多选题,你必须选择两个或以上选项,请根据题目和选项回答问题,以json格式输出正确的选项内容,示例回答:{\"Answer\": [\"答案1\",\n\"答案2\",\n\"答案3\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}\n选项:{options}" + } + ] + ) + elif q_info['type'] == 'completion': + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为填空题,你必须根据语境和相关知识填入合适的内容,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"答案\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}" + } + ] + ) + elif q_info['type'] == 'judgement': + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为判断题,你只能回答正确或者错误,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"正确\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}" + } + ] + ) + else: + completion = client.chat.completions.create( + model = self.model, + messages=[ + { + "role": "system", + "content": "本题为简答题,你必须根据语境和相关知识填入合适的内容,请根据题目回答问题,以json格式输出正确的答案,示例回答:{\"Answer\": [\"这是我的答案\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + }, + { + "role": "user", + "content": f"题目:{q_info['title']}" + } + ] + ) + + try: + if self.last_request_time: + interval_time = time.time() - self.last_request_time + if interval_time < self.min_interval_seconds: + sleep_time = self.min_interval_seconds - interval_time + logger.debug(f"API请求间隔过短, 等待 {sleep_time} 秒") + time.sleep(sleep_time) + self.last_request_time = time.time() + response = json.loads(remove_md_json_wrapper(completion.choices[0].message.content)) + sep = "\n" + return sep.join(response['Answer']).strip() + except: + logger.error("无法解析大模型输出内容") + return None + + def _init_tiku(self): + self.endpoint = self._conf['endpoint'] + self.key = self._conf['key'] + self.model = self._conf['model'] + self.http_proxy = self._conf['http_proxy'] + self.min_interval_seconds = int(self._conf['min_interval_seconds']) +class SiliconFlow(Tiku): + """硅基流动大模型答题实现""" + def __init__(self): + super().__init__() + self.name = '硅基流动大模型' + self.last_request_time = None + + def _query(self, q_info: dict): + def remove_md_json_wrapper(md_str): + # 解析可能存在的JSON包装 + pattern = r'^\s*```(?:json)?\s*(.*?)\s*```\s*$' + match = re.search(pattern, md_str, re.DOTALL) + return match.group(1).strip() if match else md_str.strip() + + # 构造请求头 + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + # 构造系统提示词 + system_prompt = "" + if q_info['type'] == "single": + system_prompt = "本题为单选题,请根据题目和选项选择唯一正确答案,输出的是选项的具体内容,而不是内容前的ABCD,并以JSON格式输出:示例回答:{\"Answer\": [\"正确选项内容\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + elif q_info['type'] == 'multiple': + system_prompt = "本题为多选题,请选择所有正确选项,输出的是选项的具体内容,而不是内容前的ABCD,以JSON格式输出:示例回答:{\"Answer\": [\"选项1\",\"选项2\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + elif q_info['type'] == 'completion': + system_prompt = "本题为填空题,请直接给出填空内容,以JSON格式输出:示例回答:{\"Answer\": [\"答案文本\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + elif q_info['type'] == 'judgement': + system_prompt = "本题为判断题,请回答'正确'或'错误',以JSON格式输出:示例回答:{\"Answer\": [\"正确\"]}。除此之外不要输出任何多余的内容,也不要使用MD语法。如果你使用了互联网搜索,也请不要返回搜索的结果和参考资料" + + # 构造请求体 + payload = { + "model": self.model_name, + "messages": [ + { + "role": "system", + "content": system_prompt + }, + { + "role": "user", + "content": f"题目:{q_info['title']}\n选项:{q_info['options']}" + } + ], + "stream": False, + + "max_tokens": 4096, + + "temperature": 0.7, + "top_p": 0.7, + "response_format": {"type": "text"} + } + + # 处理请求间隔 + if self.last_request_time: + interval = time.time() - self.last_request_time + if interval < self.min_interval: + time.sleep(self.min_interval - interval) + + try: + response = requests.post( + self.api_endpoint, + headers=headers, + json=payload, + timeout=30 + ) + self.last_request_time = time.time() + + if response.status_code == 200: + result = response.json() + content = result['choices'][0]['message']['content'] + parsed = json.loads(remove_md_json_wrapper(content)) + return "\n".join(parsed['Answer']).strip() + else: + logger.error(f"API请求失败:{response.status_code} {response.text}") + return None + + except Exception as e: + logger.error(f"硅基流动API异常:{e}") + return None + + def _init_tiku(self): + # 从配置文件读取参数 + self.api_endpoint = self._conf.get('siliconflow_endpoint', 'https://api.siliconflow.cn/v1/chat/completions') + self.api_key = self._conf['siliconflow_key'] + + self.model_name = self._conf.get('siliconflow_model', 'deepseek-ai/DeepSeek-V3') + + + self.min_interval = int(self._conf.get('min_interval_seconds', 3)) diff --git a/api/answer_check.py b/api/answer_check.py new file mode 100644 index 0000000..527ebd3 --- /dev/null +++ b/api/answer_check.py @@ -0,0 +1,83 @@ +def check_single(answer): + _t = cut(answer) + if _t is not None and len(_t) == 1: + return True + else: + return False + + +def check_multiple(answer): + _t = cut(answer) + if _t is not None and len(_t) > 0: + return True + return False + + +def check_judgement(answer, true_list, false_list): + if answer in true_list: + return 1 + elif answer in false_list: + return 0 + else: + return -1 + + +def check_completion(answer): + if len(answer) > 0: + return True + else: + return False + + +def check_answer(answer, type, tiku): # 只会写小杯代码,这里用个tiku感觉怪怪的,但先这么写着 + if type == 'single': + if check_single(answer) and check_judgement(answer, tiku.true_list, tiku.false_list) == -1: + return True + elif type == 'multiple': + if check_multiple(answer) and check_judgement(answer, tiku.true_list, tiku.false_list) == -1: + return True + elif type == 'completion': + if check_completion(answer): + return True + elif type == 'judgement': + if check_judgement(answer, tiku.true_list, tiku.false_list) != -1: + return True + else: # 未知类型不匹配 + return True + return False + + +def cut(answer): + # cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 + # ',' 在常规被正确划分的, 选项中出现, 导致 multi_cut 无法正确划分选项 #391 + # IndexError: Cannot choose from an empty sequence #391 + # 同时为了避免没有考虑到的 case, 应该先按照 '\n' 匹配, 匹配不到再按照其他字符匹配 + cut_char = [ + "\n", + ",", + ",", + "|", + "\r", + "\t", + "#", + "*", + "-", + "_", + "+", + "@", + "~", + "/", + "\\", + ".", + "&", + " ", + "、", + ] # 多选答案切割符 + res = [] + for char in cut_char: + res = [ + opt for opt in answer.split(char) if opt.strip() + ] # Filter empty strings + if len(res) > 0: + return res + return None diff --git a/api/base.py b/api/base.py index 6734caf..a7c61ff 100644 --- a/api/base.py +++ b/api/base.py @@ -1,23 +1,24 @@ # -*- coding: utf-8 -*- -import re -import time -import random -import requests +from enum import Enum from hashlib import md5 + +import requests from requests.adapters import HTTPAdapter +from api.answer import * from api.cipher import AESCipher -from api.logger import logger +from api.config import GlobalConst as gc from api.cookies import save_cookies, use_cookies +from api.decode import ( + decode_course_list, + decode_course_point, + decode_course_card, + decode_course_folder, + decode_questions_info, +) from api.process import show_progress -from api.config import GlobalConst as gc -from api.decode import (decode_course_list, - decode_course_point, - decode_course_card, - decode_course_folder, - decode_questions_info - ) -from api.answer import * +from api.exceptions import MaxRetryExceeded + def get_timestamp(): return str(int(time.time() * 1000)) @@ -30,8 +31,8 @@ def get_random_seconds(): def init_session(isVideo: bool = False, isAudio: bool = False): _session = requests.session() _session.verify = False - _session.mount('http://', HTTPAdapter(max_retries=3)) - _session.mount('https://', HTTPAdapter(max_retries=3)) + _session.mount("http://", HTTPAdapter(max_retries=3)) + _session.mount("https://", HTTPAdapter(max_retries=3)) if isVideo: _session.headers = gc.VIDEO_HEADERS elif isAudio: @@ -47,32 +48,49 @@ class Account: password = None last_login = None isSuccess = None + def __init__(self, _username, _password): self.username = _username self.password = _password class Chaoxing: - def __init__(self, account: Account = None,tiku:Tiku=None): + class StudyResult(Enum): + SUCCESS = 0 + FORBIDDEN = 1 # 403 + ERROR = 2 + TIMEOUT = 3 + + @staticmethod + def is_success(result): + return result == Chaoxing.StudyResult.SUCCESS + + @staticmethod + def is_failure(result): + return result != Chaoxing.StudyResult.SUCCESS + + def __init__(self, account: Account = None, tiku: Tiku = None,**kwargs): self.account = account self.cipher = AESCipher() self.tiku = tiku + self.kwargs = kwargs + self.rollback_times = 0 def login(self): _session = requests.session() _session.verify = False _url = "https://passport2.chaoxing.com/fanyalogin" - _data = {"fid": "-1", - "uname": self.cipher.encrypt(self.account.username), - "password": self.cipher.encrypt(self.account.password), - "refer": "https%3A%2F%2Fi.chaoxing.com", - "t": True, - "forbidotherlogin": 0, - "validate": "", - "doubleFactorLogin": 0, - "independentId": 0, - } - + _data = { + "fid": "-1", + "uname": self.cipher.encrypt(self.account.username), + "password": self.cipher.encrypt(self.account.password), + "refer": "https%3A%2F%2Fi.chaoxing.com", + "t": True, + "forbidotherlogin": 0, + "validate": "", + "doubleFactorLogin": 0, + "independentId": 0, + } logger.trace("正在尝试登录...") resp = _session.post(_url, headers=gc.HEADERS, data=_data) if resp and resp.json()["status"] == True: @@ -93,21 +111,16 @@ def get_uid(self): def get_course_list(self): _session = init_session() _url = "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata" - _data = { - "courseType": 1, - "courseFolderId": 0, - "query": "", - "superstarClass": 0 - } + _data = {"courseType": 1, "courseFolderId": 0, "query": "", "superstarClass": 0} logger.trace("正在读取所有的课程列表...") - # 接口突然抽风,增加headers + # 接口突然抽风, 增加headers _headers = { "Host": "mooc2-ans.chaoxing.com", - "sec-ch-ua-platform": "\"Windows\"", + "sec-ch-ua-platform": '"Windows"', "X-Requested-With": "XMLHttpRequest", "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", "Accept": "text/html, */*; q=0.01", - "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", + "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "sec-ch-ua-mobile": "?0", "Origin": "https://mooc2-ans.chaoxing.com", @@ -115,9 +128,9 @@ def get_course_list(self): "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", "Referer": "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction?moocDomain=https://mooc1-1.chaoxing.com/mooc-ans", - "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" + "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", } - _resp = _session.post(_url,headers=_headers,data=_data) + _resp = _session.post(_url, headers=_headers, data=_data) # logger.trace(f"原始课程列表内容:\n{_resp.text}") logger.info("课程列表读取完毕...") course_list = decode_course_list(_resp.text) @@ -130,7 +143,7 @@ def get_course_list(self): "courseType": 1, "courseFolderId": folder["id"], "query": "", - "superstarClass": 0 + "superstarClass": 0, } _resp = _session.post(_url, data=_data) course_list += decode_course_list(_resp.text) @@ -149,13 +162,21 @@ def get_job_list(self, _clazzid, _courseid, _cpi, _knowledgeid): _session = init_session() job_list = [] job_info = {} - for _possible_num in ["0", "1","2"]: # 学习界面任务卡片数,很少有3个的,但是对于章节解锁任务点少一个都不行,可以从API /mooc-ans/mycourse/studentstudyAjax获取值,或者干脆直接加,但二者都会造成额外的请求 + for _possible_num in [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + ]: # 学习界面任务卡片数, 很少有3个的, 但是对于章节解锁任务点少一个都不行, 可以从API /mooc-ans/mycourse/studentstudyAjax获取值, 或者干脆直接加, 但二者都会造成额外的请求 _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" logger.trace("开始读取章节所有任务点...") _resp = _session.get(_url) _job_list, _job_info = decode_course_card(_resp.text) - if _job_info.get('notOpen',False): - # 直接返回,节省一次请求 + if _job_info.get("notOpen", False): + # 直接返回, 节省一次请求 logger.info("该章节未开放") return [], _job_info job_list += _job_list @@ -168,47 +189,60 @@ def get_job_list(self, _clazzid, _courseid, _cpi, _knowledgeid): def get_enc(self, clazzId, jobid, objectId, playingTime, duration, userid): return md5( - f"[{clazzId}][{userid}][{jobid}][{objectId}][{playingTime * 1000}][d_yHJ!$pdA~5][{duration * 1000}][0_{duration}]" - .encode()).hexdigest() - - def video_progress_log(self, _session, _course, _job, _job_info, _dtoken, _duration, _playingTime, _type: str = "Video"): - if "courseId" in _job['otherinfo']: + f"[{clazzId}][{userid}][{jobid}][{objectId}][{playingTime * 1000}][d_yHJ!$pdA~5][{duration * 1000}][0_{duration}]".encode() + ).hexdigest() + + def video_progress_log( + self, + _session, + _course, + _job, + _job_info, + _dtoken, + _duration, + _playingTime, + _type: str = "Video", + ): + if "courseId" in _job["otherinfo"]: _mid_text = f"otherInfo={_job['otherinfo']}&" else: _mid_text = f"otherInfo={_job['otherinfo']}&courseId={_course['courseId']}&" _success = False for _possible_rt in ["0.9", "1"]: - _url = (f"https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" - f"{_course['cpi']}/" - f"{_dtoken}?" - f"clazzId={_course['clazzId']}&" - f"playingTime={_playingTime}&" - f"duration={_duration}&" - f"clipTime=0_{_duration}&" - f"objectId={_job['objectid']}&" - f"{_mid_text}" - f"jobid={_job['jobid']}&" - f"userid={self.get_uid()}&" - f"isdrag=3&" - f"view=pc&" - f"enc={self.get_enc(_course['clazzId'], _job['jobid'], _job['objectid'], _playingTime, _duration, self.get_uid())}&" - f"rt={_possible_rt}&" - f"dtype={_type}&" - f"_t={get_timestamp()}") + _url = ( + f"https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" + f"{_course['cpi']}/" + f"{_dtoken}?" + f"clazzId={_course['clazzId']}&" + f"playingTime={_playingTime}&" + f"duration={_duration}&" + f"clipTime=0_{_duration}&" + f"objectId={_job['objectid']}&" + f"{_mid_text}" + f"jobid={_job['jobid']}&" + f"userid={self.get_uid()}&" + f"isdrag=3&" + f"view=pc&" + f"enc={self.get_enc(_course['clazzId'], _job['jobid'], _job['objectid'], _playingTime, _duration, self.get_uid())}&" + f"rt={_possible_rt}&" + f"dtype={_type}&" + f"_t={get_timestamp()}" + ) resp = _session.get(_url) if resp.status_code == 200: _success = True - break # 如果返回为200正常,则跳出循环 + break # 如果返回为200正常, 则跳出循环 elif resp.status_code == 403: - continue # 如果出现403无权限报错,则继续尝试不同的rt参数 + continue # 如果出现403无权限报错, 则继续尝试不同的rt参数 if _success: - return resp.json() + return resp.json(), 200 else: - # 若出现两个rt参数都返回403的情况,则跳过当前任务 - logger.warning("出现403报错,尝试修复无效,正在跳过当前任务点...") - return False - - def study_video(self, _course, _job, _job_info, _speed: float = 1.0, _type: str = "Video"): + # 若出现两个rt参数都返回403的情况, 则跳过当前任务 + logger.warning("出现403报错, 尝试修复无效, 正在跳过当前任务点...") + return {"isPassed": False}, 403 # 返回一个字典和当前状态 + def study_video( + self, _course, _job, _job_info, _speed: float = 1.0, _type: str = "Video" + ) -> StudyResult: if _type == "Video": _session = init_session(isVideo=True) else: @@ -225,203 +259,446 @@ def study_video(self, _course, _job, _job_info, _speed: float = 1.0, _type: str _isFinished = False _playingTime = 0 logger.info(f"开始任务: {_job['name']}, 总时长: {_duration}秒") + state = 200 while not _isFinished: if _isFinished: _playingTime = _duration - _isPassed = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, _duration, _playingTime, _type) + _isPassed, state = self.video_progress_log( + _session, + _course, + _job, + _job_info, + _dtoken, + _duration, + _playingTime, + _type, + ) if not _isPassed or (_isPassed and _isPassed["isPassed"]): break + if _isPassed and not _isPassed["isPassed"] and state == 403: + return self.StudyResult.FORBIDDEN _wait_time = get_random_seconds() if _playingTime + _wait_time >= int(_duration): _wait_time = int(_duration) - _playingTime - _isFinished = True + _isPassed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, _duration, _duration, _type) + if _isPassed['isPassed']: + _isFinished = True # 播放进度条 - show_progress(_job['name'], _playingTime, _wait_time, _duration, _speed) + show_progress(_job["name"], _playingTime, _wait_time, _duration, _speed) _playingTime += _wait_time print("\r", end="", flush=True) logger.info(f"任务完成: {_job['name']}") - - def study_document(self, _course, _job): + return self.StudyResult.SUCCESS + else: + return self.StudyResult.ERROR + def study_document(self, _course, _job) -> StudyResult: + """ + Study a document in Chaoxing platform. + + This method makes a GET request to fetch document information for a given course and job. + + Args: + _course (dict): Dictionary containing course information with keys: + - courseId: ID of the course + - clazzId: ID of the class + _job (dict): Dictionary containing job information with keys: + - jobid: ID of the job + - otherinfo: String containing node information + - jtoken: Authentication token for the job + + Returns: + requests.Response: Response object from the GET request + + Note: + This method requires the following helper functions: + - init_session(): To initialize a new session + - get_timestamp(): To get current timestamp + - re module for regular expression matching + """ _session = init_session() _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()}" _resp = _session.get(_url) + if _resp.status_code != 200: + return self.StudyResult.ERROR + else: + return self.StudyResult.SUCCESS - def study_work(self, _course, _job,_job_info) -> None: + def study_work(self, _course, _job, _job_info) -> StudyResult: if self.tiku.DISABLE or not self.tiku: - return None - _ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码,帮助修复#391错误 + return self.StudyResult.SUCCESS + _ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码, 帮助修复#391错误 - def random_answer(options:str) -> str: - answer = '' + def random_answer(options: str) -> str: + answer = "" if not options: return answer - - if q['type'] == "multiple": + + if q["type"] == "multiple": + logger.debug(f"当前选项列表[cut前] -> {options}") _op_list = multi_cut(options) - for i in range(random.choices([2,3,4],weights=[0.1,0.5,0.4],k=1)[0]): # 此处表示随机多选答案几率:2个 10%,3个 50% ,4个 40% - _choice = random.choice(_op_list) - _op_list.remove(_choice) - answer+=_choice[:1] # 取首字为答案,例如A或B - # 对答案进行排序,否则会提交失败 + logger.debug(f"当前选项列表[cut后] -> {_op_list}") + + if not _op_list: + logger.error( + "选项为空, 未能正确提取题目选项信息! 请反馈并提供以上信息" + ) + return answer + + available_options = len(_op_list) + select_count = 0 + + # 根据可用选项数量调整可能选择的选项数 + if available_options <= 1: + select_count = available_options + else: + max_possible = min(4, available_options) + min_possible = min(2, available_options) + + weights_map = { + 2: [1.0], + 3: [0.3, 0.7], + 4: [0.1, 0.5, 0.4], + 5: [0.1, 0.4, 0.3, 0.2], + } + + weights = weights_map.get(max_possible, [0.3, 0.4, 0.3]) + possible_counts = list(range(min_possible, max_possible + 1)) + + weights = weights[:len(possible_counts)] + + weights_sum = sum(weights) + if weights_sum > 0: + weights = [w/weights_sum for w in weights] + + select_count = random.choices(possible_counts, weights=weights, k=1)[0] + + selected_options = random.sample(_op_list, select_count) if select_count > 0 else [] + + for option in selected_options: + answer += option[:1] # 取首字为答案,例如A或B + answer = "".join(sorted(answer)) - elif q['type'] == "single": - answer = random.choice(options.split('\n'))[:1] # 取首字为答案,例如A或B + elif q["type"] == "single": + answer = random.choice(options.split("\n"))[ + :1 + ] # 取首字为答案, 例如A或B # 判断题处理 - elif q['type'] == "judgement": + elif q["type"] == "judgement": # answer = self.tiku.jugement_select(_answer) - answer = "true" if random.choice([True,False]) else "false" - logger.info(f'随机选择 -> {answer}') + answer = "true" if random.choice([True, False]) else "false" + logger.info(f"随机选择 -> {answer}") return answer - - def multi_cut(answer:str) -> list[str]: - cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 - res = [] - for char in cut_char: - res = answer.split(char) - if len(res)>1: - return res - logger.warning(f"未能从网页中提取题目信息,以下为相关信息:\n{answer}\n\n{_ORIGIN_HTML_CONTENT}\n") # 尝试输出网页内容和选项信息 - logger.warning("未能正确提取题目选项信息!请反馈并提供以上信息。") - return ['A','B','C','D'] # 默认多选题为4个选项 - - - # 学习通这里根据参数差异能重定向至两个不同接口,需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle + + def multi_cut(answer: str): + """ + 将多选题答案字符串按特定字符进行切割, 并返回切割后的答案列表 + + 参数: + answer(str): 多选题答案字符串. + + 返回: + list[str]: 切割后的答案列表, 如果无法切割, 则返回默认的选项列表None + + 注意: + 如果无法从网页中提取题目信息, 将记录警告日志并返回None + """ + # cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符 + # ',' 在常规被正确划分的, 选项中出现, 导致 multi_cut 无法正确划分选项 #391 + # IndexError: Cannot choose from an empty sequence #391 + # 同时为了避免没有考虑到的 case, 应该先按照 '\n' 匹配, 匹配不到再按照其他字符匹配 + cut_char = [ + "\n", + ",", + ",", + "|", + "\r", + "\t", + "#", + "*", + "-", + "_", + "+", + "@", + "~", + "/", + "\\", + ".", + "&", + " ", + "、", + ] # 多选答案切割符 + res = cut(answer) + if res is None: + logger.warning( + f"未能从网页中提取题目信息, 以下为相关信息:\n\t{answer}\n\n{_ORIGIN_HTML_CONTENT}\n" + ) # 尝试输出网页内容和选项信息 + logger.warning("未能正确提取题目选项信息! 请反馈并提供以上信息") + return None + else: + return res + + def clean_res(res): + cleaned_res = [] + if isinstance(res, str): + res = [res] + for c in res: + cleaned_res.append(re.sub(r'^[A-Za-z]|[.,!?;:,。!?;:]', '', c)) + + return cleaned_res + + def is_subsequence(a, o): + iter_o = iter(o) + return all(c in iter_o for c in a) + + def with_retry(max_retries=3, delay=1): + def decorator(func): + def wrapper(*args, **kwargs): + retries = 0 + while retries < max_retries: + try: + _resp = func(*args, **kwargs) + + # 未创建完成该测验则不进行答题,目前遇到的情况是未创建完成等同于没题目 + if '教师未创建完成该测验' in _resp.text: + raise PermissionError("教师未创建完成该测验") + + questions = decode_questions_info(_resp.text) + + if _resp.status_code == 200 and questions.get("questions"): + return (_resp, questions) + + logger.warning(f"无效响应 (Code: {getattr(_resp, 'status_code', 'Unknown')}), 重试中... ({retries+1}/{max_retries})") + + except requests.exceptions.RequestException as e: + logger.warning(f"请求失败: {str(e)[:50]}, 重试中... ({retries+1}/{max_retries})") + retries += 1 + time.sleep(delay * (2 ** retries)) + raise MaxRetryExceeded(f"超过最大重试次数 ({max_retries})") + return wrapper + return decorator + + # 学习通这里根据参数差异能重定向至两个不同接口, 需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle _session = init_session() - headers={ + headers = { "Host": "mooc1.chaoxing.com", - "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", + "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": "\"Windows\"", + "sec-ch-ua-platform": '"Windows"', "Upgrade-Insecure-Requests": "1", "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", "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", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "iframe", - "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" + "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", } cookies = _session.cookies.get_dict() - - _url = "https://mooc1.chaoxing.com/mooc-ans/api/work" - _resp = requests.get( - _url, - headers=headers, - cookies=cookies, - verify=False, - params = { - "api": "1", - "workId": _job['jobid'].replace("work-",""), - "jobid": _job['jobid'], - "originJobId": _job['jobid'], - "needRedirect": "true", - "skipHeader": "true", - "knowledgeid": str(_job_info['knowledgeid']), - 'ktoken': _job_info['ktoken'], - "cpi": _job_info['cpi'], - "ut": "s", - "clazzId": _course['clazzId'], - "type": "", - "enc": _job['enc'], - "mooc2": "1", - "courseid": _course['courseId'] - } - ) - _ORIGIN_HTML_CONTENT = _resp.text # 用于配合输出网页源码,帮助修复#391错误 - questions = decode_questions_info(_resp.text) # 加载题目信息 + _url = "https://mooc1.chaoxing.com/mooc-ans/api/work" + + @with_retry(max_retries=3, delay=1) + def fetch_response(): + return requests.get( + _url, + headers=headers, + cookies=cookies, + verify=False, + params={ + "api": "1", + "workId": _job["jobid"].replace("work-", ""), + "jobid": _job["jobid"], + "originJobId": _job["jobid"], + "needRedirect": "true", + "skipHeader": "true", + "knowledgeid": str(_job_info["knowledgeid"]), + "ktoken": _job_info["ktoken"], + "cpi": _job_info["cpi"], + "ut": "s", + "clazzId": _course["clazzId"], + "type": "", + "enc": _job["enc"], + "mooc2": "1", + "courseid": _course["courseId"], + } + ) + + final_resp = {} + questions = {} + + try: + final_resp, questions = fetch_response() + except Exception as e: + logger.error(f"请求失败: {e}") + return self.StudyResult.ERROR + + _ORIGIN_HTML_CONTENT = final_resp.text # 用于配合输出网页源码, 帮助修复#391错误 # 搜题 - for q in questions['questions']: + total_questions = len(questions["questions"]) + found_answers = 0 + for q in questions["questions"]: + logger.debug(f"当前题目信息 -> {q}") + # 添加搜题延迟 #428 - 默认0s延迟 + query_delay = self.kwargs.get("query_delay",0) + time.sleep(query_delay) res = self.tiku.query(q) - answer = '' + answer = "" if not res: # 随机答题 - answer = random_answer(q['options']) + answer = random_answer(q["options"]) + q[f'answerSource{q["id"]}'] = "random" else: # 根据响应结果选择答案 - options_list = multi_cut(q['options']) - if q['type'] == "multiple": + if q["type"] == "multiple": # 多选处理 - for _a in multi_cut(res): + options_list = multi_cut(q["options"]) + res_list = multi_cut(res) + if res_list is not None and options_list is not None: + for _a in clean_res(res_list): + for o in options_list: + if ( + is_subsequence(_a, o) # 去掉各种符号和前面ABCD的答案应当是选项的子序列 + ): + answer += o[:1] + # 对答案进行排序, 否则会提交失败 + answer = "".join(sorted(answer)) + # else 如果分割失败那么就直接到下面去随机选 + elif q["type"] == "single": + # 单选也进行切割,主要是防止返回的答案有异常字符 + options_list = multi_cut(q["options"]) + if options_list is not None: + t_res = clean_res(res) for o in options_list: - if _a.upper() in o: # 题库返回的答案可能包含选项,如A,B,C,全部转成大写与学习通一致 - answer += o[:1] - # 对答案进行排序,否则会提交失败 - answer = "".join(sorted(answer)) - elif q['type'] == 'judgement': - answer = 'true' if self.tiku.jugement_select(res) else 'false' + if is_subsequence(t_res[0], o): + answer = o[:1] + break + elif q["type"] == "judgement": + answer = "true" if self.tiku.judgement_select(res) else "false" + elif q["type"] == "completion": + if isinstance(res,list): + answer = "".join(answer) + elif isinstance(res,str): + answer = res + else: + # 其他类型直接使用答案 (目前仅知有简答题,待补充处理) + answer = res + + if not answer: # 检查 answer 是否为空 + logger.warning(f"找到答案但答案未能匹配 -> {res}\t随机选择答案") + answer = random_answer(q["options"]) # 如果为空,则随机选择答案 + q[f'answerSource{q["id"]}'] = "random" else: - for o in options_list: - if res in o: - answer = o[:1] - break - # 如果未能匹配,依然随机答题 - answer = answer if answer else random_answer(q['options']) + logger.info(f"成功获取到答案:{answer}") + q[f'answerSource{q["id"]}'] = "cover" + found_answers += 1 # 填充答案 - q['answerField'][f'answer{q["id"]}'] = answer + q["answerField"][f'answer{q["id"]}'] = answer logger.info(f'{q["title"]} 填写答案为 {answer}') - - # 提交模式 现在与题库绑定 - questions['pyFlag'] = self.tiku.get_submit_params() - + cover_rate = (found_answers / total_questions) * 100 + logger.info(f"章节检测题库覆盖率: {cover_rate:.0f}%") + # 提交模式 现在与题库绑定,留空直接提交, 1保存但不提交 + if self.tiku.get_submit_params() == "1": + questions["pyFlag"] = "1" + elif cover_rate >= self.tiku.COVER_RATE*100 or self.rollback_times >= 1: + questions["pyFlag"] = "" + else: + questions["pyFlag"] = "1" + logger.info(f"章节检测题库覆盖率低于{self.tiku.COVER_RATE*100:.0f}%,不予提交") # 组建提交表单 - for q in questions["questions"]: - questions.update({ - f'answer{q["id"]}':q['answerField'][f'answer{q["id"]}'], - f'answertype{q["id"]}':q['answerField'][f'answertype{q["id"]}'] - }) - + if questions["pyFlag"] == "1": + for q in questions["questions"]: + questions.update( + { + f'answer{q["id"]}': + q["answerField"][f'answer{q["id"]}'] if q[f'answerSource{q["id"]}'] == "cover" else '', + f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'], + } + ) + else: + for q in questions["questions"]: + questions.update( + { + f'answer{q["id"]}': q["answerField"][f'answer{q["id"]}'], + f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'], + } + ) del questions["questions"] res = _session.post( - 'https://mooc1.chaoxing.com/mooc-ans/work/addStudentWorkNew', + "https://mooc1.chaoxing.com/mooc-ans/work/addStudentWorkNew", data=questions, - headers= { + headers={ "Host": "mooc1.chaoxing.com", - "sec-ch-ua-platform": "\"Windows\"", + "sec-ch-ua-platform": '"Windows"', "X-Requested-With": "XMLHttpRequest", "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", "Accept": "application/json, text/javascript, */*; q=0.01", - "sec-ch-ua": "\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", + "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "sec-ch-ua-mobile": "?0", "Origin": "https://mooc1.chaoxing.com", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", - #"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", - "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" - } + # "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", + "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", + }, ) if res.status_code == 200: res_json = res.json() - if res_json['status']: - logger.info(f'提交答题成功 -> {res_json["msg"]}') + if res_json["status"]: + logger.info(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题成功 -> {res_json["msg"]}') else: - logger.error(f'提交答题失败 -> {res_json["msg"]}') + logger.error(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题失败 -> {res_json["msg"]}') + return self.StudyResult.ERROR else: - logger.error(f"提交答题失败 -> {res.text}") + logger.error(f'{"提交" if questions["pyFlag"] == "" else "保存"}答题失败 -> {res.text}') + return self.StudyResult.ERROR + return self.StudyResult.SUCCESS - def strdy_read(self, _course, _job,_job_info) -> None: + def strdy_read(self, _course, _job, _job_info) -> StudyResult: """ - 阅读任务学习,仅完成任务点,并不增长时长 + 阅读任务学习, 仅完成任务点, 并不增长时长 """ _session = init_session() _resp = _session.get( url="https://mooc1.chaoxing.com/ananas/job/readv2", params={ - 'jobid': _job['jobid'], - 'knowledgeid':_job_info['knowledgeid'], - 'jtoken': _job['jtoken'], - 'courseid': _course['courseId'], - 'clazzid': _course['clazzId'] - } + "jobid": _job["jobid"], + "knowledgeid": _job_info["knowledgeid"], + "jtoken": _job["jtoken"], + "courseid": _course["courseId"], + "clazzid": _course["clazzId"], + }, ) if _resp.status_code != 200: logger.error(f"阅读任务学习失败 -> [{_resp.status_code}]{_resp.text}") + return self.StudyResult.ERROR else: _resp_json = _resp.json() logger.info(f"阅读任务学习 -> {_resp_json['msg']}") + return self.StudyResult.SUCCESS - + def study_emptypage(self, _course, _chapterId): + _session = init_session() + # &cpi=0&verificationcode=&mooc2=1µTopicId=0&editorPreview=0 + _resp = _session.get( + url="https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudyAjax", + params={ + "courseId": _course["courseId"], + "clazzid": _course["clazzId"], + "chapterId": _chapterId['id'], + "cpi": 0, + "verificationcode": "", + "mooc2": 1, + "microTopicId": 0, + "editorPreview": 0, + }, + ) + if _resp.status_code != 200: + logger.error(f"空页面任务失败 -> [{_resp.status_code}]{_chapterId['title']}") + return self.StudyResult.ERROR + else: + logger.info(f"空页面任务完成 -> {_chapterId['title']}") + return self.StudyResult.SUCCESS diff --git a/api/captcha.py b/api/captcha.py new file mode 100644 index 0000000..5afeb44 --- /dev/null +++ b/api/captcha.py @@ -0,0 +1,138 @@ +""" +Captcha API for Chaoxing + +本模块用于通过CX验证码,提供包括验证码获取、识别、验证等接口。 +使用了开源的验证码识别库[DdddOcr](https://github.com/sml2h3/ddddocr) + +Author: skreon +Email: 1340554713@qq.com +Date: 2025-06-05 +Version: 1.0.0 +""" + +__author__ = "skreon 1340554713@qq.com" +__version__ = "1.0.0" + +from random import randint +from typing import Optional +from requests import session +from ddddocr import DdddOcr + + +def ocr_init() -> DdddOcr: + """ + 初始化OCR对象 + + Returns: DdddOcr对象 + """ + return DdddOcr(show_ad=False) + + +class CxCaptcha: + """ + CxCaptcha 类用于处理学习任务中出现的验证码 + + 该类提供了获取、识别和提交验证码的方法,使用 requests 库进行 HTTP 请求, + 并利用 DdddOcr 进行验证码识别。 + + Attributes: + host (str): 超星平台的主机地址。 + api (dict): 包含获取和提交验证码的 API 路径。 + user_agent (str): 用户代理字符串。 + cookies (str): 会话 cookies。 + s (requests.Session): 用于管理会话的请求对象。 + """ + + host = 'https://mooc1.chaoxing.com' + api = { + 'get': '/processVerifyPng.ac', + 'submit': '/html/processVerify.ac' + } + + def __init__(self, user_agent: str, cookies: str, ocr: Optional[DdddOcr] = None): + """ + 初始化 CxCaptcha 实例。 + + Args: + user_agent (str): 用户代理字符串。 + cookies (str): 会话 cookies。 + ocr (DdddOcr, optional): 已初始化的 DdddOcr 对象。默认为 None。据DdddOcr官方说明,每次初始化和初始化后的首次识别速度都非常慢,所以推荐传入一个现成的DdddOcr对象实现复用。 + """ + + self.user_agent = user_agent + self.cookies = cookies + self.s = session() + self.s.headers.update({ + 'User-Agent': self.user_agent, + 'Cookie': self.cookies, + 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8' + }) + self.s.verify = False + + self.ocr = ocr if ocr else ocr_init() + + def getCaptcha(self) -> Optional[bytes]: + """ + 获取验证码图片。 + + Returns: + Optional[bytes]: 返回验证码图片的二进制数据,如果获取失败则返回 None。 + """ + api = self.host + self.api['get'] + random_t = randint(0, 2147483647) + + res = self.s.get(api, params={'t': random_t}) + if res.status_code == 200 and res.headers['Content-Type'] == 'image/png': + return res.content + else: + # 提供的Cookies或UA存在问题,导致未能正常获取验证码内容 + return None + + def submitCaptcha(self, cap_token: str) -> bool: + """ + 提交验证码以完成验证。 + + Args: + cap_token (str): 验证码 token。 + + Returns: + bool: 如果提交成功并重定向,则返回 True;否则返回 False。 + """ + api = self.host + self.api['submit'] + params = { + 'ucode': cap_token, + 'app': 0 + } + res = self.s.get(api, params=params) + if res.status_code == 302: + return True + else: + return False + + def recognition(self, img: bytes) -> str: + """ + 使用 DdddOcr 对验证码图片进行识别。 + + Args: + img (bytes): 验证码图片的二进制数据。 + + Returns: + str: 返回识别出的验证码字符串。 + """ + res = self.ocr.classification(img) + return res + + def try_pass(self) -> bool: + """ + 尝试通过验证码验证流程。 + + 该方法会自动获取验证码、识别并提交。 + + Returns: + bool: 如果验证码成功通过验证,则返回 True;否则返回 False。 + """ + cap_img = self.getCaptcha() + if not cap_img: + return False + cap_token = self.recognition(cap_img) + return self.submitCaptcha(cap_token) diff --git a/api/cipher.py b/api/cipher.py index e06e7cd..e101bef 100644 --- a/api/cipher.py +++ b/api/cipher.py @@ -1,12 +1,11 @@ # -*- coding:utf-8 -*- -# -*- coding: utf-8 -*- import base64 import pyaes from api.config import GlobalConst as gc def pkcs7_unpadding(string): - return string[0:-ord(string[-1])] + return string[0 : -ord(string[-1])] def pkcs7_padding(s, block_size=16): @@ -29,15 +28,15 @@ def split_to_data_blocks(byte_str, block_size=16): return blocks -class AESCipher(): +class AESCipher: def __init__(self): self.key = str(gc.AESKey).encode("utf8") self.iv = str(gc.AESKey).encode("utf8") def encrypt(self, plaintext: str): - ciphertext = b'' + ciphertext = b"" cbc = pyaes.AESModeOfOperationCBC(self.key, self.iv) - plaintext = plaintext.encode('utf-8') + plaintext = plaintext.encode("utf-8") blocks = split_to_data_blocks(pkcs7_padding(plaintext)) for b in blocks: ciphertext = ciphertext + cbc.encrypt(b) @@ -51,4 +50,4 @@ def encrypt(self, plaintext: str): # ptext = b"" # for b in split_to_data_blocks(ciphertext): # ptext = ptext + cbc.decrypt(b) - # return pkcs7_unpadding(ptext.decode()) \ No newline at end of file + # return pkcs7_unpadding(ptext.decode()) diff --git a/api/config.py b/api/config.py index bee24b8..bc0a956 100644 --- a/api/config.py +++ b/api/config.py @@ -3,17 +3,17 @@ class GlobalConst: AESKey = "u2oh6Vu^HWe4_AES" HEADERS = { "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", - "Sec-Ch-Ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"' + "Sec-Ch-Ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"', } COOKIES_PATH = "cookies.txt" VIDEO_HEADERS = { "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", "Referer": "https://mooc1.chaoxing.com/ananas/modules/video/index.html?v=2023-1110-1610", - "Host": "mooc1.chaoxing.com" + "Host": "mooc1.chaoxing.com", } AUDIO_HEADERS = { "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", "Referer": "https://mooc1.chaoxing.com/ananas/modules/audio/index_new.html?v=2023-0428-1705", - "Host": "mooc1.chaoxing.com" + "Host": "mooc1.chaoxing.com", } - THRESHOLD = 3 \ No newline at end of file + THRESHOLD = 3 diff --git a/api/cookies.py b/api/cookies.py index 1158d8e..ce99d99 100644 --- a/api/cookies.py +++ b/api/cookies.py @@ -5,12 +5,12 @@ def save_cookies(_session): - with open(gc.COOKIES_PATH, 'wb') as f: + with open(gc.COOKIES_PATH, "wb") as f: pickle.dump(_session.cookies, f) def use_cookies(): if os.path.exists(gc.COOKIES_PATH): - with open(gc.COOKIES_PATH, 'rb') as f: + with open(gc.COOKIES_PATH, "rb") as f: _cookies = pickle.load(f) - return _cookies \ No newline at end of file + return _cookies diff --git a/api/cxsecret_font.py b/api/cxsecret_font.py index f44fb5b..3cee62c 100644 --- a/api/cxsecret_font.py +++ b/api/cxsecret_font.py @@ -1,17 +1,23 @@ ## # @Author: SocialSisterYi +# @Edit: Samueli924 # @Reference: https://github.com/SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy -# +# import base64 import hashlib import json +import os +import sys from io import BytesIO from pathlib import Path -from typing import IO, Union, Dict +from typing import Dict, IO, Optional, Union from fontTools.ttLib.tables._g_l_y_f import Glyph, table__g_l_y_f from fontTools.ttLib.ttFont import TTFont +from api.exceptions import FontDecodeError +from api.logger import logger + # 康熙部首替换表 KX_RADICALS_TAB = str.maketrans( @@ -22,64 +28,186 @@ ) +def resource_path(relative_path: str) -> str: + """ + 获取资源文件的路径,兼容PyInstaller打包后的环境 + + Args: + relative_path: 相对路径 + + Returns: + 资源文件的绝对路径 + """ + try: + # PyInstaller创建临时文件夹,定位路径 + base_path = sys._MEIPASS + except Exception: + # 非打包环境,使用当前目录 + base_path = os.path.abspath(".") + + return os.path.join(base_path, relative_path) + + class FontHashDAO: - """原始字体hashmap DAO""" - char_map: Dict[str, str] # unicode -> hsah - hash_map: Dict[str, str] # hash -> unicode - - def __init__(self, file: str = "./resource/font_map_table.json"): - with open(file, "r") as fp: - _map: dict = json.load(fp) - self.char_map = _map - self.hash_map = dict(zip(_map.values(), _map.keys())) - - def find_char(self, font_hash: str) -> str: - """以hash查内码""" + """ + 字体哈希数据访问对象,负责管理字体哈希映射表 + """ + + def __init__(self, file_path: str = "resource/font_map_table.json"): + """ + 初始化字体哈希数据访问对象 + + Args: + file_path: 字体映射表JSON文件路径,相对于资源目录 + + Raises: + FileNotFoundError: 当字体映射表文件不存在时 + json.JSONDecodeError: 当字体映射表JSON格式错误时 + """ + self.char_map: Dict[str, str] = {} # unicode -> hash + self.hash_map: Dict[str, str] = {} # hash -> unicode + + full_path = resource_path(file_path) + try: + with open(full_path, "r", encoding="utf-8") as fp: + self.char_map = json.load(fp) + self.hash_map = {hash_val: char for char, hash_val in self.char_map.items()} + except (FileNotFoundError, json.JSONDecodeError) as e: + raise FontDecodeError(f"加载字体映射表失败: {full_path} - {e}") from e + + def find_char(self, font_hash: str) -> Optional[str]: + """ + 通过字体哈希值查找对应的Unicode字符编码 + + Args: + font_hash: 字体哈希值 + + Returns: + 对应的Unicode字符编码,如果未找到则返回None + """ return self.hash_map.get(font_hash) - def find_hash(self, char: str) -> str: - """以内码查hash""" + def find_hash(self, char: str) -> Optional[str]: + """ + 通过Unicode字符编码查找对应的字体哈希值 + + Args: + char: Unicode字符编码 (如 "uni4E00") + + Returns: + 对应的字体哈希值,如果未找到则返回None + """ return self.char_map.get(char) -fonthash_dao = FontHashDAO() +# 初始化字体哈希DAO单例 +try: + fonthash_dao = FontHashDAO() +except Exception as e: + logger.warning(f"初始化字体哈希数据失败 - {e}") + fonthash_dao = FontHashDAO.__new__(FontHashDAO) + fonthash_dao.char_map = {} + fonthash_dao.hash_map = {} def hash_glyph(glyph: Glyph) -> str: - """ttf字形曲线转hash算法实现""" - pos_bin = "" - last = 0 + """ + 计算TTF字体字形的哈希值 + + Args: + glyph: TTF字体字形对象 + + Returns: + 字形的MD5哈希值 + """ + if glyph.numberOfContours <= 0: + return "" + + pos_data = [] + last_index = 0 + for i in range(glyph.numberOfContours): - for j in range(last, glyph.endPtsOfContours[i] + 1): - pos_bin += f"{glyph.coordinates[j][0]}{glyph.coordinates[j][1]}{glyph.flags[j] & 0x01}" - last = glyph.endPtsOfContours[i] + 1 + end_point = glyph.endPtsOfContours[i] + for j in range(last_index, end_point + 1): + x, y = glyph.coordinates[j] + flag = glyph.flags[j] & 0x01 + pos_data.append(f"{x}{y}{flag}") + last_index = end_point + 1 + + pos_bin = "".join(pos_data) return hashlib.md5(pos_bin.encode()).hexdigest() -def font2map(file: Union[IO, Path, str]) -> Dict[str, str]: - """以加密字体计算hashMap""" +def font2map(font_data: Union[IO, Path, str]) -> Dict[str, str]: + """ + 从字体文件或Base64编码的字体数据中提取字形哈希映射表 + + Args: + font_data: 字体文件路径、文件对象或Base64编码的字体数据 + + Returns: + 字形名称到哈希值的映射字典 ({"uni4E00": "hash值", ...}) + + Raises: + ValueError: 当无法解析字体数据时 + """ font_hashmap = {} - if isinstance(file, str): - file = BytesIO(base64.b64decode(file[47:])) - with TTFont(file, lazy=False) as fontFile: - table: table__g_l_y_f = fontFile["glyf"] - for name in table.glyphOrder: - font_hashmap[name] = hash_glyph(table.glyphs[name]) + + # 处理Base64编码的字体数据 + if isinstance(font_data, str) and font_data.startswith("data:application/font-ttf;charset=utf-8;base64,"): + try: + font_data = BytesIO(base64.b64decode(font_data[47:])) + except Exception as e: + raise FontDecodeError(f"无法解码Base64字体数据: {e}") from e + + try: + with TTFont(font_data, lazy=False) as font_file: + table: table__g_l_y_f = font_file["glyf"] + for name in table.glyphOrder: + if name.startswith("uni"): + glyph_hash = hash_glyph(table.glyphs[name]) + if glyph_hash: + font_hashmap[name] = glyph_hash + except Exception as e: + raise FontDecodeError(f"无法解析字体文件: {e}") from e + return font_hashmap -def decrypt(dststr_fontmap: Dict[str, str], dst_str: str) -> str: - """解码字体解密""" - ori_str = "" - for char in dst_str: - if dstchar_hash := dststr_fontmap.get(f"uni{ord(char):X}"): - # 存在于“密钥”字体,解密 - orichar_hash = fonthash_dao.find_char(dstchar_hash) - if orichar_hash is not None: - ori_str += chr(int(orichar_hash[3:], 16)) - else: - # 不存在于“密钥”字体,直接复制 - ori_str += char +def decrypt(dst_fontmap: Dict[str, str], encrypted_text: str) -> str: + """ + 解密超星学习通加密字体的文本 + + Args: + dst_fontmap: 目标字体的字形哈希映射表 + encrypted_text: 加密的文本 + + Returns: + 解密后的文本 + """ + result = [] + + for char in encrypted_text: + # 构造Unicode字符名称 (如 "uni4E00") + char_code = f"uni{ord(char):X}" + + # 查找字符在目标字体中的哈希值 + if char_code in dst_fontmap: + dst_hash = dst_fontmap[char_code] + # 通过哈希值找回原始字符 + original_char_code = fonthash_dao.find_char(dst_hash) + if original_char_code: + # 将Unicode编码转换为字符 + try: + original_char = chr(int(original_char_code[3:], 16)) + result.append(original_char) + continue + except (ValueError, IndexError): + pass + + # 如果无法解密,则保留原字符 + result.append(char) + # 替换解密后的康熙部首 - ori_str = ori_str.translate(KX_RADICALS_TAB) - return ori_str + decrypted_text = "".join(result).translate(KX_RADICALS_TAB) + return decrypted_text diff --git a/api/decode.py b/api/decode.py index 7fe7463..18734b4 100644 --- a/api/decode.py +++ b/api/decode.py @@ -1,231 +1,461 @@ # -*- coding: utf-8 -*- +""" +超星学习通数据解析模块 + +该模块负责解析超星学习通平台的课程、章节、任务点等各种数据, +并转换为程序内部使用的结构化数据格式。 +""" import re import json -from bs4 import BeautifulSoup +from typing import List, Dict, Tuple, Any, Optional +from bs4 import BeautifulSoup, NavigableString from api.logger import logger from api.font_decoder import FontDecoder -def decode_course_list(_text): + +def decode_course_list(html_text: str) -> List[Dict[str, str]]: + """ + 解析课程列表页面,提取课程信息 + + Args: + html_text: 课程列表页面的HTML内容 + + Returns: + 课程信息列表,每个课程包含id、title、teacher等信息 + """ logger.trace("开始解码课程列表...") - _soup = BeautifulSoup(_text, "lxml") - _raw_courses = _soup.select("div.course") - _course_list = list() - for course in _raw_courses: - if not course.select_one("a.not-open-tip") and not course.select_one("div.not-open-tip"): - _course_detail = {} - _course_detail["id"] = course.attrs["id"] - _course_detail["info"] = course.attrs["info"] - _course_detail["roleid"] = course.attrs["roleid"] - - _course_detail["clazzId"] = course.select_one("input.clazzId").attrs["value"] - _course_detail["courseId"] = course.select_one("input.courseId").attrs["value"] - _course_detail["cpi"] = re.findall(r"cpi=(.*?)&", course.select_one("a").attrs["href"])[0] - _course_detail["title"] = course.select_one("span.course-name").attrs["title"] - if course.select_one("p.margint10") is None: - _course_detail["desc"] = '' - else: - _course_detail["desc"] = course.select_one("p.margint10").attrs["title"] - _course_detail["teacher"] = course.select_one("p.color3").attrs["title"] - _course_list.append(_course_detail) - return _course_list - -def decode_course_folder(_text): + soup = BeautifulSoup(html_text, "lxml") + raw_courses = soup.select("div.course") + course_list = [] + + for course in raw_courses: + # 跳过未开放课程 + if course.select_one("a.not-open-tip") or course.select_one("div.not-open-tip"): + continue + + course_detail = { + "id": course.attrs["id"], + "info": course.attrs["info"], + "roleid": course.attrs["roleid"], + "clazzId": course.select_one("input.clazzId").attrs["value"], + "courseId": course.select_one("input.courseId").attrs["value"], + "cpi": re.findall(r"cpi=(.*?)&", course.select_one("a").attrs["href"])[0], + "title": course.select_one("span.course-name").attrs["title"], + "desc": course.select_one("p.margint10").attrs["title"] if course.select_one("p.margint10") else "", + "teacher": course.select_one("p.color3").attrs["title"] + } + course_list.append(course_detail) + + return course_list + + +def decode_course_folder(html_text: str) -> List[Dict[str, str]]: + """ + 解析二级课程列表页面,提取文件夹信息 + + Args: + html_text: 二级课程列表页面的HTML内容 + + Returns: + 课程文件夹信息列表 + """ logger.trace("开始解码二级课程列表...") - _soup = BeautifulSoup(_text, "lxml") - _raw_courses = _soup.select("ul.file-list>li") - _course_folder_list = list() - for course in _raw_courses: - if course.attrs["fileid"]: - _course_folder_detail = {} - _course_folder_detail["id"] = course.attrs["fileid"] - _course_folder_detail["rename"] = course.select_one("input.rename-input").attrs["value"] - _course_folder_list.append(_course_folder_detail) - return _course_folder_list - -def decode_course_point(_text): + soup = BeautifulSoup(html_text, "lxml") + raw_courses = soup.select("ul.file-list>li") + course_folder_list = [] + + for course in raw_courses: + if not course.attrs.get("fileid"): + continue + + course_folder_detail = { + "id": course.attrs["fileid"], + "rename": course.select_one("input.rename-input").attrs["value"] + } + course_folder_list.append(course_folder_detail) + + return course_folder_list + + +def decode_course_point(html_text: str) -> Dict[str, Any]: + """ + 解析章节列表页面,提取章节点信息 + + Args: + html_text: 章节列表页面的HTML内容 + + Returns: + 章节信息字典,包含是否锁定状态和章节点列表 + """ logger.trace("开始解码章节列表...") - _soup = BeautifulSoup(_text, "lxml") - _course_point = { - "hasLocked": False, # 用于判断该课程任务是否是需要解锁 - "points": [] + soup = BeautifulSoup(html_text, "lxml") + course_point = { + "hasLocked": False, # 用于判断该课程任务是否是需要解锁 + "points": [], } + + for chapter_unit in soup.find_all("div", class_="chapter_unit"): + points = _extract_points_from_chapter(chapter_unit) + # 检查是否有锁定内容 + for point in points: + if point.get("need_unlock", False): + course_point["hasLocked"] = True + + course_point["points"].extend(points) + return course_point + + +def _extract_points_from_chapter(chapter_unit) -> List[Dict[str, Any]]: + """ + 从章节单元中提取章节点信息 + + Args: + chapter_unit: BeautifulSoup对象,表示一个章节单元 + + Returns: + 章节点信息列表 + """ + point_list = [] + raw_points = chapter_unit.find_all("li") - for _chapter_unit in _soup.find_all("div",class_="chapter_unit") : - _point_list = [] - _raw_points = _chapter_unit.find_all("li") - for _point in _raw_points: - _point = _point.div - if (not "id" in _point.attrs): - continue - _point_detail = {} - _point_detail["id"] = re.findall(r"^cur(\d{1,20})$", _point.attrs["id"])[0] - _point_detail["title"] = _point.select_one("a.clicktitle").text.replace("\n",'').strip(' ') - _point_detail["jobCount"] = 1 # 默认为1 - if _point.select_one("input.knowledgeJobCount"): - _point_detail["jobCount"] = _point.select_one("input.knowledgeJobCount").attrs["value"] - else: - # 判断是不是因为需要解锁 - if '解锁' in _point.select_one("span.bntHoverTips").text: - _course_point["hasLocked"] = True + for raw_point in raw_points: + point = raw_point.div + if "id" not in point.attrs: + continue + + point_id = re.findall(r"^cur(\d{1,20})$", point.attrs["id"])[0] + point_title = point.select_one("a.clicktitle").text.replace("\n", "").strip() + + # 提取任务数量 + job_count = 1 # 默认为1 + need_unlock = False + if point.select_one("input.knowledgeJobCount"): + job_count = point.select_one("input.knowledgeJobCount").attrs["value"] + elif point.select_one("span.bntHoverTips") and "解锁" in point.select_one("span.bntHoverTips").text: + need_unlock = True - _point_list.append(_point_detail) - _course_point["points"]+=_point_list - return _course_point + # 判断是否已完成 + is_finished = False + if point.select_one("span.bntHoverTips") and "已完成" in point.select_one("span.bntHoverTips").text: + is_finished = True + + point_detail = { + "id": point_id, + "title": point_title, + "jobCount": job_count, + "has_finished": is_finished, + "need_unlock": need_unlock + } + point_list.append(point_detail) + + return point_list -def decode_course_card(_text: str): +def decode_course_card(html_text: str) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """ + 解析任务点列表页面,提取任务点信息 + + Args: + html_text: 任务点列表页面的HTML内容 + + Returns: + 任务点列表和任务信息的元组 + """ logger.trace("开始解码任务点列表...") - _job_info = {} - _job_list = [] - # 对于未开放章节检测 - if '章节未开放' in _text: - _job_info['notOpen'] = True - return [],_job_info - - _temp = re.findall(r"mArg=\{(.*?)\};", _text.replace(" ", "")) - if _temp: - _temp = _temp[0] - else: - return [],{} - _cards = json.loads("{" + _temp + "}") - - if _cards: - _job_info = {} - _job_info["ktoken"] = _cards["defaults"]["ktoken"] - _job_info["mtEnc"] = _cards["defaults"]["mtEnc"] - _job_info["reportTimeInterval"] = _cards["defaults"]["reportTimeInterval"] # 60 - _job_info["defenc"] = _cards["defaults"]["defenc"] - _job_info["cardid"] = _cards["defaults"]["cardid"] - _job_info["cpi"] = _cards["defaults"]["cpi"] - _job_info["qnenc"] = _cards["defaults"]["qnenc"] - _job_info['knowledgeid'] = _cards["defaults"]["knowledgeid"] - _cards = _cards["attachments"] - _job_list = [] - for _card in _cards: - # 已经通过的任务 - if "isPassed" in _card and _card["isPassed"] is True: - continue - # 不属于任务点的任务 - if "job" not in _card or _card["job"] is False: - if _card.get('type') and _card['type'] == "read": - # 发现有在视频任务下掺杂阅读任务,不完成可能会导致无法开启下一章节 - if _card['property'].get('read',False): - # 已阅读,跳过 - continue - _job = {} - _job['title'] = _card['property']['title'] - _job["type"] = "read" - _job['id'] = _card['property']['id'] - _job["jobid"] = _card["jobid"] - _job["jtoken"] = _card["jtoken"] - _job['mid'] = _card['mid'] - _job['otherinfo'] = _card["otherInfo"] - _job['enc'] = _card["enc"] - _job['aid'] = _card["aid"] - _job_list.append(_job) - continue - # 视频任务 - if _card["type"] == "video": - _job = {} - _job["type"] = "video" - _job["jobid"] = _card["jobid"] - _job["name"] = _card["property"]["name"] - _job["otherinfo"] = _card["otherInfo"] - try: - _job["mid"] = _card["mid"] - except KeyError: - logger.warning("出现转码失败视频,已跳过...") - continue - _job["objectid"] = _card["objectId"] - _job["aid"] = _card["aid"] - # _job["doublespeed"] = _card["property"]["doublespeed"] - _job_list.append(_job) - continue - if _card["type"] == "document": - _job = {} - _job["type"] = "document" - _job["jobid"] = _card["jobid"] - _job["otherinfo"] = _card["otherInfo"] - _job["jtoken"] = _card["jtoken"] - _job["mid"] = _card["mid"] - _job["enc"] = _card["enc"] - _job["aid"] = _card["aid"] - _job["objectid"] = _card["property"]["objectid"] - _job_list.append(_job) - continue - if _card["type"] == "workid": - # 章节检测 - _job = {} - _job["type"] = "workid" - _job["jobid"] = _card["jobid"] - _job["otherinfo"] = _card["otherInfo"] - _job["mid"] = _card["mid"] - _job["enc"] = _card["enc"] - _job["aid"] = _card["aid"] - _job_list.append(_job) - continue - - if _card["type"] == "vote": - # 调查问卷 同上 - continue - return _job_list, _job_info - - -def decode_questions_info(html_content) -> dict: - def replace_rtn(text): - return text.replace('\r', '').replace('\t', '').replace('\n', '') + job_list = [] + + # 检查章节是否未开放 + if "章节未开放" in html_text: + return [], {"notOpen": True} + + # 提取mArg参数 + temp = re.findall(r"mArg=\{(.*?)\};", html_text.replace(" ", "")) + if not temp: + return [], {} + + # 解析JSON数据 + cards_data = json.loads("{" + temp[0] + "}") + if not cards_data: + return [], {} + + # 提取任务信息 + job_info = _extract_job_info(cards_data) + + # 处理所有附件任务 + cards = cards_data.get("attachments", []) + job_list = _process_attachment_cards(cards) + + return job_list, job_info + + +def _extract_job_info(cards_data: Dict[str, Any]) -> Dict[str, Any]: + """ + 从卡片数据中提取任务基本信息 + + Args: + cards_data: 卡片数据字典 + + Returns: + 任务基本信息字典 + """ + defaults = cards_data.get("defaults", {}) + if not defaults: + return {} + + return { + "ktoken": defaults.get("ktoken", ""), + "mtEnc": defaults.get("mtEnc", ""), + "reportTimeInterval": defaults.get("reportTimeInterval", 60), + "defenc": defaults.get("defenc", ""), + "cardid": defaults.get("cardid", ""), + "cpi": defaults.get("cpi", ""), + "qnenc": defaults.get("qnenc", ""), + "knowledgeid": defaults.get("knowledgeid", "") + } + + +def _process_attachment_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 处理所有附件任务卡片 + + Args: + cards: 附件任务卡片列表 + + Returns: + 处理后的任务列表 + """ + job_list = [] + + for card in cards: + # 跳过已通过的任务 + if card.get("isPassed", False): + continue + + # 处理不同类型的任务 + if card.get("job", False) == False: + # 处理阅读类型任务 + read_job = _process_read_task(card) + if read_job: + job_list.append(read_job) + continue + + # 根据任务类型处理 + card_type = card.get("type", "") + if card_type == "video": + video_job = _process_video_task(card) + if video_job: + job_list.append(video_job) + elif card_type == "document": + doc_job = _process_document_task(card) + if doc_job: + job_list.append(doc_job) + elif card_type == "workid": + work_job = _process_work_task(card) + if work_job: + job_list.append(work_job) + + return job_list + +def _process_read_task(card: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """处理阅读类型任务""" + if not (card.get("type") == "read" and not card.get("property", {}).get("read", False)): + return None + + return { + "title": card.get("property", {}).get("title", ""), + "type": "read", + "id": card.get("property", {}).get("id", ""), + "jobid": card.get("jobid", ""), + "jtoken": card.get("jtoken", ""), + "mid": card.get("mid", ""), + "otherinfo": card.get("otherInfo", ""), + "enc": card.get("enc", ""), + "aid": card.get("aid", "") + } + + +def _process_video_task(card: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """处理视频类型任务""" + try: + return { + "type": "video", + "jobid": card.get("jobid", ""), + "name": card.get("property", {}).get("name", ""), + "otherinfo": card.get("otherInfo", ""), + "mid": card["mid"], # 必须字段,如果不存在会抛出异常 + "objectid": card.get("objectId", ""), + "aid": card.get("aid", "") + } + except KeyError: + logger.warning("出现转码失败视频,已跳过...") + return None + + +def _process_document_task(card: Dict[str, Any]) -> Dict[str, Any]: + """处理文档类型任务""" + return { + "type": "document", + "jobid": card.get("jobid", ""), + "otherinfo": card.get("otherInfo", ""), + "jtoken": card.get("jtoken", ""), + "mid": card.get("mid", ""), + "enc": card.get("enc", ""), + "aid": card.get("aid", ""), + "objectid": card.get("property", {}).get("objectid", "") + } + + +def _process_work_task(card: Dict[str, Any]) -> Dict[str, Any]: + """处理作业类型任务""" + return { + "type": "workid", + "jobid": card.get("jobid", ""), + "otherinfo": card.get("otherInfo", ""), + "mid": card.get("mid", ""), + "enc": card.get("enc", ""), + "aid": card.get("aid", "") + } + + +def decode_questions_info(html_content: str) -> Dict[str, Any]: + """ + 解析题目信息,提取表单数据和问题列表 + + Args: + html_content: 题目页面HTML内容 + + Returns: + 包含表单数据和问题列表的字典 + """ soup = BeautifulSoup(html_content, "lxml") + form_data = _extract_form_data(soup) + + # 检查是否存在字体加密 + has_font_encryption = bool(soup.find("style", id="cxSecretStyle")) + font_decoder = None + + if has_font_encryption: + font_decoder = FontDecoder(html_content) + else: + logger.warning("未找到字体文件,可能是未加密的题目不进行解密") + + # 处理所有问题 + questions = [] + for div_tag in soup.find("form").find_all("div", class_="singleQuesId"): + question = _process_question(div_tag, font_decoder) + if question: + questions.append(question) + + # 更新表单数据 + form_data["questions"] = questions + form_data["answerwqbid"] = ",".join([q["id"] for q in questions]) + "," + + return form_data + + +def _extract_form_data(soup: BeautifulSoup) -> Dict[str, Any]: + """从BeautifulSoup对象中提取表单数据""" form_data = {} form_tag = soup.find("form") - - fd = FontDecoder(html_content) # 加载字体 - # 抽取表单信息 + if not form_tag: + return form_data + + # 提取所有非答案字段的input for input_tag in form_tag.find_all("input"): - if 'name' not in input_tag.attrs or 'answer' in input_tag.attrs["name"]: + if "name" not in input_tag.attrs or "answer" in input_tag.attrs["name"]: continue - form_data.update({ - input_tag.attrs["name"]: input_tag.attrs.get("value",'') - }) - - form_data['questions'] = [] - for div_tag in form_tag.find_all("div",class_="singleQuesId"): # 目前来说无论是单选还是多选的题class都是这个 - q_title = replace_rtn(fd.decode(div_tag.find("div", class_="Zy_TItle").text)) - q_options = '' - for li_tag in div_tag.find("ul").find_all("li"): - q_options += replace_rtn(fd.decode(li_tag.text))+'\n' - q_options=q_options[:-1] # 去除尾部'\n' - - # 尝试使用 data 属性来判断题型 - q_type_code = div_tag.find('div',class_='TiMu').attrs['data'] - q_type = '' - # 此处可能需要完善更多题型的判断 - if q_type_code == '0': - q_type = 'single' - elif q_type_code == '1': - q_type = 'multiple' - elif q_type_code == '2': - q_type = 'completion' - elif q_type_code == '3': - q_type = 'judgement' - else: - logger.info("未知题型代码 -> "+q_type_code) - q_type = 'unknown' # 避免出现未定义取值错误 - - form_data["questions"].append({ - 'id': div_tag.attrs["data"], - 'title':q_title, # 题目 - 'options':q_options, # 选项 可提供给题库作为辅助 - 'type': q_type, # 题型 可提供给题库作为辅助 - 'answerField':{ - 'answer'+div_tag.attrs["data"]:'', # 答案填入处 - 'answertype'+div_tag.attrs["data"]:q_type_code - } - }) - # 处理答题信息 - form_data['answerwqbid'] = ",".join([q['id'] for q in form_data['questions']])+"," + form_data[input_tag.attrs["name"]] = input_tag.attrs.get("value", "") + return form_data +def _process_question(div_tag, font_decoder=None) -> Dict[str, Any]: + """处理单个问题""" + # 提取问题ID和题目类型 + question_id = div_tag.attrs.get("data", "") + q_type_code = div_tag.find("div", class_="TiMu").attrs.get("data", "") + q_type = _get_question_type(q_type_code) + + # 提取题目内容和选项 + title_div = div_tag.find("div", class_="Zy_TItle") + options_list = div_tag.find("ul").find_all("li") if div_tag.find("ul") else [] + + # 解析题目和选项 + q_title = _extract_title(title_div, font_decoder) + q_options = [] + for li in options_list: + q_options.append(_extract_choices(li, font_decoder)) + # 排序选项 + q_options.sort() + q_options = '\n'.join(q_options) + + return { + "id": question_id, + "title": q_title, + "options": q_options, + "type": q_type, + "answerField": { + f"answer{question_id}": "", + f"answertype{question_id}": q_type_code, + }, + } + + +def _get_question_type(type_code: str) -> str: + """根据题型代码返回题型名称""" + type_map = { + "0": "single", # 单选题 + "1": "multiple", # 多选题 + "2": "completion", # 填空题 + "3": "judgement", # 判断题 + "4": "shortanswer", # 简答题 + } + + if type_code in type_map: + return type_map[type_code] + + logger.info(f"未知题型代码 -> {type_code}") + return "unknown" + + +def _extract_title(element, font_decoder=None) -> str: + """提取标题内容,支持解码加密字体""" + if not element: + return "" + + # 收集元素中的所有文本和图片 + content = [] + for item in element.descendants: + if isinstance(item, NavigableString): + content.append(item.string or "") + elif item.name == "img": + img_url = item.get("src", "") + content.append(f'') + + raw_content = "".join(content) + cleaned_content = raw_content.replace("\r", "").replace("\t", "").replace("\n", "") + + # 如果有字体解码器,进行解码 + if font_decoder: + return font_decoder.decode(cleaned_content) + + return cleaned_content + +def _extract_choices(element, font_decoder=None) -> str: + """提取选项内容,支持解码加密字体""" + if not element: + return "" + + # 提取aria-label属性值作为选项,解决#474 + choice = element.get('aria-label') + + cleaned_content = choice.replace("\r", "").replace("\t", "").replace("\n", "") + + # 如果有字体解码器,进行解码 + if font_decoder: + return font_decoder.decode(cleaned_content) + + return cleaned_content \ No newline at end of file diff --git a/api/exceptions.py b/api/exceptions.py index 913e71e..e8eb7ba 100644 --- a/api/exceptions.py +++ b/api/exceptions.py @@ -9,11 +9,21 @@ def __init__(self, *args: object): super().__init__(*args) -class FormatError(Exception): +class InputFormatError(Exception): def __init__(self, *args: object): super().__init__(*args) -class MaxRollBackError(Exception): + +class MaxRollBackExceeded(Exception): + def __init__(self, *args: object): + super().__init__(*args) + + +class MaxRetryExceeded(Exception): + def __init__(self, *args: object): + super().__init__(*args) + + +class FontDecodeError(Exception): def __init__(self, *args: object): super().__init__(*args) - \ No newline at end of file diff --git a/api/font_decoder.py b/api/font_decoder.py index 1742c04..d59ca7e 100644 --- a/api/font_decoder.py +++ b/api/font_decoder.py @@ -1,22 +1,80 @@ from bs4 import BeautifulSoup -import api.cxsecret_font as cxfont import re +from typing import Dict, Optional + +import api.cxsecret_font as cxfont +from api.exceptions import FontDecodeError +from api.logger import logger class FontDecoder: - def __init__(self,html_content:str=None): - self.html_content = html_content - # self.__isNeedDecode = True - self.__font_hash_map = None - self.__decode_init(html_content) + """超星加密字体解码器。 + + 用于解码超星平台使用特殊字体加密的内容。 + """ + + # 正则表达式常量 + FONT_BASE64_PATTERN = r"base64,([\w\W]+?)\'" + FONT_DATA_URL_PREFIX = "data:application/font-ttf;charset=utf-8;base64," - def __decode_init(self, html_content): + def __init__(self, html_content: Optional[str] = None): + """初始化字体解码器。 + + Args: + html_content: 包含加密字体信息的HTML内容 + """ + self.html_content = html_content + self.__font_map: Optional[Dict] = None + if html_content: + self.__init_font_map(html_content) + + def __init_font_map(self, html_content: str) -> None: + """从HTML内容中提取字体信息并初始化字体映射。 + + Args: + html_content: 包含加密字体信息的HTML内容 + """ + try: soup = BeautifulSoup(html_content, "lxml") - style_tag = soup.find("style",id="cxSecretStyle") - match = re.search(r'base64,([\w\W]+?)\'', style_tag.text) - self.__font_hash_map = cxfont.font2map('data:application/font-ttf;charset=utf-8;base64,'+match.group(1)) + style_tag = soup.find("style", id="cxSecretStyle") + + if not style_tag or not style_tag.text: + raise FontDecodeError("未找到加密字体样式标签") + + match = re.search(self.FONT_BASE64_PATTERN, style_tag.text) + if not match: + raise FontDecodeError("无法从样式标签中提取字体数据") - def decode(self,target_str:str) -> str: - return cxfont.decrypt(self.__font_hash_map, target_str) + font_base64 = match.group(1) + font_data_url = self.FONT_DATA_URL_PREFIX + font_base64 + self.__font_map = cxfont.font2map(font_data_url) + except Exception as e: + logger.warning(f"初始化字体映射失败: {e}") + self.__font_map = None + + def decode(self, target_str: str) -> str: + """解码加密字符串。 + + Args: + target_str: 需要解码的加密字符串 + + Returns: + 解码后的字符串 + + Raises: + ValueError: 当字体映射未初始化时抛出 + """ + if not self.__font_map: + raise FontDecodeError("字体映射未初始化,无法解码") + return cxfont.decrypt(self.__font_map, target_str) + + def set_html_content(self, html_content: str) -> None: + """设置新的HTML内容并重新初始化字体映射。 + + Args: + html_content: 包含加密字体信息的HTML内容 + """ + self.html_content = html_content + self.__init_font_map(html_content) diff --git a/api/logger.py b/api/logger.py index 2caa568..ba1e61d 100644 --- a/api/logger.py +++ b/api/logger.py @@ -1,3 +1,3 @@ from loguru import logger -logger.add("chaoxing.log", rotation="10 MB", level="TRACE") \ No newline at end of file +logger.add("chaoxing.log", rotation="10 MB", level="TRACE") diff --git a/api/notification.py b/api/notification.py new file mode 100644 index 0000000..4c1f575 --- /dev/null +++ b/api/notification.py @@ -0,0 +1,277 @@ +""" +通知服务模块,用于向外部服务发送通知消息。 +支持多种通知服务,如ServerChan、Qmsg和Bark。 +""" + +import configparser +import requests +from abc import ABC, abstractmethod +from typing import Dict, Optional, Any +from api.logger import logger + + +class NotificationService(ABC): + """ + 通知服务基类,定义通知服务的公共接口和实现。 + 所有具体的通知服务类应继承此类并实现必要的方法。 + """ + + CONFIG_PATH = "config.ini" + + def __init__(self): + """初始化通知服务""" + self.name = self.__class__.__name__ + self.url = "" + self._conf = None + self.disabled = False + + def config_set(self, config: Dict[str, str]) -> None: + """ + 设置通知服务的配置 + + Args: + config: 包含配置参数的字典 + """ + self._conf = config + + def _load_config_from_file(self) -> Optional[Dict[str, str]]: + """ + 从配置文件中加载通知服务的配置 + + Returns: + 成功返回配置字典,失败返回None + """ + try: + config = configparser.ConfigParser() + config.read(self.CONFIG_PATH, encoding="utf8") + return config['notification'] + except (KeyError, FileNotFoundError): + logger.info("未找到notification配置,已忽略外部通知功能") + self.disabled = True + return None + + def init_notification(self) -> None: + """初始化通知服务,加载配置并进行必要的设置""" + if not self._conf: + self._conf = self._load_config_from_file() + + if not self.disabled and self._conf: + self._init_service() + + @abstractmethod + def _init_service(self) -> None: + """ + 初始化特定的通知服务,由子类实现 + """ + pass + + @abstractmethod + def _send(self, message: str) -> None: + """ + 发送通知消息,由子类实现 + + Args: + message: 要发送的消息内容 + """ + pass + + def send(self, message: str) -> None: + """ + 发送通知消息的公共接口 + + Args: + message: 要发送的消息内容 + """ + if not self.disabled: + self._send(message) + + +class NotificationFactory: + """ + 通知服务工厂类,用于创建和获取通知服务实例 + """ + + @staticmethod + def create_service(config: Optional[Dict[str, str]] = None) -> NotificationService: + """ + 根据配置创建通知服务实例 + + Args: + config: 通知服务的配置,如果为None则从配置文件加载 + + Returns: + 通知服务实例 + """ + service = DefaultNotification() + + if config: + service.config_set(config) + + # 尝试获取具体的通知服务 + service = service.get_notification_from_config() + service.init_notification() + + return service + + +class DefaultNotification(NotificationService): + """ + 默认通知服务,当未配置任何通知服务时使用 + """ + + def _init_service(self) -> None: + pass + + def _send(self, message: str) -> None: + pass + + def get_notification_from_config(self) -> NotificationService: + """ + 根据配置创建具体的通知服务实例 + + Returns: + 通知服务实例 + """ + if not self._conf: + self._conf = self._load_config_from_file() + + if self.disabled: + return self + + try: + provider_name = self._conf['provider'] + if not provider_name: + raise KeyError("未指定通知服务提供商") + + # 获取对应的通知服务类 + provider_class = globals().get(provider_name) + if not provider_class: + logger.error(f"未找到名为 {provider_name} 的通知服务提供商") + self.disabled = True + return self + + # 创建通知服务实例 + service = provider_class() + service.config_set(self._conf) + return service + + except KeyError: + self.disabled = True + logger.info("未找到外部通知配置,已忽略外部通知功能") + return self + + +class ServerChan(NotificationService): + """ + Server酱通知服务 + """ + + def _init_service(self) -> None: + """初始化Server酱服务""" + if not self._conf or not self._conf.get('url'): + self.disabled = True + logger.info("未找到Server酱url配置,已忽略该通知服务") + return + + self.url = self._conf['url'] + logger.info(f"已初始化Server酱通知服务,URL: {self.url}") + + def _send(self, message: str) -> None: + """ + 通过Server酱发送通知 + + Args: + message: 要发送的消息内容 + """ + params = { + 'text': message, # 兼容两个版本的Server酱 + 'desp': message, + } + headers = { + 'Content-Type': 'application/json;charset=utf-8' + } + + try: + response = requests.post(self.url, json=params, headers=headers) + response.raise_for_status() + result = response.json() + logger.info(f"Server酱通知发送成功: {result}") + except requests.RequestException as e: + logger.error(f"Server酱通知发送失败: {e}") + except ValueError as e: + logger.error(f"Server酱返回数据解析失败: {e}") + + +class Qmsg(NotificationService): + """ + Qmsg酱通知服务 + """ + + def _init_service(self) -> None: + """初始化Qmsg酱服务""" + if not self._conf or not self._conf.get('url'): + self.disabled = True + logger.info("未找到Qmsg酱url配置,已忽略该通知服务") + return + + self.url = self._conf['url'] + logger.info(f"已初始化Qmsg酱通知服务,URL: {self.url}") + + def _send(self, message: str) -> None: + """ + 通过Qmsg酱发送通知 + + Args: + message: 要发送的消息内容 + """ + params = {'msg': message} + headers = {'Content-Type': 'application/json;charset=utf-8'} + + try: + response = requests.post(self.url, params=params, headers=headers) + response.raise_for_status() + result = response.json() + logger.info(f"Qmsg酱通知发送成功: {result}") + except requests.RequestException as e: + logger.error(f"Qmsg酱通知发送失败: {e}") + except ValueError as e: + logger.error(f"Qmsg酱返回数据解析失败: {e}") + + +class Bark(NotificationService): + """ + Bark通知服务 + """ + + def _init_service(self) -> None: + """初始化Bark服务""" + if not self._conf or not self._conf.get('url'): + self.disabled = True + logger.info("未找到Bark的url配置,已忽略该通知服务") + return + + self.url = self._conf['url'] + logger.info(f"已初始化Bark通知服务,URL: {self.url}") + + def _send(self, message: str) -> None: + """ + 通过Bark发送通知 + + Args: + message: 要发送的消息内容 + """ + params = {'body': message} + + try: + response = requests.post(self.url, params=params) + response.raise_for_status() + result = response.json() + logger.info(f"Bark通知发送成功: {result}") + except requests.RequestException as e: + logger.error(f"Bark通知发送失败: {e}") + except ValueError as e: + logger.error(f"Bark返回数据解析失败: {e}") + + +# 为了向后兼容,保留原来的Notification类 +Notification = DefaultNotification \ No newline at end of file diff --git a/api/process.py b/api/process.py index 7e5d9cb..6147702 100644 --- a/api/process.py +++ b/api/process.py @@ -1,24 +1,62 @@ import time +from typing import Union from api.config import GlobalConst as gc -def sec2time(sec: int): - h = int(sec / 3600) - m = int(sec % 3600 / 60) - s = int(sec % 60) - if h != 0: - return f'{h}:{m:02}:{s:02}' - if sec != 0: - return f'{m:02}:{s:02}' - return '--:--' +def sec2time(seconds: int) -> str: + """ + 将秒数转换为时分秒格式的字符串。 + + Args: + seconds: 要转换的秒数 + + Returns: + 格式化的时间字符串,格式为 "h:mm:ss" 或 "mm:ss",如果秒数为0则返回"--:--" + """ + hours = int(seconds / 3600) + minutes = int(seconds % 3600 / 60) + secs = int(seconds % 60) + + if hours > 0: + return f"{hours}:{minutes:02}:{secs:02}" + if seconds > 0: + return f"{minutes:02}:{secs:02}" + return "--:--" -def show_progress(name: str, start: int, span: int, total: int, _speed: float): + +def show_progress(task_name: str, start_position: int, duration: int, + total_length: int, speed: float) -> None: + """ + 显示任务进度条,模拟任务进度。 + + Args: + task_name: 当前执行的任务名称 + start_position: 起始位置(以秒为单位) + duration: 任务持续时间(以秒为单位) + total_length: 任务总长度(以秒为单位) + speed: 任务执行速度 + + Returns: + None + """ start_time = time.time() - while int(time.time() - start_time) < int(span / _speed): - current = start + int((time.time() - start_time) * _speed) - percent = int(current / total * 100) - length = int(percent * 40 // 100) - progress = ("#" * length).ljust(40, " ") - # remain = (total - current) - print(f"\r当前任务: {name} |{progress}| {percent}% {sec2time(current)}/{sec2time(total)}", end="", flush=True) - time.sleep(gc.THRESHOLD) \ No newline at end of file + expected_end_time = start_time + (duration / speed) + + while time.time() < expected_end_time: + # 计算当前进度 + current_position = start_position + int((time.time() - start_time) * speed) + percent_complete = min(int(current_position / total_length * 100), 100) + + # 生成进度条 + bar_length = 40 + filled_length = int(percent_complete * bar_length // 100) + progress_bar = ("#" * filled_length).ljust(bar_length, " ") + + # 格式化输出进度信息 + progress_text = ( + f"\r当前任务: {task_name} |{progress_bar}| {percent_complete}% " + f"{sec2time(current_position)}/{sec2time(total_length)}" + ) + + print(progress_text, end="", flush=True) + time.sleep(gc.THRESHOLD) diff --git a/app.py b/app.py index 486af05..ba72eb4 100644 --- a/app.py +++ b/app.py @@ -15,7 +15,7 @@ def __call__(self, *args: object, **kwargs: object) -> object: return celery_app -if __name__ == '__main__': +if __name__ == "__main__": app = Flask(__name__) app.config.from_mapping( CELERY=dict( @@ -24,4 +24,4 @@ def __call__(self, *args: object, **kwargs: object) -> object: task_ignore_result=True, ), ) - celery_app = celery_init_app(app) \ No newline at end of file + celery_app = celery_init_app(app) diff --git a/main.py b/main.py index d97cbf1..3ef420b 100644 --- a/main.py +++ b/main.py @@ -1,194 +1,405 @@ # -*- coding: utf-8 -*- import argparse import configparser +import random +import time +import sys +import os +import traceback +from urllib3 import disable_warnings, exceptions + from api.logger import logger from api.base import Chaoxing, Account -from api.exceptions import LoginError, FormatError, JSONDecodeError,MaxRollBackError +from api.exceptions import LoginError, InputFormatError, MaxRollBackExceeded from api.answer import Tiku -from urllib3 import disable_warnings,exceptions -import os +from api.notification import Notification -# # 定义全局变量,用于存储配置文件路径 -# textPath = './resource/BookID.txt' +# 关闭警告 +disable_warnings(exceptions.InsecureRequestWarning) -# # 获取文本 -> 用于查看学习过的课程ID -# def getText(): -# try: -# if not os.path.exists(textPath): -# with open(textPath, 'x') as file: pass -# return [] -# with open(textPath, 'r', encoding='utf-8') as file: content = file.read().split(',') -# content = {int(item.strip()) for item in content if item.strip()} -# return list(content) -# except Exception as e: logger.error(f"获取文本失败: {e}"); return [] -# # 追加文本 -> 用于记录学习过的课程ID -# def appendText(text): -# if not os.path.exists(textPath): return -# with open(textPath, 'a', encoding='utf-8') as file: file.write(f'{text}, ') +def parse_args(): + """解析命令行参数""" + parser = argparse.ArgumentParser( + description="Samueli924/chaoxing", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "-c", "--config", type=str, default=None, help="使用配置文件运行程序" + ) + parser.add_argument("-u", "--username", type=str, default=None, help="手机号账号") + parser.add_argument("-p", "--password", type=str, default=None, help="登录密码") + parser.add_argument( + "-l", "--list", type=str, default=None, help="要学习的课程ID列表, 以 , 分隔" + ) + parser.add_argument( + "-s", "--speed", type=float, default=1.0, help="视频播放倍速 (默认1, 最大2)" + ) + parser.add_argument( + "-v", + "--verbose", + "--debug", + action="store_true", + help="启用调试模式, 输出DEBUG级别日志", + ) + parser.add_argument( + "-a", "--notopen-action", type=str, default="retry", + choices=["retry", "ask", "continue"], + help="遇到关闭任务点时的行为: retry-重试, ask-询问, continue-继续" + ) + + # 在解析之前捕获 -h 的行为 + if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"}: + parser.print_help() + sys.exit(0) + + return parser.parse_args() + + +def load_config_from_file(config_path): + """从配置文件加载设置""" + config = configparser.ConfigParser() + config.read(config_path, encoding="utf8") + + common_config = {} + tiku_config = {} + notification_config = {} + # 检查并读取common节 + if config.has_section("common"): + common_config = dict(config.items("common")) + # 处理course_list,将字符串转换为列表 + if "course_list" in common_config and common_config["course_list"]: + common_config["course_list"] = common_config["course_list"].split(",") + # 处理speed,将字符串转换为浮点数 + if "speed" in common_config: + common_config["speed"] = float(common_config["speed"]) + # 处理notopen_action,设置默认值为retry + if "notopen_action" not in common_config: + common_config["notopen_action"] = "retry" + + # 检查并读取tiku节 + if config.has_section("tiku"): + tiku_config = dict(config.items("tiku")) + # 处理数值类型转换 + for key in ["delay", "cover_rate"]: + if key in tiku_config: + tiku_config[key] = float(tiku_config[key]) + + # 检查并读取notification节 + if config.has_section("notification"): + notification_config = dict(config.items("notification")) + + return common_config, tiku_config, notification_config + + +def build_config_from_args(args): + """从命令行参数构建配置""" + common_config = { + "username": args.username, + "password": args.password, + "course_list": args.list.split(",") if args.list else None, + "speed": args.speed if args.speed else 1.0, + "notopen_action": args.notopen_action if args.notopen_action else "retry" + } + return common_config, {}, {} -# 关闭警告 -disable_warnings(exceptions.InsecureRequestWarning) def init_config(): - parser = argparse.ArgumentParser(description='Samueli924/chaoxing') # 命令行传参 - parser.add_argument("-c", "--config", type=str, default=None, help="使用配置文件运行程序") - parser.add_argument("-u", "--username", type=str, default=None, help="手机号账号") - parser.add_argument("-p", "--password", type=str, default=None, help="登录密码") - parser.add_argument("-l", "--list", type=str, default=None, help="要学习的课程ID列表") - parser.add_argument("-s", "--speed", type=float, default=1.0, help="视频播放倍速(默认1,最大2)") - args = parser.parse_args() + """初始化配置""" + args = parse_args() + if args.config: - config = configparser.ConfigParser() - config.read(args.config, encoding="utf8") - return (config.get("common", "username"), - config.get("common", "password"), - str(config.get("common", "course_list")).split(",") if config.get("common", "course_list") else None, - int(config.get("common", "speed")), - config['tiku'] - ) + return load_config_from_file(args.config) else: - return (args.username, args.password, args.list.split(",") if args.list else None, int(args.speed) if args.speed else 1,None) + return build_config_from_args(args) + class RollBackManager: - def __init__(self) -> None: + """课程回滚管理器,避免无限回滚""" + def __init__(self): self.rollback_times = 0 self.rollback_id = "" - def add_times(self,id:str) -> None: + def add_times(self, id: str): + """增加回滚次数""" if id == self.rollback_id and self.rollback_times == 3: - raise MaxRollBackError("回滚次数已达3次,请手动检查学习通任务点完成情况") - elif id != self.rollback_id: - # 新job - self.rollback_id = id - self.rollback_times = 1 - else: + raise MaxRollBackExceeded("回滚次数已达3次, 请手动检查学习通任务点完成情况") + else: self.rollback_times += 1 + def new_job(self, id: str): + """设置新任务,重置回滚次数""" + if id != self.rollback_id: + self.rollback_id = id + self.rollback_times = 0 + + +def init_chaoxing(common_config, tiku_config): + """初始化超星实例""" + username = common_config.get("username", "") + password = common_config.get("password", "") + + # 如果没有提供用户名密码,从命令行获取 + if not username or not password: + username = input("请输入你的手机号, 按回车确认\n手机号:") + password = input("请输入你的密码, 按回车确认\n密码:") + + account = Account(username, password) + + # 设置题库 + tiku = Tiku() + tiku.config_set(tiku_config) # 载入配置 + tiku = tiku.get_tiku_from_config() # 载入题库 + tiku.init_tiku() # 初始化题库 + + # 获取查询延迟设置 + query_delay = tiku_config.get("delay", 0) + + # 实例化超星API + chaoxing = Chaoxing(account=account, tiku=tiku, query_delay=query_delay) + + return chaoxing -if __name__ == '__main__': + +def handle_not_open_chapter(notopen_action, point, tiku, RB, auto_skip_notopen=False): + """处理未开放章节""" + if notopen_action == "retry": + # 默认处理方式:重试 + # 针对题库启用情况 + if not tiku or tiku.DISABLE or not tiku.SUBMIT: + # 未启用题库或未开启题库提交, 章节检测未完成会导致无法开始下一章, 直接退出 + logger.error( + "章节未开启, 可能由于上一章节的章节检测未完成, 也可能由于该章节因为时效已关闭," + "请手动检查完成并提交再重试。或者在配置中配置(自动跳过关闭章节/开启题库并启用提交)" + ) + return -1 # 退出标记 + RB.add_times(point["id"]) + return 0 # 重试上一章节 + + elif notopen_action == "ask": + # 询问模式 - 判断是否需要询问 + if not auto_skip_notopen: + user_choice = input(f"章节 {point['title']} 未开放,是否继续检查后续章节?(y/n): ") + if user_choice.lower() != 'y': + # 用户选择停止 + logger.info("根据用户选择停止检查后续章节") + return -1 # 退出标记 + # 用户选择继续,设置自动跳过标志 + logger.info("用户选择继续检查后续章节,将自动跳过连续的未开放章节") + return 1, True # 继续下一章节, 设置自动跳过 + else: + logger.info(f"章节 {point['title']} 未开放,自动跳过") + return 1, auto_skip_notopen # 继续下一章节, 保持自动跳过状态 + + else: # notopen_action == "continue" + # 继续模式,直接跳过当前章节 + logger.info(f"章节 {point['title']} 未开放,根据配置跳过此章节") + return 1 # 继续下一章节 + + +def process_job(chaoxing, course, job, job_info, speed): + """处理单个任务点""" + # 视频任务 + if job["type"] == "video": + logger.trace(f"识别到视频任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") + # 超星的接口没有返回当前任务是否为Audio音频任务 + video_result = chaoxing.study_video( + course, job, job_info, _speed=speed, _type="Video" + ) + if chaoxing.StudyResult.is_failure(video_result): + logger.warning("当前任务非视频任务, 正在尝试音频任务解码") + video_result = chaoxing.study_video( + course, job, job_info, _speed=speed, _type="Audio") + if chaoxing.StudyResult.is_failure(video_result): + logger.warning( + f"出现异常任务 -> 任务章节: {course['title']} 任务ID: {job['jobid']}, 已跳过" + ) + # 文档任务 + elif job["type"] == "document": + logger.trace(f"识别到文档任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") + chaoxing.study_document(course, job) + # 测验任务 + elif job["type"] == "workid": + logger.trace(f"识别到章节检测任务, 任务章节: {course['title']}") + chaoxing.study_work(course, job, job_info) + # 阅读任务 + elif job["type"] == "read": + logger.trace(f"识别到阅读任务, 任务章节: {course['title']}") + chaoxing.strdy_read(course, job, job_info) + + +def process_chapter(chaoxing, course, point, RB, notopen_action, speed, auto_skip_notopen=False): + """处理单个章节""" + logger.info(f'当前章节: {point["title"]}') + + if point["has_finished"]: + logger.info(f'章节:{point["title"]} 已完成所有任务点') + return 1, auto_skip_notopen # 继续下一章节 + + # 随机等待,避免请求过快 + sleep_duration = random.uniform(1, 3) + logger.debug(f"本次随机等待时间: {sleep_duration:.3f}s") + time.sleep(sleep_duration) + + # 获取当前章节的所有任务点 + jobs = [] + job_info = None + jobs, job_info = chaoxing.get_job_list( + course["clazzId"], course["courseId"], course["cpi"], point["id"] + ) + + # 发现未开放章节, 根据配置处理 try: - # 避免异常的无限回滚 - RB = RollBackManager() - # 初始化登录信息 - username, password, course_list, speed,tiku_config= init_config() - # 规范化播放速度的输入值 - speed = min(2.0, max(1.0, speed)) - if (not username) or (not password): - username = input("请输入你的手机号,按回车确认\n手机号:") - password = input("请输入你的密码,按回车确认\n密码:") - account = Account(username, password) - # 设置题库 - tiku = Tiku() - tiku.config_set(tiku_config) # 载入配置 - tiku = tiku.get_tiku_from_config() # 载入题库 - tiku.init_tiku() # 初始化题库 - # 实例化超星API - chaoxing = Chaoxing(account=account,tiku=tiku) - # 检查当前登录状态,并检查账号密码 + if job_info.get("notOpen", False): + result = handle_not_open_chapter( + notopen_action, point, chaoxing.tiku, RB, auto_skip_notopen + ) + + if isinstance(result, tuple): + return result # 返回继续标志和更新后的auto_skip_notopen + else: + return result, auto_skip_notopen + + # 遇到开放的章节,重置自动跳过状态 + auto_skip_notopen = False + RB.new_job(point["id"]) + + except MaxRollBackExceeded: + logger.error("回滚次数已达3次, 请手动检查学习通任务点完成情况") + # 跳过该课程 + return -1, auto_skip_notopen # 退出标记 + + chaoxing.rollback_times = RB.rollback_times + + # 可能存在章节无任何内容的情况 + if not jobs: + if RB.rollback_times > 0: + logger.trace(f"回滚中 尝试空页面任务, 任务章节: {course['title']}") + chaoxing.study_emptypage(course, point) + return 1, auto_skip_notopen # 继续下一章节 + + # 遍历所有任务点 + for job in jobs: + process_job(chaoxing, course, job, job_info, speed) + + return 1, auto_skip_notopen # 继续下一章节 + + +def process_course(chaoxing, course, notopen_action, speed): + """处理单个课程""" + logger.info(f"开始学习课程: {course['title']}") + + # 获取当前课程的所有章节 + point_list = chaoxing.get_course_point( + course["courseId"], course["clazzId"], course["cpi"] + ) + + # 为了支持课程任务回滚, 采用下标方式遍历任务点 + __point_index = 0 + # 记录用户是否选择继续跳过连续的未开放任务点 + auto_skip_notopen = False + # 初始化回滚管理器 + RB = RollBackManager() + + while __point_index < len(point_list["points"]): + point = point_list["points"][__point_index] + logger.debug(f"当前章节 __point_index: {__point_index}") + + result, auto_skip_notopen = process_chapter( + chaoxing, course, point, RB, notopen_action, speed, auto_skip_notopen + ) + + if result == -1: # 退出当前课程 + break + elif result == 0: # 重试前一章节 + __point_index -= 1 # 默认第一个任务总是开放的 + else: # 继续下一章节 + __point_index += 1 + + +def filter_courses(all_course, course_list): + """过滤要学习的课程""" + if not course_list: + # 手动输入要学习的课程ID列表 + print("*" * 10 + "课程列表" + "*" * 10) + for course in all_course: + print(f"ID: {course['courseId']} 课程名: {course['title']}") + print("*" * 28) + try: + course_list = input( + "请输入想要学习的课程列表,以逗号分隔,例: 2151141,189191,198198\n" + ).split(",") + except Exception as e: + raise InputFormatError("输入格式错误") from e + + # 筛选需要学习的课程 + course_task = [] + for course in all_course: + if course["courseId"] in course_list: + course_task.append(course) + + # 如果没有指定课程,则学习所有课程 + if not course_task: + course_task = all_course + + return course_task + + +def main(): + """主程序入口""" + try: + # 初始化配置 + common_config, tiku_config, notification_config = init_config() + + # 规范化播放速度 + speed = min(2.0, max(1.0, common_config.get("speed", 1.0))) + notopen_action = common_config.get("notopen_action", "retry") + + # 初始化超星实例 + chaoxing = init_chaoxing(common_config, tiku_config) + + # 设置外部通知 + notification = Notification() + notification.config_set(notification_config) + notification = notification.get_notification_from_config() + notification.init_notification() + + # 检查当前登录状态 _login_state = chaoxing.login() if not _login_state["status"]: raise LoginError(_login_state["msg"]) + # 获取所有的课程列表 all_course = chaoxing.get_course_list() - course_task = [] - # 手动输入要学习的课程ID列表 - if not course_list: - print("*" * 10 + "课程列表" + "*" * 10) - for course in all_course: - print(f"ID: {course['courseId']} 课程名: {course['title']}") - print("*" * 28) - try: - course_list = input("请输入想要学习的课程列表,以逗号分隔,例: 2151141,189191,198198\n").split(",") - except Exception as e: - raise FormatError("输入格式错误") from e - # 筛选需要学习的课程 - for course in all_course: - if course["courseId"] in course_list: - course_task.append(course) - if not course_task: - course_task = all_course - # 开始遍历要学习的课程列表 - logger.info(f"课程列表过滤完毕,当前课程任务数量: {len(course_task)}") + + # 过滤要学习的课程 + course_task = filter_courses(all_course, common_config.get("course_list")) + + # 开始学习 + logger.info(f"课程列表过滤完毕, 当前课程任务数量: {len(course_task)}") for course in course_task: - logger.info(f"开始学习课程: {course['title']}") - # 获取当前课程的所有章节 - point_list = chaoxing.get_course_point(course["courseId"], course["clazzId"], course["cpi"]) - - # 为了支持课程任务回滚,采用下标方式遍历任务点 - __point_index = 0 - while __point_index < len(point_list["points"]): - point = point_list["points"][__point_index] - logger.info(f'当前章节: {point["title"]}') - # 获取当前章节的所有任务点 - jobs = [] - job_info = None - jobs, job_info = chaoxing.get_job_list(course["clazzId"], course["courseId"], course["cpi"], point["id"]) - - # bookID = job_info["knowledgeid"] # 获取视频ID - - # 发现未开放章节,尝试回滚上一个任务重新完成一次 - try: - if job_info.get('notOpen',False): - __point_index -= 1 # 默认第一个任务总是开放的 - # 针对题库启用情况 - if not tiku or tiku.DISABLE or not tiku.SUBMIT: - # 未启用题库或未开启题库提交,章节检测未完成会导致无法开始下一章,直接退出 - logger.error(f"章节未开启,可能由于上一章节的章节检测未完成,请手动完成并提交再重试,或者开启题库并启用提交") - break - RB.add_times(point["id"]) - continue - except MaxRollBackError as e: - logger.error("回滚次数已达3次,请手动检查学习通任务点完成情况") - # 跳过该课程,继续下一课程 - break - - - # 可能存在章节无任何内容的情况 - if not jobs: - __point_index += 1 - continue - # 遍历所有任务点 - for job in jobs: - # 视频任务 - if job["type"] == "video": - # TODO: 目前这个记录功能还不够完善,中途退出的课程ID也会被记录 - # TextBookID = getText() # 获取学习过的课程ID - # if TextBookID.count(bookID) > 0: - # logger.info(f"课程: {course['title']} 章节: {point['title']} 任务: {job['title']} 已学习过或在学习中,跳过") # 如果已经学习过该课程,则跳过 - # break # 如果已经学习过该课程,则跳过 - # appendText(bookID) # 记录正在学习的课程ID - - logger.trace(f"识别到视频任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") - # 超星的接口没有返回当前任务是否为Audio音频任务 - isAudio = False - try: - chaoxing.study_video(course, job, job_info, _speed=speed, _type="Video") - except JSONDecodeError as e: - logger.warning("当前任务非视频任务,正在尝试音频任务解码") - isAudio = True - if isAudio: - try: - chaoxing.study_video(course, job, job_info, _speed=speed, _type="Audio") - except JSONDecodeError as e: - logger.warning(f"出现异常任务 -> 任务章节: {course['title']} 任务ID: {job['jobid']}, 已跳过") - # 文档任务 - elif job["type"] == "document": - logger.trace(f"识别到文档任务, 任务章节: {course['title']} 任务ID: {job['jobid']}") - chaoxing.study_document(course, job) - # 测验任务 - elif job["type"] == "workid": - logger.trace(f"识别到章节检测任务, 任务章节: {course['title']}") - chaoxing.study_work(course, job,job_info) - # 阅读任务 - elif job["type"] == "read": - logger.trace(f"识别到阅读任务, 任务章节: {course['title']}") - chaoxing.strdy_read(course, job,job_info) - __point_index += 1 + process_course(chaoxing, course, notopen_action, speed) + logger.info("所有课程学习任务已完成") + notification.send("chaoxing : 所有课程学习任务已完成") + + except SystemExit as e: + if e.code != 0: + logger.error(f"错误: 程序异常退出, 返回码: {e.code}") + sys.exit(e.code) + except KeyboardInterrupt as e: + logger.error(f"错误: 程序被用户手动中断, {e}") except BaseException as e: - import traceback logger.error(f"错误: {type(e).__name__}: {e}") logger.error(traceback.format_exc()) - raise e \ No newline at end of file + try: + notification.send(f"chaoxing : 出现错误 {type(e).__name__}: {e}\n{traceback.format_exc()}") + except Exception: + pass # 如果通知发送失败,忽略异常 + raise e + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6c4f70b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "chaoxing" +version = "3.1.3" +description = "超星学习通/超星尔雅/泛雅超星全自动无人值守完成任务点" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10,<4.0" +dependencies = [ + "argparse>=1.4.0", + "beautifulsoup4>=4.13.3", + "celery>=5.4.0", + "flask>=3.1.0", + "fonttools>=4.56.0", + "loguru>=0.7.3", + "lxml>=5.3.1", + "openai>=1.66.2", + "pyaes>=1.6.1", + "requests>=2.32.3", +] diff --git a/requirements.txt b/requirements.txt index 4575e6d..9a1b252 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,6 @@ argparse loguru celery flask -fonttools \ No newline at end of file +fonttools +openai +ddddocr==1.5.6 \ No newline at end of file diff --git a/resource/README.md b/resource/README.md new file mode 100644 index 0000000..b73d7cd --- /dev/null +++ b/resource/README.md @@ -0,0 +1,7 @@ +# Resource 文件夹 + +此文件夹包含项目所需的资源文件: + +- `font_map_table.json`:字体映射表,包含字体字符与其对应哈希值的映射关系,用于字体渲染和处理。 + +这些映射关系被用于将字符转换为对应的唯一标识符,支持多种语言和符号的显示。 From ab3bce6c667e411faa4ccb6812990bf1ddcf597b Mon Sep 17 00:00:00 2001 From: Lucky05077 <3039280648@qq.com> Date: Wed, 22 Oct 2025 16:34:56 +0800 Subject: [PATCH 21/21] =?UTF-8?q?=E5=88=B7=E8=AF=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 高等数学 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ff73827..cda61c4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,4 +25,4 @@ jobs: - name: Run main.py - run: python main.py -u 学习通账号 -p 学习通密码 -l 244403509 #课程id,默认为心理课的 + run: python main.py -u 17609003132 -p b1628361522b -l 257100786