learn_dashboard_prototype.js

#

这个油猴脚本展示了如何用代码 自动获取清华大学网络学堂上的用户数据。 启动此脚本后访问网络学堂,它会:

  1. 自动登陆、跳转至课程列表界面
  2. 获取课程列表并吐出一大堆日志
  3. 在课程列表最下方显示一个表格,列出你上过所有课的课程文件

大概像这样。

屏幕截图

网页文档见这里, 文件请移步 Github,把它丢到油猴里就能跑。

#

为啥?

暑假开始的时候,Learn HelperLearnX 都崩了。 顺藤摸瓜找到作者写的 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";

#

脚本设置

最大文件大小大概是 200MB(这个数字直接传进API里,单位我是猜的)

  const MAX_FILE_SIZE = 200;
#

语言设置,zh 或 en 。

  const LANG = "zh";
#

课程类型,student 或 teacher 。必须在这里设置,因为“常量”部分需要根据这个选择合适的 API。

  const COURSE_TYPE = "student";

#

常量

这些常量来自 API,是脚本访问课堂文件需要访问的地址。(很长!)需要用到:

  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);

#

工具函数

最爱工具函数了!

logWarn 在出现问题(不ok)时发出警告、打印堆栈,否则正常打印日志。

  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 对象外,它还检查了 messageresult 字段,看看这次 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));
})();
#

你竟然看到了这里,我的朋友!真是一个热爱阅读的大善人。看完之后如果有什么问题或者想法, 务必在 Github 上 或者发邮件联系我。祝你一天好心情!