UCAS Helper

A helper script for UCAS online systems.

// ==UserScript==
// @name         UCAS Helper
// @namespace    http://tampermonkey.net/
// @version      0.1.6
// @description  A helper script for UCAS online systems.
// @author       PRO-2684
// @match        https://sep.ucas.ac.cn/*
// @match        http://xkgo.ucas.ac.cn:3000/*
// @match        https://xkcts.ucas.ac.cn:8443/evaluate/*
// @match        https://mooc.ucas.edu.cn/portal
// @match        https://mooc.mooc.ucas.edu.cn/mooc-ans/js/*
// @match        https://mooc.mooc.ucas.edu.cn/ananas/modules/pdf/index.html*
// @icon         http://ucas.ac.cn/favicon.ico
// @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.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,
                },
            }
        },
        courseEvaluation: {
            name: "📝 Course Evaluation",
            title: "Course evaluation system related helpers (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,
                },
            },
        },
        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,
                },
            },
        },
    };
    const config = new GM_config(configDesc);

    switch (location.host) {
        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:3000": {
            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;
                    for (const row of listing.rows) {
                        newly_selected ||= checkRow(row);
                    }
                    if (newly_selected) {
                        focus();
                    }

                    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) {
                            focus();
                        }
                    });
                    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.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("http://xkgo.ucas.ac.cn:3000/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 "xkcts.ucas.ac.cn:8443": {
            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();
                    }
                });
            }
            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/";
                    }
                }
                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;
        }
        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);
                }
            }
        });
    }
})();