diff --git a/README.md b/README.md index 120b3c05..f02e3a03 100644 --- a/README.md +++ b/README.md @@ -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`,它会作为刷课完成后的追加步骤执行,而不是独立模式。 + **外部通知配置说明** 这功能会在所有课程学习任务结束后,或是程序出现错误时,使用外部通知服务推送消息告知你(~~有用但不多~~) diff --git a/api/base.py b/api/base.py index baa9af94..6e921633 100644 --- a/api/base.py +++ b/api/base.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import functools import random +import secrets import re import threading import time @@ -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µTopicId=0&editorPreview=0 @@ -1072,6 +1110,7 @@ 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']}") @@ -1079,3 +1118,114 @@ def study_emptypage(self, _course, point): 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µTopicId=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, + ) + 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']+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 + 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 diff --git a/api/process.py b/api/process.py index c4df1bbf..58fb107e 100644 --- a/api/process.py +++ b/api/process.py @@ -1,5 +1,6 @@ import time +from api.logger import logger from api.config import GlobalConst as gc @@ -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']}] 章节学习次数增加失败") diff --git a/config_template.ini b/config_template.ini index a8f08519..adf20483 100644 --- a/config_template.ini +++ b/config_template.ini @@ -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/) diff --git a/main.py b/main.py index f97e7187..3e0efcc6 100644 --- a/main.py +++ b/main.py @@ -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, @@ -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() @@ -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: @@ -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, {}, {} @@ -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) @@ -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: