某高校ulearning系统基于 learnCourseViewModel.js + Section.js 的自动完成脚本实战
零、写在前面
免责声明:本文仅用于技术研究与课堂自动化测试示范,不鼓励也不支持任何违反平台服务协议、侵犯教师及学校权益的行为。切勿在真实生产账号、正式教学场景下使用本文脚本,否则一切后果自负。
一、learnCourseViewModel.js分析
该模块的主要保存逻辑:
- 节级别记录保存(Section Record):每学完一个节,保存一次节的学习记录,包括下属所有页面的完成状态、答题状态等。
- 页级别学习状态收集:每一页的记录(如观看视频、做题、语音录入)由页面组件自行维护,但最终集中在节级数据中上传保存。
- 定时保存 + 离开保存 + 页面切换保存:确保在意外退出、断网、跳转等情况下最大程度保留进度。
- 支持三方平台同步保存:为 LMS 或其他学习平台提供回调。
1.1、保存学习记录的核心函数
section.createRecord(force, status, chapterId, successCallback, failCallback, isLeave)
参数含义:
参数 | 含义 |
---|---|
force | 是否强制保存(true 表示即使没有变更也强制提交) |
status | 是否完成(0未完成,1已完成) |
chapterId | 章节 ID(用于汇总上传) |
successCallback | 保存成功后的回调函数 |
failCallback | 保存失败的回调函数 |
isLeave | 是否是在离开页面时调用(控制是否提示) |
用法举例:
self.currentSection().createRecord(true, 0, self.currentChapter().id());
这行代码的意思是:保存当前节的学习记录,标记为未完成,触发记录上传。
1.2、触发保存的时机(非常关键)
1. 自动定时保存(每5分钟)
window.autoSaveTimer = setInterval(function () {
self.currentSection().createRecord(true, 1, self.currentChapter().id());
}, autoSaveTime); // 默认5分钟,微信小程序20秒
2. 离开页面时保存
$(window).on("beforeunload", function () {
if (!hasSavedBeforeLeave && !isPreviewMode && !isExpiredMode) {
self.currentSection().createRecord(false, 1, self.currentChapter().id());
return "保存记录";
}
});
3. 页面跳转时保存
在 selectPage
函数中,如果切换节,先保存当前节记录再跳转:
js
self.currentSection().createRecord(true, 0, self.currentChapter().id(), function () {
studyNewSection();
});
4. 返回目录页、退出学习时保存
if (!isPreviewMode && !isExpiredMode) {
self.currentSection().createRecord(true, 0, self.currentChapter().id(), function () {
goBackCallback();
});
}
5. 用户手动点击保存按钮
self.userSaveRecord = function() {
self.currentSection().createRecord(true, 1, self.currentChapter().id(), function () {
showToast(self.i18nMsgText().savedSuccessfully, 'success', 1000);
});
}
1.3、节学习完成自动保存逻辑
有一段很巧妙的定时监听器:
js
course.completeListener = setInterval(function () {
// 判断当前节是否所有页面都完成
// 若是,自动触发 createRecord 保存为已完成
}, 5000);
这说明:系统每5秒扫描当前节下的所有页面,一旦发现都学完,自动保存记录为“完成状态”。
1.4、与第三方平台的同步机制
1. URL 参数控制
支持以下参数触发同步保存:
jtoken
:用于标识第三方身份令牌trdCourseId
、classId
、callbackUrl
2. 调用同步接口
window.saveTo3rd = function () {
if (jtoken) {
$.ajax({
url: CONFIG_API_HOST + "/studyrecord/3rd",
type: "GET",
data: {
jtoken, trdCourseId, chapterId, classId, callbackUrl
}
});
}
}
1.5、保存失败恢复机制
系统会记录保存失败的节记录至 localStorage.failureRecord
中:
js
var failureRecord = localStorage.failureRecord || {};
// 当用户再次进入学习页面时会弹窗询问是否恢复未提交记录
1.6、容错机制与状态判断
在保存前后都大量使用以下判断防止重复或错误触发:
isPreviewMode
,isExpiredMode
→ 是否为预览模式或已过期(跳过保存)section.isRecordLoaded()
→ 节的学习记录是否已加载page.hasStarted
,page.questionIncomplete
→ 判断是否完成题目或语音
1.7、保存请求发起点分析
关键调用:
self.currentSection().createRecord(force, status, chapterId, ...)
这是保存学习记录的统一入口,而 Section
对象是从:
js
import Section from "model/Section"
模块加载的,也就是说它的 .createRecord()
方法就是发送保存请求的核心方法。
请求头信息
所有的 AJAX 请求在 $.ajaxSetup
中已统一配置:
xhr.setRequestHeader("Content-Type", "application/json");
xhr.setRequestHeader("UA-AUTHORIZATION", window.AUTHORIZATION);
xhr.setRequestHeader("AUTHORIZATION", window.AUTHORIZATION);
xhr.setRequestHeader("Accept-Language", lang);
保存的 URL 路径
从第三方同步接口路径可以看到 API 主机名配置为:
CONFIG_API_HOST + "/studyrecord/3rd"
而自己的学习记录保存接口可能是:
POST CONFIG_API_HOST + "/studyrecord/saveSectionRecord"
或类似:
/course/study/record/save
/course/section/record/update
这些路径可能在
model/Section.js
模块中定义。
二、Section.js分析
根据 Section.js
文件,“保存学习记录”时提交给后端的数据在通过 CryptoJS 加密 后通过 AJAX POST
请求发出,接口路径为:
POST /yws/api/personal/sync
2.1、请求数据结构
这是 ItemStudyRecordUpdateDTO
最终被提交的数据对象:
{
"itemid": "节ID", // 节(Section)的唯一标识
"autoSave": 0, // 是否为自动保存(1自动保存;0用户主动;5表示自动失败重发)
"version": "节记录版本号",
"withoutOld": 1, // 是否缺少旧记录(用于合并判断)
"complete": 1, // 本节是否完成(1完成,0未完成)
"studyStartTime": 1712563200000, // 学习开始的时间戳
"userName": "用户名",
"score": 85, // 本节最终得分(页平均分)
"pageStudyRecordDTOList": [
{
"pageid": "页面ID",
"complete": 1, // 本页是否完成
"studyTime": 85, // 学习时长(上限1000秒)
"score": 90, // 得分
"answerTime": 1, // 作答时长(写死为1)
"submitTimes": 1, // 提交次数
"coursepageId": "题组件ID",
"questions": [
{
"questionid": "题ID",
"answerList": ["A", "C"], // 多选题答案
"score": 5 // 用户得分
}
],
"videos": [
{
"videoid": "视频ID",
"current": 240, // 当前播放时间点
"status": 1, // 播放完成状态
"recordTime": 60, // 本次观看有效时长
"time": 300, // 视频总时长
"startEndTimeList": [
{ "startTime": 0, "endTime": 30 },
{ "startTime": 200, "endTime": 230 }
]
}
],
"speaks": [
{
"speakingid": "口语任务ID",
"score": 80,
"time": 30,
"url": "https://cdn.xx.com/rec/xxx.mp3",
"answer": "my name is tom"
}
]
}
// ...更多页
]
}
⚠️ 该结构会在请求前用 DES 加密处理:
js CryptoJS.DES.encrypt( ko.toJSON(ItemStudyRecordUpdateDTO), CryptoJS.enc.Utf8.parse("12345678"), { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 } )
2.2、构建过程简析
页面循环构建
pageStudyRecordDTOList
:js for (var i = 0; i < record.pageRecords().length; i++) { const pageRecord = record.pageRecords()[i]; const PageStudyRecordDTO = { ... } // 加入 questions、videos、speaks }
每页内再提取题目记录:
js for (var j = 0; j < pageRecord.questionRecords().length; j++) { var QuestionStudyRecordDTO = { questionid: ..., answerList: [...], score: ... } }
视频记录细化结构:
js { videoid, current, status, recordTime, time, startEndTimeList }
口语题记录结构:
js { speakingid, score, time, url, answer }
2.3、失败处理与本地缓存
当请求失败时,将调用:
js
self.localSaveRecord(ItemStudyRecordUpdateDTO)
保存结构如下:
localStorage.failureRecord = {
"userId": {
"sectionId": {
"name": "节名称",
"param": "?courseType=1&platform=PC",
"record": { /* 上述 JSON 结构 */ }
}
}
}
2.4、加密与解密
在 Section.js
中有非常关键的这一段:
var data = CryptoJS.DES.encrypt(
ko.toJSON(ItemStudyRecordUpdateDTO),
CryptoJS.enc.Utf8.parse("12345678"), // 固定密钥
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
}
).toString();
加密算法参数如下:
参数 | 值 |
---|---|
算法 | DES(对称加密) |
密钥 | "12345678" (固定字符串) |
编码 | Utf8.parse(...) |
模式 | ECB (电子密码本模式) |
填充 | Pkcs7 (常见填充方式) |
加密结果
该方法生成的加密字符串是 Base64 编码过的密文。它作为 POST 请求的 body 被发送出去。
三、源码逆向要点
关键点 | 细节 |
---|---|
学习记录结构 | itemid、autoSave、complete、pageStudyRecordDTOList … |
加密实现 | CryptoJS.DES.encrypt(JSON, key="12345678", ECB, Pkcs7) |
接口 | POST /api/yws/api/personal/sync?courseType=4&platform=PC |
目录 API | /api/course/stu/{courseId}/directory?classId=… |
Chapter → WholePage | /api/wholepage/chapter/stu/{nodeId} |
题目答案 API | /api/questionAnswer/{questionId}?parentId={parentId} |
→ 前端把所有页面数据整合后 一次性 提交节级学习记录,只要字段对,后端就写库。
→ DES 密钥硬编码、ECB 模式 ⇒ 可完全复现加密。
四、脚本整体流程图
┌─►1. 用户输入 TOKEN
│
│ 2. /api/user → 获取昵称 (可选)
│ 3. /api/courses/… → 展示可选课程列表
│ 4. /api/textbook/… → 拿到教材 courseId
│ 5. /directory → 拉取章目录 (nodeid)
│ ┌───────────────────────────────────────────────┐
│ │ foreach nodeid │
│ │ 6. /wholepage/chapter/stu/{nodeid} │
│ │ ├─ 解析 video / question │
│ │ ├─ 如有题目 → /api/questionAnswer/… │
│ │ └─ 组织 pageStudyRecordDTO │
│ └───────────────────────────────────────────────┘
│
│ 7. 组装 ItemStudyRecordUpdateDTO
│ 8. DES-ECB + Base64 加密
│ 9. POST /yws/api/personal/sync
│10. 打印结果日志
└─► Done
五、关键代码片段解析
4.1 DES-ECB 加密函数
def pad_pkcs7(data: bytes) -> bytes:
pad_len = 8 - len(data) % 8
return data + bytes([pad_len] * pad_len)
def encrypt_des_ecb_base64(json_str: str, key="12345678") -> str:
cipher = DES.new(key.encode(), DES.MODE_ECB)
encrypted = cipher.encrypt(pad_pkcs7(json_str.encode()))
return base64.b64encode(encrypted).decode()
与前端 CryptoJS 行为保持 100% 一致。
4.2 伪造单页学习记录
def make_page_study(video, answers, ts):
return {
"pageid": video["pageid"],
"complete": 1,
"studyTime": video["videoLength"],
"score": 100,
"answerTime": 1,
"submitTimes": 1,
"questions": answers, # 多题聚合
"videos": [{
"videoid": video["videoid"],
"current": video["videoLength"],
"status": 1,
"recordTime":video["videoLength"],
"time": video["videoLength"],
"startEndTimeList": [{
"startTime": ts - video["videoLength"],
"endTime" : ts
}]
}] if video["videoid"] else [],
"speaks": []
}
4.3 组装节级 DTO 并上传
payload = {
"itemid": video["itemid"],
"autoSave": 0,
"withoutOld": 1,
"complete": 1,
"studyStartTime": ts - video["videoLength"] - 10,
"userName": USER_NAME,
"score": 100,
"pageStudyRecordDTOList": [page_study]
}
enc_body = encrypt_des_ecb_base64(json.dumps(payload, separators=(',',':')))
url = f"{BASE_URL}/api/yws/api/personal/sync?courseType=4&platform=PC"
resp = requests.post(url, data=enc_body, headers=HEADERS)
六、脚本完整功能亮点
功能 | 说明 |
---|---|
多课程交互 | 调用课程列表接口,支持用户选择 |
章节-小节智能遍历 | 自动识别视频页 / PPT页 / 仅答题页 |
答题正确率 100% | 题目答案接口返回 correctAnswerList ,脚本依题型拼装 |
时间戳合理 | studyStartTime, startEndTimeList 与服务器时区一致 |
日志友好 | 上传成功 / 失败打印 itemid + pageid + 小节标题 |
异常回退 | 若接口 4xx/5xx,显示 res.text ,方便排查 |
完整代码
import requests
import json
import base64
import time
from Crypto.Cipher import DES
BASE_URL = "https://ua.*.edu.cn"
COURSE_BASE_URL = "https://ulearning.*.edu.cn"
# 用户输入 TOKEN
USER_TOKEN = input("请输入你的授权 Token:").strip()
HEADERS = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0",
"Authorization": USER_TOKEN,
"UA-AUTHORIZATION": USER_TOKEN
}
# 默认 DES 加密密钥
KEY = "12345678"
# 默认用户名(如无法获取则使用)
USER_NAME = "自动化脚本"
def fetch_username():
global USER_NAME
try:
res = requests.get(f"{BASE_URL}/api/user", headers=HEADERS)
res.raise_for_status()
data = res.json()
if 'name' in data:
USER_NAME = data['name']
print(f"👤 当前用户:{USER_NAME}")
except Exception:
print(f"⚠️ 获取用户名失败,使用默认: {USER_NAME}")
def get_course_list():
url = f"{COURSE_BASE_URL}/api/courses/students?keyword=&publishStatus=1&type=1&pn=1&ps=15&lang=zh"
headers = {
**HEADERS,
"Referer": f"{COURSE_BASE_URL}/courseweb/ulearning/index.html",
"version": "1"
}
res = requests.get(url, headers=headers)
res.raise_for_status()
data = res.json()
course_list = data.get("courseList", [])
print("\n📚 可选课程如下:")
for idx, course in enumerate(course_list):
print(f"[{idx}] {course['name']}(教师:{course['teacherName']})")
select_idx = int(input("\n请输入课程编号:"))
selected = course_list[select_idx]
return selected['id'], selected['classId'], selected['name']
def get_textbook_courseid(course_id):
url = f"{COURSE_BASE_URL}/api/textbook/student/{course_id}/list?lang=zh"
res = requests.get(url, headers=HEADERS)
res.raise_for_status()
data = res.json()
if not data:
raise Exception("未能获取教材信息")
return data[0]['courseId'], data[0]['name']
def pad_pkcs7(data):
pad_len = 8 - len(data) % 8
return data + bytes([pad_len] * pad_len)
def encrypt_des_ecb_base64(json_data: str, key: str) -> str:
data = pad_pkcs7(json_data.encode('utf-8'))
cipher = DES.new(key.encode('utf-8'), DES.MODE_ECB)
encrypted = cipher.encrypt(data)
return base64.b64encode(encrypted).decode('utf-8')
def get_all_nodeids(course_id, class_id):
url = f"{BASE_URL}/api/course/stu/{course_id}/directory?classId={class_id}"
res = requests.get(url, headers=HEADERS)
res.raise_for_status()
data = res.json()
return [(chapter["nodeid"], chapter.get("nodetitle", "未知章节")) for chapter in data.get("chapters", [])]
def get_video_info_by_nodeid(nodeid):
url = f"{BASE_URL}/api/wholepage/chapter/stu/{nodeid}"
res = requests.get(url, headers=HEADERS)
res.raise_for_status()
data = res.json()
content_list = []
for item in data.get("wholepageItemDTOList", []):
itemid = item.get("itemid")
for wp in item.get("wholepageDTOList", []):
pageid = wp.get("relationid")
title = wp.get("content", "未知小节")
video_info = None
questions_info = []
content_type = wp.get("contentType")
for cp in wp.get("coursepageDTOList", []):
if cp.get("type") == 4:
video_info = {
"itemid": itemid,
"pageid": pageid,
"videoid": cp.get("resourceid"),
"videoLength": cp.get("videoLength", 100),
"video_content": title,
"contentType": content_type
}
if cp.get("questionDTOList"):
parent_id = cp.get("parentid")
for question in cp.get("questionDTOList"):
questions_info.append({
"questionid": question.get("questionid"),
"parentid": parent_id
})
if video_info or questions_info or content_type == 5:
content_list.append({
"video": video_info,
"questions": questions_info,
"itemid": itemid,
"pageid": pageid,
"title": title,
"contentType": content_type
})
return content_list
def get_answers(question_id, parent_id):
url = f"{BASE_URL}/api/questionAnswer/{question_id}?parentId={parent_id}"
res = requests.get(url, headers=HEADERS)
res.raise_for_status()
data = res.json()
answers = []
if 'subQuestionAnswerDTOList' in data and data['subQuestionAnswerDTOList']:
for sub in data['subQuestionAnswerDTOList']:
answers.append({
'questionid': sub['questionid'],
'answerList': sub['correctAnswerList'],
'score': 100
})
elif 'questionid' in data and 'correctAnswerList' in data:
answers.append({
'questionid': data['questionid'],
'answerList': data['correctAnswerList'],
'score': 100
})
return answers
def send_faked_complete_request(video_info, questions=[], chapter_title="未知章节"):
ts = int(time.time())
page_study = {
"pageid": video_info["pageid"],
"complete": 1,
"studyTime": video_info.get("videoLength", 100),
"score": 100,
"answerTime": 1,
"submitTimes": 1,
"questions": questions,
"videos": [],
"speaks": []
}
if video_info["videoid"]:
page_study["videos"].append({
"videoid": video_info["videoid"],
"current": video_info["videoLength"],
"status": 1,
"recordTime": video_info["videoLength"],
"time": video_info["videoLength"],
"startEndTimeList": [
{"startTime": ts - video_info["videoLength"], "endTime": ts}
]
})
payload = {
"itemid": video_info["itemid"],
"autoSave": 0,
"withoutOld": 1,
"complete": 1,
"studyStartTime": ts - video_info.get("videoLength", 100) - 10,
"userName": USER_NAME,
"score": 100,
"pageStudyRecordDTOList": [page_study]
}
print(f"\n🔹 当前小节:《{chapter_title}》")
if questions:
print(f"📖 提交题目 answers={questions}")
encrypted_payload = encrypt_des_ecb_base64(json.dumps(payload, separators=(',', ':')), KEY)
res = requests.post(f"{BASE_URL}/api/yws/api/personal/sync?courseType=4&platform=PC",
data=encrypted_payload, headers=HEADERS)
if res.status_code == 200:
print(f"✅ 完成上传: itemid={video_info['itemid']}, pageid={video_info['pageid']}, videoid={video_info['videoid']}, 小节《{chapter_title}》")
else:
print(f"❌ 上传失败: itemid={video_info['itemid']}, pageid={video_info['pageid']}, 状态码={res.status_code}, 错误={res.text}")
def main():
print("🔐 初始化课程选择...")
fetch_username()
course_id, class_id, course_name = get_course_list()
print(f"✅ 已选择课程《{course_name}》,课程ID: {course_id},班级ID: {class_id}")
textbook_course_id, textbook_name = get_textbook_courseid(course_id)
print(f"📖 获取到教材:《{textbook_name}》,教材 courseId: {textbook_course_id}")
node_list = get_all_nodeids(textbook_course_id, class_id)
for nodeid, chapter_title in node_list:
content_items = get_video_info_by_nodeid(nodeid)
for content in content_items:
video = content.get("video")
questions_info = content.get("questions", [])
content_type = content.get("contentType")
questions = []
for q in questions_info:
questions.extend(get_answers(q["questionid"], q["parentid"]))
if video and video.get("videoLength"):
send_faked_complete_request(video, questions, content.get("title", chapter_title))
time.sleep(1)
elif content_type == 5:
fake_video_info = {
"itemid": content["itemid"],
"pageid": content["pageid"],
"videoid": 0,
"videoLength": 17,
"video_content": "PPT",
"contentType": 5
}
send_faked_complete_request(fake_video_info, questions, content.get("title", chapter_title))
time.sleep(1)
elif questions:
fake_video_info = {
"itemid": content["itemid"],
"pageid": content["pageid"],
"videoid": 0,
"videoLength": 100,
"video_content": "仅答题"
}
send_faked_complete_request(fake_video_info, questions, content.get("title", chapter_title))
time.sleep(1)
else:
print(f"⚠️ 跳过小节《{content.get('title', chapter_title)}》,无视频无题目")
if __name__ == "__main__":
main()
七、风险点与避坑
- 后台风控
- 长时间批量高频提交容易触发
429 Too Many Requests
;脚本已time.sleep(1)
节流。
- 长时间批量高频提交容易触发
- 题库变动
- 若题目设置了「随机抽题」,旧 questionid 会失效 → 脚本取实时 id 即可。
- 密钥硬编码
- 官方如若更新密钥或改成 AES/CBC,脚本需同步调整。
- 账号封禁风险
- 海量秒学完 + 100 分极易被后台标记,务必仅在 测试班级 或 教辅白名单 环境使用。
八、一键运行指南
# 环境准备
pip install requests pycryptodome
# 运行
python auto_learn.py
运行时交互流程:
- 复制浏览器
token
→ 粘贴到脚本 - 选择课程序号
- 坐和收菜,观察日志
九、效果示例
👤 当前用户:张三
📚 可选课程:
[0] Python语言程序设计(教师:李老师)
[1] 数据结构(教师:王老师)
请输入课程编号:0
✅ 已选择课程《Python语言程序设计》...
...
✅ 完成上传: itemid=123456, pageid=123457, videoid=987654, 小节《第1章·程序与数据》
...
全部节上传耗时 < 2 分钟,后台学习报告显示 已学完 / 100 分。
十、结语
通过阅读前端源码,我们发现 弱对称加密 + 前端硬编码密钥 常见于低成本 LMS 平台。
这为测试人员提供了便利,也暴露了平台接口安全短板。
正道是:
- 后端应对每条记录做 有效时长校验 / 视频播放区间比对
- 采用 动态密钥协商 或 JWT + HMAC 验签
- 服务端保存 原始播放日志 做二次风控
希望本文思路能帮助 QA、教辅或课程开发者更快地 自动化验证 课程链路,同时也能给平台安全团队一些完善的参考。