Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ uv run --python 3.13 main.py -c config.ini
python main.py -a ask # 使用询问模式
```

### 章节学习次数配置说明

在配置文件的 `[common]` 部分,可以通过下面两个选项控制章节学习次数功能:

- `add_learning_count = false`:是否在完成刷课任务后,继续对课程章节执行学习次数增加
- `target_count = 100`:章节学习次数的目标总次数,程序会轮询课程章节直到达到该次数

当前实现会先完成所选课程的任务点,再统一执行章节学习次数增加流程。如果开启了 `add_learning_count`,它会作为刷课完成后的追加步骤执行,而不是独立模式。

**外部通知配置说明**

这功能会在所有课程学习任务结束后,或是程序出现错误时,使用外部通知服务推送消息告知你(~~有用但不多~~)
Expand Down
150 changes: 150 additions & 0 deletions api/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import functools
import random
import secrets
import re
import threading
import time
Expand Down Expand Up @@ -1057,6 +1058,43 @@ def study_read(self, _course, _job, _job_info) -> StudyResult:
logger.info(f"阅读任务学习 -> {_resp_json['msg']}")
return StudyResult.SUCCESS

def _send_monitor_heartbeat(self, course, point):
"""
发送章节监控心跳包到 detect.chaoxing.com。

模拟真实浏览器的 JSONP 打点请求,佐证访问行为的真人属性。

Args:
course: 课程信息字典
point: 当前章节信息字典
"""
version = get_timestamp()
callback = f"jsonp{secrets.randbelow(10**21 - 10**20) + 10**20}"
params = {
"version": version,
"refer": "http://i.mooc.chaoxing.com",
"from": "",
"fid": self.get_fid(),
"jsoncallback": callback,
"t": get_timestamp(),
}
referer_url = (
f"https://mooc1.chaoxing.com/mycourse/studentstudy?"
f"chapterId={point['id']}&courseId={course['courseId']}"
f"&clazzid={course['clazzId']}&cpi={course['cpi']}&mooc2=1"
)
try:
session = SessionManager.get_session()
resp = session.get(
"https://detect.chaoxing.com/api/monitor",
params=params,
headers={"Referer": referer_url},
timeout=5,
)
logger.trace(f"Monitor heartbeat sent -> {resp.status_code}")
except Exception as e:
logger.trace(f"Monitor heartbeat failed (non-critical): {e}")

def study_emptypage(self, _course, point):
_session = SessionManager.get_session()
# &cpi=0&verificationcode=&mooc2=1&microTopicId=0&editorPreview=0
Expand All @@ -1072,10 +1110,122 @@ def study_emptypage(self, _course, point):
"microTopicId": 0,
"editorPreview": 0,
},
timeout=8,
)
if _resp.status_code != 200:
logger.error(f"空页面任务失败 -> [{_resp.status_code}]{point['title']}")
return StudyResult.ERROR
else:
logger.info(f"空页面任务完成 -> {point['title']}")
return StudyResult.SUCCESS

def _access_chapter_for_count(self, _course, point):
_session = SessionManager.get_session()
# &cpi=0&verificationcode=&mooc2=1&microTopicId=0&editorPreview=0
_resp = _session.get(
url="https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudyAjax",
params={
"courseId": _course["courseId"],
"clazzid": _course["clazzId"],
"chapterId": point["id"],
"cpi": _course["cpi"],
"verificationcode": "",
"mooc2": 1,
"microTopicId": 0,
"editorPreview": 0,
},
timeout=8,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if _resp.status_code != 200:
logger.error(f"章节访问失败 -> [{_resp.status_code}]{point['title']}")
return None
else:
logger.info(f"章节访问成功 -> {point['title']}")
return _resp.text

def _extract_and_send_setlog(self, html_text):
"""
从 studentstudyAjax 返回的 HTML 中提取 setlog URL 并执行。

该 URL 包含服务端生成的 encode 参数,是记录章节学习次数的关键 API。

Args:
html_text: studentstudyAjax 返回的 HTML 内容
"""
match = re.search(
r'<script[^>]+src="(https://fystat-ans\.chaoxing\.com/log/setlog[^"]+)"',
html_text
)
if not match:
logger.trace("未在响应中找到 setlog URL")
return

setlog_url = match.group(1)
try:
session = SessionManager.get_session()
resp = session.get(setlog_url, timeout=5)
logger.trace(f"Setlog sent -> {resp.status_code}")
except Exception as e:
logger.trace(f"Setlog failed (non-critical): {e}")

def increase_chapter_learning_count(self, course, points, target_count):
"""
增加课程章节学习次数。

循环遍历课程的所有章节,每访问一个章节页面:
1. 调 studentstudyAjax 获取页面 HTML(含服务端生成的 setlog URL)
2. 提取并执行 setlog URL(记录学习次数)
3. 立即发送 monitor 心跳包(模拟 fn() 首次心跳)
4. 停留 30 秒(模拟前端 setInterval(fn, 30000) 的间隔)
5. 再次发送 monitor 心跳包(模拟 30s 后的第二次心跳)
6. 计数器 +1,继续下一个章节

Args:
course: 课程信息字典
points: 课程所有章节列表
target_count: 目标总次数

Returns:
StudyResult: 操作结果
"""
total = 0
consecutive_failures = 0
max_consecutive_failures = 10
logger.info(f"开始增加章节学习次数, 目标总次数: {target_count}, 章节数: {len(points)}")
if not points:
logger.warning("章节列表为空, 跳过章节学习次数增加")
return StudyResult.SUCCESS
while total < target_count:
for point in points:
if total >= target_count:
break
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.rate_limiter.limit_rate(random_time=True, random_min=0, random_max=0.2)
html_text = self._access_chapter_for_count(course, point)
if not html_text:
logger.error(f"章节学习次数增加失败, 当前章节: {point['title']}")
consecutive_failures += 1
if consecutive_failures >= max_consecutive_failures:
logger.error(
f"章节学习次数增加连续失败 {consecutive_failures} 次, 终止任务"
)
return StudyResult.ERROR
continue

consecutive_failures = 0

# 第 1 步:从 HTML 中提取 setlog URL 并执行(真正的计次 API)
self._extract_and_send_setlog(html_text)

# 第 2 步:立即发送 monitor 心跳包(模拟 fn())
self._send_monitor_heartbeat(course, point)

# 第 3 步:停留 30 秒(模拟前端 setInterval 间隔)
time.sleep(30)

# 第 4 步:再次发送 monitor 心跳包(模拟 setInterval 触发的第二次心跳)
self._send_monitor_heartbeat(course, point)

total += 1
logger.info(f"章节学习次数进度: {total}/{target_count}")
logger.info(f"章节学习次数增加完成, 共完成: {total} 次")
return StudyResult.SUCCESS
34 changes: 34 additions & 0 deletions api/process.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import time

from api.logger import logger
from api.config import GlobalConst as gc


Expand Down Expand Up @@ -60,3 +61,36 @@ def show_progress(task_name: str, start_position: int, duration: int,

print(progress_text, end="", flush=True)
time.sleep(gc.THRESHOLD)


def increase_learning_count_for_course(chaoxing, course, config):
"""
为单个课程增加章节学习次数。

遍历课程的所有章节,轮询调用 studentstudyAjax,直到总次数达到 target_count。

Args:
chaoxing: Chaoxing 实例
course: 课程信息字典
config: 配置字典,需包含 target_count 字段
"""
target_count = config.get("target_count", 100)
logger.info(f"开始为课程 [{course['title']}] 增加章节学习次数, 目标总次数: {target_count}")

# 获取课程所有章节
point_list = chaoxing.get_course_point(
course["courseId"], course["clazzId"], course["cpi"]
)
points = point_list.get("points", [])
if not points:
logger.warning(f"课程 [{course['title']}] 没有章节, 跳过")
return

logger.info(f"课程 [{course['title']}] 共有 {len(points)} 个章节")

# 调用核心方法
result = chaoxing.increase_chapter_learning_count(course, points, target_count)
if result.is_success():
logger.info(f"课程 [{course['title']}] 章节学习次数增加完成")
else:
logger.error(f"课程 [{course['title']}] 章节学习次数增加失败")
7 changes: 7 additions & 0 deletions config_template.ini
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ jobs = 4

; 遇到关闭任务点时的行为: retry-重试(默认), continue-继续
notopen_action = retry

; 是否在刷完课程任务点后增加章节学习次数
add_learning_count = false

; 章节学习次数的目标总次数(所有章节轮询访问,每访问一次计1次)
target_count = 100

[tiku]
; 可选项 :
; 1. TikuYanxi(言溪题库 https://tk.enncy.cn/)
Expand Down
40 changes: 37 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from api.notification import Notification
from api.live import Live
from api.live_process import LiveProcessor
from api.process import increase_learning_count_for_course

class ChapterResult(enum.Enum):
SUCCESS=0,
Expand Down Expand Up @@ -86,6 +87,20 @@ def parse_args():

parser.add_argument("--auto-sign", action="store_true", help="自动签到")

parser.add_argument(
"-lc",
"--add-learning-count",
action="store_true",
help="开启章节学习次数增加模式",
)
parser.add_argument(
"-tc",
"--target-count",
type=int,
default=100,
help="章节学习次数目标总次数 (默认100)",
)

# 在解析之前捕获 -h 的行为
if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"}:
parser.print_help()
Expand Down Expand Up @@ -119,6 +134,10 @@ def load_config_from_file(config_path):
common_config["notopen_action"] = "retry"
if "use_cookies" in common_config:
common_config["use_cookies"] = str_to_bool(common_config["use_cookies"])
if "add_learning_count" in common_config:
common_config["add_learning_count"] = str_to_bool(common_config["add_learning_count"])
if "target_count" in common_config:
common_config["target_count"] = int(common_config["target_count"])
if "username" in common_config and common_config["username"] is not None:
common_config["username"] = common_config["username"].strip()
if "password" in common_config and common_config["password"] is not None:
Expand Down Expand Up @@ -148,7 +167,9 @@ def build_config_from_args(args):
"course_list": [item.strip() for item in args.list.split(",") if item.strip()] if args.list else None,
"speed": args.speed if args.speed else 1.0,
"jobs": args.jobs,
"notopen_action": args.notopen_action if args.notopen_action else "retry"
"notopen_action": args.notopen_action if args.notopen_action else "retry",
"add_learning_count": args.add_learning_count,
"target_count": args.target_count,
}
return common_config, {}, {}

Expand Down Expand Up @@ -496,6 +517,10 @@ def main():
common_config["speed"] = min(2.0, max(1.0, common_config.get("speed", 1.0)))
common_config["notopen_action"] = common_config.get("notopen_action", "retry")

# 初始化增加章节学习次数配置
add_learning_count = str_to_bool(common_config.get("add_learning_count", False))
target_count = int(common_config.get("target_count", 100))

# 初始化超星实例
chaoxing = init_chaoxing(common_config, tiku_config)

Expand All @@ -515,14 +540,23 @@ def main():

# 过滤要学习的课程
course_task = filter_courses(all_course, common_config.get("course_list"))

# 开始学习
logger.info(f"课程列表过滤完毕, 当前课程任务数量: {len(course_task)}")

# 开始学习
for course in course_task:
process_course(chaoxing, course, common_config)

logger.info("所有课程学习任务已完成")
notification.send("chaoxing : 所有课程学习任务已完成")

# 刷课完成后,如果开启了增加章节学习次数,则执行
if add_learning_count:
logger.info("刷课完成,开始增加章节学习次数...")
common_config["target_count"] = target_count
for course in course_task:
increase_learning_count_for_course(chaoxing, course, common_config)
logger.info("所有课程章节学习次数增加完成")
notification.send("chaoxing : 所有课程章节学习次数增加完成")

except SystemExit as e:
if e.code != 0:
Expand Down