[Auto CQUPT Plan] 长江雨课堂自动做题(补档)

5110 字
26 分钟
[Auto CQUPT Plan] 长江雨课堂自动做题(补档)

1. 前言#

长江雨课堂章节测试课堂上的限时测验 基本上都是选择题,一题一题地艘AI太难受了,所以我抓包并逆向了一下 长江雨课堂网页版 ,本篇文章总结了其相关的API调用,方便写自动化脚本,但是因为以前抓包时 yakit 的工程文件不见了,现在的这些json结构都是我让AI根据Go结构体反推出来的,可能和实际情况有出入,不过应该不影响使用。

Warning

免责声明:

1. 本文档仅供学习研究与接口分析使用,请勿用于非法用途,否则后果自负。

2. 若你已经掌握课程知识、希望通过自动化从机械化练习中节省时间,可结合自身情况参考。

3. 平台接口、参数或风控策略可能随时变更,文档不保证长期有效。

2. 底层是怎么运作的#

整个系统横跨两个独立的域,登录态互不相通:

负责的事靠什么鉴权
changjiang.yuketang.cn(主站)扫码登录、用户 / 课程 / 作业信息、生成考试凭证主站 cookie,其中有一个关键值 csrftoken
changjiang-exam.yuketang.cn(考试系统)进考场、开始答卷、拉题、写答案、交卷考试域独立下发的一套 cookie

考试域的 cookie 并不是登录就自动有的,而是要在”主站生成了考试凭证 → 访问考试域 /login”这一步之后,才由服务端新下发的一批 cookie。所以“‘前面正常主站登录,后面考试接口一定可用’是一种常见的错误假设”——中间隔了这次跨域登录。

2.2 请求头约定#

服务端靠请求头区分”你在调主站还是考试系统”:

需要的请求头
主站xtbz: ykt;其中生成考试凭证那一步额外要 X-CSRFToken
考试系统xtbz: cloud;拉题、写答案、交卷等接口再额外要一个 x-client: web

2.3 重定向与登录态保存#

有两类接口(第 6 步登录回调、第 13 步考试系统登录)正常浏览器里表现为一个”跳转”,即服务端返回 HTTP 302。我们要判断”登录是否成功”,就得拿到”302 这次响应本身”,而不是让客户端自动跟着跳下去。所以 HTTP 客户端要关掉自动跟随重定向,改用收到 302 即视为成功:

client := &http.Client{
// 不自动跟随 302,让上层拿到重定向响应来判断登录结果
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}

Cookie 由客户端统一保管并随后续请求自动携带;退出前写回磁盘,实现登录态跨进程复用,省得每次扫码。

2.4 接口总览#

一共 20 个接口,分三组:微信扫码登录(3.1)、用户 / 课程 / 作业信息(3.2)、考试流程(3.3)。

#步骤Method完整路径用途
1触发 CSRFGET主站v/course_meta/user_info让服务端种下 csrftoken cookie
2获取 OAuth 参数POST主站api/v3/user/login/wechat-auth-param获取 appId/state
3取二维码页面GET微信open.weixin.qq.com/connect/qrconnect抠二维码 uid
4取二维码图片GET微信open.weixin.qq.com/connect/qrcode/{uid}下载二维码 PNG
5轮询扫码GET微信lp.open.weixin.qq.com/connect/l/qrconnectwx_code
6换取 SessionGET主站api/v3/user/login/wechat-web-callback微信回调完成登录(302)
7用户信息GET主站api/v3/user/basic-info当前用户信息
8课程列表GET主站v2/api/web/courses/list?identity=2cid(classroom_id)
9作业 / 章节树GET主站mooc-api/v1/lms/learn/course/chapterleaf_id
10作业详情GET主站mooc-api/v1/lms/learn/leaf_info/{cid}/{leafID}/单个作业详情
11作业封面GET主站v/exam/coverexam_id 及考试信息
12生成 tokenPOST主站v/exam/gen_token换加密 token(需 CSRF)
13考试系统登录GET考试系统login种考试域 cookie(302)
14进入考场GET考试系统start/{examID}?isFrom=2进入考场页
15开始答卷POST考试系统exam_room/start_paper返回是否限时 / 剩余时间
16试卷封面GET考试系统exam_room/cover考试域试卷封面
17拉取题目GET考试系统exam_room/show_paper返回整卷题目
18刷新剩余时间GET考试系统exam_room/refresh_time心跳保活
19提交单题答案POST考试系统exam_room/answer_problem逐题写答案(暂存)
20交卷POST考试系统exam_room/submit_paper整卷一次性提交

