Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 在右上角控制台添加课程,设置优先级、时间/教师筛选、替换课程或定时开抢,然后开始选课。

## 🔎 适用范围与兼容性

Expand All @@ -26,6 +27,8 @@

运行前请在选课页检查是否能看到课程卡片和教学班信息。当前脚本依赖正方页面中的课程卡片结构(例如 `.panel-heading.kc_head` 与 `input[name="kch_id"]`);若页面结构不同,请先提交脱敏兼容性报告,而不要直接提高请求频率。

脚本只能识别和操作**已经加载到当前页面 DOM 中**的课程。若目标课程还藏在页面底部的“点此查看更多”之后,或需要继续查询/翻页后才会出现,脚本无法凭课程号自动把它从未加载的数据中找出来。建议先用课程号精确搜索,并确认目标课程已经呈现在页面列表里,再启动脚本。

## ✨ 主要特性

### 🚀 核心功能
Expand All @@ -49,11 +52,13 @@
- **全局 / 课程级过滤器** ——
支持设置全局过滤规则,或为单门课程单独配置过滤条件,满足不同选课策略需求。

### 🔽 自动展开课程信息
### 🔽 页面加载与自动展开课程信息

在选课过程中,页面需要完整展示课程的时间、教师等详细信息,因此**过滤逻辑仅在课程信息卡片处于展开状态时才会生效**。
在选课过程中,页面需要完整展示课程的时间、教师等详细信息,因此**过滤逻辑仅在课程信息卡片处于展开状态时才会生效**。
但由于正方选课系统在使用 jQuery 刷新列表时会重建 DOM 树,已展开的课程卡片会被自动折叠。为此,本脚本在页面刷新后**主动触发点击事件**,自动展开目标课程卡片。

这里的“展开课程信息”不等同于页面底部的“点此查看更多”。“点此查看更多”负责把更多课程加载进当前页面;脚本不能操作尚未加载进页面的课程。“展开课程信息”只是在目标课程已经出现在页面后,展开其教学班详情。

需要注意的是,展开课程信息会带来一定的性能开销,可能对抢课请求的响应时延产生影响。因此,在以下场景下,脚本会自动将 `CLICK2EXPEND_ENABLED` 设置为 `false`,以避免不必要的展开操作:

1. 页面中仅存在一门课程,且该课程为目标课程
Expand Down Expand Up @@ -257,7 +262,7 @@ grab.config.getConcurrentMode() // 获取并发模式状态

### ⚠️ 重要提醒

1. **必须点击"点此查看更多"** - DOM树必须完全展开,否则无法找到教学班
1. **目标课程必须已经出现在页面列表中** - 推荐用课程号精确搜索;若课程还在“点此查看更多”之后,脚本无法找到或操作
2. **保持页面打开** - 脚本运行期间不要关闭或刷新页面
3. **网络稳定** - 确保网络连接稳定
4. **提前测试** - 建议在正式选课前测试脚本功能
Expand All @@ -274,7 +279,7 @@ grab.config.getConcurrentMode() // 获取并发模式状态
### 🐛 常见问题

**Q: 脚本运行后找不到教学班?**
A: 请确保点击了"点此查看更多"按钮,将所有教学班展开
A: 请先确认目标课程已经加载到当前页面列表中。推荐使用课程号精确搜索;如果目标课程仍在“点此查看更多”之后,脚本无法找到。目标课程出现后,脚本会按需自动展开课程详情以读取教学班信息

**Q: 提示时间冲突怎么办?**
A: 脚本会自动尝试其他教学班,如果所有教学班都冲突会自动停止。
Expand Down
180 changes: 149 additions & 31 deletions courseGrabber.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions docs/usage-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 使用注意

本文说明脚本对正方自主选课页面的加载、展开与换课要求。

## 页面加载要求

脚本只能读取当前页面 DOM 中已经存在的课程和教学班。开始前请先在自主选课页面使用学校系统自带的搜索功能,把目标课程加载到课程列表中。

推荐使用课程号精确搜索。课程名可能存在简称、同名课、教学班名称截断或页面定制差异,课程号更稳定。

如果目标课程还在页面底部的“点此查看更多”之后,或需要继续查询/翻页后才会出现在列表中,脚本无法找到该课程,也无法对该课程执行选课或退选前置操作。此时请先手动加载更多或重新搜索,直到目标课程明确呈现在页面中。

## 课程详情展开

“点此查看更多”和“展开课程详情”是两件事:

- “点此查看更多”会让正方页面继续加载更多课程进当前列表。
- “展开课程详情”是在课程已经出现后,展开该课程下的教学班、教师、时间、容量和操作按钮。

脚本会在需要时间过滤或教师过滤时自动尝试展开目标课程详情;页面刷新或搜索刷新后,正方系统可能重建 DOM 并折叠已展开课程,脚本也会再次尝试展开目标课程。

自动展开不能替代“点此查看更多”。如果课程从未加载进当前页面,脚本没有可操作的 DOM 节点。

## 换课功能

换课,也就是“骑驴找马”,会在目标课程出现空位时,先退选配置中的替换课程,再尝试选择目标课程。

使用时请确认:

1. 目标课程已经出现在当前页面列表中。
2. 替换课程已经在右侧或已选课程区域中显示,并且页面上能看到“退选”按钮。
3. 替换课程尽量使用课程号填写,避免课程名简称或同名课程导致匹配不稳定。

退选后目标课程可能被其他人抢走,因此换课天然存在“退了旧课但没抢到新课”的风险。请只在能接受该风险时使用。

## 刷新影响

脚本会周期性刷新课程列表来观察余量变化。正方页面刷新列表时通常会还原为当前搜索结果的初始展示状态,之前手动加载的更多课程可能消失。

如果学校课程量很大,需要连续多次点击“点此查看更多”才能看到目标课程,建议先用课程号搜索缩小结果范围,再启动脚本。不要依赖持续刷新去维护一长串手动加载出来的课程列表。
Loading