Skip to content
Merged
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 @@ -32,6 +33,8 @@ https://www.douyin.com/video/7651581603861720441

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

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

## ✨ 主要特性

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

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

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

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

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

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

### ⚠️ 重要提醒

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

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

**Q: 提示时间冲突怎么办?**
A: 脚本会自动尝试其他教学班,如果所有教学班都冲突会自动停止。
Expand Down
295 changes: 265 additions & 30 deletions courseGrabber.js
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,197 @@
return allClasses;
}

const DROP_BUTTON_EXCLUDED_TEXTS = ["取消退选", "已退选", "不可退选"];

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"))
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findClickableElementByTextincludes(targetText) 匹配,退选场景未传 excludedTexts,可能选中含“退选”字样的非目标可点击元素;建议对退选传入排除项或校验 onclick 语义。


function findClickableElementByText(root, targetText, excludedTexts = []) {
if (!root) {
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findClickableElementByTexttextContent.includes(targetText) 匹配,父容器文本包含子按钮文本时会同时入选 candidate,虽有 depth 排序偏向内层,但排除项 DROP_BUTTON_EXCLUDED_TEXTS 基于整段 elementText.includes;确认“取消退选”按钮的祖先节点文本也含“取消退选”从而被正确排除(测试 misleading 用例已覆盖单节点,未覆盖嵌套祖先)。


const elements = root.querySelectorAll("*");
const candidates = [];
for (let element of elements) {
const elementText = (element.textContent || "").trim();
if (!elementText.includes(targetText)) {
continue;
}

if (excludedTexts.some((text) => elementText.includes(text))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findClickableElementByTextquerySelectorAll("*") 遍历全部后代并做 textContent.includes,父容器 textContent 会包含子节点“退选”文本,可能先命中外层可点击容器而非真正的退选按钮;建议优先返回文本最贴近目标且自身可点击的最内层元素。

continue;
}

if (isElementClickable(element)) {
let depth = 0;
let current = element.parentElement;
while (current && current !== root) {
depth++;
current = current.parentElement;
}

candidates.push({
element,
exact: elementText === targetText,
depth,
textLength: elementText.length,
nativeControl:
element.tagName === "BUTTON" ||
element.tagName === "A" ||
(element.tagName === "INPUT" &&
element.getAttribute("type") === "button"),
});
}
}

candidates.sort((a, b) => {
if (a.exact !== b.exact) return a.exact ? -1 : 1;
if (a.depth !== b.depth) return b.depth - a.depth;
if (a.nativeControl !== b.nativeControl)
return a.nativeControl ? -1 : 1;
return a.textLength - b.textLength;
});

return candidates[0]?.element || 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,
"退选",
DROP_BUTTON_EXCLUDED_TEXTS,
);
const rowText = row.textContent || "";
if (!dropButton && !rowText.includes("退选")) {
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

非课程号分支仅靠 headingText.includes(input) 匹配 section,简称/同名课会匹配多 section,配合下游只取首行存在退错课风险;建议对多命中情况告警。

}

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 isTeachingClassMatchingIdentifier(teachingClass, courseIdentifier) {
if (!teachingClass || !teachingClass.row) {
return false;
}

const input = String(courseIdentifier).trim();
if (!input) {
return false;
}

if (isCourseCode(input)) {
const kchIdCell = teachingClass.row.querySelector("td.kch_id");
if (kchIdCell) {
return kchIdCell.textContent.trim() === input;
}

const classCourseCode = String(teachingClass.courseCode || "").trim();
return classCourseCode === input;
}

const className = teachingClass.info?.className || "";
const courseName = extractCourseNameFromJxbmc(className);
if (
courseName &&
(courseName === input ||
courseName.includes(input) ||
input.includes(courseName))
) {
return true;
}

const rowText = teachingClass.row.textContent || "";
return rowText.includes(input);
}

// 提取教学班信息
function extractTeachingClassInfo(row) {
try {
Expand Down Expand Up @@ -851,29 +1042,87 @@
}
}

// 退选指定课程
// 退选指定课程;courseCode 可为课程号或课程名,课程号匹配更安全
function dropCourse(courseCode) {
return new Promise((resolve) => {
try {
log(`🔄 开始退选课程: ${courseCode}`, "info", courseCode);

// 查找该课程的所有教学班
const teachingClasses = findAllTeachingClasses(courseCode);

if (teachingClasses.length === 0) {
log(`未找到课程 ${courseCode} 的教学班`, "warning", courseCode);
const selectedCourseRows = findSelectedCourseRows(courseCode);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

换课名匹配歧义拒绝依赖 findSelectedCourseRows 返回多行;该 helper 对课程名用 includes 宽松匹配,歧义语义仅在此处拦截,复用时需注意。

if (!isCourseCode(courseCode) && selectedCourseRows.length > 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

course-name 换课路径用 selectedCourseRows.length > 1 判定歧义,但同一门课的多教学班会产生多行,可能把正常单课程误判为歧义并放弃退选;建议按课程维度去重后再计数。

log(
`替换课程 ${courseCode} 匹配到多个已选课程,已放弃退选;请改用课程号避免误退`,
"warning",
courseCode,
);
resolve(false);
return;
}

// 查找包含"退选"按钮的教学班
let dropClass = null;
for (let tc of teachingClasses) {
const rowText = tc.row ? tc.row.textContent : "";
if (rowText.includes("退选")) {
dropClass = tc;
break;
let dropClass =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

选中行命中但无可点击按钮时,dropClass 非空导致跳过 findAllTeachingClasses fallback,相比旧逻辑可能退步;建议无按钮时继续 fallback。

selectedCourseRows.find((selectedRow) => selectedRow.button) || null;

if (dropClass) {
log(`已在已选课程列表中找到课程 ${courseCode}`, "info", courseCode);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

右侧列表命中后直接 dropClass = selectedCourseRows[0],未校验其 button 是否存在即进入后续;虽后面有 dropClass.button || findClickableElementByText(row, "退选") 兜底,但当 row 含“退选”文本却无可点击按钮时会走到 未找到退选按钮 分支,行为可接受,建议确认这是预期降级。

} else {
if (selectedCourseRows.length > 0) {
log(
`已选课程列表中找到 ${courseCode},但未找到可点击的退选按钮,尝试回退查找教学班`,
"warning",
courseCode,
);
}

// 回退:部分页面会把已选课程同时保留在左侧课程卡片中
const teachingClasses = findAllTeachingClasses(courseCode);

if (teachingClasses.length === 0) {
log(`未找到课程 ${courseCode} 的教学班`, "warning", courseCode);
resolve(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

回退分支已计算 button: findClickableElementByText(tc.row, "退选"),与下方 1057 行的 dropClass.button || findClickableElementByText(row, "退选") 重复定位同一按钮;建议回退分支只赋 dropClass = tc,按钮定位收敛到一处。

return;
}

// 查找包含"退选"按钮的教学班
const dropCandidates = [];
for (let tc of teachingClasses) {
if (!isTeachingClassMatchingIdentifier(tc, courseCode)) {
continue;
}

const dropButton = findClickableElementByText(
tc.row,
"退选",
DROP_BUTTON_EXCLUDED_TEXTS,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fallback 分支只在 dropCandidates.length > 1 时防歧义,length === 1 时未校验该教学班是否真正匹配替换课程标识;请对单一候选也做 courseCode/courseName 匹配校验,避免误退。

);
if (dropButton) {
dropCandidates.push({
...tc,
button: dropButton,
});
}
}

if (dropCandidates.length === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

回退分支 dropCandidates.length === 0 记录 warning 后未早返回,控制流继续依赖后续 !dropClass 兜底;建议此处直接 resolve(false); return;,与下方歧义分支的早返回结构一致。

log(
`未匹配替换课程 ${courseCode} 的可退选教学班`,
"warning",
courseCode,
);
resolve(false);
return;
}

if (!isCourseCode(courseCode) && dropCandidates.length > 1) {
log(
`替换课程 ${courseCode} 匹配到多个可退选教学班,已放弃退选;请改用课程号避免误退`,
"warning",
courseCode,
);
resolve(false);
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dropClass.button || findClickableElementByText(...) 二次查找可复用已收集的 button;确认 selected-course 路径下 dropClass.button 已非空时不会重复扫描 DOM,否则冗余。

dropClass = dropCandidates[0] || null;
}

if (!dropClass) {
Expand All @@ -884,23 +1133,9 @@

// 查找退选按钮
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, "退选", DROP_BUTTON_EXCLUDED_TEXTS);

if (!dropButton) {
log(`未找到课程 ${courseCode} 的退选按钮`, "warning", courseCode);
Expand Down
Loading