约定:下文中 {x} 表示需要由上一个接口的返回结果回填的变量;给出的 JSON 都是能直接照抄的完整结构。

3. 接口到底怎么调#

3.1 微信扫码登录#

登录的目的,是让主站 cookie 生效;之后的信息查询与考试流程都依赖它。

3.1.1 触发 CSRF Token(接口 1)#

初始化 CSRF Token 使用的 Cookie

GET https://changjiang.yuketang.cn/v/course_meta/user_info
  • 请求:无入参,无特殊请求头。
  • 响应:期望 HTTP 200。它没有需要解析的数据,唯一作用是触发 cookie 下发。

3.1.2 获取微信 OAuth 参数(接口 2)#

要拉起微信扫码页,得先知道三件事:用哪个微信公众号(appId)、登录回调去哪个地址(redirectUri)、以及怎么防伪造(state)。

POST https://changjiang.yuketang.cn/api/v3/user/login/wechat-auth-param
Content-Type: application/json
  • 请求 Body:空。

  • 响应(完整 JSON):

{
"code": 0,
"msg": "OK",
"data": {
"appId": "wx4d919285d...",
"state": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjN...",
"redirectUri": "https://changjiang.yuketang.cn/api/v3/user/login/wechat-web-callback"
}
}
字段(data 内)说明
appId微信应用 ID,扫码页入参
state状态值,扫码成功后回传校验(防 CSRF)
redirectUri回调地址,扫码页入参

code = 0 表示成功。

3.1.3 获取二维码页面(接口 3)#

用上一步的参数去打开微信的扫码页。微信不是返回一张图片给我们,而是返回一段 HTML,真正的二维码地址藏在里面(形如 /connect/qrcode/一长串字符)。

GET https://open.weixin.qq.com/connect/qrconnect?appid={appId}&scope=snsapi_login&redirect_uri={redirectUri}&state={state}&login_type=jssdk&self_redirect=true&styletype=&sizetype=&bgcolor=&rst=&ts=&stylelite=1
  • 请求appid/redirect_uri/state 来自 3.1.2,其余为固定值。
  • 响应:HTML 文本(string),没有 JSON。
<!-- 普通登录 -->
<div class="js_normal_login web_qrcode_img_area">
<div class="web_qrcode_img_wrp">
<img class="js_qrcode_img web_qrcode_img" src="/connect/qrcode/031TVnLl0m7zGa1w"/>
<button style="display:none" type="button" class="weui-btn_reset web_qrcode_refresh_btn js_refresh_qrcode" title="刷新"><i class="web_qrcode_refresh_icon"></i></button>
<i style="display:none" class="weui-loading js_refresh_qrcode_loading web_qrcode_refresh_loading"></i>
<div style="display:none" class="web_qrcode_img_mask js_refresh_qrcode_mask"></div>
</div>
<div class="web_qrcode_switch_wrp js_switchToFast_wrp" style="display:none;">
<button type="button" class="weui-btn_reset weui-link js_switchToFast web_qrcode_switch">快捷登录</button>
</div>
</div>

需要提取其中 /connect/qrcode/031TVnLl0m7zGa1w 部分的 031TVnLl0m7zGa1w

关键实现:要从 HTML 里抠出二维码地址里的 uid,用一条正则即可:

// 从扫码页 HTML 中提取二维码 uid
re := regexp.MustCompile("/connect/qrcode/([A-Za-z0-9]+)")
res := re.FindStringSubmatch(content)
uid := res[1] // 后续下载二维码、轮询扫码都要用到

3.1.4 下载二维码图片(接口 4)#

拿到 uid 后,把二维码 PNG 下载下来,渲染给用户扫码。

GET https://open.weixin.qq.com/connect/qrcode/{uid}
  • 请求:路径参数 uid 来自 3.1.3。
  • 响应Content-Typeimage/ 开头的 PNG 二进制字节。

关键实现 1:下载前先校验响应头确实是图片,避免拿到一段错误页当图片处理:

contentType := resp.Header.Get("Content-Type")
if contentType[:5] != "image" {
return fmt.Errorf("Content-Type Error: %s", contentType)
}

