// 河北地质大学华信学院强智教务 (61.182.88.214:8090) 拾光课程表适配脚本
// 本校 25 届开发者适配,无法覆盖所有边界情况
// 出现问题请联系 4831884790@qq.com 或提交 pr,pr 更加快速
const BASE_URL = "http://61.182.88.214:8090";
/**
* 带错误检查的 fetch,返回响应文本
*/
async function fetchText(url, options = {}) {
const response = await fetch(url, { credentials: "include", ...options });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
}
/**
* 解析周次字符串为数组
* 支持 "1-17"、"1-17(周)"、"(单周)"/"(双周)" 标记(按段生效)、中英文逗号与顿号分隔
*/
function parseWeeks(weekStr) {
const weeks = [];
if (!weekStr) return weeks;
// 移除节次信息(防御性),保留周次段
const pureWeekData = weekStr.replace(/\[.*?节\]/g, '').trim();
if (!pureWeekData) return weeks;
// 分割并处理每个段
const segments = pureWeekData.split(/[,,、]/);
segments.forEach(seg => {
seg = seg.trim();
if (!seg) return;
// 检测本段的单双周标记(如 "1-17(单周)"、"6(双)"),需在剥离标记前记录
const onlyOdd = /单/.test(seg);
const onlyEven = /双/.test(seg);
seg = seg.replace(/单|双|周|\(.*?\)/g, '').trim();
if (!seg) return;
if (seg.includes('-')) {
const [start, end] = seg.split('-').map(Number);
if (!isNaN(start) && !isNaN(end)) {
for (let i = start; i <= end; i++) {
if (onlyOdd && i % 2 === 0) continue;
if (onlyEven && i % 2 === 1) continue;
weeks.push(i);
}
}
} else {
const w = parseInt(seg, 10);
if (!isNaN(w)) {
if (onlyOdd && w % 2 === 0) return;
if (onlyEven && w % 2 === 1) return;
weeks.push(w);
}
}
});
return [...new Set(weeks)].sort((a, b) => a - b);
}
/**
* 合并连续节次的相同课程
*/
function mergeAndDistinctCourses(courses) {
if (courses.length <= 1) return courses;
// 排序以便合并
courses.sort((a, b) => {
if (a.name !== b.name) return a.name.localeCompare(b.name);
if (a.day !== b.day) return a.day - b.day;
if (a.startSection !== b.startSection) return a.startSection - b.startSection;
if (a.teacher !== b.teacher) return a.teacher.localeCompare(b.teacher);
if (a.position !== b.position) return a.position.localeCompare(b.position);
return a.weeks.join(',').localeCompare(b.weeks.join(','));
});
const merged = [];
let current = courses[0];
for (let i = 1; i < courses.length; i++) {
const next = courses[i];
// 判断是否为同一门课程
const isSameCourse =
current.name === next.name &&
current.teacher === next.teacher &&
current.position === next.position &&
current.day === next.day &&
current.weeks.join(',') === next.weeks.join(',');
// 判断节次是否连续
const isContinuous = (current.endSection + 1 === next.startSection);
if (isSameCourse && isContinuous) {
// 合并连续节次
current.endSection = next.endSection;
} else if (!(isSameCourse && current.startSection === next.startSection && current.endSection === next.endSection)) {
// 完全重复的条目直接丢弃,其余推入结果
merged.push(current);
current = next;
}
}
merged.push(current);
return merged;
}
/**
* 从课程块中提取周次和节次
* 匹配 "1-17(周)[01-02节]" / "1-17(周)[01-02 节]" 格式,空格均可选
*/
function extractWeekAndSection(weekFull) {
const match = weekFull.match(/(.+?)\(周\)\s*\[\s*(\d+)-(\d+)\s*节\s*\]/);
if (match) {
return {
weekStr: match[1],
startSection: parseInt(match[2], 10),
endSection: parseInt(match[3], 10)
};
}
// 兜底:只匹配周次范围,节次按 1-2 节假设
const altMatch = weekFull.match(/(\d+)-(\d+)/);
if (altMatch) {
return {
weekStr: altMatch[0],
startSection: 1,
endSection: 2
};
}
return null;
}
/**
* 将 HTML 源码解析为课程模型
*/
function parseTimetableToModel(htmlString) {
const doc = new DOMParser().parseFromString(htmlString, "text/html");
const timetable = doc.getElementById('kbtable');
if (!timetable) {
return [];
}
const rawCourses = [];
// 行 = 大节,列 = 星期;只处理含 td 的行(跳过表头),备注/中午行会因缺少周次标记被自然过滤
const rows = Array.from(timetable.querySelectorAll('tr')).filter(r => r.querySelector('td'));
rows.forEach((row) => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, dayIndex) => {
const day = dayIndex + 1; // 星期几(1-7)
// 获取所有课程详情 div,包括所有状态的
const detailDivs = Array.from(cell.querySelectorAll('div.kbcontent'));
detailDivs.forEach((detailDiv) => {
const rawHtml = detailDiv.innerHTML.trim();
const innerText = detailDiv.innerText.trim();
if (!rawHtml || rawHtml === " " || innerText.length < 2) return;
// 分割同一个格子内的多门课程(10 个以上连字符即为分隔线)
const blocks = rawHtml.split(/-{10,}/);
blocks.forEach((block) => {
if (!block.trim()) return;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = block;
// 1. 提取课程名(跳过开头的空行,处理
开头的分隔块)
// 注意:innerHTML 中的 等实体不会自动解码,需手动处理
let name = "";
const htmlLines = tempDiv.innerHTML.split('
');
for (const line of htmlLines) {
const text = line
.replace(/<[^>]*>/g, '')
.replace(/ /gi, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text) {
name = text;
break;
}
}
if (!name) return;
// 2. 提取周次和节次信息
const weekFont = tempDiv.querySelector('font[title="周次(节次)"]');
const weekFull = weekFont?.innerText || "";
const weekSection = extractWeekAndSection(weekFull);
if (!weekSection) return;
// 3. 提取教师信息
const teacher = tempDiv.querySelector('font[title="老师"]')?.innerText.trim() || "未知教师";
// 4. 提取教室地点
const position = tempDiv.querySelector('font[title="教室"]')?.innerText.trim() || "未知地点";
rawCourses.push({
"name": name,
"teacher": teacher,
"weeks": parseWeeks(weekSection.weekStr),
"position": position,
"day": day,
"startSection": weekSection.startSection,
"endSection": weekSection.endSection
});
});
});
});
});
return mergeAndDistinctCourses(rawCourses);
}
/**
* 从网页中提取学期选项列表
*/
function extractSemesterOptions(htmlString) {
const doc = new DOMParser().parseFromString(htmlString, "text/html");
const semesterSelect = doc.getElementById('xnxq01id');
if (!semesterSelect) {
return [];
}
return Array.from(semesterSelect.querySelectorAll('option'))
.filter(opt => opt.value)
.map(opt => ({
value: opt.value,
text: opt.text
}));
}
/**
* 时间字符串加分钟数,"08:30" + 45 → "09:15"
*/
function addMinutes(hhmm, minutes) {
const parts = hhmm.split(':').map(Number);
const total = parts[0] * 60 + parts[1] + minutes;
const h = Math.floor(total / 60) % 24;
const m = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
/**
* 从网页中提取作息时间
* 课表页 #kbtable 中每个大节行头 th 形如 "第一大节\n08:30-10:05"
* 拆分规则:第 1 小节 = 开始 ~ 开始+45 分钟;第 2 小节 = 开始+55 分钟 ~ 大节结束
*/
function extractTimeSlots(htmlString) {
const doc = new DOMParser().parseFromString(htmlString, "text/html");
const timetable = doc.getElementById('kbtable');
if (!timetable) return null;
const timeSlots = [];
const rows = Array.from(timetable.querySelectorAll('tr'));
rows.forEach((row) => {
const th = row.querySelector('th');
if (!th) return;
// 提取时间范围,如 "08:30-10:05"
const timeText = th.innerText.trim();
const timeMatch = timeText.match(/(\d{2}:\d{2})-(\d{2}:\d{2})/);
if (!timeMatch) return;
const startTime = timeMatch[1];
const endTime = timeMatch[2];
// 判断是否是有效的时间段(排除"中午"等)
const sectionName = timeText.split('\n')[0].trim();
if (sectionName === '中午') return;
// 为每个大节创建两个小节(时间加法,勿用分钟硬替换)
timeSlots.push(
{
number: timeSlots.length + 1,
startTime: startTime,
endTime: addMinutes(startTime, 45)
},
{
number: timeSlots.length + 2,
startTime: addMinutes(startTime, 55),
endTime: endTime
}
);
});
return timeSlots.length > 0 ? timeSlots : null;
}
/**
* 根据学期字符串估算学期第一周周一日期
* 学期格式如 "2025-2026-2"
* 春季学期:3月第二个周一,秋季学期:9月1日所在周的周一
* 仅作兜底,优先使用教学周历解析出的真实日期
*/
function getSemesterStartMonday(semesterId) {
if (!semesterId) return null;
const parts = semesterId.split('-');
if (parts.length < 3) return null;
const year1 = parseInt(parts[0], 10);
const year2 = parseInt(parts[1], 10);
const semester = parseInt(parts[2], 10);
if (isNaN(year1) || isNaN(year2) || isNaN(semester)) return null;
// 估算学期开始日期
let startDate;
if (semester === 1) {
startDate = new Date(year1, 8, 1); // 秋季学期:约9月1日
} else {
// 春季学期:3月第二个周一
const march1Day = new Date(year2, 2, 1).getDay();
const firstMondayDate = march1Day === 1 ? 1 : march1Day === 0 ? 2 : 9 - march1Day;
startDate = new Date(year2, 2, firstMondayDate + 7); // 第二个周一
}
// 找到学期开始日期所在周的周一
const startDay = startDate.getDay(); // 0=周日
const monday = new Date(startDate);
monday.setDate(startDate.getDate() + (startDay === 0 ? -6 : 1 - startDay));
return monday;
}
/**
* 从教学周历页面 HTML 中解析第一周周一的日期
* 表格结构: