/* * AnyClass - ZhengFang V9 Safari Shortcut Parser * V2-C Phase 2 * * Read-only boundary: * - Reads only the current page's timetable DOM and semester selectors. * - Does not read cookies, browser storage, credentials, or unrelated page data. * - Does not navigate, submit forms, mutate the page, or make network requests. * - Returns a JSON-compatible dictionary to Apple Shortcuts via completion(). */ (() => { "use strict"; const EXPECTED_ORIGIN = "https://www.gupt.edu.cn"; const TABLE_SELECTOR = "table#kbgrid_table_0"; const BLOCK_SELECTOR = ".timetable_con"; const MAX_MEETINGS = 2000; const MAX_TEXT_LENGTH = 300; const finishError = (errorCode, errorMessage) => { completion({ ok: false, errorCode, errorMessage }); }; const fail = (code, message) => { const error = new Error(message); error.code = code; throw error; }; const compactText = value => String(value || "").replace(/\s+/g, " ").trim(); const checkedText = (value, label, required = false) => { const text = compactText(value); if (required && !text) fail("INVALID_FIELD", `${label}\u4e0d\u80fd\u4e3a\u7a7a`); if (text.length > MAX_TEXT_LENGTH) fail("INVALID_FIELD", `${label}\u8fc7\u957f`); return text || null; }; const selectedText = element => { if (!element) return ""; const selected = element.options && element.selectedIndex >= 0 ? element.options[element.selectedIndex] : null; return compactText(selected ? selected.textContent : ""); }; const parseAcademicYear = element => { if (!element) fail("SEMESTER_NOT_FOUND", "\u672a\u627e\u5230\u5b66\u5e74\u9009\u62e9\u5668"); const candidates = [selectedText(element), compactText(element.value)].filter(Boolean); for (const candidate of candidates) { const range = candidate.match(/(\d{4})\s*[-\u2014\u2013~\u81f3]\s*(\d{4})/); if (range && Number(range[2]) === Number(range[1]) + 1) { return `${range[1]}-${range[2]}`; } } for (const candidate of candidates) { const single = candidate.match(/^\s*(\d{4})(?:\s*\u5b66\u5e74)?\s*$/); if (single) { const start = Number(single[1]); return `${start}-${start + 1}`; } } fail("SEMESTER_PARSE_FAILED", "\u65e0\u6cd5\u8bc6\u522b\u5f53\u524d\u5b66\u5e74"); }; const parseTerm = element => { if (!element) fail("SEMESTER_NOT_FOUND", "\u672a\u627e\u5230\u5b66\u671f\u9009\u62e9\u5668"); const label = selectedText(element); if (/\u7b2c?\s*(?:1|\u4e00)\s*\u5b66\u671f|\u4e0a\u5b66\u671f/.test(label)) return "1"; if (/\u7b2c?\s*(?:2|\u4e8c)\s*\u5b66\u671f|\u4e0b\u5b66\u671f/.test(label)) return "2"; const value = compactText(element.value); if (value === "3" || value === "1") return "1"; if (value === "12" || value === "2") return "2"; fail("SEMESTER_PARSE_FAILED", "\u65e0\u6cd5\u8bc6\u522b\u5f53\u524d\u5b66\u671f"); }; const parseWeeks = expression => { const normalized = compactText(expression) .replace(/[\uff08]/g, "(") .replace(/[\uff09]/g, ")") .replace(/\s+/g, "") .replace(/[\uff0c\u3001\uff1b;]/g, ","); if (!normalized) fail("WEEK_PARSE_FAILED", "\u8bfe\u7a0b\u5468\u6b21\u4e3a\u7a7a"); const weeks = []; for (const token of normalized.split(",").filter(Boolean)) { const match = token.match(/^(\d+)(?:[-\u2014\u2013~\u81f3](\d+))?\u5468?(?:\((\u5355|\u53cc)\))?$/); if (!match) fail("UNKNOWN_WEEK_TOKEN", "\u5b58\u5728\u65e0\u6cd5\u8bc6\u522b\u7684\u5468\u6b21\u683c\u5f0f"); const start = Number(match[1]); const end = Number(match[2] || match[1]); const parity = match[3] || ""; if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start || end > 60) { fail("WEEK_PARSE_FAILED", "\u8bfe\u7a0b\u5468\u6b21\u8303\u56f4\u65e0\u6548"); } for (let week = start; week <= end; week += 1) { if (parity === "\u5355" && week % 2 === 0) continue; if (parity === "\u53cc" && week % 2 !== 0) continue; weeks.push(week); } } const result = [...new Set(weeks)].sort((left, right) => left - right); if (!result.length) fail("WEEK_PARSE_FAILED", "\u8bfe\u7a0b\u5468\u6b21\u4e3a\u7a7a"); return result; }; const textForIcon = (block, selector) => { const icon = block.querySelector(selector); const container = icon && (icon.closest("p") || icon.parentElement); return checkedText(container ? container.textContent : "", selector, false); }; const parseTimetable = table => { const allBlocks = [...table.querySelectorAll(BLOCK_SELECTOR)]; if (!allBlocks.length) fail("EMPTY_TIMETABLE", "\u5f53\u524d\u9875\u9762\u672a\u53d1\u73b0\u8bfe\u7a0b\u5b89\u6392"); if (allBlocks.length > MAX_MEETINGS) fail("TOO_MANY_MEETINGS", "\u8bfe\u7a0b\u5b89\u6392\u6570\u91cf\u8d85\u51fa\u9650\u5236"); const meetings = []; for (const cell of table.querySelectorAll("td[id]")) { let previousTitle = null; for (const block of cell.querySelectorAll(BLOCK_SELECTOR)) { const titleNode = block.querySelector(".title"); const rawTitle = compactText(titleNode ? titleNode.textContent : ""); let courseName = rawTitle ? rawTitle.replace(/\u3010\u8c03\u3011/g, "").replace(/[*&\s]+$/g, "").trim() : ""; if (courseName) { previousTitle = courseName; } else if (previousTitle) { courseName = previousTitle; } else { fail("MISSING_TITLE", "\u5b58\u5728\u65e0\u6cd5\u8bc6\u522b\u8bfe\u7a0b\u540d\u79f0\u7684\u8bfe\u7a0b\u5757"); } courseName = checkedText(courseName, "\u8bfe\u7a0b\u540d\u79f0", true); const weekdayMatch = String(cell.id || "").match(/^(\d+)-(\d+)$/); const weekday = weekdayMatch ? Number(weekdayMatch[1]) : NaN; if (!Number.isInteger(weekday) || weekday < 1 || weekday > 7) { fail("INVALID_WEEKDAY", "\u5b58\u5728\u65e0\u6cd5\u8bc6\u522b\u661f\u671f\u7684\u8bfe\u7a0b\u5757"); } const timeText = textForIcon(block, ".glyphicon-time") || ""; const periodMatch = timeText.match(/[\uff08(]\s*(\d+)\s*[-\u2014\u2013~\u81f3]\s*(\d+)\s*\u8282\s*[\uff09)]/); if (!periodMatch) fail("PERIOD_PARSE_FAILED", "\u5b58\u5728\u65e0\u6cd5\u8bc6\u522b\u8282\u6b21\u7684\u8bfe\u7a0b\u5757"); const startPeriod = Number(periodMatch[1]); const endPeriod = Number(periodMatch[2]); if (!Number.isInteger(startPeriod) || !Number.isInteger(endPeriod) || startPeriod < 1 || endPeriod < startPeriod || endPeriod > 30) { fail("PERIOD_PARSE_FAILED", "\u5b58\u5728\u65e0\u6548\u8282\u6b21\u8303\u56f4"); } const weekExpression = timeText .replace(/[\uff08(]\s*\d+\s*[-\u2014\u2013~\u81f3]\s*\d+\s*\u8282\s*[\uff09)]/, "") .replace(/^[^0-9]+/, "") .trim(); const adjustedByClass = Boolean(titleNode && titleNode.matches(".showJxbtkjl")); meetings.push({ courseName, weekday, startPeriod, endPeriod, weeks: parseWeeks(weekExpression), teacher: textForIcon(block, ".glyphicon-user"), locationRaw: textForIcon(block, ".glyphicon-map-marker"), isAdjusted: /\u3010\u8c03\u3011/.test(rawTitle) || adjustedByClass }); } } if (meetings.length !== allBlocks.length) { fail("BLOCK_COUNT_MISMATCH", "\u90e8\u5206\u8bfe\u7a0b\u5757\u4e0d\u5728\u53ef\u8bc6\u522b\u7684\u8bfe\u8868\u5355\u5143\u683c\u4e2d"); } return meetings; }; try { if (window.location.origin !== EXPECTED_ORIGIN) { fail("UNSUPPORTED_ORIGIN", "\u8bf7\u5728\u5e7f\u4e1c\u90ae\u7535\u804c\u4e1a\u6280\u672f\u5b66\u9662\u6559\u52a1\u7cfb\u7edf\u4e2d\u8fd0\u884c\u6b64\u5feb\u6377\u6307\u4ee4"); } const tables = document.querySelectorAll(TABLE_SELECTOR); if (tables.length !== 1) fail("TABLE_NOT_FOUND", "\u8bf7\u5148\u6253\u5f00\u5e76\u5b8c\u6574\u663e\u793a\u4e2a\u4eba\u8bfe\u8868"); const academicYear = parseAcademicYear(document.getElementById("xnm")); const term = parseTerm(document.getElementById("xqm")); const meetings = parseTimetable(tables[0]); const dataset = { schemaVersion: 1, school: { id: "gupt", name: "\u5e7f\u4e1c\u90ae\u7535\u804c\u4e1a\u6280\u672f\u5b66\u9662" }, semester: { academicYear, term }, meetings }; completion({ ok: true, fileName: `timetable-${academicYear}-${term}.json`, jsonText: JSON.stringify(dataset, null, 2), meetingCount: meetings.length }); } catch (error) { finishError( error && typeof error.code === "string" ? error.code : "PARSER_FAILED", error instanceof Error ? error.message : "\u8bfe\u8868\u8bfb\u53d6\u5931\u8d25" ); } })();