这个油猴脚本展示了如何用代码 自动获取清华大学网络学堂上的用户数据。 启动此脚本后访问网络学堂,它会:
大概像这样。
暑假开始的时候,Learn Helper 与 LearnX 都崩了。 顺藤摸瓜找到作者写的 API 库 thu-learn-lib, 发现 API 或许需要大改(见此 issue), 因为 thu-learn-lib 依赖的登陆方式失效了。真是伤心!
学校的各网络平台(如网络学堂)都有前端和后端,前端运行在浏览器里,后端运行在服务器上。 身份验证通过后,浏览器通过 API 与后端通信,就能让服务器把数据发给它。 现有的工具通过伪装成浏览器获取服务器上的数据,相当于绕过了前端;Learn Helper, LearnX 还有 THU Info 都通过这一原理工作。
这条脚本展示了另一个方法。通过油猴,该脚本能在浏览器里运行额外的代码,从而直接修改前端的行为, 实现了和现有工具类似的功能,也能从服务器获取数据。这样一来:
于是应用需要做的事情更少,实现想要的功能也更快;但也会遇到浏览器的限制, 比如跨域访问,文件读写之类的。所以这个方法只适用于简单功能。 好在查看网络学堂课程信息是一个简单功能!
抛砖引玉,希望大家能受此启发写出更好的工具,让网络学堂用起来更舒服。
—— 临时普通土豆于 用 dok.py 生成
首先是油猴需要的一些元数据:
==UserScript==
@name THU Learn Dashboard Prototype
@namespace http://tampermonkey.net/
@version 2025-06-26
@description making learn.tsinghua.edu.cn a better place
@author Peter Huang
@match https://learn.tsinghua.edu.cn/*
@icon https://www.google.com/s2/favicons?sz=64&domain=tsinghua.edu.cn
@grant none
==/UserScript==
await (async function () {
"use strict";
const MAX_FILE_SIZE = 200;
语言设置,zh 或 en 。
const LANG = "zh";
课程类型,student 或 teacher 。必须在这里设置,因为“常量”部分需要根据这个选择合适的 API。
const COURSE_TYPE = "student";
const LEARN_PREFIX = "https://learn.tsinghua.edu.cn";
学期列表与当前学期。
const LEARN_SEMESTER_LIST = `${LEARN_PREFIX}/b/wlxt/kc/v_wlkc_xs_xktjb_coassb/queryxnxq`;
const LEARN_CURRENT_SEMESTER = `${LEARN_PREFIX}/b/kc/zhjw_v_code_xnxq/getCurrentAndNextSemester`;
给定学期,获得该学期课程列表。
const LEARN_COURSE_LIST =
COURSE_TYPE === "student"
? (semester) =>
`${LEARN_PREFIX}/b/wlxt/kc/v_wlkc_xs_xkb_kcb_extend/student/loadCourseBySemesterId/${semester}/${LANG}`
: (semester) =>
`${LEARN_PREFIX}/b/kc/v_wlkc_kcb/queryAsorCoCourseList/${semester}/0`;
给定课程 ID,获得该课程的文件列表。
const LEARN_FILE_LIST =
COURSE_TYPE === "student"
? (courseID) => [
返回的是一个列表,因为它需要增加一些查询参数。(不美观,代码有待改进)
`${LEARN_PREFIX}/b/wlxt/kj/wlkc_kjxxb/student/kjxxbByWlkcidAndSizeForStudent`,
{
search: {
wlkcid: courseID,
size: MAX_FILE_SIZE,
},
},
]
: (courseID) => [
`${LEARN_PREFIX}/b/wlxt/kj/v_kjxxb_wjwjb/teacher/queryByWlkcid`,
{ search: { wlkcid: courseID, size: MAX_FILE_SIZE } },
];
给定课程 ID,获得课程的文件分类列表、文件下载链接。这俩现在没用上
const LEARN_FILE_CATEGORY_LIST = (courseID) => [
`${LEARN_PREFIX}/b/wlxt/kj/wlkc_kjflb/${COURSE_TYPE}/pageList`,
{
search: {
wlkcid: courseID,
},
},
];
const LEARN_FILE_DOWNLOAD = (fileID) => [
`${LEARN_PREFIX}/b/wlxt/kj/wlkc_kjxxb/${COURSE_TYPE}/downloadFile`,
{
search: {
wjid: fileID,
sfgk: 0,
},
},
];
给定文件 ID 与内容类型,获得文件预览链接。
const ContentType = [
"notification",
"file",
"homework",
"discussion",
"question",
"questionnaire",
];
API 需要这乱码,它们与上面的 ContentType 一一对应,
const MKs = ["kcgg", "kcwj", "kczj", "", "", ""];
const LEARN_FILE_PREVIEW = (contentType, fileID, firstOrAll) => [
`${LEARN_PREFIX}/f/wlxt/kc/wj_wjb/${COURSE_TYPE}/beforePlay`,
{
search: {
wjid: fileID,
如此。
mk: `mk_${MKs[ContentType.indexOf(contentType)]}`,
browser: -1,
sfgk: 0,
pageType: firstOrAll,
},
},
];
if (document.location.href === `${LEARN_PREFIX}/f/login`) {
const button = document.getElementById("loginButtonId");
if (button) {
console.log("logging in...");
document.getElementById("loginButtonId").click();
return;
}
}
脚本只在课程列表页面运行;如果不在就停止。
if (
document.location.href !==
`${LEARN_PREFIX}/f/wlxt/index/course/${COURSE_TYPE}/`
) {
console.log("Not on main page, exiting...");
return;
}
如果我们已经登陆了,页面上会有一个
CSRF token,
储存在全局变量 csrf_token
里。访问 API 时仅需要这个 token,相当方便。
let token = null;
try {
token = csrf_token;
} catch (e) {
没找到 token 就重新登陆。
console.error("failed to get csrf_token, relogging in...");
top.document.location = "/";
return;
}
console.log("Already logged in, csrf_token:", token);
function logWarn(ok, ...args) {
if (ok) console.log(...args);
else {
console.warn(...args);
console.trace();
}
}
u
用于生成带查询参数的 URL,这样其他代码能用字典表示查询参数。
const u = (url, search) => `${url}?${new URLSearchParams(search)}`;
g
用于发起 GET 请求,自动添加上面说的 CSRF token。
function g(url, opts) {
if (opts === undefined) opts = {};
const search = { ...opts.search, _csrf: token };
return fetch(u(url, search));
}
get
, getL
, getJ
封装了 g
,在出问题时会警告你。get
返回原始响应,
const get = (url, opts) =>
g(url, opts).then((res) => {
logWarn(res.ok, `GET`, url, res);
return res;
});
getL
检查并返回一个列表,
const getL = (url, opts) =>
g(url, opts)
.then((res) => res.json())
.then((list) => {
logWarn(Array.isArray(list), `GET list`, url, list);
return list;
});
getJ
检查并返回一个 JSON 对象。大部分 API 都返回 JSON 对象。
const getJ = (url, opts) =>
g(url, opts)
.then((res) => res.json())
.then((json) => {
logWarn(
除了保证是 JSON 对象外,它还检查了 message
和 result
字段,看看这次 API 调用成没成功。
[json.message, json.result].includes("success"),
`GET json`,
url,
json
);
return json;
});
now
返回当前时间,atNow
给对象添加一个 createdAt
字段代表现在时间。
const now = () => new Date();
function atNow(obj) {
obj.createdAt = now();
return obj;
}
const notNil = (x) => x !== null && x !== undefined;
h1
创建一个 HTML 元素,
function h1(tag, attrs = {}, children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key.startsWith("on"))
el.addEventListener(key.slice(2).toLowerCase(), value);
else if (key === "style") {
for (const [skey, svalue] of Object.entries(svalue))
el.style[skey] = svalue;
} else el.setAttribute(key, value);
}
for (const child of children) {
if (typeof child === "string")
el.appendChild(document.createTextNode(child));
else el.appendChild(child);
}
return el;
}
h
则利用 h1
,把一个列表转换成一个 HTML 元素。
const h = (x) => (typeof x === "string" ? x : h1(x[0], x[1], x[2].map(h)));
首先获取当前学期。
const currentSemester = (await getJ(LEARN_CURRENT_SEMESTER)).result;
学期列表中可能出现 null,要去掉。
const semesterIDs = (await getL(LEARN_SEMESTER_LIST)).filter(notNil);
const courses = (
await Promise.all(
对每一个学期,
semesterIDs.map((id) =>
获取该学期的课程列表,
getJ(LEARN_COURSE_LIST(id)).then((json) =>
json.resultList.filter(notNil).map(atNow)
)
)
然后把它们整合成一个大列表,就得到了所有课程。
)
).flat();
const files = (
await Promise.all(
对每一个课程,
courses.map((course) =>
获取该课程的文件列表(要用...
因为是个列表),
getJ(...LEARN_FILE_LIST(course.wlkcid)).then((json) =>
json.object.filter(notNil).map(atNow)
)
)
然后整合就得到了所有文件。
)
).flat();
console.log(currentSemester, semesterIDs, courses, files);
const fileTableHead = ["课程名", "文件名", "预览"].map((t) => [
"th",
{},
[t],
]);
const fileTableBody = files
好吧,其实只显示前 1000 个。相信你数不出区别 ;)
.slice(0, Math.min(1000, files.length))
(如果只想显示本学期的文件,取消下面的注释。本学期文件似乎一直在最上方。)
.filter(
(file) =>
courses.filter((c) => c.wlkcid === file.wlkcid)[0].xnxq ===
currentSemester.xnxq
)
.map((file) => {
每一行包含课程名、文件名和预览链接。
const [url, opts] = LEARN_FILE_PREVIEW("file", file.wjid, "first");
return [
file.wlkcid,
file.bt,
["a", { href: u(url, opts.search) }, ["预览"]],
制表。
].map((x) => ["td", {}, [x]]);
})
.map((arr) => ["tr", {}, arr]);
const fileTable = [
"table",
{},
[
["thead", {}, fileTableHead],
["tbody", {}, fileTableBody],
],
];
console.log(fileTable);
最后,把表格添加到课程列表底部就大功告成!
document.getElementById("selfcourse").appendChild(h(fileTable));
})();