HKUST Course Scraper & API Interceptor

Scrape course data and intercept planner API locally

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         HKUST Course Scraper & API Interceptor
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  Scrape course data and intercept planner API locally
// @match        https://admlu65.ust.hk/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @connect      w5.ab.ust.hk
// @connect      admlu65.ust.hk
// @run-at       document-start
// @author       Atopos
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ==================== DOM 安全方法(绕过页面 monkey patch) ====================
    // 一些站点会重写 appendChild/insertBefore 导致调用时报奇怪的 SyntaxError。
    // 在 document-start 保存原生引用,然后后续统一用 safeAppend/safeInsertBefore。
    const __nativeAppendChild = Node.prototype.appendChild;
    const __nativeInsertBefore = Node.prototype.insertBefore;
    const __nativeInnerHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');

    function safeSetInnerHTML(element, html) {
        if (!element) return;
        if (!__nativeInnerHTML || !__nativeInnerHTML.set) {
            element.innerHTML = html;
            return;
        }
        __nativeInnerHTML.set.call(element, html);
    }

    function safeAppend(parent, child) {
        if (!parent || !child) return;
        return __nativeAppendChild.call(parent, child);
    }

    function safeInsertBefore(parent, child, before) {
        if (!parent || !child) return;
        return __nativeInsertBefore.call(parent, child, before || null);
    }

    function getBodyOrRoot() {
        return document.body || document.documentElement;
    }

    function setOnclickById(id, handler) {
        const el = document.getElementById(id);
        if (el) el.onclick = handler;
    }

    // ==================== 通用工具 ====================

    let debugMode = GM_getValue('debugMode', false);

    function debugLog(category, message, data = null) {
        if (!debugMode) return;
        const ts = new Date().toISOString();
        console.log(`[DEBUG ${ts}] [${category}]`, message);
        if (data !== null && data !== undefined) console.log('Data:', data);
    }

    function generateHash() {
        const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        let out = '';
        for (let i = 0; i < 32; i++) out += chars.charAt(Math.floor(Math.random() * chars.length));
        return out;
    }

    function getDefaultTerm() {
        const now = new Date();
        const year = now.getFullYear();
        const month = now.getMonth() + 1;

        let termYear = year;
        let termCode = '10';

        if (month >= 7 && month <= 11) termCode = '10';           // Fall
        else if (month === 12) termCode = '20';                   // Winter
        else if (month >= 1 && month <= 5) { termYear = year - 1; termCode = '30'; } // Spring
        else if (month === 6) { termYear = year - 1; termCode = '40'; }              // Summer

        return String(termYear).slice(-2) + termCode;
    }

    function termCodeToHuman(termCode) {
        const s = String(termCode || '');
        const m = s.match(/^(\d{2})(10|20|30|40)$/);
        if (!m) return s;

        const yy = parseInt(m[1], 10);
        const code = m[2];
        const map = { '10': 'Fall', '20': 'Winter', '30': 'Spring', '40': 'Summer' };

        const nowYear = new Date().getFullYear();
        const baseCentury = Math.floor(nowYear / 100) * 100;
        let fullYear = baseCentury + yy;
        if (fullYear > nowYear + 1) fullYear -= 100;

        return `${fullYear} ${map[code] || code}`;
    }

    function getCookieValue(name) {
        const target = `${encodeURIComponent(name)}=`;
        const parts = String(document.cookie || '').split(/;\s*/);
        for (const p of parts) {
            if (p.startsWith(target)) return p.slice(target.length);
        }
        return '';
    }

    function getCsrfTokenFromDocument() {
        const meta = document.querySelector('meta[name="csrf_token"], meta[name="csrf-token"], meta[name="csrf"]');
        const metaToken = meta?.getAttribute('content') || '';
        if (metaToken) return metaToken;

        const inputToken = document.querySelector('input[name="_token"]')?.getAttribute('value') || '';
        if (inputToken) return inputToken;

        const xsrf = getCookieValue('XSRF-TOKEN');
        if (xsrf) {
            try { return decodeURIComponent(xsrf); } catch { return xsrf; }
        }

        return '';
    }

    async function fetchCsrfTokenByRequest() {
        try {
            const resp = await originalFetch('https://admlu65.ust.hk/', { method: 'GET', credentials: 'include' });
            const html = await resp.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');
            const meta = doc.querySelector('meta[name="csrf_token"], meta[name="csrf-token"], meta[name="csrf"]');
            const metaToken = meta?.getAttribute('content') || '';
            if (metaToken) return metaToken;
            const inputToken = doc.querySelector('input[name="_token"]')?.getAttribute('value') || '';
            return inputToken || '';
        } catch {
            return '';
        }
    }

    function buildPlannerCsrfHeaders(token) {
        const t = String(token || '');
        if (!t) return {};
        return {
            'X-CSRF-Token': t,
            'X-XSRF-TOKEN': t,
            'X-Requested-With': 'XMLHttpRequest'
        };
    }

    function getInterceptedTerms() {
        const v = GM_getValue('interceptedTerms', []);
        return Array.isArray(v) ? v : [];
    }

    function setInterceptedTerms(terms) {
        const t = Array.isArray(terms) ? terms : [];
        GM_setValue('interceptedTerms', t);
        debugLog('INTERCEPTOR', 'Updated intercepted terms', t);
    }

    function shouldIntercept(term) {
        const ok = !!term && getInterceptedTerms().includes(term);
        debugLog('INTERCEPTOR', `Should intercept ${term}?`, ok);
        return ok;
    }

    function normalizeBodyToUrlEncoded(body) {
        if (body == null) return '';
        if (typeof body === 'string') return body;
        if (body instanceof URLSearchParams) return body.toString();

        if (typeof FormData !== 'undefined' && body instanceof FormData) {
            const usp = new URLSearchParams();
            for (const [k, v] of body.entries()) usp.append(k, String(v));
            return usp.toString();
        }

        try {
            return new URLSearchParams(body).toString();
        } catch {
            return '';
        }
    }

    function safeUrl(url) {
        try { return new URL(String(url), window.location.href); }
        catch { return null; }
    }

    function getCurrentTermFromPage() {
        const m = window.location.pathname.match(/\/(\d{4})(?:\/|$)/);
        const term = m ? m[1] : null;
        debugLog('INTERCEPTOR', 'Current term from page', term);
        return term;
    }

    function extractTermFromPath(pathname) {
        if (!pathname) return null;
        const m = String(pathname).match(/\/(\d{4})(?:\/|$)/);
        return m ? m[1] : null;
    }

    function extractTermFromAny(u, body) {
        const fromPath = extractTermFromPath(u?.pathname);
        if (fromPath) return fromPath;

        const qs = u?.searchParams;
        const fromQs = qs?.get('semester') || qs?.get('term') || qs?.get('sem') || null;
        if (fromQs && /^\d{4}$/.test(fromQs)) return fromQs;

        const bodyParams = new URLSearchParams(normalizeBodyToUrlEncoded(body));
        const fromBody = bodyParams.get('semester') || bodyParams.get('term') || bodyParams.get('sem') || null;
        if (fromBody && /^\d{4}$/.test(fromBody)) return fromBody;

        const fromPage = getCurrentTermFromPage();
        if (fromPage && /^\d{4}$/.test(fromPage)) return fromPage;

        return null;
    }

    function extractHashFromPath(pathname) {
        if (!pathname) return null;
        const parts = String(pathname).split('/').filter(Boolean);
        if (parts.length >= 3 && parts[0] === 'planner' && ['save', 'delete', 'rename'].includes(parts[1])) {
            return parts[2] || null;
        }
        if (parts.length >= 3 && parts[0] === 'sis' && parts[1] === 'shopping-cart') return parts[2] || null;
        return null;
    }

    // ==================== 页面学期下拉扩充(近四年) ====================

    function ensureSemesterDropdownYears() {
        const ul = document.querySelector('ul.semester-dropdown');
        if (!ul) return;

        const nowYear = new Date().getFullYear(); // 例如 2026
        const want = [];
        for (let i = 0; i < 4; i++) {
            const startYear = nowYear - i; // 最新为 nowYear~nowYear+1
            const dataYear = String(startYear).slice(-2); // "26"
            const label = `${startYear}-${String(startYear + 1).slice(-2)}`; // "2026-27"
            want.push({ dataYear, label });
        }

        const existing = new Set(
            Array.from(ul.querySelectorAll('li[data-year]'))
                .map(li => String(li.getAttribute('data-year') || '').trim())
                .filter(Boolean)
        );

        const toAdd = want.filter(x => !existing.has(x.dataYear));
        if (toAdd.length === 0) return;

        const makeLi = ({ dataYear, label }) => {
            const li = document.createElement('li');
            li.setAttribute('data-year', dataYear);

            const h5 = document.createElement('h5');
            h5.textContent = label;
            safeAppend(li, h5);

            ['Fall', 'Winter', 'Spring', 'Summer'].forEach(t => {
                const a = document.createElement('a');
                a.textContent = t;
                safeAppend(li, a);
            });

            return li;
        };

        const lis = Array.from(ul.querySelectorAll('li[data-year]'));
        const getNum = (li) => parseInt(li.getAttribute('data-year') || '0', 10) || 0;

        toAdd.forEach(item => {
            const newLi = makeLi(item);
            const newNum = parseInt(item.dataYear, 10) || 0;

            // 找到第一个 data-year 小于 newNum 的位置插入(保持降序:26,25,24,23)
            const insertBefore = lis.find(li => getNum(li) < newNum) || null;
            safeInsertBefore(ul, newLi, insertBefore);

            lis.push(newLi);
            lis.sort((a, b) => getNum(b) - getNum(a));
        });

        // 强制启用被站点置灰的学期(站点点击事件会检查 disabled="disabled" 并直接拒绝)
        ul.querySelectorAll('a[disabled], a[aria-disabled="true"]').forEach(a => {
            a.removeAttribute('disabled');
            a.removeAttribute('aria-disabled');
        });

        debugLog('UI', 'semester-dropdown years ensured', { added: toAdd.map(x => x.label) });
    }

    function installSemesterDropdownObserver() {
        const obs = new MutationObserver(() => {
            try { ensureSemesterDropdownYears(); } catch { /* ignore */ }
        });
        obs.observe(document.documentElement, { childList: true, subtree: true });
        ensureSemesterDropdownYears();
    }

    // ==================== selector 搜索(模糊) ====================

    function normalizeSearchValue(s) {
        return String(s || '')
            .trim()
            .toUpperCase()
            .replace(/\s+/g, '');
    }

    function splitAlphaNumeric(q) {
        const qq = normalizeSearchValue(q);
        const letters = (qq.match(/[A-Z]+/g) || []).join('');
        const digits = (qq.match(/\d+/g) || []).join('');
        return { qq, letters, digits };
    }

    function courseMatchesQuery(course, q) {
        if (!course || !course.metadata) return false;
        const { qq, letters, digits } = splitAlphaNumeric(q);
        if (!qq) return false;

        const subj = normalizeSearchValue(course.metadata.subject || '');
        const codeNorm = normalizeSearchValue(String(course.metadata.code || '').trim());
        const titleNorm = normalizeSearchValue(course.title || '');
        const valueNorm = normalizeSearchValue(course.value || '');

        if (valueNorm.includes(qq) || titleNorm.includes(qq) || (subj + codeNorm).includes(qq)) return true;

        if (letters && !digits) {
            if (subj.startsWith(letters)) return true;
            if (titleNorm.includes(letters)) return true;
            return false;
        }

        if (!letters && digits) {
            if (codeNorm.includes(digits)) return true;
            return false;
        }

        if (letters && digits) {
            if (subj.startsWith(letters) && codeNorm.includes(digits)) return true;
            if (codeNorm.includes(digits) && (subj.includes(letters) || titleNorm.includes(letters))) return true;
            return false;
        }

        return false;
    }

    function searchCourses(termData, query, limit = 50) {
        const out = [];
        const q = String(query || '').trim();
        if (!q) return out;

        const subjects = Object.keys(termData || {});
        for (const s of subjects) {
            const list = termData[s];
            if (!Array.isArray(list)) continue;

            for (const c of list) {
                if (courseMatchesQuery(c, q)) {
                    out.push(c);
                    if (out.length >= limit) return out;
                }
            }
        }
        return out;
    }

    // ==================== 课程爬取 ====================

    async function fetchSubjectList(term) {
        return new Promise((resolve, reject) => {
            const url = `https://w5.ab.ust.hk/wcq/cgi-bin/${term}/`;
            debugLog('SCRAPER', `Fetching subject list from: ${url}`);

            GM_xmlhttpRequest({
                method: 'GET',
                url,
                onload: (resp) => {
                    try {
                        const doc = new DOMParser().parseFromString(resp.responseText, 'text/html');
                        const subjects = [];
                        for (const a of doc.querySelectorAll('a[href*="subject/"]')) {
                            const m = a.href.match(/subject\/([A-Z]+)/);
                            if (m) subjects.push(m[1]);
                        }
                        resolve([...new Set(subjects)].sort());
                    } catch (e) {
                        debugLog('SCRAPER', 'Error parsing subjects', e);
                        reject(e);
                    }
                },
                onerror: (e) => {
                    debugLog('SCRAPER', 'Network error', e);
                    reject(e);
                }
            });
        });
    }

    function parseTime(timeStr) {
        const m = String(timeStr).match(/(\d{1,2}):(\d{2})(AM|PM)/);
        if (!m) return null;
        let hour = parseInt(m[1], 10);
        const minute = parseInt(m[2], 10);
        const ap = m[3];
        if (ap === 'PM' && hour !== 12) hour += 12;
        if (ap === 'AM' && hour === 12) hour = 0;
        return hour * 100 + minute;
    }

    function parseDayOfWeek(dayStr) {
        const map = { Mo: 1, Tu: 2, We: 3, Th: 4, Fr: 5, Sa: 6, Su: 0 };
        return map[dayStr] ?? null;
    }

    function parseCourse(courseDiv) {
        const subjectDiv = courseDiv.querySelector('.subject');
        if (!subjectDiv) return null;

        const fullTitle = subjectDiv.textContent.trim();
        const m = fullTitle.match(/^([A-Z]+)\s+(\d+[A-Z]*)\s+-\s+(.+?)\s+\((\d+)\s+units?\)/);
        if (!m) return null;

        const [, subject, code, name, credits] = m;

        const rawText = (courseDiv.textContent || '');
        const matchingRequired = /Matching between Lecture\s*&\s*(Lab|Tutorial) required/i.test(rawText);

        const courseInfo = {
            type: 'timetable-planner',
            value: subject + code,
            title: `${subject} ${code}`,
            subtitle: name,
            half: false,
            icon: '',
            is_favourited: false,
            metadata: {
                credits: parseInt(credits, 10),
                show_flags: false,
                taken: false,
                prerequisite: true,
                corequisite: true,
                exclusion: true,
                review_count: null,
                id: null,
                subject,
                code,
                name,
                matching: !!matchingRequired,
                is_favourited: false,
                is_subscribed: null,
                sections: [],
                is_planned: false,
                quota: 0,
                waitlist: false,
                category_short: '',
                category_long: ''
            }
        };

        const normalizeWs = (s) => String(s || '').replace(/\s+/g, ' ').trim();

        const sectionsTable = courseDiv.querySelector('table.sections');
        const allRows = sectionsTable ? Array.from(sectionsTable.querySelectorAll('tr')) : [];

        for (let i = 0; i < allRows.length; i++) {
            const row = allRows[i];
            if (!row.classList.contains('newsect')) continue;

            const sectionCell = row.cells[0];
            const quotaCell = row.cells[5];
            const enrollCell = row.cells[6];
            const availCell = row.cells[7];
            const waitCell = row.cells[8];

            if (!sectionCell) continue;

            const sm = normalizeWs(sectionCell.textContent).match(/^([A-Z]+\d+[A-Z]*)\s*\((\d+)\)/);
            if (!sm) continue;

            const [, sectionName, sectionNumber] = sm;

            const section = {
                section: sectionName,
                number: parseInt(sectionNumber, 10),
                quota: parseInt(normalizeWs(quotaCell?.textContent) || 0, 10),
                enrolled: parseInt(normalizeWs(enrollCell?.textContent) || 0, 10),
                available: parseInt(normalizeWs(availCell?.textContent) || 0, 10),
                waitlist: parseInt(normalizeWs(waitCell?.textContent) || 0, 10),
                sessions: []
            };

            // 同一个 section 可能有多行时间:mainRow + 若干 otherRow(直到下一个 newsect)
            const timeRows = [row];
            let j = i + 1;
            while (j < allRows.length && !allRows[j].classList.contains('newsect')) {
                const r = allRows[j];
                // 跳过移动端展开行
                if (r.classList.contains('mobileInstructorRow') || r.classList.contains('mobileViewDetail')) {
                    j++;
                    continue;
                }

                // otherRow 通常第 2 列是时间;如果像时间段则收进来
                const timeText = normalizeWs(r.cells?.[1]?.textContent);
                if (timeText && /\d{1,2}:\d{2}[AP]M\s*-\s*\d{1,2}:\d{2}[AP]M/i.test(timeText)) {
                    timeRows.push(r);
                }

                j++;
            }
            i = j - 1;

            for (const tr of timeRows) {
                const dateTimeText = normalizeWs(tr.cells?.[1]?.textContent);
                let roomText = normalizeWs(tr.cells?.[2]?.textContent);
                if (roomText.toUpperCase() === 'TBA') roomText = '';

                const instructors = [];
                (tr.cells?.[3]?.querySelectorAll?.('a') || []).forEach(a => {
                    const t = normalizeWs(a.textContent);
                    if (t) instructors.push(t);
                });

                const tm = dateTimeText.match(/([A-Za-z]+)\s+(\d{1,2}:\d{2}[AP]M)\s*-\s*(\d{1,2}:\d{2}[AP]M)/);
                if (tm) {
                    const days = (tm[1].match(/.{2}/g) || []).map(d => d.trim()).filter(Boolean);
                    const start = parseTime(tm[2]);
                    const end = parseTime(tm[3]);
                    for (const d of days) {
                        const dow = parseDayOfWeek(d);
                        if (dow !== null && start !== null && end !== null) {
                            section.sessions.push({
                                day_of_week: dow,
                                start,
                                end,
                                venue: roomText,
                                instructors
                            });
                        }
                    }
                }
            }

            if (section.sessions.length === 0) {
                section.sessions.push({
                    day_of_week: 0,
                    start: 0,
                    end: 0,
                    venue: '',
                    instructors: []
                });
            }

            courseInfo.metadata.sections.push(section);
            courseInfo.metadata.quota += section.quota;
            if (section.waitlist > 0) courseInfo.metadata.waitlist = true;
        }

        return courseInfo;
    }

    async function scrapeSubject(term, subject, progressCallback) {
        return new Promise((resolve, reject) => {
            const url = `https://w5.ab.ust.hk/wcq/cgi-bin/${term}/subject/${subject}`;
            debugLog('SCRAPER', `Fetching courses for ${subject}`, { url });

            GM_xmlhttpRequest({
                method: 'GET',
                url,
                onload: (resp) => {
                    try {
                        const doc = new DOMParser().parseFromString(resp.responseText, 'text/html');
                        const courses = [];
                        doc.querySelectorAll('.course').forEach(div => {
                            const c = parseCourse(div);
                            if (c) courses.push(c);
                        });

                        progressCallback && progressCallback(`${subject}: ${courses.length} courses`);
                        resolve({ subject, courses });
                    } catch (e) {
                        debugLog('SCRAPER', `Error parsing ${subject}`, e);
                        reject(e);
                    }
                },
                onerror: (e) => {
                    debugLog('SCRAPER', `Network error for ${subject}`, e);
                    reject(e);
                }
            });
        });
    }

    // ==================== 本地存储/全局 hash 查找 ====================

    function listAllTimetableTermsFromStorage() {
        let keys = [];
        try {
            keys = (typeof GM_listValues === 'function') ? GM_listValues() : [];
        } catch {
            keys = [];
        }

        const terms = [];
        for (const k of keys) {
            if (typeof k === 'string' && k.startsWith('timetables_')) {
                const term = k.slice('timetables_'.length);
                if (term) terms.push(term);
            }
        }
        return [...new Set(terms)];
    }

    function findTimetableByHashGlobal(hash) {
        if (!hash) return null;

        const terms = listAllTimetableTermsFromStorage();
        for (const t of getInterceptedTerms()) if (!terms.includes(t)) terms.push(t);

        for (const term of terms) {
            const key = `timetables_${term}`;
            const timetables = GM_getValue(key, []);
            if (!Array.isArray(timetables) || timetables.length === 0) continue;

            const idx = timetables.findIndex(tt => tt && tt.hash === hash);
            if (idx >= 0) return { term, key, timetables, index: idx, timetable: timetables[idx] };
        }
        return null;
    }

    function getLocalTimetables(term) {
        const key = `timetables_${term}`;
        const list = GM_getValue(key, []);
        const timetables = Array.isArray(list) ? list : [];

        if (timetables.length === 0) {
            timetables.push({ hash: generateHash(), title: 'Untitled Timetable', data: [] });
            setLocalTimetables(term, timetables);
            debugLog('INTERCEPTOR', `Created default empty timetable for term ${term}`);
        }
        return timetables;
    }

    function getLocalTimetablesRaw(term) {
        const key = `timetables_${term}`;
        const list = GM_getValue(key, []);
        return Array.isArray(list) ? list : [];
    }

    function setLocalTimetables(term, timetables) {
        const list = Array.isArray(timetables) ? timetables : [];
        if (list.length === 0) list.push({ hash: generateHash(), title: 'Untitled Timetable', data: [] });
        GM_setValue(`timetables_${term}`, list);
        debugLog('INTERCEPTOR', `Saved ${list.length} timetable(s) for term ${term}`);
    }

    function getLocalCourseData(term) {
        const allData = GM_getValue('courseData', {});
        if (!allData || typeof allData !== 'object') return {};
        return allData[term] || {};
    }

    function ensureTimetableExists(term, hash) {
        if (!term || !hash) return null;

        const timetables = getLocalTimetables(term);
        const idx = timetables.findIndex(t => t && t.hash === hash);
        if (idx >= 0) return { term, timetables, index: idx, timetable: timetables[idx] };

        const tt = { hash, title: 'Imported Timetable', data: [] };
        timetables.push(tt);
        setLocalTimetables(term, timetables);
        return { term, timetables, index: timetables.length - 1, timetable: tt };
    }

    // ==================== 本地 Timetable 管理(UI/导出覆盖) ====================

    function escapeHtml(s) {
        return String(s ?? '')
            .replaceAll('&', '&amp;')
            .replaceAll('<', '&lt;')
            .replaceAll('>', '&gt;')
            .replaceAll('"', '&quot;')
            .replaceAll("'", '&#039;');
    }

    function buildLocalTimetableIndex() {
        const terms = listAllTimetableTermsFromStorage().sort();
        const rows = [];

        for (const term of terms) {
            const list = getLocalTimetablesRaw(term);
            if (!Array.isArray(list) || list.length === 0) continue;

            for (const tt of list) {
                if (!tt || !tt.hash) continue;
                rows.push({
                    term,
                    hash: String(tt.hash),
                    title: String(tt.title || 'Untitled Timetable')
                });
            }
        }

        rows.sort((a, b) => {
            if (a.term !== b.term) return a.term < b.term ? 1 : -1;
            return a.title.localeCompare(b.title);
        });

        return rows;
    }

    // 同样修改 renderLocalTimetableManager(约 715 行)和 loadServerListIntoModal(约 868 行):
    function renderLocalTimetableManager() {
        const host = document.getElementById('local-tt-list');
        if (!host) return;

        const rows = buildLocalTimetableIndex();
        if (rows.length === 0) {
            safeSetInnerHTML(host, `<div style="font-size:12px;color:#666;">No local timetables found (timetables_* not in storage).</div>`);
            return;
        }

        safeSetInnerHTML(host, rows.map(r => `
        <div style="border:1px solid #ddd;border-radius:6px;padding:8px;margin-bottom:8px;background:#fff;">
            <div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start;">
                <div style="min-width:0;">
                    <div style="font-weight:600;font-size:13px;line-height:1.2;">${escapeHtml(r.title)}</div>
                    <div style="font-size:12px;color:#444;margin-top:2px;">Term: <b>${escapeHtml(termCodeToHuman(r.term))}</b></div>
                    <div style="font-size:11px;color:#888;margin-top:2px;word-break:break-all;">${escapeHtml(r.hash)}</div>
                </div>
                <div style="display:flex;flex-direction:column;gap:6px;flex:0 0 auto;">
                    <button class="export-tt-btn"
                        data-term="${escapeHtml(r.term)}"
                        data-hash="${escapeHtml(r.hash)}"
                        style="padding:6px 8px;background:#673AB7;color:white;border:none;border-radius:4px;cursor:pointer;font-size:12px;">
                        导出覆盖到服务器…
                    </button>
                </div>
            </div>
        </div>
    `).join(''));

        host.querySelectorAll('.export-tt-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                const term = btn.getAttribute('data-term') || '';
                const hash = btn.getAttribute('data-hash') || '';
                openExportModal(term, hash);
            });
        });
    }

    function ensureExportModalDom() {
        if (document.getElementById('export-modal-overlay')) return;

        const overlay = document.createElement('div');
        overlay.id = 'export-modal-overlay';
        overlay.style.cssText = `
        position: fixed; inset: 0;
        background: rgba(0,0,0,0.45);
        z-index: 20000;
        display: none;
        align-items: center;
        justify-content: center;
        padding: 20px;
    `;

        // 用 safeSetInnerHTML 替代直接赋值
        safeSetInnerHTML(overlay, `
        <div id="export-modal" style="
            width: min(720px, 96vw);
            max-height: 85vh;
            overflow: auto;
            background: #fff;
            border-radius: 10px;
            border: 2px solid #333;
            box-shadow: 0 8px 24px rgba(0,0,0,0.25);
            padding: 14px;
            font-family: Arial, sans-serif;
        ">
            <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">
                <div style="min-width:0;">
                    <div style="font-weight:700;">导出覆盖到服务器</div>
                    <div id="export-modal-subtitle" style="font-size:12px;color:#555;margin-top:2px;word-break:break-word;"></div>
                </div>
                <button id="export-modal-close" style="padding:6px 10px;background:#eee;border:1px solid #ccc;border-radius:6px;cursor:pointer;">关闭</button>
            </div>

            <div style="margin-top:12px;">
                <div style="font-size:13px;font-weight:600;margin-bottom:6px;">选择服务器端要被覆盖的 Timetable</div>
                <div id="export-modal-serverlist" style="border:1px solid #ddd;border-radius:8px;background:#fafafa;padding:10px;min-height:90px;">
                    加载中...
                </div>
            </div>

            <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;">
                <button id="export-modal-refresh" style="padding:8px 10px;background:#607D8B;color:white;border:none;border-radius:6px;cursor:pointer;">刷新列表</button>
                <button id="export-modal-confirm" style="padding:8px 10px;background:#d32f2f;color:white;border:none;border-radius:6px;cursor:pointer;">确认覆盖</button>
            </div>

            <div id="export-modal-status" style="margin-top:10px;font-size:12px;color:#444;"></div>
        </div>
    `);

        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) closeExportModal();
        });

        safeAppend(getBodyOrRoot(), overlay);

        // 延迟绑定事件,确保 innerHTML 渲染完成
        setTimeout(() => {
            document.getElementById('export-modal-close')?.addEventListener('click', closeExportModal);
        }, 0);
    }

    function setExportModalStatus(msg) {
        const el = document.getElementById('export-modal-status');
        if (el) el.textContent = msg || '';
    }

    function closeExportModal() {
        const overlay = document.getElementById('export-modal-overlay');
        if (overlay) overlay.style.display = 'none';
        setExportModalStatus('');
    }

    function encodeTimetableToFormData(timetable, options = {}) {
        const formData = new URLSearchParams();
        const data = Array.isArray(timetable?.data) ? timetable.data : [];
        const padSessionsTo = Number.isFinite(options.padSessionsTo) ? options.padSessionsTo : 0;

        const v = (x) => (x === null || x === undefined) ? '' : String(x);
        const normalizeNumberLike = (x) => {
            const s = v(x).trim();
            if (s === '') return '';
            const n = Number(s);
            return Number.isFinite(n) ? String(n) : s;
        };
        const isValidDay = (d) => {
            const s = String(d ?? '').trim();
            if (s === '') return false;
            const n = Number(s);
            return Number.isFinite(n) && n >= 0 && n <= 7;
        };
        const isValidTime = (t) => {
            const s = String(t ?? '').trim();
            if (s === '') return false;
            const n = Number(s);
            return Number.isFinite(n) && n >= 0;
        };

        let outIndex = 0;
        data.forEach((course) => {
            const subject = String(course?.subject ?? '').trim();
            const code = String(course?.code ?? '').trim();
            if (!subject || !code) return;

            const rawSessions = Array.isArray(course?.sessions) ? course.sessions : [];
            const cleaned = [];
            const seen = new Set();
            for (const session of rawSessions) {
                const section = v(session?.section).trim();
                const venue = v(session?.venue).trim();
                const number = v(session?.number).trim();
                const day = normalizeNumberLike(session?.dayOfWeek);
                const start = normalizeNumberLike(session?.startTime);
                const end = normalizeNumberLike(session?.endTime);

                const allEmpty = !section && !venue && !number && !day && !start && !end;
                if (allEmpty) continue;

                // 站点的“占位 session”允许 day=0/start=0/end=0(比如未排课/占位)
                if (!isValidDay(day) || !isValidTime(start) || !isValidTime(end)) continue;

                const key = [section, number, day, start, end, venue].join('|');
                if (seen.has(key)) continue;
                seen.add(key);
                cleaned.push({ section, venue, number, dayOfWeek: day, startTime: start, endTime: end });
            }

            // 如果完全没有 session,就不发送这门课(否则可能触发服务器校验失败)
            if (cleaned.length === 0) return;

            // 先写课程字段,再写 sessions(字段顺序尽量贴近站点原始请求)
            formData.append(`data[${outIndex}][enabled]`, v(course?.enabled ?? 'true'));
            formData.append(`data[${outIndex}][subject]`, subject);
            formData.append(`data[${outIndex}][code]`, code);
            formData.append(`data[${outIndex}][credits]`, v(course?.credits ?? 0));

            const total = Math.max(cleaned.length, padSessionsTo);
            for (let j = 0; j < total; j++) {
                const s = cleaned[j] || { section: '', venue: '', number: '', dayOfWeek: '', startTime: '', endTime: '' };
                formData.append(`data[${outIndex}][sessions][${j}][section]`, v(s.section));
                formData.append(`data[${outIndex}][sessions][${j}][venue]`, v(s.venue));
                formData.append(`data[${outIndex}][sessions][${j}][number]`, v(s.number));
                formData.append(`data[${outIndex}][sessions][${j}][dayOfWeek]`, v(s.dayOfWeek));
                formData.append(`data[${outIndex}][sessions][${j}][startTime]`, v(s.startTime));
                formData.append(`data[${outIndex}][sessions][${j}][endTime]`, v(s.endTime));
            }

            formData.append(`data[${outIndex}][color]`, v(course?.color ?? '#34495e'));
            formData.append(`data[${outIndex}][constraint]`, v(course?.constraint ?? 'false'));

            outIndex++;
        });

        return formData;
    }

    async function fetchServerTimetables(term) {
        // 用 originalFetch 走真实服务器,避免被本脚本拦截
        const url = `https://admlu65.ust.hk/planner/query/${encodeURIComponent(term)}`;

        const resp = await originalFetch(url, { method: 'GET', credentials: 'include' });
        const text = await resp.text();
        try {
            return JSON.parse(text);
        } catch {
            throw new Error('Server returned non-JSON (GET)');
        }
    }

    async function overwriteServerTimetable(serverHash, localTimetable) {
        const url = `https://admlu65.ust.hk/planner/save/${encodeURIComponent(serverHash)}`;
        const padSessionsTo = 0;

        const doPost = async (csrfToken) => {
            const formData = encodeTimetableToFormData(localTimetable, { padSessionsTo });
            if (csrfToken) formData.set('_token', String(csrfToken));
            formData.set('hash', String(serverHash));
            const headers = {
                'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
                ...buildPlannerCsrfHeaders(csrfToken)
            };
            return await originalFetch(url, {
                method: 'POST',
                body: formData.toString(),
                credentials: 'include',
                headers
            });
        };

        let csrfToken = getCsrfTokenFromDocument();
        let resp = await doPost(csrfToken);
        if (resp.status === 419) {
            csrfToken = (await fetchCsrfTokenByRequest()) || csrfToken;
            resp = await doPost(csrfToken);
        }

        const text = await resp.text();
        let json = null;
        try { json = JSON.parse(text); } catch { /* ignore */ }

        if (!resp.ok) {
            const hint = resp.status === 419 ? '(CSRF/Session 过期,Page Expired)' : '';
            throw new Error(`HTTP ${resp.status}: ${resp.statusText} ${hint}`.trim());
        }

        if (json && json.error && json.error !== false) {
            throw new Error(typeof json.error === 'string' ? json.error : 'Server error');
        }

        return json || { ok: true };
    }

    let exportContext = { term: '', localHash: '' };

    async function loadServerListIntoModal(term) {
        const box = document.getElementById('export-modal-serverlist');
        if (!box) return;

        safeSetInnerHTML(box, '加载中...');
        setExportModalStatus('');

        try {
            const data = await fetchServerTimetables(term);
            const list = Array.isArray(data?.timetables) ? data.timetables : [];

            if (list.length === 0) {
                safeSetInnerHTML(box, `<div style="font-size:12px;color:#666;">服务器端该学期没有 timetables(或未能读取)。</div>`);
                return;
            }

            safeSetInnerHTML(box, list.map((tt, idx) => {
                const hash = String(tt?.hash || '');
                const title = String(tt?.title || 'Untitled Timetable');
                const id = `server-tt-${idx}`;
                return `
                <label for="${id}" style="display:block;border:1px solid #e0e0e0;background:#fff;border-radius:8px;padding:8px;margin-bottom:8px;cursor:pointer;">
                    <div style="display:flex;gap:10px;align-items:flex-start;">
                        <input type="radio" name="server-tt" id="${id}" value="${escapeHtml(hash)}" style="margin-top:3px;">
                        <div style="min-width:0;">
                            <div style="font-weight:600;font-size:13px;">${escapeHtml(title)}</div>
                            <div style="font-size:11px;color:#888;word-break:break-all;margin-top:2px;">${escapeHtml(hash)}</div>
                        </div>
                    </div>
                </label>
            `;
            }).join(''));

            const first = box.querySelector('input[type="radio"][name="server-tt"]');
            if (first) first.checked = true;
        } catch (e) {
            safeSetInnerHTML(box, `<div style="font-size:12px;color:#b00020;">加载失败:${escapeHtml(e?.message || String(e))}</div>`);
        }
    }

    async function openExportModal(term, localHash) {
        ensureExportModalDom();

        exportContext = { term: String(term || ''), localHash: String(localHash || '') };

        const overlay = document.getElementById('export-modal-overlay');
        const subtitle = document.getElementById('export-modal-subtitle');
        if (subtitle) subtitle.textContent = `本地:${termCodeToHuman(exportContext.term)} / Hash ${exportContext.localHash}`;

        if (overlay) overlay.style.display = 'flex';

        await loadServerListIntoModal(exportContext.term);

        setOnclickById('export-modal-refresh', async () => {
            await loadServerListIntoModal(exportContext.term);
        });

        setOnclickById('export-modal-confirm', async () => {
            try {
                const chosen = document.querySelector('input[name="server-tt"]:checked');
                const serverHash = chosen?.value || '';
                if (!serverHash) {
                    setExportModalStatus('请选择一个服务器端 timetable。');
                    return;
                }

                const found = findTimetableByHashGlobal(exportContext.localHash);
                if (!found || !found.timetable) {
                    setExportModalStatus('本地 timetable 未找到(可能已被清空)。');
                    return;
                }

                if (!confirm(`确认用本地 timetable 覆盖服务器端 hash=${serverHash} 吗?此操作不可撤销。`)) return;

                setExportModalStatus('正在覆盖...');
                await overwriteServerTimetable(serverHash, found.timetable);
                setExportModalStatus('覆盖成功。');
            } catch (e) {
                setExportModalStatus(`覆盖失败:${e?.message || String(e)}`);
            }
        });
    }

    // ==================== API handlers(本地响应) ====================

    const apiHandlers = {
        query: (term) => {
            debugLog('API', `Query timetables for term ${term}`);
            return { error: false, timetables: getLocalTimetables(term) };
        },

        course: (term, subject, code) => {
            const subj = String(subject || '').trim().toUpperCase();
            const c = String(code || '').trim().toUpperCase();
            debugLog('API', `Course detail ${term} ${subj}${c}`);

            if (!term || !shouldIntercept(term)) {
                return { error: true, message: 'Term is not intercepted', metadata: null };
            }

            const termData = getLocalCourseData(term);
            const list = (termData && subj && Array.isArray(termData[subj])) ? termData[subj] : [];
            const found = list.find(x => String(x?.metadata?.subject || '').toUpperCase() === subj && String(x?.metadata?.code || '').toUpperCase() === c);

            if (!found || !found.metadata) {
                return {
                    error: true,
                    message: 'Course not found in local cache. Please scrape this subject first.',
                    metadata: null
                };
            }

            const m = found.metadata;
            const id = (typeof m.id === 'number' && Number.isFinite(m.id)) ? m.id : 0;

            return {
                error: false,
                metadata: {
                    id,
                    subject: m.subject || subj,
                    code: m.code || c,
                    name: m.name || '',
                    credits: (typeof m.credits === 'number' ? m.credits : parseInt(String(m.credits || '0'), 10) || 0),
                    matching: !!m.matching,
                    is_favourited: !!m.is_favourited,
                    is_subscribed: m.is_subscribed ?? null,
                    sections: Array.isArray(m.sections) ? m.sections : [],
                    is_planned: !!m.is_planned
                }
            };
        },

        selectorQuery: (params) => {
            debugLog('API', 'Selector query', params);
            const { type, value, semester } = params;

            if (!shouldIntercept(semester)) return null;
            const termData = getLocalCourseData(semester);

            if (type === 'default' || !type) {
                const subjects = Object.keys(termData).sort();
                const list = [
                    { type: 'categories', value: '', title: 'Common Core Courses', subtitle: '', half: false, icon: 'list', is_favourited: false, metadata: [] },
                    { type: 'heading', value: '', title: 'My Collections', subtitle: '', half: false, icon: '', is_favourited: false, metadata: [] },
                    { type: 'favourites', value: '', title: 'Favourites', subtitle: '', half: false, icon: 'heart', is_favourited: false, metadata: [] },
                    { type: 'plan', value: '', title: 'My Plan', subtitle: '', half: false, icon: 'fa-lightbulb-o', is_favouritted: false, metadata: [] },
                    { type: 'heading', value: '', title: 'Subjects', subtitle: '', half: false, icon: '', is_favouritted: false, metadata: [] }
                ];

                subjects.forEach(s => list.push({ type: 'subject', value: s, title: s, subtitle: '', half: true, icon: '', is_favouritted: false, metadata: [] }));
                return { error: false, list };
            }

            if (type === 'subject' && value) {
                return { error: false, list: termData[value] || [] };
            }

            if (type === 'search') {
                const results = searchCourses(termData, value, 80);
                return { error: false, list: results };
            }

            return { error: false, list: [] };
        },

        enrl: (term) => ({ error: 'Enrollment API is not supported in offline mode', term }),
        shoppingCart: (hash) => ({ error: 'Shopping cart API is not supported in offline mode', hash }),

        create: (term) => {
            debugLog('API', `Create timetable for term ${term}`);
            const timetables = getLocalTimetables(term);
            const newHash = generateHash();
            timetables.push({ hash: newHash, title: 'Untitled Timetable', data: [] });
            setLocalTimetables(term, timetables);
            return { error: false, hash: newHash, timetables };
        },

        save: (hash, body, term /* optional */) => {
            debugLog('API', `Save timetable ${hash}`, { term });

            let found = findTimetableByHashGlobal(hash);

            if (!found && term && shouldIntercept(term)) {
                found = ensureTimetableExists(term, hash);
                if (found) {
                    found.key = `timetables_${term}`;
                    found.timetables = GM_getValue(found.key, []);
                    found.index = found.timetables.findIndex(t => t && t.hash === hash);
                    found.timetable = found.timetables[found.index];
                }
            }

            if (!found) return { error: 'Timetable not found (hash not in local storage)' };

            const params = new URLSearchParams(normalizeBodyToUrlEncoded(body));

            const data = [];
            for (let i = 0; params.has(`data[${i}][subject]`); i++) {
                const courseData = {
                    enabled: params.get(`data[${i}][enabled]`) || 'true',
                    subject: params.get(`data[${i}][subject]`) || '',
                    code: params.get(`data[${i}][code]`) || '',
                    credits: parseInt(params.get(`data[${i}][credits]`) || '0', 10) || 0,
                    color: params.get(`data[${i}][color]`) || '#34495e',
                    constraint: params.get(`data[${i}][constraint]`) || 'false',
                    sessions: []
                };

                for (let j = 0; params.has(`data[${i}][sessions][${j}][section]`); j++) {
                    const getStr = (k) => {
                        const v = params.get(k);
                        return v === null ? '' : v; // 保留 "0"
                    };

                    courseData.sessions.push({
                        section: getStr(`data[${i}][sessions][${j}][section]`),
                        venue: getStr(`data[${i}][sessions][${j}][venue]`),
                        number: getStr(`data[${i}][sessions][${j}][number]`),
                        dayOfWeek: getStr(`data[${i}][sessions][${j}][dayOfWeek]`),
                        startTime: getStr(`data[${i}][sessions][${j}][startTime]`),
                        endTime: getStr(`data[${i}][sessions][${j}][endTime]`)
                    });
                }

                data.push(courseData);
            }

            found.timetables[found.index].data = data;
            setLocalTimetables(found.term, found.timetables);

            return { error: false };
        },

        delete: (hash, term /* optional */) => {
            debugLog('API', `Delete timetable ${hash}`, { term });

            let found = findTimetableByHashGlobal(hash);

            if (!found && term && shouldIntercept(term)) {
                return { error: false, timetables: getLocalTimetables(term) };
            }

            if (!found) return { error: 'Timetable not found (hash not in local storage)' };

            const newList = found.timetables.filter(t => t && t.hash !== hash);
            setLocalTimetables(found.term, newList);
            return { error: false, timetables: GM_getValue(`timetables_${found.term}`, []) };
        },

        rename: (hash, title, term /* optional */) => {
            debugLog('API', `Rename timetable ${hash} -> "${title}"`, { term });

            let found = findTimetableByHashGlobal(hash);

            if (!found && term && shouldIntercept(term)) {
                found = ensureTimetableExists(term, hash);
                if (found) {
                    found.key = `timetables_${term}`;
                    found.timetables = GM_getValue(found.key, []);
                    found.index = found.timetables.findIndex(t => t && t.hash === hash);
                    found.timetable = found.timetables[found.index];
                }
            }

            if (!found) return { error: 'Timetable not found (hash not in local storage)' };

            found.timetables[found.index].title = title || '';
            setLocalTimetables(found.term, found.timetables);
            return { error: false };
        }
    };

    // ==================== XHR 拦截 ====================

    const originalOpen = XMLHttpRequest.prototype.open;
    const originalSend = XMLHttpRequest.prototype.send;

    function mockJsonResponse(xhr, obj, status = 200, statusText = 'OK') {
        const responseText = JSON.stringify(obj);

        const headerMap = { 'content-type': 'application/json; charset=utf-8' };
        xhr.getResponseHeader = (name) => headerMap[String(name || '').toLowerCase()] ?? null;
        xhr.getAllResponseHeaders = () => Object.entries(headerMap).map(([k, v]) => `${k}: ${v}\r\n`).join('');

        Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
        Object.defineProperty(xhr, 'status', { value: status, configurable: true });
        Object.defineProperty(xhr, 'statusText', { value: statusText, configurable: true });
        Object.defineProperty(xhr, 'responseText', { value: responseText, configurable: true });
        Object.defineProperty(xhr, 'response', { value: responseText, configurable: true });

        try { Object.defineProperty(xhr, 'responseURL', { value: xhr._absUrl || '', configurable: true }); } catch { /* ignore */ }

        try { if (xhr.onreadystatechange) xhr.onreadystatechange(); } catch { /* ignore */ }
        try { if (xhr.onload) xhr.onload(); } catch { /* ignore */ }
        try { if (xhr.onloadend) xhr.onloadend(); } catch { /* ignore */ }
    }

    XMLHttpRequest.prototype.open = function (method, url, ...args) {
        this._method = method;
        const u = safeUrl(url);
        this._parsedUrl = u;
        this._absUrl = u ? u.toString() : String(url);
        debugLog('XHR', `open ${method} ${this._absUrl}`);
        return originalOpen.apply(this, [method, url, ...args]);
    };

    function handlePlannerLikeRequest(u, method, body) {
        if (!u) return { shouldBlock: false, response: null };

        const path = u.pathname || '';
        const hashFromPath = extractHashFromPath(path);
        const term = extractTermFromAny(u, body);

        const isPlanner =
            path.startsWith('/planner/') ||
            path === '/planner/query' ||
            path === '/planner/create' ||
            path === '/planner/save' ||
            path === '/planner/delete' ||
            path === '/planner/rename';

        const isSelector = (path === '/selector/query');
        const isEnrl = path.startsWith('/course/enrl/');
        const isCart = path.startsWith('/sis/shopping-cart/');

        if (!isPlanner && !isSelector && !isEnrl && !isCart) return { shouldBlock: false, response: null };

        if (isSelector) {
            const semester = u.searchParams.get('semester') || term;
            if (semester && shouldIntercept(semester)) {
                const resp = apiHandlers.selectorQuery({
                    type: u.searchParams.get('type'),
                    value: u.searchParams.get('value'),
                    semester,
                    page: u.searchParams.get('page')
                }) || { error: false, list: [] };
                return { shouldBlock: true, response: resp };
            }
            return { shouldBlock: false, response: null };
        }

        if (path.startsWith('/planner/query/') || path === '/planner/query') {
            const t = path.startsWith('/planner/query/') ? path.split('/').pop() : term;
            if (t && shouldIntercept(t)) return { shouldBlock: true, response: apiHandlers.query(t) };
            return { shouldBlock: false, response: null };
        }

        if (path.startsWith('/planner/course/') || path === '/planner/course') {
            const t = path.startsWith('/planner/course/') ? path.split('/').pop() : (u.searchParams.get('term') || term);
            const subject = u.searchParams.get('subject') || '';
            const code = u.searchParams.get('code') || '';
            if (t && shouldIntercept(t)) return { shouldBlock: true, response: apiHandlers.course(t, subject, code) };
            return { shouldBlock: false, response: null };
        }

        if (path.startsWith('/planner/create/') || path === '/planner/create') {
            const t = path.startsWith('/planner/create/') ? path.split('/').pop() : term;
            if (t && shouldIntercept(t)) return { shouldBlock: true, response: apiHandlers.create(t) };
            return { shouldBlock: false, response: null };
        }

        if (path.startsWith('/planner/save/') || path === '/planner/save') {
            const params = new URLSearchParams(normalizeBodyToUrlEncoded(body));
            const hash = hashFromPath || params.get('hash') || params.get('id') || null;
            if (!hash) return { shouldBlock: false, response: null };

            const found = findTimetableByHashGlobal(hash);
            const t = term;

            if (found || (t && shouldIntercept(t))) {
                return { shouldBlock: true, response: apiHandlers.save(hash, body, t) };
            }
            return { shouldBlock: false, response: null };
        }

        if (path.startsWith('/planner/delete/') || path === '/planner/delete') {
            const params = new URLSearchParams(normalizeBodyToUrlEncoded(body));
            const hash = hashFromPath || params.get('hash') || params.get('id') || null;
            if (!hash) return { shouldBlock: false, response: null };

            const found = findTimetableByHashGlobal(hash);
            const t = term;
            if (found || (t && shouldIntercept(t))) {
                return { shouldBlock: true, response: apiHandlers.delete(hash, t) };
            }
            return { shouldBlock: false, response: null };
        }

        if (path.startsWith('/planner/rename/') || path === '/planner/rename') {
            const params = new URLSearchParams(normalizeBodyToUrlEncoded(body));
            const hash = hashFromPath || params.get('hash') || params.get('id') || null;
            const title = params.get('title') || params.get('name') || '';
            if (!hash) return { shouldBlock: false, response: null };

            const found = findTimetableByHashGlobal(hash);
            const t = term;
            if (found || (t && shouldIntercept(t))) {
                return { shouldBlock: true, response: apiHandlers.rename(hash, title, t) };
            }
            return { shouldBlock: false, response: null };
        }

        if (isEnrl) {
            const t = path.split('/').pop() || term;
            if (t && shouldIntercept(t)) return { shouldBlock: true, response: apiHandlers.enrl(t) };
            return { shouldBlock: false, response: null };
        }

        if (isCart) {
            const hash = hashFromPath;
            const t = term || getCurrentTermFromPage();
            if (hash && t && shouldIntercept(t)) return { shouldBlock: true, response: apiHandlers.shoppingCart(hash) };
            return { shouldBlock: false, response: null };
        }

        return { shouldBlock: false, response: null };
    }

    XMLHttpRequest.prototype.send = function (data) {
        const method = (this._method || 'GET').toUpperCase();
        const u = this._parsedUrl || safeUrl(this._absUrl);

        debugLog('XHR', `send ${method} ${this._absUrl || ''}`, { hasBody: data != null });

        try {
            const { shouldBlock, response } = handlePlannerLikeRequest(u, method, data);

            if (shouldBlock) {
                debugLog('XHR', `INTERCEPTING ${method} ${this._absUrl || ''}`, response);
                setTimeout(() => mockJsonResponse(this, response), 0);
                return;
            }
        } catch (e) {
            debugLog('XHR', 'Interceptor error, fallback to native send()', e);
        }

        return originalSend.apply(this, arguments);
    };

    // ==================== fetch 拦截 ====================

    const originalFetch = window.fetch;
    window.fetch = function (url, options = {}) {
        try {
            const method = (options.method || 'GET').toUpperCase();
            const u = safeUrl(url);
            if (!u) return originalFetch.apply(this, arguments);

            debugLog('FETCH', `${method} ${u.toString()}`, { hasBody: options.body != null });

            const { shouldBlock, response } = handlePlannerLikeRequest(u, method, options.body);

            if (shouldBlock) {
                debugLog('FETCH', `INTERCEPTING ${method} ${u.toString()}`, response);
                return Promise.resolve(new Response(JSON.stringify(response), {
                    status: 200,
                    headers: { 'Content-Type': 'application/json; charset=utf-8' }
                }));
            }
        } catch (e) {
            debugLog('FETCH', 'Interceptor error, fallback to native fetch()', e);
        }

        return originalFetch.apply(this, arguments);
    };

    // ==================== UI ====================

    let currentOpenPanel = null;

    function togglePanel(panelName) {
        if (currentOpenPanel === panelName) {
            collapsePanel(panelName);
            currentOpenPanel = null;
            return;
        }
        if (currentOpenPanel) collapsePanel(currentOpenPanel);
        expandPanel(panelName);
        currentOpenPanel = panelName;
    }

    function expandPanel(panelName) {
        const panel = document.getElementById(`${panelName}-panel`);
        const content = document.getElementById(`${panelName}-content`);
        const toggle = document.getElementById(`${panelName}-toggle`);
        if (!panel || !content || !toggle) return;
        content.style.display = 'block';
        toggle.textContent = '−';
        panel.style.width = '420px';
        panel.style.opacity = '1';
        panel.style.padding = '15px';
    }

    function collapsePanel(panelName) {
        const panel = document.getElementById(`${panelName}-panel`);
        const content = document.getElementById(`${panelName}-content`);
        const toggle = document.getElementById(`${panelName}-toggle`);
        if (!panel || !content || !toggle) return;
        content.style.display = 'none';
        toggle.textContent = '▶';
        panel.style.width = '240px';
        panel.style.opacity = '0.85';
        panel.style.padding = '8px 10px';
    }

    function updateScraperProgress(message) {
        const area = document.getElementById('scraper-progress');
        if (!area) return;
        area.innerHTML += `${message}<br>`;
        area.scrollTop = area.scrollHeight;
    }

    function updateInterceptorStatus(message) {
        const area = document.getElementById('interceptor-status');
        if (!area) return;
        area.innerHTML += `${message}<br>`;
        area.scrollTop = area.scrollHeight;
    }

    function updateTotalCourses() {
        const data = GM_getValue('courseData', {});
        let total = 0;
        if (data && typeof data === 'object') {
            Object.values(data).forEach(termData => {
                if (!termData || typeof termData !== 'object') return;
                Object.values(termData).forEach(courses => total += Array.isArray(courses) ? courses.length : 0);
            });
        }
        const span = document.getElementById('total-courses');
        if (span) span.textContent = String(total);
    }

    function clearAllData() {
        if (!confirm('这会清空 courseData/currentTerm/currentSubjects 以及所有 timetables_*。确定继续?')) return;

        try {
            const keys = (typeof GM_listValues === 'function') ? GM_listValues() : [];
            for (const k of keys) {
                if (k === 'courseData' || k === 'currentTerm' || k === 'currentSubjects') {
                    GM_deleteValue(k);
                    continue;
                }
                if (typeof k === 'string' && k.startsWith('timetables_')) {
                    GM_deleteValue(k);
                }
            }
            updateScraperProgress('已清空所有已存数据。');
            updateTotalCourses();
            updateInterceptorStatus('本地数据已清空。如有需要请刷新页面。');
            renderLocalTimetableManager();
        } catch (e) {
            alert(`清空失败:${e?.message || String(e)}`);
        }
    }

    async function loadSubjects() {
        const year = document.getElementById('year-select')?.value;
        const term = document.getElementById('term-select')?.value;
        const termCode = `${year}${term}`;

        const select = document.getElementById('subject-select');
        if (!select) return;

        select.disabled = true;
        select.innerHTML = '<option value="">加载中...</option>';
        updateScraperProgress(`正在加载 ${termCode} 的科目列表...`);

        try {
            const subjects = await fetchSubjectList(termCode);
            select.disabled = false;
            select.innerHTML = '<option value="">请选择科目</option>' + subjects.map(s => `<option value="${s}">${s}</option>`).join('');
            GM_setValue('currentSubjects', subjects);
            GM_setValue('currentTerm', termCode);
            updateScraperProgress(`加载完成:共 ${subjects.length} 个科目`);
        } catch (e) {
            select.innerHTML = '<option value="">加载失败</option>';
            updateScraperProgress(`错误:${e?.message || String(e)}`);
        }
    }

    async function scrapeSingleSubject() {
        const select = document.getElementById('subject-select');
        const subject = select?.value;
        const term = GM_getValue('currentTerm', '');

        if (!subject) { alert('请先选择一个科目!'); return; }

        const area = document.getElementById('scraper-progress');
        if (area) area.innerHTML = '';
        updateScraperProgress(`正在抓取 ${subject}...`);

        try {
            const result = await scrapeSubject(term, subject, (m) => updateScraperProgress(m));
            const allData = GM_getValue('courseData', {});
            if (!allData[term]) allData[term] = {};
            allData[term][subject] = result.courses;
            GM_setValue('courseData', allData);

            updateScraperProgress(`已保存:${result.courses.length} 门课`);
            updateTotalCourses();
        } catch (e) {
            updateScraperProgress(`错误:${e?.message || String(e)}`);
        }
    }

    async function scrapeAllSubjects() {
        const subjects = GM_getValue('currentSubjects', []);
        const term = GM_getValue('currentTerm', '');
        const concurrency = Math.max(1, Math.min(10, parseInt(document.getElementById('concurrency-input')?.value || '3', 10) || 3));

        if (!Array.isArray(subjects) || subjects.length === 0) { alert('请先加载科目列表!'); return; }
        if (!confirm(`将以并发 ${concurrency} 抓取 ${subjects.length} 个科目,确定继续?`)) return;

        const area = document.getElementById('scraper-progress');
        if (area) area.innerHTML = '';
        updateScraperProgress(`开始抓取,并发=${concurrency}`);

        const termData = {};
        let completed = 0;
        let failed = 0;

        for (let i = 0; i < subjects.length; i += concurrency) {
            const batch = subjects.slice(i, i + concurrency);
            updateScraperProgress(`批次 ${Math.floor(i / concurrency) + 1}:${batch.join(', ')}`);

            const results = await Promise.all(batch.map(subject =>
                scrapeSubject(term, subject, (m) => updateScraperProgress(`  ${m}`))
                    .then(r => ({ ok: true, subject: r.subject, count: r.courses.length, courses: r.courses }))
                    .catch(e => ({ ok: false, subject, error: e?.message || String(e) }))
            ));

            results.forEach(r => {
                if (r.ok) {
                    termData[r.subject] = r.courses;
                    completed++;
                    updateScraperProgress(`  成功 ${r.subject}:${r.count}`);
                } else {
                    failed++;
                    updateScraperProgress(`  失败 ${r.subject}:${r.error}`);
                }
            });

            const allData = GM_getValue('courseData', {});
            allData[term] = { ...(allData[term] || {}), ...termData };
            GM_setValue('courseData', allData);
            updateTotalCourses();
        }

        updateScraperProgress(`完成:${completed}/${subjects.length},失败=${failed}`);
    }

    function exportJSON() {
        const data = GM_getValue('courseData', {});
        if (!data || typeof data !== 'object' || Object.keys(data).length === 0) { alert('没有可导出的数据!'); return; }

        const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);

        const a = document.createElement('a');
        a.href = url;
        a.download = `hkust_courses_${new Date().toISOString().slice(0, 10)}.json`;
        a.click();

        URL.revokeObjectURL(url);
        updateScraperProgress('已导出。');
    }

    function viewStoredData() {
        const data = GM_getValue('courseData', {});
        if (!data || typeof data !== 'object' || Object.keys(data).length === 0) { alert('没有数据。'); return; }

        let summary = '';
        let total = 0;

        Object.keys(data).forEach(term => {
            const subjects = Object.keys(data[term] || {});
            let count = 0;
            subjects.forEach(s => count += (data[term][s] || []).length);
            total += count;
            summary += `${termCodeToHuman(term)}:${count} 门课(${subjects.length} 个科目)\n`;
        });

        alert(`总计:${total} 门课\n\n${summary}`);
    }

    function applyInterception() {
        const checkboxes = document.querySelectorAll('#term-checkboxes input[type="checkbox"]');
        const selected = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value);
        setInterceptedTerms(selected);
        updateInterceptorStatus(`已启用拦截:${selected.length} 个学期`);
        alert('拦截设置已应用。建议刷新页面以确保生效。');
    }

    function createIntegratedPanels() {
        const defaultTerm = getDefaultTerm();
        const currentYear = new Date().getFullYear();

        const yearOptions = [];
        for (let i = -1; i <= 1; i++) {
            const y = currentYear + i;
            yearOptions.push(`<option value="${String(y).slice(-2)}">${y}</option>`);
        }

        const currentTerms = getInterceptedTerms();
        const terms = [];
        for (let i = -1; i <= 1; i++) {
            const y = currentYear + i;
            const yy = String(y).slice(-2);
            terms.push({ value: yy + '10', label: `${y} Fall` });
            terms.push({ value: yy + '20', label: `${y} Winter` });
            terms.push({ value: yy + '30', label: `${y} Spring` });
            terms.push({ value: yy + '40', label: `${y} Summer` });
        }

        const container = document.createElement('div');
        container.id = 'unified-panel-container';
        container.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            z-index: 10000;
            display: flex;
            flex-direction: column;
            gap: 10px;
        `;

        const baseStyle = `
            width: 420px;
            background: white;
            border: 2px solid #333;
            border-radius: 8px;
            padding: 15px;
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
            font-family: Arial, sans-serif;
            transition: all 0.3s ease;
        `;

        const interceptorPanel = document.createElement('div');
        interceptorPanel.id = 'interceptor-panel';
        interceptorPanel.style.cssText = baseStyle;

        const courseData = GM_getValue('courseData', {});
        const hasData = courseData && typeof courseData === 'object' && Object.keys(courseData).length > 0;

        interceptorPanel.innerHTML = `
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;cursor:pointer;" data-panel="interceptor">
                <h3 style="margin:0;">接口拦截器</h3>
                <span id="interceptor-toggle" style="font-size:20px;user-select:none;">−</span>
            </div>
            <div id="interceptor-content">
                <div style="padding:10px;background:#f1f8ff;border:1px solid #b6d4fe;border-radius:8px;margin-bottom:10px;font-size:12px;color:#084298;line-height:1.5;">
                    <div style="font-weight:700;margin-bottom:4px;">使用说明</div>
                    <div>1)先在“课程抓取”里抓取课程数据(否则拦截列表为空)。</div>
                    <div>2)在下方勾选要拦截的学期,点击“应用拦截”。</div>
                    <div>3)“本地 Timetable 管理”里可导出覆盖到服务器端某个 timetable。</div>
                </div>

                ${!hasData ? `<div style="padding:10px;background:#fff3cd;border:1px solid #ffc107;border-radius:4px;margin-bottom:10px;font-size:13px;color:#856404;">
                    还没有课程数据:请先在“课程抓取”中抓取一次。
                </div>` : ''}

                <div style="margin-bottom:10px;">
                    <label style="display:block;margin-bottom:5px;font-size:13px;">拦截学期:</label>
                    <div id="term-checkboxes" style="max-height:150px;overflow-y:auto;border:1px solid #ddd;padding:10px;background:#f9f9f9;">
                        ${terms.map(t => `
                            <label style="display:block;margin-bottom:5px;">
                                <input type="checkbox" value="${t.value}" ${currentTerms.includes(t.value) ? 'checked' : ''}>
                                ${t.label}
                            </label>
                        `).join('')}
                    </div>
                </div>

                <div style="margin-bottom:10px;">
                    <button id="apply-intercept-btn" style="width:100%;padding:8px;background:#4CAF50;color:white;border:none;border-radius:4px;cursor:pointer;">
                        应用拦截
                    </button>
                </div>

                <div style="margin:12px 0 8px;font-weight:700;">本地 Timetable 管理</div>
                <div style="display:flex;gap:8px;margin-bottom:10px;">
                    <button id="refresh-local-tt-btn" style="flex:1;padding:8px;background:#455A64;color:white;border:none;border-radius:4px;cursor:pointer;">
                        刷新列表
                    </button>
                </div>
                <div id="local-tt-list" style="max-height:260px;overflow:auto;border:1px solid #ddd;padding:10px;background:#f9f9f9;border-radius:8px;margin-bottom:10px;"></div>

                <div id="interceptor-status" style="height:90px;overflow-y:auto;border:1px solid #ddd;padding:10px;font-size:12px;background:#f9f9f9;">
                    就绪。
                </div>
            </div>
        `;

        const scraperPanel = document.createElement('div');
        scraperPanel.id = 'scraper-panel';
        scraperPanel.style.cssText = baseStyle;

        scraperPanel.innerHTML = `
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;cursor:pointer;" data-panel="scraper">
                <h3 style="margin:0;">课程抓取</h3>
                <span id="scraper-toggle" style="font-size:20px;user-select:none;">−</span>
            </div>
            <div id="scraper-content">
                <div style="margin-bottom:10px;">
                    <label style="display:block;margin-bottom:5px;font-size:13px;">选择学期:</label>
                    <div style="display:flex;gap:5px;">
                        <select id="year-select" style="flex:1;padding:5px;">${yearOptions.join('')}</select>
                        <select id="term-select" style="flex:1;padding:5px;">
                            <option value="10">Fall</option>
                            <option value="20">Winter</option>
                            <option value="30">Spring</option>
                            <option value="40">Summer</option>
                        </select>
                    </div>
                </div>

                <div style="margin-bottom:10px;">
                    <label style="display:block;margin-bottom:5px;font-size:13px;">并发(1-10):</label>
                    <input id="concurrency-input" type="number" min="1" max="10" value="3" style="width:100%;padding:5px;box-sizing:border-box;">
                </div>

                <div style="margin-bottom:10px;">
                    <select id="subject-select" style="width:100%;padding:5px;" disabled>
                        <option value="">正在加载科目列表...</option>
                    </select>
                </div>

                <div style="margin-bottom:10px;">
                    <button id="scrape-single-btn" style="width:100%;padding:8px;background:#4CAF50;color:white;border:none;border-radius:4px;cursor:pointer;">
                        抓取所选科目
                    </button>
                </div>

                <div style="margin-bottom:10px;">
                    <button id="scrape-all-btn" style="width:100%;padding:8px;background:#2196F3;color:white;border:none;border-radius:4px;cursor:pointer;">
                        抓取全部科目
                    </button>
                </div>

                <div style="display:flex;gap:5px;margin-bottom:10px;">
                    <button id="export-json-btn" style="flex:1;padding:8px;background:#FF9800;color:white;border:none;border-radius:4px;cursor:pointer;">导出</button>
                    <button id="view-data-btn" style="flex:1;padding:8px;background:#607D8B;color:white;border:none;border-radius:4px;cursor:pointer;">查看</button>
                </div>

                <div style="margin-bottom:10px;">
                    <button id="clear-all-btn" style="width:100%;padding:8px;background:#d32f2f;color:white;border:none;border-radius:4px;cursor:pointer;">
                        清空所有已存数据
                    </button>
                </div>

                <div id="scraper-progress" style="height:120px;overflow-y:auto;border:1px solid #ddd;padding:10px;font-size:12px;background:#f9f9f9;">
                    准备就绪。
                </div>

                <div style="margin-top:10px;font-size:11px;color:#666;">
                    共 <span id="total-courses">0</span> 门课
                </div>
            </div>
        `;

        safeAppend(container, interceptorPanel);
        safeAppend(container, scraperPanel);
        safeAppend(getBodyOrRoot(), container);

        interceptorPanel.querySelector('[data-panel="interceptor"]')?.addEventListener('click', () => togglePanel('interceptor'));
        scraperPanel.querySelector('[data-panel="scraper"]')?.addEventListener('click', () => togglePanel('scraper'));

        currentOpenPanel = 'interceptor';
        collapsePanel('scraper');

        const ysel = document.getElementById('year-select');
        const tsel = document.getElementById('term-select');
        if (ysel) ysel.value = defaultTerm.slice(0, 2);
        if (tsel) tsel.value = defaultTerm.slice(2, 4);

        ysel?.addEventListener('change', loadSubjects);
        tsel?.addEventListener('change', loadSubjects);
        document.getElementById('scrape-single-btn')?.addEventListener('click', scrapeSingleSubject);
        document.getElementById('scrape-all-btn')?.addEventListener('click', scrapeAllSubjects);
        document.getElementById('export-json-btn')?.addEventListener('click', exportJSON);
        document.getElementById('view-data-btn')?.addEventListener('click', viewStoredData);
        document.getElementById('clear-all-btn')?.addEventListener('click', clearAllData);
        document.getElementById('apply-intercept-btn')?.addEventListener('click', applyInterception);

        document.getElementById('refresh-local-tt-btn')?.addEventListener('click', () => {
            renderLocalTimetableManager();
        });

        ensureExportModalDom();
        updateTotalCourses();
        loadSubjects();
        renderLocalTimetableManager();
    }

    // ==================== 初始化(只执行一次) ====================

    function initOnce() {
        if (window.__HKUST_PANEL_INITED__) return;
        window.__HKUST_PANEL_INITED__ = true;

        try { installSemesterDropdownObserver(); } catch (e) { debugLog('UI', 'installSemesterDropdownObserver failed', e); }

        try {
            if (!document.getElementById('unified-panel-container')) {
                createIntegratedPanels();
            }
        } catch (e) {
            debugLog('UI', 'createIntegratedPanels failed', e);
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initOnce, { once: true });
    } else {
        initOnce();
    }
    window.addEventListener('load', initOnce, { once: true });

})();