关键实现 2:终端里没有图形界面时,可以把 PNG 转成”文字二维码”。原理是取每个像素的灰度,把上下两行合成一个字符,用 ' ''▄''▀''█' 四个半块字符表达”上黑 / 下黑 / 都黑”:

// 把二维码缩放后在外围加一圈白边框,便于在深色终端上识别
blocks := []rune{' ', '', '', ''}
for y := 0; y < total; y += 2 { // 每两行像素输出一行
for x := 0; x < total; x++ {
idx := 0
if isBlack(img.At(x, y)) { idx |= 2 } // 上黑
if y+1 < total && isBlack(img.At(x, y+1)) { idx |= 1 } // 下黑
fmt.Printf("%c", blocks[idx])
}
fmt.Println()
}

3.1.5 轮询扫码状态(接口 5)#

二维码显示出来后,要反复询问微信”用户扫了没有”。每 5 秒问一次,直到拿到扫码授权码。

GET https://lp.open.weixin.qq.com/connect/l/qrconnect?uuid={uid}
  • 请求:Query 参数 uuid 来自 3.1.3。
  • 响应:一段文本(非 JSON)。用户若已扫码,会包含 window.wx_code='xxx';还没扫则没有。
window.wx_errcode=405;window.wx_code='031GcKFa1pT...';

提取其中的 031GcKFa1pT... 这段值

关键实现:先用正则判断是否出现 wx_code,取到就跳出循环:

func extractWxCode(content string) (string, bool) {
matches := regexp.MustCompile(`window\.wx_code='([^']+)'`).FindStringSubmatch(content)
if len(matches) == 0 {
return "", false // 尚未扫码,继续等
}
return matches[1], true
}

外层再套一个轮询循环:

var wxCode string
for {
time.Sleep(5 * time.Second) // 每 5 秒轮询一次
content, _ := httpGet(pollURL) // 请求本接口
if code, ok := extractWxCode(content); ok {
wxCode = code // 拿到授权码,登录成功
break
}
}

3.1.6 换取 Session(接口 6)#

拿到 wx_code 后,带着它和 state 去访问回调地址,主站据此完成登录、下发登录态 cookie。

GET https://changjiang.yuketang.cn/api/v3/user/login/wechat-web-callback?path=/authorize/wx-qrlogin%3Fsuccess%3D1&code={wx_code}&state={state}
  • 请求code 来自 3.1.5,state 来自 3.1.2;path 是固定值并做了 URL 编码。
  • 响应期望 HTTP 302(见 2.3,收到 302 即判定登录成功)。

到这里主站登录完成,返回的 Cookie 里会包含 sessionid=1x7v7rguev0z... ,使用这个 Cookie ,§3 的接口 7 以后都能用了。


3.2 用户 / 课程 / 作业信息(主站)#

在调考试接口之前,得先把三个 ID 找齐:cid(课程 id)、leaf_id(作业 id)、exam_id(考试 id)。这条链路一环扣一环。

3.2.1 用户基本信息(接口 7)#

登录后第一次请求,确认登录态真的生效,同时拿用户信息。

GET https://changjiang.yuketang.cn/api/v3/user/basic-info
  • 请求:无入参、无特殊请求头。
  • 响应(完整 JSON):
{
"code": 0,
"msg": "OK",
"data": {
"id": "114514",
"avatar": "https://thirdwx.qlogo.cn/mmopen/vi_32/...",
"name": "达啥必",
"school": "重庆邮电大学",
"schoolNumber": "2029111000",
"edited": true,
"binding": true,
"role": 2,
"dateJoined": "2029-13-32T25:61:61",
"nickname": "微信用户"
}
}

3.2.2 课程列表(接口 8)#

列出当前用户所有课程,我们要找的是课程的 classroom_id,下文统称 cid

GET https://changjiang.yuketang.cn/v2/api/web/courses/list?identity=2
  • 请求:固定 Query 参数 identity=2
  • 响应(完整 JSON):
{
"errcode": 0,
"errmsg": "Success",
"data": {
"list": [
{
"term": 202502,
"name": "2026春-思想道德与法治-20班",
"short_name": null,
"students_count": 89,
"time": 1772605262000,
"teacher": {
"user_id": 0,
"name": "教师",
"avatar": ""
},
"published_kg_id": null,
"create_time": 1772011715000,
"role": 6,
"agent_id": null,
"project_type": null,
"ways_of_operation": null,
"university_course_series_id": "202502-xxxxxxxx",
"color_code": 3,
"classroom_id": 0,
"top_time": 0,
"course": {
"en_name": null,
"manage_permission": false,
"name": "思想道德与法治",
"short_name": null,
"admin_id": 0,
"platform": null,
"university_id": 0,
"type": 2,
"id": 2123735,
"is_pro": true,
"university_mini_logo": "https://qn-next.xuetangx.com/xxxx.png",
"university_name": "大学"
}
}
]
}
}

