0. Preface
Disclaimer: This article is for technical research and classroom automation testing demonstration only. It does not encourage or support any behavior that violates the platform service agreement or infringes upon the rights of teachers and schools. Do not use the script in this article on real production accounts or in formal teaching scenarios; otherwise, you are solely responsible for all consequences.
I. Analysis of learnCourseViewModel.js
The main saving logic of this module:
- Section Record Saving: Every time a section is finished, the learning record of the section is saved, including the completion status and answering status of all subordinate pages.
- Page-level Learning Status Collection: The record of each page (such as watching videos, doing exercises, voice input) is maintained by the page component itself, but ultimately uploaded and saved centrally in the section-level data.
- Timed Save + Save on Leave + Save on Page Switch: Ensures progress is retained to the maximum extent in cases of accidental exit, network disconnection, jumping, etc.
- Support for Third-party Platform Synchronization Saving: Provides callbacks for LMS or other learning platforms.
1.1 Core function for saving learning records
section.createRecord(force, status, chapterId, successCallback, failCallback, isLeave)
Parameter Meaning:
| Parameter | Meaning |
|---|---|
force | Whether to force save (true means force submit even if there are no changes) |
status | Whether completed (0 incomplete, 1 completed) |
chapterId | Chapter ID (used for aggregated upload) |
successCallback | Callback function after successful save |
failCallback | Callback function after failed save |
isLeave | Whether it is called when leaving the page (controls whether to prompt) |
Usage Example:
self.currentSection().createRecord(true, 0, self.currentChapter().id());
The meaning of this line of code is: Save the learning record of the current section, mark it as incomplete, and trigger record upload.
1.2 Timing of Triggering Save (Very Critical)
1. Automatic Timed Save (Every 5 minutes)
window.autoSaveTimer = setInterval(function () {
self.currentSection().createRecord(true, 1, self.currentChapter().id());
}, autoSaveTime); // 默认5分钟,微信小程序20秒
2. Save When Leaving the Page
$(window).on("beforeunload", function () {
if (!hasSavedBeforeLeave && !isPreviewMode && !isExpiredMode) {
self.currentSection().createRecord(false, 1, self.currentChapter().id());
return "保存记录";
}
});
3. Save When Switching Pages
In the selectPage function, if switching sections, save the current section record first before jumping:
self.currentSection().createRecord(true, 0, self.currentChapter().id(), function () {
studyNewSection();
});
4. Save When Returning to Directory or Exiting Learning
if (!isPreviewMode && !isExpiredMode) {
self.currentSection().createRecord(true, 0, self.currentChapter().id(), function () {
goBackCallback();
});
}
5. User Manually Clicks Save Button
self.userSaveRecord = function() {
self.currentSection().createRecord(true, 1, self.currentChapter().id(), function () {
showToast(self.i18nMsgText().savedSuccessfully, 'success', 1000);
});
}
1.3 Automatic Save Logic Upon Section Completion
There is a very clever interval listener:
course.completeListener = setInterval(function () {
// 判断当前节是否所有页面都完成
// 若是,自动触发 createRecord 保存为已完成
}, 5000);
This indicates: The system scans all pages under the current section every 5 seconds. Once it finds that all are finished, it automatically saves the record as "completed status".
1.4 Synchronization Mechanism with Third-Party Platforms
1. URL Parameter Control
Supports the following parameters to trigger synchronization save:
jtoken: Used to identify the third-party identity tokentrdCourseId,classId,callbackUrl
2. Call Synchronization Interface
window.saveTo3rd = function () {
if (jtoken) {
$.ajax({
url: CONFIG_API_HOST + "/studyrecord/3rd",
type: "GET",
data: {
jtoken, trdCourseId, chapterId, classId, callbackUrl
}
});
}
}
1.5 Save Failure Recovery Mechanism
The system records failed section records to localStorage.failureRecord:
var failureRecord = localStorage.failureRecord || {};
// 当用户再次进入学习页面时会弹窗询问是否恢复未提交记录
1.6 Fault Tolerance Mechanism and Status Judgment
The following judgments are used extensively before and after saving to prevent repeated or erroneous triggers:
isPreviewMode,isExpiredMode→ Whether it is preview mode or expired mode (skip save)section.isRecordLoaded()→ Whether the section's learning record has been loadedpage.hasStarted,page.questionIncomplete→ Determine if questions or voice tasks are completed
1.7 Analysis of Save Request Origin
Key Call:
self.currentSection().createRecord(force, status, chapterId, ...)
This is the unified entry point for saving learning records, and the Section object is loaded from:
import Section from "model/Section"
This means its .createRecord() method is the core method for sending save requests.
Request Header Information
All AJAX requests are uniformly configured in $.ajaxSetup:
xhr.setRequestHeader("Content-Type", "application/json");
xhr.setRequestHeader("UA-AUTHORIZATION", window.AUTHORIZATION);
xhr.setRequestHeader("AUTHORIZATION", window.AUTHORIZATION);
xhr.setRequestHeader("Accept-Language", lang);
Save URL Path
From the third-party synchronization interface path, it can be seen that the API host is configured as:
CONFIG_API_HOST + "/studyrecord/3rd"
And the own learning record save interface might be:
POST CONFIG_API_HOST + "/studyrecord/saveSectionRecord"
Or similar:
/course/study/record/save
/course/section/record/update
These paths may be defined in the
model/Section.jsmodule.
II. Analysis of Section.js
According to the Section.js file, the data submitted to the backend during "save learning record" is encrypted via CryptoJS and then sent via an AJAX POST request. The interface path is:
POST /yws/api/personal/sync
2.1 Request Data Structure
This is the ItemStudyRecordUpdateDTO data object ultimately submitted:
{
"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"
}
]
}
// ...更多页
]
}
⚠️ This structure is processed with DES encryption before the request:
CryptoJS.DES.encrypt( ko.toJSON(ItemStudyRecordUpdateDTO), CryptoJS.enc.Utf8.parse("12345678"), { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 } )
2.2 Brief Analysis of Construction Process
Page Loop Construction
pageStudyRecordDTOList:for (var i = 0; i < record.pageRecords().length; i++) { const pageRecord = record.pageRecords()[i]; const PageStudyRecordDTO = { ... } // 加入 questions、videos、speaks }Extract Question Records Within Each Page:
for (var j = 0; j < pageRecord.questionRecords().length; j++) { var QuestionStudyRecordDTO = { questionid: ..., answerList: [...], score: ... } }Video Record Detailed Structure:
{ videoid, current, status, recordTime, time, startEndTimeList }Speaking Task Record Structure:
{ speakingid, score, time, url, answer }
2.3 Failure Handling and Local Cache
When a request fails, the following is called:
self.localSaveRecord(ItemStudyRecordUpdateDTO)
The save structure is as follows:
localStorage.failureRecord = {
"userId": {
"sectionId": {
"name": "节名称",
"param": "?courseType=1&platform=PC",
"record": { /* 上述 JSON 结构 */ }
}
}
}
2.4 Encryption and Decryption
There is a very critical section in Section.js:
var data = CryptoJS.DES.encrypt(
ko.toJSON(ItemStudyRecordUpdateDTO),
CryptoJS.enc.Utf8.parse("12345678"), // 固定密钥
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
}
).toString();
Encryption Algorithm Parameters:
| Parameter | Value |
|---|---|
| Algorithm | DES (Symmetric Encryption) |
| Key | "12345678" (Fixed String) |
| Encoding | Utf8.parse(...) |
| Mode | ECB (Electronic Codebook Mode) |
| Padding | Pkcs7 (Common Padding Method) |
Encryption Result
The encrypted string generated by this method is Base64 encoded ciphertext. It is sent as the body of the POST request.
III. Key Points of Source Code Reverse Engineering
| Key Point | Details |
|---|---|
| Learning Record Structure | itemid、autoSave、complete、pageStudyRecordDTOList … |
| Encryption Implementation | CryptoJS.DES.encrypt(JSON, key="12345678", ECB, Pkcs7) |
| Interface | POST /api/yws/api/personal/sync?courseType=4&platform=PC |
| Directory API | /api/course/stu/{courseId}/directory?classId=… |
| Chapter → WholePage | /api/wholepage/chapter/stu/{nodeId} |
| Question Answer API | /api/questionAnswer/{questionId}?parentId={parentId} |
→ The frontend integrates all page data and submits the section-level learning record at once. As long as the fields are correct, the backend writes to the database. → DES key is hardcoded, ECB mode ⇒ Encryption can be fully reproduced.
IV. Overall Script Flowchart
┌─►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
V. Analysis of Key Code Snippets
4.1 DES-ECB Encryption Function
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()
Behaves 100% consistently with frontend CryptoJS.
4.2 Forge Single Page Learning Record
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 Assemble Section-Level DTO and Upload
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)
VI. Highlights of Full Script Functionality
| Feature | Description |
|---|---|
| Multi-course interaction | Calls course list interface, supports user selection |
| Chapter-Section Smart Traversal | Automatically identifies Video pages / PPT pages / Quiz-only pages |
| 100% Quiz Accuracy | Question answer interface returns correctAnswerList, script assembles based on question type |
| Reasonable Timestamp | studyStartTime, startEndTimeList consistent with server time zone |
| Friendly Logging | Prints itemid + pageid + section title on upload success/failure |
| Exception Fallback | If interface returns 4xx/5xx, displays res.text for easy troubleshooting |
Full Code
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()
VII. Risks and Pitfalls
- Backend Risk Control
- Long-term high-frequency batch submission is easy to trigger
429 Too Many Requests; the script hastime.sleep(1)throttling.
- Long-term high-frequency batch submission is easy to trigger
- Question Bank Changes
- If the questions are set to "random selection", the old questionid will be invalid → the script just needs to fetch the real-time id.
- Hardcoded Keys
- If the official updates the key or changes to AES/CBC, the script needs to be adjusted synchronously.
- Account Ban Risk
- Massive completion in seconds + 100 points is extremely easy to be marked by the backend. Be sure to use it only in test classes or teaching aid whitelist environments.
VIII. One-Click Run Guide
# Environment Preparation
pip install requests pycryptodome
# Run
python auto_learn.py
Runtime interaction flow:
- Copy browser
token→ Paste into script - Select course number
- Sit back and harvest, observe logs
IX. Example Results
👤 当前用户:张三
📚 可选课程:
[0] Python语言程序设计(教师:李老师)
[1] 数据结构(教师:王老师)
请输入课程编号:0
✅ 已选择课程《Python语言程序设计》...
...
✅ 完成上传: itemid=123456, pageid=123457, videoid=987654, 小节《第1章·程序与数据》
...
All sections uploaded in < 2 minutes, backend learning report shows Completed / 100 Points.
X. Conclusion
Through reading the frontend source code, we found that weak symmetric encryption + frontend hardcoded keys are common in low-cost LMS platforms. This provides convenience for testers but also exposes the platform's interface security shortcomings.
The Right Way:
- The backend should perform effective duration verification / video playback interval comparison for each record.
- Adopt dynamic key negotiation or JWT + HMAC signature verification.
- The server should save original playback logs for secondary risk control.
I hope the ideas in this article can help QA, teaching assistants, or course developers to automatically verify course links faster, and also provide some reference for improvement to the platform security team.