diff --git a/README.md b/README.md index e9d07a7..da845f7 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,14 @@ > 正在搜索“正方教务系统自动选课”“正方教务系统抢课脚本”“新正方教务管理系统定时选课”?请先阅读下方的适用范围,再按三步快速启动。 -有关学校覆盖与证据等级,请参阅[正方高校覆盖与兼容性记录](docs/zhengfang-university-coverage.md);远程仓库的 Description 和 Topics 请按[GitHub 元数据清单](docs/github-metadata.md)手动设置。 +有关学校覆盖与证据等级,请参阅[正方高校覆盖与兼容性记录](docs/zhengfang-university-coverage.md);页面加载、课程展开与换课限制请参阅[使用注意](docs/usage-notes.md);远程仓库的 Description 和 Topics 请按[GitHub 元数据清单](docs/github-metadata.md)手动设置。 -## ⚡ 三步开始 +## ⚡ 快速开始 1. 登录学校的正方教务系统并进入**自主选课**页面。 -2. 在浏览器开发者工具的 Console 中粘贴并运行 [`courseGrabber.js`](courseGrabber.js)。 -3. 在右上角控制台添加课程,设置优先级、时间/教师筛选或定时开抢,然后开始选课。 +2. 使用页面自带搜索先把目标课程加载到当前列表中;推荐输入课程号精确搜索。 +3. 在浏览器开发者工具的 Console 中粘贴并运行 [`courseGrabber.js`](courseGrabber.js)。 +4. 在右上角控制台添加课程,设置优先级、时间/教师筛选、替换课程或定时开抢,然后开始选课。 ## 🔎 适用范围与兼容性 @@ -26,6 +27,8 @@ 运行前请在选课页检查是否能看到课程卡片和教学班信息。当前脚本依赖正方页面中的课程卡片结构(例如 `.panel-heading.kc_head` 与 `input[name="kch_id"]`);若页面结构不同,请先提交脱敏兼容性报告,而不要直接提高请求频率。 +脚本只能识别和操作**已经加载到当前页面 DOM 中**的课程。若目标课程还藏在页面底部的“点此查看更多”之后,或需要继续查询/翻页后才会出现,脚本无法凭课程号自动把它从未加载的数据中找出来。建议先用课程号精确搜索,并确认目标课程已经呈现在页面列表里,再启动脚本。 + ## ✨ 主要特性 ### 🚀 核心功能 @@ -49,11 +52,13 @@ - **全局 / 课程级过滤器** —— 支持设置全局过滤规则,或为单门课程单独配置过滤条件,满足不同选课策略需求。 -### 🔽 自动展开课程信息 +### 🔽 页面加载与自动展开课程信息 -在选课过程中,页面需要完整展示课程的时间、教师等详细信息,因此**过滤逻辑仅在课程信息卡片处于展开状态时才会生效**。 +在选课过程中,页面需要完整展示课程的时间、教师等详细信息,因此**过滤逻辑仅在课程信息卡片处于展开状态时才会生效**。 但由于正方选课系统在使用 jQuery 刷新列表时会重建 DOM 树,已展开的课程卡片会被自动折叠。为此,本脚本在页面刷新后**主动触发点击事件**,自动展开目标课程卡片。 +这里的“展开课程信息”不等同于页面底部的“点此查看更多”。“点此查看更多”负责把更多课程加载进当前页面;脚本不能操作尚未加载进页面的课程。“展开课程信息”只是在目标课程已经出现在页面后,展开其教学班详情。 + 需要注意的是,展开课程信息会带来一定的性能开销,可能对抢课请求的响应时延产生影响。因此,在以下场景下,脚本会自动将 `CLICK2EXPEND_ENABLED` 设置为 `false`,以避免不必要的展开操作: 1. 页面中仅存在一门课程,且该课程为目标课程 @@ -257,7 +262,7 @@ grab.config.getConcurrentMode() // 获取并发模式状态 ### ⚠️ 重要提醒 -1. **必须点击"点此查看更多"** - DOM树必须完全展开,否则无法找到教学班 +1. **目标课程必须已经出现在页面列表中** - 推荐用课程号精确搜索;若课程还在“点此查看更多”之后,脚本无法找到或操作 2. **保持页面打开** - 脚本运行期间不要关闭或刷新页面 3. **网络稳定** - 确保网络连接稳定 4. **提前测试** - 建议在正式选课前测试脚本功能 @@ -274,7 +279,7 @@ grab.config.getConcurrentMode() // 获取并发模式状态 ### 🐛 常见问题 **Q: 脚本运行后找不到教学班?** -A: 请确保点击了"点此查看更多"按钮,将所有教学班展开。 +A: 请先确认目标课程已经加载到当前页面列表中。推荐使用课程号精确搜索;如果目标课程仍在“点此查看更多”之后,脚本无法找到。目标课程出现后,脚本会按需自动展开课程详情以读取教学班信息。 **Q: 提示时间冲突怎么办?** A: 脚本会自动尝试其他教学班,如果所有教学班都冲突会自动停止。 diff --git a/courseGrabber.js b/courseGrabber.js index 1651257..c4afad9 100644 --- a/courseGrabber.js +++ b/courseGrabber.js @@ -637,6 +637,130 @@ return allClasses; } + function isElementClickable(element) { + if (!element) { + return false; + } + + const tagName = element.tagName; + return ( + tagName === "BUTTON" || + tagName === "A" || + (tagName === "INPUT" && element.getAttribute("type") === "button") || + Boolean(element.onclick) || + Boolean(element.getAttribute("onclick")) + ); + } + + function findClickableElementByText(root, targetText, excludedTexts = []) { + if (!root) { + return null; + } + + const elements = root.querySelectorAll("*"); + for (let element of elements) { + const elementText = (element.textContent || "").trim(); + if (!elementText.includes(targetText)) { + continue; + } + + if (excludedTexts.some((text) => elementText.includes(text))) { + continue; + } + + if (isElementClickable(element)) { + return element; + } + } + + return null; + } + + function findSelectedCourseRows(courseCodeOrName, root = document) { + const rows = []; + const seenRows = new Set(); + const input = String(courseCodeOrName).trim(); + const isCode = isCourseCode(input); + + const addRowsFromSection = (section) => { + if (!section) { + return; + } + + const rowCandidates = section.querySelectorAll( + "li.list-group-item, table tbody tr, tr", + ); + for (let row of rowCandidates) { + if (seenRows.has(row)) { + continue; + } + + const dropButton = findClickableElementByText(row, "退选"); + const rowText = row.textContent || ""; + if (!dropButton && !rowText.includes("退选")) { + continue; + } + + seenRows.add(row); + rows.push({ + row: row, + button: dropButton, + courseCode: courseCodeOrName, + }); + } + }; + + const matchesSelectedCourseSection = (section) => { + const sectionId = section.id || ""; + const courseCodeInput = section.querySelector('input[name="right_kchid"]'); + const sectionCourseCode = courseCodeInput + ? String(courseCodeInput.value).trim() + : ""; + const headingText = section.querySelector("h6")?.textContent || ""; + + if (isCode) { + return ( + sectionCourseCode === input || + sectionId === `right_${input}` || + sectionId === `right_ul_${input}` || + headingText.includes(`(${input})`) + ); + } + + return headingText.includes(input); + }; + + const selectedSections = root.querySelectorAll( + '.outer_xkxx_list, [id^="right_"]', + ); + for (let section of selectedSections) { + if (matchesSelectedCourseSection(section)) { + addRowsFromSection(section); + } + } + + const rightCourseInputs = root.querySelectorAll('input[name="right_kchid"]'); + for (let courseInput of rightCourseInputs) { + const sectionCourseCode = String(courseInput.value).trim(); + if (isCode && sectionCourseCode !== input) { + continue; + } + + const section = + courseInput.closest(".outer_xkxx_list") || + courseInput.closest("ul") || + courseInput.parentElement; + const headingText = section?.querySelector("h6")?.textContent || ""; + if (!isCode && !headingText.includes(input)) { + continue; + } + + addRowsFromSection(section); + } + + return rows; + } + // 提取教学班信息 function extractTeachingClassInfo(row) { try { @@ -857,22 +981,31 @@ try { log(`🔄 开始退选课程: ${courseCode}`, "info", courseCode); - // 查找该课程的所有教学班 - const teachingClasses = findAllTeachingClasses(courseCode); + const selectedCourseRows = findSelectedCourseRows(courseCode); + let dropClass = selectedCourseRows[0] || null; - if (teachingClasses.length === 0) { - log(`未找到课程 ${courseCode} 的教学班`, "warning", courseCode); - resolve(false); - return; - } + if (dropClass) { + log(`已在已选课程列表中找到课程 ${courseCode}`, "info", courseCode); + } else { + // 回退:部分页面会把已选课程同时保留在左侧课程卡片中 + const teachingClasses = findAllTeachingClasses(courseCode); - // 查找包含"退选"按钮的教学班 - let dropClass = null; - for (let tc of teachingClasses) { - const rowText = tc.row ? tc.row.textContent : ""; - if (rowText.includes("退选")) { - dropClass = tc; - break; + if (teachingClasses.length === 0) { + log(`未找到课程 ${courseCode} 的教学班`, "warning", courseCode); + resolve(false); + return; + } + + // 查找包含"退选"按钮的教学班 + for (let tc of teachingClasses) { + const rowText = tc.row ? tc.row.textContent : ""; + if (rowText.includes("退选")) { + dropClass = { + ...tc, + button: findClickableElementByText(tc.row, "退选"), + }; + break; + } } } @@ -884,23 +1017,8 @@ // 查找退选按钮 const row = dropClass.row; - const allElements = row.querySelectorAll("*"); - let dropButton = null; - - for (let element of allElements) { - const elementText = element.textContent.trim(); - if (elementText === "退选" || elementText.includes("退选")) { - if ( - element.tagName === "BUTTON" || - element.tagName === "A" || - element.onclick || - element.getAttribute("onclick") - ) { - dropButton = element; - break; - } - } - } + let dropButton = + dropClass.button || findClickableElementByText(row, "退选"); if (!dropButton) { log(`未找到课程 ${courseCode} 的退选按钮`, "warning", courseCode); diff --git a/docs/usage-notes.md b/docs/usage-notes.md new file mode 100644 index 0000000..cfe6563 --- /dev/null +++ b/docs/usage-notes.md @@ -0,0 +1,40 @@ +# 使用注意 + +本文说明脚本对正方自主选课页面的加载、展开与换课要求。 + +## 页面加载要求 + +脚本只能读取当前页面 DOM 中已经存在的课程和教学班。开始前请先在自主选课页面使用学校系统自带的搜索功能,把目标课程加载到课程列表中。 + +推荐使用课程号精确搜索。课程名可能存在简称、同名课、教学班名称截断或页面定制差异,课程号更稳定。 + +如果目标课程还在页面底部的“点此查看更多”之后,或需要继续查询/翻页后才会出现在列表中,脚本无法找到该课程,也无法对该课程执行选课或退选前置操作。此时请先手动加载更多或重新搜索,直到目标课程明确呈现在页面中。 + +## 课程详情展开 + +“点此查看更多”和“展开课程详情”是两件事: + +- “点此查看更多”会让正方页面继续加载更多课程进当前列表。 +- “展开课程详情”是在课程已经出现后,展开该课程下的教学班、教师、时间、容量和操作按钮。 + +脚本会在需要时间过滤或教师过滤时自动尝试展开目标课程详情;页面刷新或搜索刷新后,正方系统可能重建 DOM 并折叠已展开课程,脚本也会再次尝试展开目标课程。 + +自动展开不能替代“点此查看更多”。如果课程从未加载进当前页面,脚本没有可操作的 DOM 节点。 + +## 换课功能 + +换课,也就是“骑驴找马”,会在目标课程出现空位时,先退选配置中的替换课程,再尝试选择目标课程。 + +使用时请确认: + +1. 目标课程已经出现在当前页面列表中。 +2. 替换课程已经在右侧或已选课程区域中显示,并且页面上能看到“退选”按钮。 +3. 替换课程尽量使用课程号填写,避免课程名简称或同名课程导致匹配不稳定。 + +退选后目标课程可能被其他人抢走,因此换课天然存在“退了旧课但没抢到新课”的风险。请只在能接受该风险时使用。 + +## 刷新影响 + +脚本会周期性刷新课程列表来观察余量变化。正方页面刷新列表时通常会还原为当前搜索结果的初始展示状态,之前手动加载的更多课程可能消失。 + +如果学校课程量很大,需要连续多次点击“点此查看更多”才能看到目标课程,建议先用课程号搜索缩小结果范围,再启动脚本。不要依赖持续刷新去维护一长串手动加载出来的课程列表。 diff --git a/tests/courseGrabber-selected-courses.test.mjs b/tests/courseGrabber-selected-courses.test.mjs new file mode 100644 index 0000000..a82757e --- /dev/null +++ b/tests/courseGrabber-selected-courses.test.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("../courseGrabber.js", import.meta.url), "utf8"); + +function extractFunction(name) { + const start = source.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `Expected courseGrabber.js to define ${name}()`); + + const bodyStart = source.indexOf("{", start); + let depth = 0; + for (let index = bodyStart; index < source.length; index++) { + const char = source[index]; + if (char === "{") depth++; + if (char === "}") depth--; + if (depth === 0) { + return source.slice(start, index + 1); + } + } + + throw new Error(`Could not extract ${name}()`); +} + +function loadCourseGrabberHelpers() { + const helperSource = [ + extractFunction("isCourseCode"), + extractFunction("isElementClickable"), + extractFunction("findClickableElementByText"), + extractFunction("findSelectedCourseRows"), + ].join("\n"); + + return Function(`${helperSource}\nreturn { findSelectedCourseRows };`)(); +} + +class FakeElement { + constructor(tagName, attrs = {}, ownText = "") { + this.tagName = tagName.toUpperCase(); + this.attrs = attrs; + this.ownText = ownText; + this.children = []; + this.parentElement = null; + this.onclick = attrs.onclick ? () => {} : null; + } + + append(...children) { + for (const child of children) { + child.parentElement = this; + this.children.push(child); + } + return this; + } + + get id() { + return this.attrs.id || ""; + } + + get className() { + return this.attrs.class || ""; + } + + get value() { + return this.attrs.value || ""; + } + + get textContent() { + return [this.ownText, ...this.children.map((child) => child.textContent)].join(""); + } + + getAttribute(name) { + return this.attrs[name] || null; + } + + matches(selector) { + selector = selector.trim(); + if (!selector) return false; + + if (selector.includes(",")) { + return selector.split(",").some((part) => this.matches(part)); + } + + if (selector === "*") return true; + if (selector === "[onclick]") return Boolean(this.attrs.onclick || this.onclick); + if (selector === '[id^="right_"]') return this.id.startsWith("right_"); + if (selector === 'input[name="right_kchid"]') { + return this.tagName === "INPUT" && this.attrs.name === "right_kchid"; + } + if (selector === 'input[type="button"]') { + return this.tagName === "INPUT" && this.attrs.type === "button"; + } + if (selector.startsWith(".")) { + return this.className.split(/\s+/).includes(selector.slice(1)); + } + if (selector.includes(".")) { + const [tag, className] = selector.split("."); + return ( + this.tagName === tag.toUpperCase() && + this.className.split(/\s+/).includes(className) + ); + } + + return this.tagName === selector.toUpperCase(); + } + + querySelectorAll(selector) { + const selectors = selector.split(",").map((part) => part.trim()); + const results = []; + + const visit = (node) => { + for (const child of node.children) { + if (selectors.some((part) => child.matches(part))) { + results.push(child); + } + visit(child); + } + }; + + visit(this); + return results; + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + + closest(selector) { + let current = this; + while (current) { + if (current.matches(selector)) { + return current; + } + current = current.parentElement; + } + return null; + } +} + +test("findSelectedCourseRows finds a drop button from the selected-course right list", () => { + const { findSelectedCourseRows } = loadCourseGrabberHelpers(); + + const dropButton = new FakeElement( + "button", + { + class: "btn btn-danger btn-sm", + onclick: + "cancelCourseZzxk('rightpage','JXB001','JXB001','23006153','1','XKKZ001')", + }, + "退选", + ); + + const row = new FakeElement("li", { + id: "right_JXB001", + class: "list-group-item", + }).append(new FakeElement("div", { class: "item" }).append(dropButton)); + + const selectedCourse = new FakeElement("div", { + id: "right_23006153", + class: "outer_xkxx_list", + }).append( + new FakeElement("h6", {}, "(23006153)法语综合实践(DELF) - 2.0 学分"), + new FakeElement("ul", { id: "right_ul_23006153" }).append( + new FakeElement("input", { name: "right_kchid", value: "23006153" }), + row, + ), + ); + + const root = new FakeElement("div").append(selectedCourse); + + const matches = findSelectedCourseRows("23006153", root); + + assert.equal(matches.length, 1); + assert.equal(matches[0].row, row); + assert.equal(matches[0].button, dropButton); +});