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
+
+
+
+
+
+
学习通云端刷课脚本
+
+
+ 利用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 @@
-
+
学习通云端刷课脚本
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 @@
-
-
-
-
-
-
学习通云端刷课脚本
-
-
- 利用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