feat: add Chaoxing (学习通) adapter — assignments & exams (#101)
Add CLI commands to view Chaoxing assignments and exams by reusing Chrome login session via the Browser Bridge. Chaoxing has no flat API for listing assignments/exams. The adapter follows the browser flow: establish session → fetch course list via backclazzdata API → enter each course via stucoursemiddle redirect → click tab to capture iframe URL → navigate and parse DOM. Commands: opencli chaoxing assignments [--course <name>] [--status] [--limit] opencli chaoxing exams [--course <name>] [--status] [--limit] Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
65e30b9ac4
commit
0fa9573790
6 files changed
+560
No files matched your search
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTimestamp, workStatusLabel } from './chaoxing.js';
|
||||
|
||||
describe('formatTimestamp', () => {
|
||||
it('formats millisecond timestamp', () => {
|
||||
// 2026-01-15 08:30 UTC+8
|
||||
const ts = new Date('2026-01-15T00:30:00Z').getTime();
|
||||
const result = formatTimestamp(ts);
|
||||
expect(result).toMatch(/2026-01-15/);
|
||||
});
|
||||
|
||||
it('formats second timestamp', () => {
|
||||
const ts = Math.floor(new Date('2026-06-01T12:00:00Z').getTime() / 1000);
|
||||
const result = formatTimestamp(ts);
|
||||
expect(result).toMatch(/2026-06-01/);
|
||||
});
|
||||
|
||||
it('returns empty for null/undefined/0', () => {
|
||||
expect(formatTimestamp(null)).toBe('');
|
||||
expect(formatTimestamp(undefined)).toBe('');
|
||||
expect(formatTimestamp(0)).toBe('');
|
||||
expect(formatTimestamp('')).toBe('');
|
||||
});
|
||||
|
||||
it('passes through readable date strings', () => {
|
||||
expect(formatTimestamp('2026-03-20 23:59')).toBe('2026-03-20 23:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workStatusLabel', () => {
|
||||
it('maps numeric status codes', () => {
|
||||
expect(workStatusLabel(0)).toBe('未交');
|
||||
expect(workStatusLabel(1)).toBe('已交');
|
||||
expect(workStatusLabel(2)).toBe('已批阅');
|
||||
});
|
||||
|
||||
it('passes through string status', () => {
|
||||
expect(workStatusLabel('已交')).toBe('已交');
|
||||
});
|
||||
|
||||
it('returns 未知 for empty/null', () => {
|
||||
expect(workStatusLabel(null)).toBe('未知');
|
||||
expect(workStatusLabel('')).toBe('未知');
|
||||
});
|
||||
});
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Chaoxing (学习通) shared helpers.
|
||||
*
|
||||
* Flow: initSession → getCourses → enterCourse → getTabIframeUrl → navigate → parse DOM
|
||||
* Chaoxing has no flat "list all assignments" API; data is behind session-gated
|
||||
* course pages loaded as iframes.
|
||||
*/
|
||||
|
||||
import type { IPage } from './types.js';
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
/** Sleep for given milliseconds (anti-scraping delay). */
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Execute a credentialed fetch in the browser context, returning JSON or text. */
|
||||
export async function fetchChaoxing(page: IPage, url: string): Promise<any> {
|
||||
const urlJs = JSON.stringify(url);
|
||||
return page.evaluate(`
|
||||
async () => {
|
||||
const res = await fetch(${urlJs}, { credentials: "include" });
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); } catch {}
|
||||
return text;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
/** Format a timestamp (seconds or milliseconds or date string) to YYYY-MM-DD HH:mm. */
|
||||
export function formatTimestamp(ts: unknown): string {
|
||||
if (ts == null || ts === '' || ts === 0) return '';
|
||||
if (typeof ts === 'string' && !/^\d+$/.test(ts.trim())) return ts.trim();
|
||||
const num = Number(ts);
|
||||
if (Number.isNaN(num) || num <= 0) return String(ts);
|
||||
const millis = num > 1e12 ? num : num * 1000;
|
||||
const d = new Date(millis);
|
||||
if (Number.isNaN(d.getTime())) return String(ts);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
/** Map numeric work status to Chinese label. */
|
||||
export function workStatusLabel(status: unknown): string {
|
||||
if (status == null || status === '') return '未知';
|
||||
const s = Number(status);
|
||||
if (s === 0) return '未交';
|
||||
if (s === 1) return '已交';
|
||||
if (s === 2) return '已批阅';
|
||||
const str = String(status).trim();
|
||||
return str || '未知';
|
||||
}
|
||||
|
||||
// ── Course list ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ChaoxingCourse {
|
||||
courseId: string;
|
||||
classId: string;
|
||||
cpi: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Fetch enrolled course list via backclazzdata JSON API. */
|
||||
export async function getCourses(page: IPage): Promise<ChaoxingCourse[]> {
|
||||
const resp = await fetchChaoxing(
|
||||
page,
|
||||
'https://mooc1-api.chaoxing.com/mycourse/backclazzdata?view=json&rss=1',
|
||||
);
|
||||
if (!resp || typeof resp !== 'object') return [];
|
||||
const channelList: any[] = resp.channelList ?? [];
|
||||
const courses: ChaoxingCourse[] = [];
|
||||
for (const channel of channelList) {
|
||||
const content = channel?.content;
|
||||
if (!content) continue;
|
||||
const courseData = content.course?.data;
|
||||
if (!Array.isArray(courseData)) continue;
|
||||
for (const c of courseData) {
|
||||
courses.push({
|
||||
courseId: String(c.id ?? ''),
|
||||
classId: String(content.id ?? ''),
|
||||
cpi: String(channel.cpi ?? ''),
|
||||
title: String(c.name ?? ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
return courses;
|
||||
}
|
||||
|
||||
// ── Session & course entry ───────────────────────────────────────────
|
||||
|
||||
/** Navigate to the interaction page to establish a Chaoxing session. */
|
||||
export async function initSession(page: IPage): Promise<void> {
|
||||
await page.goto('https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction');
|
||||
await page.wait(3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a course via stucoursemiddle redirect (establishes course session + enc).
|
||||
* After this call the browser is on the course page.
|
||||
*/
|
||||
export async function enterCourse(page: IPage, course: ChaoxingCourse): Promise<void> {
|
||||
const url =
|
||||
`https://mooc1.chaoxing.com/visit/stucoursemiddle` +
|
||||
`?courseid=${course.courseId}&clazzid=${course.classId}&cpi=${course.cpi}&ismooc2=1&v=2`;
|
||||
await page.goto(url);
|
||||
await page.wait(3);
|
||||
}
|
||||
|
||||
/**
|
||||
* On the course page, click a tab (作业 / 考试) and return the iframe src
|
||||
* that gets loaded. Returns empty string if the tab is not found.
|
||||
*/
|
||||
export async function getTabIframeUrl(page: IPage, tabName: string): Promise<string> {
|
||||
const nameJs = JSON.stringify(tabName);
|
||||
const result: any = await page.evaluate(`
|
||||
async () => {
|
||||
const tabs = document.querySelectorAll('a[data-url]');
|
||||
let target = null;
|
||||
for (const tab of tabs) {
|
||||
if ((tab.innerText || '').trim() === ${nameJs}) { target = tab; break; }
|
||||
}
|
||||
if (!target) return '';
|
||||
target.click();
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const iframe = document.getElementById('frame_content-hd') || document.querySelector('iframe');
|
||||
return iframe?.src || '';
|
||||
}
|
||||
`);
|
||||
return typeof result === 'string' ? result : '';
|
||||
}
|
||||
|
||||
// ── Assignment parsing ───────────────────────────────────────────────
|
||||
|
||||
export interface AssignmentRow {
|
||||
course: string;
|
||||
title: string;
|
||||
deadline: string;
|
||||
status: string;
|
||||
score: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse assignments from the current page DOM (the 作业列表 page).
|
||||
* The page uses `.ulDiv li` items with status/deadline/score info.
|
||||
*/
|
||||
export async function parseAssignmentsFromDom(page: IPage, courseName: string): Promise<AssignmentRow[]> {
|
||||
const raw: any[] = await page.evaluate(`
|
||||
(() => {
|
||||
const items = [];
|
||||
// Each assignment is a li or div block; try multiple selectors
|
||||
const blocks = document.querySelectorAll('.ulDiv li, .work-list-item, .listContent > div, ul > li');
|
||||
for (const block of blocks) {
|
||||
const text = (block.innerText || '').trim();
|
||||
if (!text || text.length < 3) continue;
|
||||
// Skip filter buttons and headers
|
||||
if (/^(全部|已完成|未完成|筛选)$/.test(text)) continue;
|
||||
items.push(text);
|
||||
}
|
||||
// Fallback: split body text by common patterns
|
||||
if (items.length === 0) {
|
||||
const body = (document.body?.innerText || '').trim();
|
||||
return [body];
|
||||
}
|
||||
return items;
|
||||
})()
|
||||
`) ?? [];
|
||||
|
||||
const rows: AssignmentRow[] = [];
|
||||
for (const text of raw) {
|
||||
if (typeof text !== 'string' || text.length < 3) continue;
|
||||
// Skip noise
|
||||
if (/^(全部|已完成|未完成|筛选|暂无|提交的作业将经过)/.test(text)) continue;
|
||||
|
||||
const lines = text.split('\n').map((l: string) => l.trim()).filter(Boolean);
|
||||
if (!lines.length) continue;
|
||||
|
||||
// First meaningful line is the title
|
||||
const title = lines[0].replace(/\s+/g, ' ').trim();
|
||||
if (!title || /^(全部|已完成|未完成|筛选)$/.test(title)) continue;
|
||||
|
||||
// Extract status: 未交 / 待批阅 / 已完成 / 已批阅
|
||||
const statusMatch = text.match(/(未交|待批阅|已完成|已批阅)/);
|
||||
const status = statusMatch?.[1] ?? '';
|
||||
|
||||
// Extract deadline: "剩余XXX" or date pattern
|
||||
const remainMatch = text.match(/(剩余[\d天小时分钟秒]+)/);
|
||||
const dateMatch = text.match(/(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}(?:\s+\d{1,2}:\d{2})?)/);
|
||||
const deadline = remainMatch?.[1] ?? dateMatch?.[1] ?? '';
|
||||
|
||||
// Extract score (exclude "分钟")
|
||||
const scoreMatch = text.match(/(\d+(?:\.\d+)?)\s*分(?!钟)/);
|
||||
const score = scoreMatch?.[1] ?? '';
|
||||
|
||||
rows.push({ course: courseName, title, deadline, status, score });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Exam parsing ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ExamRow {
|
||||
course: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
status: string;
|
||||
score: string;
|
||||
}
|
||||
|
||||
/** Parse exams from the current page DOM (the 考试列表 page). */
|
||||
export async function parseExamsFromDom(page: IPage, courseName: string): Promise<ExamRow[]> {
|
||||
const raw: any[] = await page.evaluate(`
|
||||
(() => {
|
||||
const items = [];
|
||||
const blocks = document.querySelectorAll('.ulDiv li, .exam-list-item, .listContent > div, ul > li');
|
||||
for (const block of blocks) {
|
||||
const text = (block.innerText || '').trim();
|
||||
if (!text || text.length < 3) continue;
|
||||
if (/^(全部|已完成|未完成|筛选|暂无)$/.test(text)) continue;
|
||||
items.push(text);
|
||||
}
|
||||
if (items.length === 0) {
|
||||
const body = (document.body?.innerText || '').trim();
|
||||
return [body];
|
||||
}
|
||||
return items;
|
||||
})()
|
||||
`) ?? [];
|
||||
|
||||
// Check for "暂无考试"
|
||||
if (raw.length === 1 && typeof raw[0] === 'string' && raw[0].includes('暂无考试')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: ExamRow[] = [];
|
||||
for (const text of raw) {
|
||||
if (typeof text !== 'string' || text.length < 3) continue;
|
||||
if (/^(全部|已完成|未完成|筛选|暂无)/.test(text)) continue;
|
||||
|
||||
const lines = text.split('\n').map((l: string) => l.trim()).filter(Boolean);
|
||||
if (!lines.length) continue;
|
||||
|
||||
const title = lines[0].replace(/\s+/g, ' ').trim();
|
||||
if (!title || /^(全部|已完成|未完成|筛选)$/.test(title)) continue;
|
||||
|
||||
// Extract dates
|
||||
const dates = text.match(/\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{2}/g) ?? [];
|
||||
const start = dates[0] ?? '';
|
||||
const end = dates[1] ?? '';
|
||||
|
||||
// Status
|
||||
const statusMatch = text.match(/(未开始|进行中|已结束|已完成|未交|待批阅)/);
|
||||
let status = statusMatch?.[1] ?? '';
|
||||
if (!status && text.includes('剩余')) status = '进行中';
|
||||
|
||||
// Score (exclude "分钟")
|
||||
const scoreMatch = text.match(/(\d+(?:\.\d+)?)\s*分(?!钟)/);
|
||||
const score = scoreMatch?.[1] ?? '';
|
||||
|
||||
rows.push({ course: courseName, title, start, end, status, score });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Chaoxing (学习通) Adapter
|
||||
|
||||
View your Chaoxing assignments and exams from the terminal by reusing your Chrome login session.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Chrome must be running and already logged into Chaoxing (`i.chaoxing.com`).
|
||||
2. The opencli Browser Bridge extension must be installed.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli chaoxing assignments` | List assignments across all courses |
|
||||
| `opencli chaoxing assignments --course "数学"` | Filter by course name (fuzzy match) |
|
||||
| `opencli chaoxing assignments --status pending` | Filter: `all` / `pending` / `submitted` / `graded` |
|
||||
| `opencli chaoxing exams` | List exams across all courses |
|
||||
| `opencli chaoxing exams --course "数学"` | Filter by course name |
|
||||
| `opencli chaoxing exams --status upcoming` | Filter: `all` / `upcoming` / `ongoing` / `finished` |
|
||||
|
||||
## How It Works
|
||||
|
||||
Chaoxing has no flat API for listing assignments/exams. The adapter follows the same
|
||||
flow a student would in the browser:
|
||||
|
||||
1. Establish session via the interaction page
|
||||
2. Fetch enrolled course list (`backclazzdata` JSON API)
|
||||
3. Enter each course via `stucoursemiddle` redirect (obtains session `enc`)
|
||||
4. Click the 作业/考试 tab and capture the iframe URL
|
||||
5. Navigate to that URL and parse the DOM
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires `--course` filter for practical use (scanning all 40+ courses is slow)
|
||||
- Does not submit homework or exams
|
||||
- If Chaoxing changes page structure, the DOM parser may need updates
|
||||
@@ -0,0 +1,35 @@
|
||||
# 学习通(Chaoxing)适配器
|
||||
|
||||
通过复用 Chrome 登录态,在终端查看学习通的作业和考试列表。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. Chrome 必须正在运行,且已经登录学习通(`i.chaoxing.com`)。
|
||||
2. 需安装 opencli Browser Bridge 扩展。
|
||||
|
||||
## 命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `opencli chaoxing assignments` | 列出所有课程的作业 |
|
||||
| `opencli chaoxing assignments --course "数学"` | 按课程名模糊过滤 |
|
||||
| `opencli chaoxing assignments --status pending` | 按状态过滤:`all` / `pending` / `submitted` / `graded` |
|
||||
| `opencli chaoxing exams` | 列出所有课程的考试 |
|
||||
| `opencli chaoxing exams --course "数学"` | 按课程名模糊过滤 |
|
||||
| `opencli chaoxing exams --status upcoming` | 按状态过滤:`all` / `upcoming` / `ongoing` / `finished` |
|
||||
|
||||
## 工作原理
|
||||
|
||||
学习通没有扁平的"作业列表"API,适配器模拟学生在浏览器中的操作流程:
|
||||
|
||||
1. 通过交互页建立会话
|
||||
2. 通过 `backclazzdata` JSON API 获取课程列表
|
||||
3. 通过 `stucoursemiddle` 重定向进入课程(获取会话 `enc`)
|
||||
4. 点击作业/考试标签,捕获 iframe URL
|
||||
5. 导航到该 URL 并解析 DOM
|
||||
|
||||
## 限制
|
||||
|
||||
- 实际使用建议配合 `--course` 过滤(扫描全部 40+ 门课程较慢)
|
||||
- 不提交作业、不参加考试
|
||||
- 如学习通页面结构变动,DOM 解析器需同步更新
|
||||
@@ -0,0 +1,88 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
getCourses, initSession, enterCourse, getTabIframeUrl,
|
||||
parseAssignmentsFromDom, sleep,
|
||||
type AssignmentRow,
|
||||
} from '../../chaoxing.js';
|
||||
|
||||
cli({
|
||||
site: 'chaoxing',
|
||||
name: 'assignments',
|
||||
description: '学习通作业列表',
|
||||
domain: 'mooc2-ans.chaoxing.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
timeoutSeconds: 90,
|
||||
args: [
|
||||
{ name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
default: 'all',
|
||||
choices: ['all', 'pending', 'submitted', 'graded'],
|
||||
help: '按状态过滤',
|
||||
},
|
||||
{ name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
|
||||
],
|
||||
columns: ['rank', 'course', 'title', 'deadline', 'status', 'score'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
|
||||
|
||||
// 1. Establish session
|
||||
await initSession(page);
|
||||
|
||||
// 2. Get courses
|
||||
const courses = await getCourses(page);
|
||||
if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通');
|
||||
|
||||
const filtered = courseFilter
|
||||
? courses.filter(c => c.title.includes(courseFilter))
|
||||
: courses;
|
||||
if (courseFilter && !filtered.length) {
|
||||
throw new Error(`未找到匹配「${courseFilter}」的课程`);
|
||||
}
|
||||
|
||||
// 3. Per-course: enter → click 作业 tab → navigate to iframe → parse
|
||||
const allRows: AssignmentRow[] = [];
|
||||
|
||||
for (const c of filtered) {
|
||||
try {
|
||||
await enterCourse(page, c);
|
||||
const iframeUrl = await getTabIframeUrl(page, '作业');
|
||||
if (!iframeUrl) continue;
|
||||
|
||||
await page.goto(iframeUrl);
|
||||
await page.wait(2);
|
||||
|
||||
const rows = await parseAssignmentsFromDom(page, c.title);
|
||||
allRows.push(...rows);
|
||||
} catch {
|
||||
// Single course failure: skip, continue
|
||||
}
|
||||
if (filtered.length > 1) await sleep(600);
|
||||
}
|
||||
|
||||
// 4. Sort: pending first, then by deadline
|
||||
allRows.sort((a, b) => {
|
||||
const order = (s: string) =>
|
||||
s === '未交' ? 0 : s === '待批阅' ? 1 : s === '已完成' ? 2 : s === '已批阅' ? 3 : 4;
|
||||
return order(a.status) - order(b.status);
|
||||
});
|
||||
|
||||
// 5. Filter by status
|
||||
const statusMap: Record<string, string[]> = {
|
||||
pending: ['未交'],
|
||||
submitted: ['待批阅', '已完成'],
|
||||
graded: ['已批阅'],
|
||||
};
|
||||
const finalRows =
|
||||
statusFilter === 'all'
|
||||
? allRows
|
||||
: allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
|
||||
|
||||
return finalRows.slice(0, Number(limit)).map((item, i) => ({
|
||||
rank: i + 1,
|
||||
...item,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
getCourses, initSession, enterCourse, getTabIframeUrl,
|
||||
parseExamsFromDom, sleep,
|
||||
type ExamRow,
|
||||
} from '../../chaoxing.js';
|
||||
|
||||
cli({
|
||||
site: 'chaoxing',
|
||||
name: 'exams',
|
||||
description: '学习通考试列表',
|
||||
domain: 'mooc2-ans.chaoxing.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
timeoutSeconds: 90,
|
||||
args: [
|
||||
{ name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
default: 'all',
|
||||
choices: ['all', 'upcoming', 'ongoing', 'finished'],
|
||||
help: '按状态过滤',
|
||||
},
|
||||
{ name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
|
||||
],
|
||||
columns: ['rank', 'course', 'title', 'start', 'end', 'status', 'score'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
|
||||
|
||||
// 1. Establish session
|
||||
await initSession(page);
|
||||
|
||||
// 2. Get courses
|
||||
const courses = await getCourses(page);
|
||||
if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通');
|
||||
|
||||
const filtered = courseFilter
|
||||
? courses.filter(c => c.title.includes(courseFilter))
|
||||
: courses;
|
||||
if (courseFilter && !filtered.length) {
|
||||
throw new Error(`未找到匹配「${courseFilter}」的课程`);
|
||||
}
|
||||
|
||||
// 3. Per-course: enter → click 考试 tab → navigate to iframe → parse
|
||||
const allRows: ExamRow[] = [];
|
||||
|
||||
for (const c of filtered) {
|
||||
try {
|
||||
await enterCourse(page, c);
|
||||
const iframeUrl = await getTabIframeUrl(page, '考试');
|
||||
if (!iframeUrl) continue;
|
||||
|
||||
await page.goto(iframeUrl);
|
||||
await page.wait(2);
|
||||
|
||||
const rows = await parseExamsFromDom(page, c.title);
|
||||
allRows.push(...rows);
|
||||
} catch {
|
||||
// Single course failure: skip, continue
|
||||
}
|
||||
if (filtered.length > 1) await sleep(600);
|
||||
}
|
||||
|
||||
// 4. Sort: upcoming first
|
||||
allRows.sort((a, b) => {
|
||||
const order = (s: string) =>
|
||||
s === '未开始' ? 0 : s === '进行中' ? 1 : s === '已结束' ? 2 : s === '已完成' ? 3 : 4;
|
||||
return order(a.status) - order(b.status);
|
||||
});
|
||||
|
||||
// 5. Filter by status
|
||||
const statusMap: Record<string, string[]> = {
|
||||
upcoming: ['未开始'],
|
||||
ongoing: ['进行中'],
|
||||
finished: ['已结束', '已完成'],
|
||||
};
|
||||
const finalRows =
|
||||
statusFilter === 'all'
|
||||
? allRows
|
||||
: allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
|
||||
|
||||
return finalRows.slice(0, Number(limit)).map((item, i) => ({
|
||||
rank: i + 1,
|
||||
...item,
|
||||
}));
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user