From 0fa9573790beb7386da73df566be578e21c84f25 Mon Sep 17 00:00:00 2001 From: dev-Flyblue <1077373674@qq.com> Date: Fri, 20 Mar 2026 00:14:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Chaoxing=20(=E5=AD=A6=E4=B9=A0?= =?UTF-8?q?=E9=80=9A)=20adapter=20=E2=80=94=20assignments=20&=20exams=20(#?= =?UTF-8?q?101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ] [--status] [--limit] opencli chaoxing exams [--course ] [--status] [--limit] Co-authored-by: Claude Opus 4.6 (1M context) --- src/chaoxing.test.ts | 45 +++++ src/chaoxing.ts | 268 ++++++++++++++++++++++++++++++ src/clis/chaoxing/README.md | 36 ++++ src/clis/chaoxing/README.zh-CN.md | 35 ++++ src/clis/chaoxing/assignments.ts | 88 ++++++++++ src/clis/chaoxing/exams.ts | 88 ++++++++++ 6 files changed, 560 insertions(+) create mode 100644 src/chaoxing.test.ts create mode 100644 src/chaoxing.ts create mode 100644 src/clis/chaoxing/README.md create mode 100644 src/clis/chaoxing/README.zh-CN.md create mode 100644 src/clis/chaoxing/assignments.ts create mode 100644 src/clis/chaoxing/exams.ts diff --git a/src/chaoxing.test.ts b/src/chaoxing.test.ts new file mode 100644 index 0000000..e79edcf --- /dev/null +++ b/src/chaoxing.test.ts @@ -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('未知'); + }); +}); diff --git a/src/chaoxing.ts b/src/chaoxing.ts new file mode 100644 index 0000000..daf343c --- /dev/null +++ b/src/chaoxing.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/clis/chaoxing/README.md b/src/clis/chaoxing/README.md new file mode 100644 index 0000000..012dbcc --- /dev/null +++ b/src/clis/chaoxing/README.md @@ -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 diff --git a/src/clis/chaoxing/README.zh-CN.md b/src/clis/chaoxing/README.zh-CN.md new file mode 100644 index 0000000..20996ab --- /dev/null +++ b/src/clis/chaoxing/README.zh-CN.md @@ -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 解析器需同步更新 diff --git a/src/clis/chaoxing/assignments.ts b/src/clis/chaoxing/assignments.ts new file mode 100644 index 0000000..47fa419 --- /dev/null +++ b/src/clis/chaoxing/assignments.ts @@ -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 = { + 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, + })); + }, +}); diff --git a/src/clis/chaoxing/exams.ts b/src/clis/chaoxing/exams.ts new file mode 100644 index 0000000..83c9de4 --- /dev/null +++ b/src/clis/chaoxing/exams.ts @@ -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 = { + 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, + })); + }, +});