重点classroom_id(即 cid)是后续所有章节 / 考试查询的入参,务必取出记录。

3.2.3 章节树(接口 9)#

给一门课的 cid,返回它的章节结构;作业 / 考试就是某一章节下的”叶子节点”。

GET https://changjiang.yuketang.cn/mooc-api/v1/lms/learn/course/chapter?cid={cid}&classroom_id={cid}
Header: xtbz: ykt
  • 请求:Query 参数 cidclassroom_id 都填 3.2.2 拿到的值;Header xtbz: ykt
  • 响应(完整 JSON):
{ "success": true,
"data": {
"course_id": 123,
"course_name": "高等数学 A",
"course_chapter": [
{
"section_leaf_list": [
{
"name": "第一次小测",
"is_locked": false,
"start_time": 1710000000,
"chapter_id": 1,
"section_id": null,
"leaf_type": 5,
"id": 1945538,
"is_show": true,
"end_time": 1713600000,
"score_deadline": 1713600000,
"is_open_type": false,
"is_score": true,
"is_assessed": true,
"order": 1,
"leafinfo_id": 1945538
}
],
"order": 1,
"id": 1,
"name": "第一章 极限"
}
]
}
}

重点:叶子节点的 leafinfo_id(或 id)就是我们要的 leaf_idleaf_type 标记类型(如 5 = 作业 / 考试)。

3.2.4 作业详情(接口 10)#

拿单个作业的详细信息,比如发布人、截止时间、计分比例。不是考试流程的必需步骤,但能帮你判断”这个作业做没做、值不值得做”。

GET https://changjiang.yuketang.cn/mooc-api/v1/lms/learn/leaf_info/{cid}/{leafID}/
Header: xtbz: ykt, classroom-id: {cid}
  • 请求:路径参数 cidleafID(来自 3.2.3);Header xtbz: yktclassroom-id
  • 响应(完整 JSON):
{ "success": true,
"msg": "",
"data": {
"upgrade_sku_status": null,
"publish_time": 1710000000.0,
"is_assessed": true,
"locked_reason": null,
"price": 0,
"sku_name": "",
"user_role": 2,
"be_in_force": true,
"current_price": 0,
"third_platform_code": "",
"class_start_time": 1710000000,
"upgrade_sku_id": null,
"university_id": 1,
"score_deadline": 1713600000.0,
"course_id": 123,
"user_id": 57510820,
"teacher": {},
"is_score": true,
"third_platform_name": "",
"sku_id": 0,
"is_deleted": false,
"name": "第一次小测",
"content_info": {
"status": "",
"video_user_play": null,
"expand_discuss": false,
"score_evaluation": {
"score_proportion": { "proportion": 0.1 },
"score": 100,
"id": 1,
"name": "平时成绩"
},
"download": [],
"is_score": true,
"is_discuss": false,
"remark": { "remark": "" },
"cover_desc": "",
"cover_thumbnail": "",
"media": {},
"cover": "",
"leaf_type_id": 5,
"is_attachment_download": false,
"context": ""
},
"classroom_id": "24211265",
"is_locked": false,
"leaf_type": 5,
"id": 1945538,
"has_classend": false,
"classroom_short_name": null,
"is_open_type": false,
"class_end_time": 0
}
}

3.2.5 作业封面(接口 11)#

打开考试的入口页数据:标题、总分、题目数、截止时间、要不要人脸认证等。这里的入参 exam_id 才是考试域真正认的考试 ID

