UCAS Helper

A helper script for UCAS online systems.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
                }
            }
        });
    }
})();