A helper script for UCAS online systems.
// ==UserScript==
// @name UCAS Helper
// @namespace http://tampermonkey.net/
// @version 0.2.4
// @description A helper script for UCAS online systems.
// @author PRO-2684
// @match https://sep.ucas.ac.cn/*
// @match https://xkgo.ucas.ac.cn/*
// @match https://xkgodj.ucas.ac.cn/*
// @match https://jwxk.ucas.ac.cn/*
// @match https://xkcts.ucas.ac.cn:8443/*
// @match https://mooc.ucas.edu.cn/*
// @match https://mooc.mooc.ucas.edu.cn/*
// @match https://i.mooc.ucas.edu.cn/*
// @icon https://ucas.ac.cn/publish/xww/images/icon1.png
// @license gpl-3.0
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addValueChangeListener
// @require https://github.com/PRO-2684/GM_config/releases/download/v1.2.2/config.min.js#md5=c45f9b0d19ba69bb2d44918746c4d7ae
// ==/UserScript==
(function () {
"use strict";
const { name, version } = GM_info.script;
const identifier = `${name}@${version}`;
const $ = document.querySelector.bind(document);
const debug = console.debug.bind(console, `[${identifier}]`);
const error = console.error.bind(console, `[${identifier}]`);
const configDesc = {
$default: {
autoClose: false,
},
sep: {
name: "🔑 SEP",
title: "SEP system related helpers (sep.ucas.ac.cn)",
type: "folder",
items: {
autoLogin: {
name: "🔐 Auto login*",
title: "Choose auto login strategy, works best with browser auto-fill",
type: "enum",
options: ["None", "Focus", "Auto"],
// None: Do nothing
// Focus: Focus on the first unfilled field (username, password or captcha), or the submit button if all filled
// Auto: Automatically submit the form when all fields are filled, otherwise focus on the first unfilled field; Not working due to browser security policy
value: 1, // Default to "Focus"
},
autoFillTimeout: {
name: "⏳ Auto fill timeout",
title: "Waiting time for potential browser auto-fill (in milliseconds)",
type: "int",
value: 500,
min: 0,
max: 10000,
},
cleanerUI: {
name: "🧼 Cleaner UI",
title: "Make the navigation page cleaner (appStoreStudent)",
type: "bool",
value: false,
},
extendedEntries: {
name: "📂 Extended entries",
title: "Add more entries in flyout menus (appStoreStudent)",
type: "bool",
value: false,
},
},
},
courseSelection: {
name: "🪶 Course Selection",
title: "Course selection system related helpers (xkgo(dj).ucas.ac.cn)",
type: "folder",
items: {
courseIDs: {
name: "📃 Course IDs*",
title: "Desired courses by ID, separated by space",
value: [],
input: (_prop, orig, desc) =>
prompt(desc.title, orig.join(" ")),
processor: (_prop, input, _desc) =>
input.split(" ").filter((s) => s),
formatter: (_prop, value, desc) =>
`${desc.name}: ${value.length} selected.`,
},
selectFollowed: {
name: "☑️ Select followed*",
title: "Also select followed courses if available",
type: "bool",
value: true,
},
keepAlive: {
name: "🟢 Keep alive",
title: "Prevent session timeout by fetching the page periodically",
type: "bool",
value: false,
},
keepAliveInterval: {
name: "⏱️ Keep alive interval",
title: "Interval (in seconds) to fetch the page (only effective when 'Keep alive' is enabled)",
type: "int",
value: 5,
min: 1,
max: 600,
},
},
},
courseSchedule: {
name: "📅 Course Schedule",
title: "Course schedule system related helpers (jwxk/xkcts.ucas.ac.cn)",
type: "folder",
items: {
enterQuery: {
name: "⏎ Enter to query*",
title: "Pressing Enter in the fields will trigger the query",
type: "bool",
value: true,
},
},
},
courseEvaluation: {
name: "📝 Course Evaluation",
title: "Course evaluation system related helpers (jwxk/xkcts.ucas.ac.cn)",
type: "folder",
items: {
largerClickArea: {
name: "📐 Larger click area*",
title: "Clicking on the cell will be treated as clicking the radio button inside, and clicking on the header will select all options in that column",
type: "bool",
value: true,
},
enterSubmit: {
name: "⏎ Enter to submit*",
title: "Pressing Enter in the validation code field will submit the form",
type: "bool",
value: false,
},
addSpaces: {
name: "➕ Add spaces*",
title: "Add spaces after your answers to circumvent the 15 characters requirement",
type: "bool",
value: false,
},
},
},
mooc: {
name: "🎓 MOOC",
title: "MOOC system related helpers (mooc.ucas.edu.cn)",
type: "folder",
items: {
autoSpace: {
name: "☁️ Auto space",
title: "Automatically go to personal space when entering the portal",
type: "bool",
value: false,
},
nativeSelector: {
name: "📂 Native selector",
title: "Use the native file selector instead of the custom one, allowing drag-and-drop",
type: "bool",
value: false,
},
forceFinish: {
name: "🏁 Force finish*",
title: "Allows you to forcibly mark the file as finished, useful if you got stuck on certain files",
type: "bool",
value: false,
},
newLayout: {
name: "🆕 New layout*",
title: "Redirect to the new course layout for a better experience",
type: "bool",
value: false,
},
hideCover: {
name: "🖼️ Hide course cover",
title: "Hide the course cover in the course list for a compact view (only for new layout)",
type: "bool",
value: false,
},
},
},
};
const config = new GM_config(configDesc);
switch (location.hostname) {
case "sep.ucas.ac.cn": {
config.down("sep");
switch (location.pathname) {
case "/": {
// Login page
document.head.appendChild(
document.createElement("style"),
).textContent = `
.btn:focus { outline: thin dotted !important; }`;
const username = $("#userName1");
const password = $("#pwd1");
const captcha = $("#certCode1"); // Optional, may not exist
const submit = $("#sb1");
setTimeout(() => {
// Wait for potential auto-fill
switch (config.get("sep.autoLogin")) {
case 0: // None
break;
case 1: {
// Focus
const toFocus = getFirstUnfilled() || submit;
toFocus.focus();
break;
}
case 2: {
// Auto
const toFocus = getFirstUnfilled();
if (toFocus) {
toFocus.focus();
} else {
submit.click();
}
}
}
}, config.get("sep.autoFillTimeout"));
function getFirstUnfilled() {
// https://stackoverflow.com/a/70182698/16468609
if (!(username.value || username.matches(":autofill")))
return username;
if (!(password.value || password.matches(":autofill")))
return password;
if (captcha && !captcha.value) return captcha;
return null;
}
break;
}
case "/appStoreStudent": {
// Navigation page
setupDynamicStyles("sep", config, {
cleanerUI: `
#page-topbar, #footer, .footer, .leftServer { display: none; }
.page-content { padding-bottom: 0 !important; }
.leftMenu .absolute { background-image: none !important; }
`,
});
let addedEntries = [];
if (config.get("sep.extendedEntries")) {
const obs = new MutationObserver((mutations) => {
obs.disconnect();
addEntries();
});
obs.observe($("#businessMenuDivId"), {
childList: true,
});
}
config.addEventListener("set", (e) => {
if (e.detail.prop === "sep.extendedEntries") {
if (e.detail.after) {
addEntries();
} else {
for (const entry of addedEntries) {
entry.remove();
}
addedEntries = [];
}
}
});
/**
* @param {string} name Entry name
* @param {string} href Entry URL
* @param {string} afterUrl Insert after this link
*/
function addEntry(name, href, afterUrl) {
const base = $(`ul > a[href="${afterUrl}"]`);
const copied = base.cloneNode(false);
copied.href = href;
copied.textContent = name;
addedEntries.push(copied);
base.insertAdjacentElement("afterend", copied);
}
function addEntries() {
addEntry(
"考勤系统",
"https://sep.ucas.ac.cn/portal/site/539/001/1",
"https://sep.ucas.ac.cn/portal/site/218/1252",
); // After 在线学习 - 实景课堂
}
break;
}
default:
debug("No actions for this page:", location.href);
break;
}
break;
}
case "xkgo.ucas.ac.cn":
case "xkgodj.ucas.ac.cn": {
config.down("courseSelection");
switch (location.pathname) {
case "/courseManage/selectCourse": {
// Course selection page
const listing = $("#courseinfo");
const courses = config.get("courseSelection.courseIDs");
const selectFollowed = config.get(
"courseSelection.selectFollowed",
);
let newly_selected = false;
let focused = false;
for (const row of listing.rows) {
newly_selected ||= checkRow(row);
}
if (newly_selected && !focused) {
focus();
focused = true;
}
const obs = new MutationObserver((mutations) => {
let newly_selected = false;
for (const mutation of mutations) {
for (const row of mutation.addedNodes) {
if (row.tagName === "TR" && checkRow(row)) {
newly_selected = true;
}
}
}
if (newly_selected && !focused) {
focus();
focused = true;
}
});
obs.observe(listing, {
childList: true,
subtree: false,
attributes: false,
});
function checkRow(row) {
const id =
row.querySelector("[id^=courseCode_]")?.textContent;
const followed =
row.querySelector("[id^=fid_]")?.checked;
const concerned =
courses.includes(id) ||
(selectFollowed && followed);
if (concerned) {
const checkbox =
row.querySelector("input[name='sids']");
const name = row.children[4].textContent;
if (checkbox.checked) {
debug("Already selected:", id, name);
return false;
} else if (checkbox.disabled) {
debug("Unavailable:", id, name);
return false;
} else {
debug("[!] Available:", id, name);
checkbox.click();
row.style.filter = "invert(1)";
return true;
}
}
}
function focus() {
const verification = $("#vcode");
verification.scrollIntoView({ behavior: "instant" });
verification.focus();
verification.style.background = "red";
}
}
}
let timer = null;
if (config.get("courseSelection.keepAlive")) {
timer = setInterval(
heartbeat,
config.get("courseSelection.keepAliveInterval") * 1000,
); // Every 4 minutes
}
config.addEventListener("set", (e) => {
if (
e.detail.prop === "courseSelection.keepAlive" ||
e.detail.prop === "courseSelection.keepAliveInterval"
) {
const keepAlive = config.get("courseSelection.keepAlive");
const interval = config.get(
"courseSelection.keepAliveInterval",
);
if (keepAlive && !timer) {
timer = setInterval(heartbeat, interval * 1000);
} else if (!keepAlive && timer) {
clearInterval(timer);
timer = null;
} else if (
keepAlive &&
timer &&
e.detail.prop === "courseSelection.keepAliveInterval"
) {
clearInterval(timer);
timer = setInterval(heartbeat, interval * 1000);
}
}
});
function heartbeat() {
unsafeWindow
.fetch("/courseManage/main", {
credentials: "include",
})
.then((res) => {
if (res.ok) {
debug("Keep alive successful.");
} else {
error(
"Keep alive failed:",
res.status,
res.statusText,
);
}
})
.catch((err) => {
error("Keep alive error:", err);
});
}
break;
}
case "jwxk.ucas.ac.cn":
case "xkcts.ucas.ac.cn": {
const paths = location.pathname.split("/").slice(1);
switch (paths[0]) {
case "evaluate": {
config.down("courseEvaluation");
const firstPart = location.pathname
.split("/")
.filter((s) => s)[0];
if (firstPart !== "evaluate") {
debug("No actions for this page:", location.href);
break;
}
const form = $("#regfrm");
const table = form?.querySelector?.("table");
if (!table) {
debug("No table found, skipping...");
break;
}
if (config.get("courseEvaluation.largerClickArea")) {
const rows = Array.from(table.rows);
const headerRow = rows.splice(0, 1)[0];
const columns = Array.from(headerRow.cells).map(
() => [],
);
// Click on cell to select the radio button inside
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
for (let c = 0; c < headerRow.cells.length; c++) {
const cell = row.cells[c];
const radio =
cell?.querySelector?.("input[type=radio]");
if (radio) {
columns[c].push(radio);
cell.style.cursor = "pointer";
cell.addEventListener("click", () => {
radio.click();
});
}
}
}
// Click on header to select all in that column
for (let c = 0; c < headerRow.cells.length; c++) {
const headerCell = headerRow.cells[c];
const count = columns[c].length;
if (count > 0) {
headerCell.title = `Click to select all ${count} options in this column`;
headerCell.style.cursor = "pointer";
headerCell.addEventListener("click", () => {
for (const radio of columns[c]) {
radio.click();
}
});
}
}
}
const vcode = $("#adminValidateCode");
const submit = $("#sb1");
if (vcode && config.get("courseEvaluation.enterSubmit")) {
vcode.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
submit.click();
}
});
}
const textareas = form.querySelectorAll("textarea");
const minimumChars = 15;
if (
textareas.length > 0 &&
config.get("courseEvaluation.addSpaces")
) {
for (const textarea of textareas) {
textarea.addEventListener("change", (e) => {
const toAdd =
minimumChars - textarea.value.length;
if (toAdd > 0) {
textarea.value += " ".repeat(toAdd);
// Trigger re-validation (no need)
// unsafeWindow.jQuery(form).validate().element(textarea);
}
});
}
}
break;
}
case "course": {
config.down("courseSchedule");
if (config.get("courseSchedule.enterQuery")) {
// When the form has a submit button, pressing Enter in any field triggers the submit
// But this page uses buttons with type="button", so we just change it to "submit"
const btn = $("button[onclick='query()']");
btn.type = "submit";
}
break;
}
default:
break;
}
}
case "mooc.ucas.edu.cn": {
config.down("mooc");
switch (location.pathname) {
case "/portal": {
// Portal page
if (config.get("mooc.autoSpace")) {
location.href = "http://i.mooc.ucas.edu.cn/";
}
}
case "/fyportal/courselist/course": {
// Hub page - replace course links to new layout
const courseList = $("#stuCourseList");
courseList.addEventListener("click", onClick);
courseList.addEventListener("auxclick", onClick);
const processedAttr = "ucas-helper-processed";
function onClick(e) {
const link = e.target.closest(
".course-list > .w_couritem a",
);
if (link.hasAttribute(processedAttr)) {
return;
}
// Extract courseId from the link
const url = new URL(link.href);
const courseId = url.searchParams.get("courseId");
const classId = url.searchParams.get("clazzId");
const personId = url.searchParams.get("cpi");
// Modify link to new layout page /visit/stucoursemiddle?courseid=${courseId}&clazzid=${classId}&cpi=${personId}&...
link.href = `https://mooc.mooc.ucas.edu.cn/visit/stucoursemiddle?courseid=${courseId}&clazzid=${classId}&cpi=${personId}&ismooc2=1&pageHeader=-1&skipFaceTimeStamp=&skipFaceEnc=&taskrefId=&workOrExam=`;
link.toggleAttribute(processedAttr, true);
}
// Hide course cover
setupDynamicStyles("mooc", config, {
hideCover: `#stuCourseList > .course-list > li.w_couritem {
height: auto;
> .course-cover { display: none; }
}`,
});
break;
}
default:
debug("No actions for this page:", location.href);
break;
}
break;
}
case "mooc.mooc.ucas.edu.cn": {
config.down("mooc");
switch (location.pathname) {
case "/mooc-ans/js/editor20150812/dialogs/attachment_new/attachment.html": {
// Answer upload page
setupDynamicStyles("mooc", config, {
nativeSelector: `
#filePickerReady {
> .webuploader-pick { display: none !important; }
> div[id^="rt_rt_"] {
position: static !important;
width: auto !important;
height: auto !important;
> input.webuploader-element-invisible {
position: static !important;
clip: auto;
border-color: gray;
border-style: dashed;
border-radius: 0.5em;
padding: 0.5em;
transition: border-color 0.2s ease-in-out;
&:focus, &:hover {
border-color: black;
}
&::file-selector-button {
background-color: transparent;
border-radius: 8px;
color: black;
border: 1px solid;
border-color: gray;
height: 2em;
transition: background-color 0.2s ease-in-out;
}
&::file-selector-button:hover {
background-color: lightgray;
}
}
}
}
`,
});
break;
}
case "/ananas/modules/pdf/index.html": {
// PDF viewer page
if (config.get("mooc.forceFinish")) {
const anchor = $("#docContainer");
const button = document.createElement("button");
button.textContent = "🏁 Force finish";
button.style.position = "fixed";
button.style.bottom = "0.5em";
button.style.left = "0.5em";
button.addEventListener("click", () => {
// Don't know why, but they inverted the logic
// See https://mooc.mooc.ucas.edu.cn/ananas/modules/pdf/index.html and search for `!checkJobFinish() && finishJob()`
if (!unsafeWindow.checkJobFinish()) {
unsafeWindow.finishJob();
button.disabled = true;
button.textContent = "✅ Finished";
} else {
alert(
"Cannot finish yet. Please make sure you have viewed all pages.",
);
}
});
anchor.insertAdjacentElement("afterend", button);
}
break;
}
default: {
debug("No actions for this page:", location.href);
break;
}
}
break;
}
case "i.mooc.ucas.edu.cn": {
switch (location.pathname) {
case "/space/index": {
// Personal space page - redirect the hub iframe to use new layout
const newLayout = config.get("mooc.newLayout");
if (newLayout) {
const iframe = $("#frame_content");
if (iframe) {
iframe.src =
"https://mooc.ucas.edu.cn/fyportal/courselist/course?version=1";
}
}
break;
}
default:
debug("No actions for this page:", location.href);
break;
}
break;
}
default: {
error("Unsupported page:", location.href);
break;
}
}
// Helper functions
function injectCSS(prefix, name, style) {
const css = document.head.appendChild(document.createElement("style"));
css.id = `ucas-helper-${prefix}-${name}`;
css.textContent = style;
}
function toggleCSS(prefix, name, style, enabled) {
const css = $(`#ucas-helper-${prefix}-${name}`);
if (css) {
css.disabled = !enabled;
} else if (enabled) {
injectCSS(prefix, name, style);
}
}
function setupDynamicStyles(prefix, config, styles) {
for (const name in styles) {
toggleCSS(
prefix,
name,
styles[name],
config.proxy[`${prefix}.${name}`],
);
}
config.addEventListener("set", (e) => {
if (e.detail.prop.startsWith(`${prefix}.`)) {
const name = e.detail.prop.split(".")[1];
if (name in styles) {
toggleCSS(prefix, name, styles[name], e.detail.after);
}
}
});
}
})();