某高校ulearning系统基于 learnCourseViewModel.js + Section.js 的自动完成脚本实战

2025 年 4 月 23 日 星期三(已编辑)
13
AI 生成的摘要
此内容由 AI 生成
输入文本详细分析了一个用于学习管理系统的模块和脚本自动化测试的实现,比如节级别记录保存、页面学习状态收集、保存时机等。此外,还分析了Section.js的请求数据结构、加密与解密过程以及如何发起保存请求。文章还给出了源码逆向要点、脚本功能亮点,并强调了使用过程中可能遇到的风险,如后台风控和账号封禁风险。最后,建议平台改善接口安全性以提高安全性和有效性。

某高校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:用于标识第三方身份令牌
  • trdCourseIdclassIdcallbackUrl

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、构建过程简析

  1. 页面循环构建 pageStudyRecordDTOList js for (var i = 0; i < record.pageRecords().length; i++) { const pageRecord = record.pageRecords()[i]; const PageStudyRecordDTO = { ... } // 加入 questions、videos、speaks }

  2. 每页内再提取题目记录: js for (var j = 0; j < pageRecord.questionRecords().length; j++) { var QuestionStudyRecordDTO = { questionid: ..., answerList: [...], score: ... } }

  3. 视频记录细化结构: js { videoid, current, status, recordTime, time, startEndTimeList }

  4. 口语题记录结构: 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()

七、风险点与避坑

  1. 后台风控
    • 长时间批量高频提交容易触发 429 Too Many Requests;脚本已 time.sleep(1) 节流。
  2. 题库变动
    • 若题目设置了「随机抽题」,旧 questionid 会失效 → 脚本取实时 id 即可。
  3. 密钥硬编码
    • 官方如若更新密钥或改成 AES/CBC,脚本需同步调整。
  4. 账号封禁风险
    • 海量秒学完 + 100 分极易被后台标记,务必仅在 测试班级教辅白名单 环境使用。

八、一键运行指南

# 环境准备
pip install requests pycryptodome

# 运行
python auto_learn.py

运行时交互流程:

  1. 复制浏览器 token → 粘贴到脚本
  2. 选择课程序号
  3. 坐和收菜,观察日志

九、效果示例

👤 当前用户:张三
📚 可选课程:
[0] Python语言程序设计(教师:李老师)
[1] 数据结构(教师:王老师)
请输入课程编号:0
✅ 已选择课程《Python语言程序设计》...
...
✅ 完成上传: itemid=123456, pageid=123457, videoid=987654, 小节《第1章·程序与数据》
...

全部节上传耗时 < 2 分钟,后台学习报告显示 已学完 / 100 分


十、结语

通过阅读前端源码,我们发现 弱对称加密 + 前端硬编码密钥 常见于低成本 LMS 平台。
这为测试人员提供了便利,也暴露了平台接口安全短板。

正道是:

  • 后端应对每条记录做 有效时长校验 / 视频播放区间比对
  • 采用 动态密钥协商JWT + HMAC 验签
  • 服务端保存 原始播放日志 做二次风控

希望本文思路能帮助 QA、教辅或课程开发者更快地 自动化验证 课程链路,同时也能给平台安全团队一些完善的参考。

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • 某高校ulearning系统基于 learnCourseViewModel.js + Section.js 的自动完成脚本实战 - 顾の博客