GET https://changjiang.yuketang.cn/v/exam/cover?exam_id={examID}&classroom_id={cid}
Header: xtbz: ykt
  • 请求:Query 参数 exam_idclassroom_id(cid);Header xtbz: ykt
  • 响应(完整 JSON):
{ "status": 0,
"msg": "",
"success": true,
"data": {
"server_time": 1710000000,
"app_front_random_snap": 0,
"paper_count": 1,
"max_retry_random": 0,
"exam_exercise_video_attachment_1G": 0,
"identity_auth": 1,
"deadline": 1713600000,
"university_id": 1,
"capture_screen": 0,
"max_screen_cuts_num": 0,
"max_retry": 0,
"is_sku_time": false,
"organize_problem_method": 0,
"classroom_name": null,
"course_name": null,
"title": "第一次小测",
"OCR_type": "",
"is_single_paper": true,
"organize_paper_method": 0,
"show_score_time": 0,
"en_copy": false,
"is_offline": false,
"open_screen_cuts": 0,
"access_restriction_info_for_graph": "",
"start_time": 1710000000,
"open_access_restriction_for_graph": false,
"online_proctor": 0,
"show_score": true,
"description": "",
"show_answer": false,
"total_score": 100,
"en_crypt": false,
"user_role": 2,
"user": {
"user_name": "张三", "user_id": 57510820,
"avatar": "", "language": null, "school_number": "2022..."
},
"access_restriction_info": "",
"app_capture_screen": 0,
"encrypt": "",
"limit_early_submission_time": 0,
"show_perm": true,
"limit_early_submission": false,
"page_switch_detection": 0,
"is_manual_review": 0,
"is_partial_participation": false,
"classroom_id": 24211265,
"face_auth_status": { "online_proctor": 0, "has_idphoto": false },
"problem_dict_count": 0,
"has_problem_dict": false,
"problem_count": 10,
"platform_type": 1,
"client_type": "",
"limit": 7200,
"open_access_restriction": false,
"way_of_score": 0,
"web_random_take_face_photo": 0,
"force_confirm": false,
"result": { "status": 0, "score": null, "incorrect_count": null, "unfinished_count": null }
} }

ID 获取链小结:课程列表 → classroom_id(cid)→ 章节树 → 叶子节点 leafinfo_id(leaf_id)→ 封面 → exam_id。三个 ID 齐了,才能进考试域。


3.3 考试流程(考试系统 changjiang-exam.yuketang.cn#

拿到 exam_idcid 后,流程从主站的”生成 token”起步,然后切到考试域。

3.3.1 生成考试 Token(接口 12,主站)#

主站签发一个加密凭证 token,交给考试系统去验证”这个用户确实有资格考这场试”。这是全程唯一需要 CSRF 的接口

POST https://changjiang.yuketang.cn/v/exam/gen_token
Header: xtbz: ykt, X-CSRFToken: {主站 cookie 中的 csrftoken}
Content-Type: application/json
  • 请求 Body
{ "exam_id": "1945538", "classroom_id": "24211265" }
字段类型说明
exam_idstring考试 ID
classroom_idstring课程 / 班级 ID(即 cid)

关键实现:请求头里的 X-CSRFToken 不是随便记的,它来自主站 cookie 里那个 csrftoken。实现时要从 cookie 容器中把它取出来回填:

for _, cookie := range jar.Cookies(mainDomainURL) {
if cookie.Name == "csrftoken" {
req.Header.Set("X-CSRFToken", cookie.Value)
break
}
}
req.Header.Set("xtbz", "ykt")
  • 响应(完整 JSON):
{ "status": 0,
"msg": "",
"success": true,
"data": {
"token": "H1tf/67TNyDZ...",
"exam_host": "changjiang-exam.yuketang.cn",
"user_id": 57510820
} }
字段(data 内)说明
token加密凭证,下一步 /logincrypt 入参
exam_host考试系统域名(据此切域)
user_id用户 ID,下一步 /login 入参

3.3.2 考试系统登录(接口 13,跨域)#

把上一步的 token 作为 crypt 交给考试系统 /login,服务端校验通过后,在考试域种下专属 cookie。此后考试域的接口都靠这套 cookie 鉴权。

GET https://changjiang-exam.yuketang.cn/login?exam_id={examID}&user_id={user_id}&crypt={token}&next=https://changjiang-exam.yuketang.cn/start/{examID}?isFrom=2&language=zh
Query 参数说明
exam_id考试 ID
user_id来自 3.3.1 的 data.user_id
crypt来自 3.3.1 的 data.token
next登录后跳转地址(进入考场页)
languagezh

关键实现next 是拼出来的,指向”进场”路径:

nextUrl := fmt.Sprintf("https://changjiang-exam.yuketang.cn/start/%d?isFrom=2", examID)
  • 响应期望 HTTP 302;服务端在考试域下发 cookie,跨域登录完成。这一步失败,后面考试域所有请求都白搭。

3.3.3 进入考场(接口 14)#

模拟”点进考场”,让服务端确认考生就位。

GET https://changjiang-exam.yuketang.cn/start/{examID}?isFrom=2
  • 请求:路径参数 examID,固定 Query isFrom=2
  • 响应:期望 HTTP 200。

3.3.4 开始答卷(接口 15)#

正式开始答卷。返回是否限时,以及已用 / 剩余时间——记住剩余时间,后面答题节奏要照着它走。

POST https://changjiang-exam.yuketang.cn/exam_room/start_paper
Header: xtbz: cloud
Content-Type: application/json
  • 请求 Body
{ "exam_id": "1945538" }
  • 响应(完整 JSON):
{ "errcode": 0,
"errmsg": "",
"data": {
"has_limit": true,
"time_past": 0,
"time_left": 7200
} }
字段(data 内)说明
has_limit是否限时
time_past已用时间(秒)
time_left剩余时间(秒)

3.3.5 试卷封面(接口 16,考试系统)#

考试域内的试卷封面,和主站的 exam/cover 数据同源。一般是想在考试域内部拿”总分、题数、截止时间”时用。

GET https://changjiang-exam.yuketang.cn/exam_room/cover?exam_id={examID}
Header: xtbz: cloud, x-client: web
  • 请求:Query 参数 exam_id
  • 响应(完整 JSON,data 结构与 3.2.5 主站封面同源):
{ "errcode": 0,
"errmsg": "",
"data": {
"title": "第一次小测",
"description": "",
"has_problem_dict": false,
"problem_dict_count": 0,
"course_name": null,
"classroom_name": null,
"total_score": 100,
"problem_count": 10,
"start_time": 1710000000,
"deadline": 1713600000,
"is_sku_time": false,
"limit": 7200,
"show_perm": true,
"show_answer": false,
"show_score": true,
"show_score_time": 0,
"online_proctor": 0,
"classroom_id": 24211265,
"university_id": 1,
"platform_type": 1,
"organize_problem_method": 0,
"organize_paper_method": 0,
"client_type": "",
"en_copy": false,
"en_crypt": false,
"app_capture_screen": 0,
"capture_screen": 0,
"app_front_random_snap": 0,
"is_single_paper": true,
"paper_count": 1,
"open_screen_cuts": 0,
"max_screen_cuts_num": 0,
"is_offline": false,
"open_access_restriction": false,
"access_restriction_info": "",
"open_access_restriction_for_graph": false,
"access_restriction_info_for_graph": "",
"OCR_type": "",
"encrypt": "",
"is_manual_review": 0,
"identity_auth": 1,
"web_random_take_face_photo": 0,
"page_switch_detection": 0,
"exam_exercise_video_attachment_1G": 0,
"is_partial_participation": false,
"limit_early_submission": false,
"limit_early_submission_time": 0,
"force_confirm": false,
"max_retry": 0,
"max_retry_random": 0,
"way_of_score": 0,
"user": { "user_id": 57510820, "user_name": "张三", "school_number": "2022...", "avatar": "", "language": null },
"cut_count": 0,
"result": { "status": 0, "score": null, "incorrect_count": null, "unfinished_count": null },
"face_auth_status": { "online_proctor": 0, "has_idphoto": false },
"server_time": 1710000000,
"user_role": 2
} }

3.3.6 拉取试卷题目(接口 17,核心接口)#

整条链路的重中之重:把整张卷子拉下来。返回一个题目数组,每道题包含题干、题型、选项、分值。拿到它,后面的”自动作答”才有得下手。

GET https://changjiang-exam.yuketang.cn/exam_room/show_paper?exam_id={examID}
Header: xtbz: cloud, x-client: web
  • 请求:Query 参数 exam_id
  • 响应(完整 JSON,problems[] 为完整字段集):
{ "errcode": 0,
"errmsg": "",
"data": {
"problems": [
{
"Body": "题干,可能含 <img>HTML 图片标签",
"Type": "single_choice",
"TypeText": "单选题",
"data": {},
"Score": 5.0,
"score": 5.0,
"index": 1,
"Options": [
{ "key": "A", "value": "选项A" },
{ "key": "B", "value": "选项B" },
{ "key": "C", "value": "选项C" },
{ "key": "D", "value": "选项D" }
],
"Version": "",
"HasRemark": false,
"LibraryID": 0,
"max_retry": 0,
"TemplateID": 0,
"difficulty": 0,
"library_id": 0,
"problem_id": 92134642,
"ProblemType": 1,
"template_id": 0,
"problem_type": 1,
"isContinueWithWrong": 0,
"create_template_type": ""
}
],
"has_problem_dict": false,
"font": "",
"title": "第一次小测"
}
}
字段(problem 内)说明
problem_id题目标识,提交时原样回传
Body题干,可能带 <img> 图片标签
Type / TypeText题型标识 / 中文名
Options[].key/value选项 key(A/B/C…)与内容
index / Score题序 / 分值

题干里可能是图片形式的题(<img> 标签)。可用正则 (?i)<img[^>]*src=... 把图片 URL 抠出来,再交给多模态模型识别,或转成 Base64 后喂给模型——这就是”自动做题里的读题”环节。

3.3.7 刷新剩余时间(接口 18,心跳保活)#

答题过程中反复刷新倒计时;同时也作为一种”心跳”,让服务端认为页面还活着。

GET https://changjiang-exam.yuketang.cn/exam_room/refresh_time?exam_id={examID}
Header: xtbz: cloud, x-client: web
  • 请求:Query 参数 exam_id
  • 响应(完整 JSON):
{ "errcode": 0,
"errmsg": "",
"data": {
"has_limit": true,
"time_past": 120,
"time_left": 7080
} }

3.3.8 提交单题答案(接口 19,暂存)#

把某道题的答案”写”上去。注意:这只是暂存,不等同于交卷。一道一道地调,避免一次性把答案全塞进去。

POST https://changjiang-exam.yuketang.cn/exam_room/answer_problem
Header: xtbz: cloud, x-client: web, Content-Type: application/json
  • 请求 Body
{
"results": [
{ "problem_id": 92134642, "result": ["A"], "time": 1710000000000 }
],
"exam_id": 1945538,
"record": []
}
字段说明
results[].problem_id来自 3.3.6 的 problem_id
results[].result答案数组:单选 ["A"],多选 ["A","C"],填空 / 主观题填答案串
results[].time作答时间戳(毫秒)
exam_id考试 ID(数字型)
record空数组,占位
  • 响应(完整 JSON):
{ "errcode": 0, "errmsg": "", "data": {} }

errcode = 0 表示成功。

3.3.9 交卷(接口 20,整卷一次性提交)#

全部答案填完后,把整卷一次性正式提交。一旦提交就不可逆,务必确认答案都填完再调。

POST https://changjiang-exam.yuketang.cn/exam_room/submit_paper
Header: xtbz: cloud, x-client: web, Content-Type: application/json
  • 请求 Body(注意为 snake_caseexam_id 是字符串):
{
"exam_id": "1945538",
"results": [
{
"problem_id": 92134642,
"result": ["A"],
"time": 1710000000000,
"show_answer": "",
"is_answered": true,
"is_save": true
}
]
}
字段说明
results[].show_answer留空字符串
results[].is_answeredtrue
results[].is_savetrue

对比 3.3.8answer_problem 逐题暂存,submit_paper 整卷提交。后者多了 show_answer / is_answered / is_save 三个字段,且 exam_id 用字符串。

  • 响应(完整 JSON):
{ "errcode": 0, "errmsg": "", "data": {} }

4. 一次考试的完整链路#

把上面 20 个接口串起来,一次自动考试分四个阶段。

4.1 阶段 A:首次登录(微信扫码)#

只有在主站 cookie 没有或失效时才需要。按 1→6:

  1. GET /v/course_meta/user_info → 种下 csrftoken cookie。
  2. POST /api/v3/user/login/wechat-auth-param(Body 空)→ 得到 appIdstateredirectUri
  3. GET open.weixin.qq.com/connect/qrconnect?… → 拿 HTML,正则 /connect/qrcode/([A-Za-z0-9]+) 抠出 uid
  4. GET open.weixin.qq.com/connect/qrcode/{uid} → 拿 PNG(校验 Content-Type: image/*),渲染成二维码。
  5. 每 5s 轮询 GET lp.open.weixin.qq.com/connect/l/qrconnect?uuid={uid} → 正则 window\.wx_code='([^']+)' 提取 wx_code,取到跳出。
  6. GET /api/v3/user/login/wechat-web-callback?code={wx_code}&state={state} → 302,主站登录完成。

4.2 阶段 B:准备考试参数#

  1. GET /api/v3/user/basic-info → 确认登录态。
  2. GET /v2/api/web/courses/list?identity=2 → 取 classroom_id(cid)。
  3. GET /mooc-api/v1/lms/learn/course/chapter?cid={cid}xtbz: ykt)→ 取 leaf_id
  4. GET /v/exam/cover?exam_id={examID}&classroom_id={cid}xtbz: ykt)→ 确认 exam_id

4.3 阶段 C:进入考试并拉题#

  1. POST /v/exam/gen_token(带 X-CSRFToken,Body {"exam_id","classroom_id"})→ 取 tokenuser_idexam_host
  2. GET 考试系统 /login?crypt={token}&user_id={user_id}&next=… → 302,考试域 cookie 种下。
  3. GET 考试系统 /start/{examID}?isFrom=2 → 进考场。
  4. POST 考试系统 /exam_room/start_paper(Body {"exam_id"})→ 开始答卷,得 time_left
  5. GET 考试系统 /exam_room/show_paper?exam_id= → 得到整卷 problems[]

4.4 阶段 D:自动作答并提交#

  1. (可选)GET /exam_room/refresh_time → 心跳保活。
  2. 对每题:POST /exam_room/answer_problem(Body {"results":[{"problem_id","result","time"}],"exam_id","record":[]})→ 暂存答案。
  3. 全部完成:POST /exam_room/submit_paper(整卷 snake_case,含 show_answer/is_answered/is_save)→ 交卷。

4.5 时序图#

sequenceDiagram participant C as 客户端 participant S as 主站 changjiang.yuketang.cn participant W as 微信开放平台 participant E as 考试系统 changjiang-exam.yuketang.cn Note over C,W: 阶段 A · 首次扫码登录 C->>S: GET /v/course_meta/user_info(种 csrftoken) C->>S: POST /api/v3/user/login/wechat-auth-param → appId/state C->>W: GET /connect/qrconnect(appid=...&state=...)→ HTML 抠 uid C->>W: GET /connect/qrcode/{uid} → 二维码 PNG W-->>C: 用户微信扫码 loop 每 5s C->>W: GET /connect/l/qrconnect?uuid={uid} → wx_code end C->>S: GET /wechat-web-callback?code={wx_code}(302,登录成) Note over C,E: 阶段 C · 进入考试并拉题 C->>S: POST /v/exam/gen_token(X-CSRFToken)→ token C->>E: GET /login?crypt={token}(302,种考试域 cookie) C->>E: GET /start/{examID}?isFrom=2 C->>E: POST /exam_room/start_paper → time_left C->>E: GET /exam_room/show_paper → problems[] Note over C,E: 阶段 D · 自动作答并提交 loop 每题 Note over C,E: 读题(正则/多模态识别) C->>E: POST /exam_room/answer_problem(result) end C->>E: POST /exam_room/submit_paper(整卷入,不可逆)

顺序约束:第 12 步”考试系统登录”必须成功,其后考试域接口才可用;answer_problem 先于 submit_paper,且 submit_paper 只能调一次。

5. 相关项目#

如果你想直接看可运行的版本,下面两个仓库已把这套链路做了落地,可作为对照实现:

Auto-CQUPT-Plan
/
RainClassSDK
Waiting for api.github.com...
00K
0K
0K
Waiting...
Auto-CQUPT-Plan
/
RainClassByeBye
Waiting for api.github.com...
00K
0K
0K
Waiting...

目标平台基础地址:

https://changjiang.yuketang.cn # 主站(登录 / 鉴权 / 信息查询)
https://changjiang-exam.yuketang.cn # 考试系统(考场 / 题目 / 提交)

6. 结尾#

没有了喵,谢谢阅读

gopher
gopher

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

[Auto CQUPT Plan] 长江雨课堂自动做题(补档)
https://blog.tuf3i.cc/posts/acp-rainclass/
作者
TuF3i
发布于
2026-08-18
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
TuF3i
一只区,什么也不想写
分类
标签
站点统计
文章
16
分类
9
标签
18
总字数
43,299
运行时长
0
最后活动
0 天前

目录