diff --git a/CHANGELOG.md b/CHANGELOG.md index e342b51..402ca1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +## [0.12.0] - 2026-09-12 + +### Added + +- Add `tis courses detail CODE` with teaching-task selection, course content, + hours, readings, prerequisite descriptions, syllabus links and aggregate + enrollment statistics. Optional `--round` enriches selection-period metadata, + course notes and conflict hints; partial read failures retain the core detail. +- Keep course-library and semester-specific task attributes separate, preserve + zero/unknown counts, and omit raw student records and internal configuration. + +### Validation + +- Type checks and all 460 tests passed. Read-only checks for BMEB316 and a + BIO102B teaching task verified course content, population counts and selection + period metadata; course notes are displayed when returned by TIS. + ## [0.11.1] - 2026-09-12 ### Fixed diff --git a/README.md b/README.md index 33ee445..fcf7ed7 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,28 @@ SUSTech Online source. The new official commands are CLI-only at present. ## Output contract +Course details supplement the existing TIS course search and selection commands: + +```bash +sustech tis courses detail BMEB316 +sustech tis courses detail BIO102B --rwh 2026-2027-1-BIO102B-001 --round bxxk --json +``` + +Use an exact course code; if multiple teaching tasks match, the command lists +their identifiers and requires `--rwh`. `--semester` selects the catalog term. +Details include course content, learning outcomes, readings, prerequisite +descriptions and referenced courses, syllabus links, and aggregate enrollment +counts. `--round` adds the relevant selection type's live counts, period, notes +and conflicts. Without it, matching enrolled/cart records can supply counts; +missing counts remain unknown. Optional read failures are reported in `sources` +while the course details remain available. + +Teaching-task and course-library attributes stay separate. Syllabus documents +may describe an older offering; links require TIS authentication and are not +downloaded automatically. Population groups and quota-related counts retain +their original meanings, and are not converted into guaranteed remaining seats. +See [TIS course detail](docs/TIS_COURSE_DETAIL.md) for output fields and examples. + ```bash # Human-readable text sustech tis courses search "machine learning" @@ -237,7 +259,7 @@ review. A successful envelope looks like this: "ok": true, "command": "version", "data": { - "version": "0.11.1", + "version": "0.12.0", "runtime": "node v22.19.0" } } diff --git a/docs/TIS_COURSE_DETAIL.md b/docs/TIS_COURSE_DETAIL.md new file mode 100644 index 0000000..0af0e69 --- /dev/null +++ b/docs/TIS_COURSE_DETAIL.md @@ -0,0 +1,62 @@ +# TIS course detail + +`tis courses detail` is a read-only addition to the course-selection workflow. +It retrieves one exact course and teaching task without changing enrollment. + +```bash +sustech tis courses detail BMEB316 --semester 2026-2027-1 +sustech tis courses detail BIO102B --rwh 2026-2027-1-BIO102B-001 --round bxxk --json +sustech tis courses detail AI203 --semester 2026-2027-1 --jsonl +``` + +The course code is matched exactly. When more than one teaching task exists, +`TIS_COURSE_AMBIGUOUS` lists candidates; specify `--rwh` to select one. The detail +request uses the catalog's `kcid`, which differs from the mutation `courseId`. +Catalog lookup is live and bounded to 100 keyword matches; incomplete catalog +results produce an error instead of silently selecting a task. + +## Output + +| Field | Content | +| --- | --- | +| `course` | Normalized semester teaching task, teachers, meetings and identifiers | +| `catalog` | Course-library language/category, hours, assessment and teaching method | +| `content` | Plain-text introductions, objectives, outcomes, outline and readings; Chinese and English when supplied | +| `prerequisites` | Upstream description and deduplicated referenced course codes | +| `enrollment` | Aggregate counts, quotas, counts paired with quotas, source and current-account selection status | +| `selection` | Selection type, period and local start/end times, when `--round` returns them | +| `attachments` | Named Chinese/English syllabus links requiring TIS login | +| `teachingTeam` | Course-library teaching-team names and roles when populated | +| `notices` | Selection remarks, conflicts, preference summary, source differences and partial failures | +| `sources` | Per-source `available`, `empty`, `unavailable` or `not-requested` status | +| `reportedAt` | Read timestamp; not the original publication date | + +Text output prefers Chinese content when available, then English. JSON retains +both languages. JSONL emits one detail item and the normal summary record. +HTML is converted to plain text; embedded scripts/styles are removed. + +## Interpretation + +- `--round` is the existing selection-type code, such as `bxxk`; it is not the + same as the period name. Without this option, exact matching current-account + enrolled/cart records supply population data when available. +- Total, undergraduate, graduate, male, female, internal (`对内`) and external + (`对外`) counts are separate marginals. Their presence does not establish a + breakdown by department, major or year, or a cross-tabulation of those groups. +- Zero remains zero. Missing fields remain absent. Percentages are emitted only + when both component counts reconcile with a positive total. Quotas and counts + paired with quotas are separate from aggregate enrollment. +- A missing prerequisite list does not establish that a course has no + requirements. The upstream description preserves alternatives such as + “CS112 or CS109”; listed course references are not interpreted as an AND rule. +- A syllabus may be older than the selected term. Course-library language and + category can differ from the semester teaching task; both remain visible. +- Status `0` means pending, `1` effective, `A` waitlisted and `-1` void. Unresolved + codes, including `W`, retain the original code with an unknown label. +- Course-specific remarks and conflict information are displayed when returned. + This command does not claim to aggregate university-wide announcements. + +The output is explicitly projected. It excludes raw student identities, grades, +SQL configuration, internal staff IDs and raw error bodies. Optional enrichment +failures retain the core detail with source status; course resolution and the +primary detail response must succeed. There are no automatic attachment writes. diff --git a/package-lock.json b/package-lock.json index d6c5d48..986f80d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sustech-cli", - "version": "0.11.1", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sustech-cli", - "version": "0.11.1", + "version": "0.12.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@modelcontextprotocol/server": "^2.0.0", diff --git a/package.json b/package.json index a507b7c..914aef6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sustech-cli", - "version": "0.11.1", + "version": "0.12.0", "description": "Human-friendly and agent-ready command-line access to SUSTech services.", "license": "PolyForm-Noncommercial-1.0.0", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 32df9e7..1a0be15 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,8 @@ import { type AuthService, } from "./core/auth-check.js"; import { inferCommandName } from "./core/argv.js"; +import { validateCourseDetailOptions } from "./tis/course-detail.js"; +import { formatCourseDetail } from "./tis/course-detail-text.js"; import { formatBrandArt, shouldUseBrandColor } from "./core/branding.js"; import { CAPABILITIES, formatCapabilities } from "./core/capabilities.js"; import { CLI_PARSE_OPTIONS, COMMAND_OPTIONS, SHARED_OUTPUT_OPTION_NAMES, type CliOptionName } from "./core/command-metadata.js"; @@ -539,6 +541,7 @@ Usage: sustech pms delete apply JOB_ID --confirm sustech tis courses search [KEYWORD] [--semester YYYY-YYYY-N] [--limit N] [--refresh] sustech tis courses available [KEYWORD] --round ROUND [--semester YYYY-YYYY-N] [--limit N] + sustech tis courses detail CODE [--rwh RWH] [--round ROUND] [--semester YYYY-YYYY-N] sustech tis enrolled [--semester YYYY-YYYY-N] sustech tis schedule [--semester YYYY-YYYY-N] [--week N|--all] sustech tis grades [--semester YYYY-YYYY-N] @@ -936,6 +939,18 @@ async function main(argv: string[]): Promise { }, output); return; } + if (command === "courses" && operation === "detail") { + if (parsed.positionals.length !== 4) throw usageError("Use: tis courses detail CODE [--rwh RWH] [--round ROUND]"); + const options = validateCourseDetailOptions({ code: parsed.positionals[3], rwh: values.rwh, round: values.round }); + const semester = parseSemester(values.semester); + const client = await tisClient(values); + const result = await client.courseDetail(semester, options); + writeSuccess({ + command: "tis courses detail", data: result, text: formatCourseDetail(result), items: [result], + summary: { course: result.course.code, rwh: result.course.rwh, semester: semester.value }, + }, output); + return; + } if (command === "courses" && operation === "available") { const semester = parseSemester(values.semester); const round = required(values.round, "--round"); diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index f9d7892..fa397ed 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -132,6 +132,7 @@ export const CAPABILITIES: readonly Capability[] = [ capability("auth check", "Verify credentials against TIS, Blackboard, WS, booking, lib-booking (library-booking alias), or PMS.", "read", { authentication: "selected-service", status: "preview" }), capability("tis courses search", "Search the campus-wide course catalog.", "read", { authentication: "tis", status: "preview" }), capability("tis courses available", "Search courses available to the authenticated student.", "read", { authentication: "tis", status: "preview" }), + capability("tis courses detail", "Read course content, prerequisites, syllabus links, teaching-task notices and aggregate enrollment counts.", "read", { authentication: "tis", status: "preview" }), capability("tis enrolled", "Read the normalized enrolled-course schedule.", "read", { authentication: "tis", status: "preview" }), capability("tis schedule", "Read a week or full semester personal schedule.", "read", { authentication: "tis", status: "preview" }), capability("tis grades", "Read normalized grades and calculate GPA.", "read", { authentication: "tis", status: "preview" }), diff --git a/src/core/command-metadata.ts b/src/core/command-metadata.ts index 7bcaaab..77bbaab 100644 --- a/src/core/command-metadata.ts +++ b/src/core/command-metadata.ts @@ -252,6 +252,7 @@ export const COMMAND_OPTIONS: Readonly> "pms delete apply": ["credentials-file", "confirm"], "tis courses search": ["credentials-file", "semester", "limit", "refresh"], "tis courses available": ["credentials-file", "semester", "limit", "round"], + "tis courses detail": ["credentials-file", "semester", "rwh", "round"], "tis enrolled": ["credentials-file", "semester"], "tis schedule": ["credentials-file", "semester", "week", "all"], "tis grades": ["credentials-file", "semester"], diff --git a/src/core/version.ts b/src/core/version.ts index c21fb4d..58e9e3f 100644 --- a/src/core/version.ts +++ b/src/core/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = "0.11.1"; +export const CLI_VERSION = "0.12.0"; export const USER_AGENT = `sustech-cli/${CLI_VERSION} (+https://github.com/wormforce/sustech-cli)`; diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 68bad25..0ddbac7 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -14,11 +14,24 @@ import { const CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url)); +test("TIS detail is discoverable and validates course selectors before authentication", () => { + const described = run(["describe", "tis", "courses", "detail", "--json"]); + assert.equal(described.status, 0); + const detail = JSON.parse(described.stdout).data; + assert.equal(detail.command, "tis courses detail"); + assert.ok(detail.options.some((option: { name: string }) => option.name === "--rwh")); + for (const args of [["../profile"], [], ["CS101", "extra"]]) { + const invalid = runWithoutCredentials(["tis", "courses", "detail", ...args, "--json"]); + assert.equal(invalid.status, 2); + assert.equal(JSON.parse(invalid.stdout).error.code, "USAGE"); + } +}); + test("compiled CLI serves human text and versioned JSON from the real entrypoint", () => { const text = run(["version"]); assert.equal(text.status, 0); assert.match(text.stdout, /:\*##: :#######:/); - assert.match(text.stdout, /sustech-cli 0\.11\.1/); + assert.match(text.stdout, /sustech-cli 0\.12\.0/); assert.doesNotMatch(text.stdout, /\u001b\[/); const json = run(["version", "--json"]); @@ -27,7 +40,7 @@ test("compiled CLI serves human text and versioned JSON from the real entrypoint schemaVersion: "1", ok: true, command: "version", - data: { version: "0.11.1", runtime: `node ${process.version}` }, + data: { version: "0.12.0", runtime: `node ${process.version}` }, }); }); diff --git a/src/test/tis-course-detail.test.ts b/src/test/tis-course-detail.test.ts new file mode 100644 index 0000000..8bab059 --- /dev/null +++ b/src/test/tis-course-detail.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Semester } from "../core/semester.js"; +import { CliError } from "../core/errors.js"; +import type { TisSession } from "../tis/auth.js"; +import { TisClient } from "../tis/client.js"; +import { formatCourseDetail } from "../tis/course-detail-text.js"; +import { readCourseDetail, validateCourseDetailOptions } from "../tis/course-detail.js"; + +const semester: Semester = { xn: "2026-2027", xq: "1", value: "2026-2027-1" }; +const task = { kcid: "COURSE-LIBRARY-ID", id: "MUTATION-ID", kcdm: "DEMO101", kcmc: "课程示例", rwh: "2026-2027-1-DEMO101-001", xf: 3, dgjsmc: "Teacher", skyymc: "英文", kclbmc: "核心课", zrl: 10 }; + +function fixture(options: { tasks?: Record[]; detailCode?: string; selection?: Record[]; prerequisiteFailure?: boolean } = {}) { + const calls: Array<{ path: string; body: Record }> = []; + const session = { + async postForm(path: string, body: Record) { + calls.push({ path, body }); + if (path === "/Xsxktz/queryRwxxcxList") return { rwList: { total: (options.tasks ?? [task]).length, list: options.tasks ?? [task] } }; + if (path === "/kck/kcxxwh/xsckViewByxk") return { code: 200, content: { + kcxxbgbEntity: { kcdm: options.detailCode ?? "DEMO101", xf: 3, sjzxs: 64, sjllxs: 32, sjsyxs: 32, + skyymc: "中文", kclbmc: "选修课", sfyxxk: "1", xxkcms: "CS100 或 CS101", jxmb: "

Learn

", + kcnr: "

Week 1

Week 2

", jcjqtzl: "Book", + p_sql: "SECRET_SQL", xuesheng: "PRIVATE_STUDENT", xscj: "PRIVATE_GRADE" }, + kcdgbentity: { kczwjj: "

简介
第二行

", kczwdgwjm: "syllabus.pdf", kczwdg: "DO_NOT_EXPOSE_PATH" }, + kctdwhEntityList: [{ jsxm: "Teacher", sffzrmc: "负责人", zgh: "PRIVATE_STAFF" }], + } }; + if (path === "/Xsxk/queryYxkc") return { yxkcList: options.selection ?? [{ ...task, + yxzrs: "10", bksyxrs: "10", yjsyxrs: "0", nansyxrs: "0", nvsyxrs: "10", sxbj: "A", + yxzrlrs: "8", bksrl: "8", dnyxrs: null, xkyq: "

Bring laptop

", + xm: "PRIVATE_NAME", ip: "PRIVATE_IP", xscj: "PRIVATE_GRADE" }], xkgwcList: [], + xsxkPage: { p_sql: "SECRET_SQL" } }; + if (path === "/Xsxk/queryXkdqXnxq") return { p_dqxn: semester.xn, p_dqxq: semester.xq }; + if (path === "/Xsxk/queryKxrw") return { jg: "1", kxrwList: { list: [{ ...task, yxzrs: "12", nansyxrs: "8", nvsyxrs: "4", ctkcxx: "Time conflict" }] }, + xsxkPage: { xkgzszOne: { xkfsdm: "bxxk", xkfsmc: "必修", lcmc: "补退选", ksrq: "2026-09-11 13:00", jsrq: "2026-09-24 17:30", yzsql: "SECRET_SQL" } } }; + throw new Error(`Unexpected read path: ${path}`); + }, + async postJson(path: string, body: Record) { + calls.push({ path, body }); + if (options.prerequisiteFailure) throw new CliError("PRIVATE_ERROR_BODY", "TEST_SOURCE_DOWN"); + if (path === "/kck/xxxxkzkc/queryXxkc") return { total: 3, list: [{ kcdm: "CS100", kcmc: "编程", xf: 3, raw: "PRIVATE" }, { kcdm: "CS100", kcmc: "编程", xf: 3 }, { kcdm: "CS101", kcmc: "Python", xf: 3 }] }; + if (path === "/kck/xxxxkzkc/queryXxkcBySq") return { xxkcms: "

CS100 或 CS101

" }; + throw new Error(`Unexpected read path: ${path}`); + }, + }; + return { session, calls }; +} + +const noSelection = async () => { throw new Error("Selection query was not requested"); }; + +test("course detail preserves zero counts, separate quotas and prerequisite alternatives without raw identity", async () => { + const { session, calls } = fixture(); + const result = await readCourseDetail(session, semester, { code: "demo101" }, noSelection); + assert.equal(result.course.code, "DEMO101"); + assert.equal(result.enrollment.counts.male, 0); + assert.equal(result.enrollment.counts.graduate, 0); + assert.equal(result.enrollment.counts.internal, undefined); + assert.equal(result.enrollment.femalePercentage, 100); + assert.equal(result.enrollment.undergraduatePercentage, 100); + assert.equal(result.enrollment.quotaCounts.total, 8); + assert.equal(result.enrollment.counts.total, 10); + assert.equal(result.enrollment.status?.label, "候补"); + assert.deepEqual(result.prerequisites.courses.map(row => row.code), ["CS100", "CS101"]); + assert.equal(result.prerequisites.description, "CS100 或 CS101"); + assert.equal(result.content.outline, "Week 1\nWeek 2"); + assert.equal(result.content.introduction, "简介\n第二行"); + assert.equal(result.catalog.hours.laboratory, 32); + assert.equal(result.course.language, "英文"); + assert.equal(result.catalog.language, "中文"); + assert.equal(result.notices.filter(row => row.kind === "source-difference").length, 2); + assert.equal(result.attachments[0].authentication, "tis"); + assert.equal(new URL(result.attachments[0].url).searchParams.get("kcid"), "COURSE-LIBRARY-ID"); + assert.equal(calls.find(call => call.path.endsWith("xsckViewByxk"))?.body.kcid, "COURSE-LIBRARY-ID"); + assert.doesNotMatch(JSON.stringify(result), /PRIVATE_|SECRET_SQL|DO_NOT_/); + const rendered = formatCourseDetail(result); + assert.match(rendered, /male 0/); + assert.match(rendered, /Week 1\nWeek 2/); + assert.match(rendered, /Bring laptop/); + assert.doesNotMatch(rendered, /

|PRIVATE_|SECRET_SQL/); +}); + +test("ambiguous teaching tasks require rwh before detail or personal queries", async () => { + const second = { ...task, rwh: "2026-2027-1-DEMO101-002" }; + const { session, calls } = fixture({ tasks: [task, second] }); + await assert.rejects(readCourseDetail(session, semester, { code: "DEMO101" }, noSelection), + (error: unknown) => error instanceof CliError && error.code === "TIS_COURSE_AMBIGUOUS"); + assert.equal(calls.length, 1); + const result = await readCourseDetail(session, semester, { code: "DEMO101", rwh: second.rwh }, noSelection); + assert.equal(result.course.rwh, second.rwh); + assert.equal(result.enrollment.counts.total, undefined); // First section's population must not leak into the second. + assert.equal(result.enrollment.source, "catalog"); +}); + +test("course detail rejects mismatched course responses and validates identifiers before reading", async () => { + const { session, calls } = fixture({ detailCode: "OTHER101" }); + await assert.rejects(readCourseDetail(session, semester, { code: "DEMO101" }, noSelection), + (error: unknown) => error instanceof CliError && error.code === "TIS_COURSE_DETAIL_PROTOCOL_ERROR"); + assert.equal(calls.length, 2); + assert.throws(() => validateCourseDetailOptions({ code: "../../profile" }), /exact course code/); + assert.throws(() => validateCourseDetailOptions({ code: "DEMO101", rwh: "A\nB" }), /single identifiers/); +}); + +test("partial enrichment failures retain the course without exposing errors or claiming no prerequisites", async () => { + const { session } = fixture({ prerequisiteFailure: true }); + const result = await readCourseDetail(session, semester, { code: "DEMO101" }, noSelection); + assert.equal(result.sources.prerequisites.state, "unavailable"); + assert.equal(result.sources.prerequisiteDescription.state, "unavailable"); + assert.equal(result.prerequisites.description, "CS100 或 CS101"); + assert.match(formatCourseDetail(result), /TEST_SOURCE_DOWN/); + assert.doesNotMatch(JSON.stringify(result), /PRIVATE_ERROR_BODY/); +}); + +test("round enrichment reuses selection transport and exposes only matching task counts and notices", async () => { + const { session, calls } = fixture(); + const result = await new TisClient(session as unknown as TisSession).courseDetail(semester, { code: "DEMO101", round: "bxxk" }); + assert.equal(result.enrollment.source, "available"); + assert.equal(result.enrollment.counts.total, 12); + assert.equal(result.enrollment.femalePercentage, 33.3); + assert.equal(result.enrollment.status?.label, "候补"); + assert.equal(result.selection?.period, "补退选"); + assert.ok(result.notices.some(row => row.kind === "time-conflict")); + assert.ok(result.notices.some(row => row.kind === "quota-context")); + const query = calls.find(call => call.path === "/Xsxk/queryKxrw")!; + assert.equal(query.body.p_xkfsdm, "bxxk"); + assert.equal(query.body.p_gjz, "DEMO101"); + assert.equal(query.body.pageSize, "100"); + assert.doesNotMatch(JSON.stringify(result), /SECRET_SQL|PRIVATE_/); +}); + +test("unavailable selection rounds do not discard course-library details", async () => { + const { session } = fixture(); + const result = await readCourseDetail(session, semester, { code: "DEMO101", round: "xxxk" }, async () => { + throw new CliError("PRIVATE_RESPONSE", "SELECTION_UNAVAILABLE"); + }); + assert.equal(result.sources.availability.state, "unavailable"); + assert.equal(result.enrollment.source, "enrolled"); + assert.equal(result.selection, undefined); + assert.doesNotMatch(JSON.stringify(result), /PRIVATE_RESPONSE/); +}); + +test("inconsistent or unknown aggregate counts do not become invented percentages", async () => { + const { session } = fixture({ selection: [{ ...task, yxzrs: "10", nansyxrs: "8", nvsyxrs: "5", bksyxrs: "", yjsyxrs: null, sxbj: "W" }] }); + const result = await readCourseDetail(session, semester, { code: "DEMO101" }, noSelection); + assert.equal(result.enrollment.femalePercentage, undefined); + assert.equal(result.enrollment.undergraduatePercentage, undefined); + assert.equal(result.enrollment.counts.undergraduate, undefined); + assert.deepEqual(result.enrollment.status, { code: "W", label: "未知" }); +}); diff --git a/src/tis/client.ts b/src/tis/client.ts index 163a45c..76cdf4c 100644 --- a/src/tis/client.ts +++ b/src/tis/client.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { CliError } from "../core/errors.js"; import type { Semester } from "../core/semester.js"; import { TisSession } from "./auth.js"; +import { readCourseDetail, type CourseDetailOptions, type TisCourseDetail } from "./course-detail.js"; import { degreeProgressErrorMessage, degreeProgressPage, @@ -57,6 +58,10 @@ export interface TisSelectionState { export class TisClient { public constructor(private readonly session: TisSession) {} + public async courseDetail(semester: Semester, options: CourseDetailOptions): Promise { + return readCourseDetail(this.session, semester, options, query => this.selectionResponse(semester, query)); + } + public async searchCatalog( semester: Semester, options: { keyword?: string; limit: number; refresh?: boolean }, @@ -113,6 +118,33 @@ export class TisClient { semester: Semester, options: { keyword?: string; round: string; limit: number; cultivation?: "1" | "2" } , ): Promise { + const { raw, dq } = await this.selectionResponseWithTerm(semester, options); + const list = asRecord(raw.kxrwList); + const directRound = asRecord(raw.xkgzszOne); + const currentRound = Object.keys(directRound).length > 0 + ? directRound + : asRecord(asRecord(raw.xsxkPage).xkgzszOne); + return { + currentTerm: dq, + courses: asRecords(list.list).map(normaliseCourse), + total: numberValue(list.total) ?? 0, + enrolled: asRecords(raw.yxkcList), + cart: asRecords(raw.xkgwcList), + round: currentRound, + }; + } + + private async selectionResponse( + semester: Semester, + options: { keyword?: string; round: string; limit: number; cultivation?: "1" | "2" }, + ): Promise> { + return (await this.selectionResponseWithTerm(semester, options)).raw; + } + + private async selectionResponseWithTerm( + semester: Semester, + options: { keyword?: string; round: string; limit: number; cultivation?: "1" | "2" }, + ): Promise<{ raw: Record; dq: Record }> { const dq = await this.currentTerm(); const raw = asRecord( await this.session.postForm("/Xsxk/queryKxrw", { @@ -154,19 +186,7 @@ export class TisClient { { round: options.round, tisCode: stringValue(raw.jg) }, ); } - const list = asRecord(raw.kxrwList); - const directRound = asRecord(raw.xkgzszOne); - const currentRound = Object.keys(directRound).length > 0 - ? directRound - : asRecord(asRecord(raw.xsxkPage).xkgzszOne); - return { - currentTerm: dq, - courses: asRecords(list.list).map(normaliseCourse), - total: numberValue(list.total) ?? 0, - enrolled: asRecords(raw.yxkcList), - cart: asRecords(raw.xkgwcList), - round: currentRound, - }; + return { raw, dq }; } public async enrolled(semester: Semester): Promise { diff --git a/src/tis/course-detail-text.ts b/src/tis/course-detail-text.ts new file mode 100644 index 0000000..d134b6a --- /dev/null +++ b/src/tis/course-detail-text.ts @@ -0,0 +1,57 @@ +import type { PopulationCounts, TisCourseDetail } from "./course-detail.js"; + +export function formatCourseDetail(report: TisCourseDetail): string { + const { course, catalog, enrollment } = report; + const lines = [ + `${course.code} — ${course.name} · ${report.semester.value}`, + `Section: ${course.classGroup || "-"} · RWH: ${course.rwh}`, + `Teacher: ${course.teachers.join(", ") || "-"}`, + `Teaching task: ${course.credits} credits · ${course.language || "language not returned"} · ${course.category || "-"} / ${course.nature || "-"}`, + ]; + for (const meeting of course.schedule) { + lines.push(` ${meeting.dayName} ${meeting.periodStart}-${meeting.periodEnd} · weeks ${meeting.weeks.join(",")} · ${meeting.room}`); + } + lines.push("", `Enrollment (${enrollment.source})`, + ` ${counts(enrollment.counts)}`, + ` Quotas: ${counts(enrollment.quotas)}`, + ` Counts paired with quotas: ${counts(enrollment.quotaCounts)}`); + if (enrollment.femalePercentage !== undefined) lines.push(` Female: ${enrollment.femalePercentage}%`); + if (enrollment.undergraduatePercentage !== undefined) lines.push(` Undergraduate: ${enrollment.undergraduatePercentage}%`); + if (enrollment.status) lines.push(` My selection status: ${enrollment.status.label} (${enrollment.status.code})`); + if (report.selection) { + const selection = report.selection; + lines.push("", `Selection: ${selection.typeName || selection.typeCode || "-"} · ${selection.period || "-"}`, + ` ${selection.startsAt || "?"} — ${selection.endsAt || "?"} (TIS local time)`); + } + lines.push("", "Course-library details", + ` ${catalog.nameEn || course.name}`, + ` Credits: ${catalog.credits ?? "?"} · Hours: ${catalog.hours.total ?? "?"} (lecture ${catalog.hours.lecture ?? "?"}, laboratory ${catalog.hours.laboratory ?? "?"})`, + ` ${catalog.teachingMethod || "-"} · ${catalog.assessmentMethod || "-"} · ${catalog.language || "-"} · ${catalog.category || "-"}`); + if (report.teachingTeam.length) lines.push(` Course team: ${report.teachingTeam.map(member => `${member.name}${member.role ? ` (${member.role})` : ""}`).join(", ")}`); + lines.push("", "Prerequisites", report.prerequisites.description || "Description not returned by TIS."); + if (report.prerequisites.courses.length) { + lines.push("Listed courses (the description determines the relationship):", + ...report.prerequisites.courses.map(row => ` ${row.code} — ${row.name}${row.credits !== undefined ? ` · ${row.credits} credits` : ""}`)); + } + const sections = [ + ["Introduction", report.content.introduction || report.content.introductionEn], + ["Objectives", report.content.objectives || report.content.objectivesEn], + ["Learning outcomes", report.content.outcomes || report.content.outcomesEn], + ["Course outline", report.content.outline || report.content.outlineEn], + ["Readings", report.content.readings || report.content.readingsEn], + ]; + for (const [heading, body] of sections) if (body) lines.push("", heading!, body); + if (report.attachments.length) lines.push("", "Syllabus attachments (TIS login required)", + ...report.attachments.map(attachment => ` ${attachment.filename}\n ${attachment.url}`)); + if (report.notices.length) lines.push("", "Notes", ...report.notices.map(notice => `- ${notice.message}`)); + lines.push("", `Read at: ${report.reportedAt}`); + return lines.join("\n"); +} + +function counts(value: PopulationCounts): string { + const labels: Record = { + total: "total", undergraduate: "undergraduate", graduate: "graduate", male: "male", female: "female", internal: "对内", external: "对外", + }; + const entries = (Object.keys(labels) as Array).filter(key => value[key] !== undefined); + return entries.length ? entries.map(key => `${labels[key]} ${value[key]}`).join(" · ") : "not returned"; +} diff --git a/src/tis/course-detail.ts b/src/tis/course-detail.ts new file mode 100644 index 0000000..ad5b9e1 --- /dev/null +++ b/src/tis/course-detail.ts @@ -0,0 +1,254 @@ +import { load } from "cheerio"; +import { CliError } from "../core/errors.js"; +import type { Semester } from "../core/semester.js"; +import type { TisSession } from "./auth.js"; +import { normaliseCourse } from "./normalise.js"; +import { assertPlanningProjection } from "./planning-projection.js"; +import type { Course } from "./types.js"; + +type RecordValue = Record; +type SourceStatus = { state: "available" | "empty" | "unavailable" | "not-requested"; errorCode?: string }; +export interface CourseDetailOptions { code: string; rwh?: string; round?: string } +export interface PopulationCounts { + total?: number; + undergraduate?: number; + graduate?: number; + male?: number; + female?: number; + internal?: number; + external?: number; +} +export interface TisCourseDetail { + semester: Semester; + course: Course; + catalog: { + nameEn?: string; + credits?: number; + hours: { total?: number; lecture?: number; laboratory?: number }; + language?: string; + category?: string; + assessmentMethod?: string; + teachingMethod?: string; + }; + content: Partial>; + prerequisites: { + enabled?: boolean; + description?: string; + courses: Array<{ code: string; name: string; credits?: number }>; + }; + enrollment: { + source: "available" | "enrolled" | "cart" | "catalog"; + counts: PopulationCounts; + quotas: PopulationCounts; + quotaCounts: PopulationCounts; + femalePercentage?: number; + undergraduatePercentage?: number; + status?: { code: string; label: string }; + }; + selection?: { typeCode?: string; typeName?: string; period?: string; startsAt?: string; endsAt?: string }; + attachments: Array<{ kind: "syllabus-zh" | "syllabus-en"; filename: string; url: string; authentication: "tis" }>; + teachingTeam: Array<{ name: string; role?: string }>; + notices: Array<{ kind: string; message: string }>; + sources: Record<"catalog" | "detail" | "enrollment" | "availability" | "prerequisites" | "prerequisiteDescription", SourceStatus>; + reportedAt: string; +} + +type SelectionReader = (options: { keyword: string; round: string; limit: number }) => Promise; + +export function validateCourseDetailOptions(options: CourseDetailOptions): CourseDetailOptions { + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(options.code)) { + throw new CliError("Supply one exact course code, for example BMEB316.", "USAGE", 2); + } + for (const value of [options.rwh, options.round]) { + if (value !== undefined && (!value.trim() || value.length > 200 || /[\s\x00-\x1f\x7f]/.test(value))) { + throw new CliError("--rwh and --round must be single identifiers.", "USAGE", 2); + } + } + return { ...options, code: options.code.toUpperCase() }; +} + +export async function readCourseDetail( + session: Pick, + semester: Semester, + input: CourseDetailOptions, + selectionReader: SelectionReader, +): Promise { + const options = validateCourseDetailOptions(input); + const catalogResponse = record(await session.postForm("/Xsxktz/queryRwxxcxList", { + p_xn: semester.xn, p_xq: semester.xq, p_xnxq: "", p_gjz: options.code, + p_xiaoqu: "", p_kkyx: "", p_rwlx: "", p_kclb: "", p_kcxz: "", p_chaxunpylx: "3", + pageNum: "1", pageSize: "100", + })); + const page = record(catalogResponse.rwList); + if (!Array.isArray(page.list)) throw protocolError(); + const allRows = records(page.list); + if ((number(page.total) ?? allRows.length) > allRows.length || page.hasNextPage === true) { + throw new CliError("The catalog result is incomplete; narrow the course query before requesting detail.", "TIS_COURSE_DETAIL_INCOMPLETE"); + } + const matches = allRows.filter(row => scalar(row.kcdm).toUpperCase() === options.code + && (!options.rwh || scalar(row.rwh) === options.rwh)); + const tasks = [...new Map(matches.map(row => [scalar(row.rwh), row])).values()]; + if (!tasks.length) throw new CliError("No matching teaching task was returned for this course and semester.", "TIS_COURSE_NOT_FOUND", 1); + if (tasks.length > 1) { + const candidates = tasks.map(row => ({ rwh: scalar(row.rwh), name: text(row.rwmc), teachers: text(row.dgjsmc) })); + throw new CliError(`This course has multiple teaching tasks. Select one with --rwh: ${candidates.map(row => row.rwh).join(", ")}`, + "TIS_COURSE_AMBIGUOUS", 2, { candidates }); + } + const task = tasks[0]; + const kcid = scalar(task.kcid); + const rwh = scalar(task.rwh); + if (!kcid || !rwh) throw protocolError(); + const detailResponse = record(await session.postForm("/kck/kcxxwh/xsckViewByxk", { kcid, rwh })); + const detail = record(detailResponse.content); + const basic = record(detail.kcxxbgbEntity); + if (String(detailResponse.code) !== "200" || scalar(basic.kcdm).toUpperCase() !== options.code) throw protocolError(); + const syllabus = record(detail.kcdgbentity); + const sources: TisCourseDetail["sources"] = { + catalog: { state: "available" }, detail: { state: "available" }, + enrollment: { state: "not-requested" }, availability: { state: "not-requested" }, + prerequisites: { state: "not-requested" }, prerequisiteDescription: { state: "not-requested" }, + }; + async function optional(name: keyof typeof sources, fetch: () => Promise): Promise { + try { + const result = record(await fetch()); + // Keep upstream bodies and authentication details out of partial-error output. + if (result.code !== undefined && String(result.code) !== "200") throw protocolError(); + sources[name] = { state: "available" }; + return result; + } catch (error) { + sources[name] = { state: "unavailable", errorCode: error instanceof CliError ? error.code : "TIS_READ_FAILED" }; + return {}; + } + } + const sameTask = (row: RecordValue) => scalar(row.rwh) === rwh && scalar(row.kcdm).toUpperCase() === options.code; + const personal = await optional("enrollment", () => session.postForm("/Xsxk/queryYxkc", { p_xn: semester.xn, p_xq: semester.xq })); + if (sources.enrollment.state === "available" && !Array.isArray(personal.yxkcList)) { + sources.enrollment = { state: "unavailable", errorCode: "TIS_COURSE_DETAIL_PROTOCOL_ERROR" }; + } + const enrolled = records(personal.yxkcList).find(sameTask); + const cart = records(personal.xkgwcList).find(sameTask); + if (sources.enrollment.state === "available" && !enrolled && !cart) sources.enrollment = { state: "empty" }; + let available: RecordValue | undefined; + let round: RecordValue = {}; + if (options.round) { + const response = await optional("availability", () => selectionReader({ keyword: options.code, round: options.round!, limit: 100 })); + available = records(record(response.kxrwList).list).find(sameTask); + round = Object.keys(record(response.xkgzszOne)).length ? record(response.xkgzszOne) : record(record(response.xsxkPage).xkgzszOne); + if (sources.availability.state === "available" && !available) sources.availability = { state: "empty" }; + } + const prerequisiteResponse = await optional("prerequisites", () => session.postJson("/kck/xxxxkzkc/queryXxkc", { kcid })); + const descriptionResponse = await optional("prerequisiteDescription", () => session.postJson("/kck/xxxxkzkc/queryXxkcBySq", { kcdm: options.code })); + if (sources.prerequisites.state === "available" && !Array.isArray(prerequisiteResponse.list)) { + sources.prerequisites = { state: "unavailable", errorCode: "TIS_COURSE_DETAIL_PROTOCOL_ERROR" }; + } + const prerequisites = records(prerequisiteResponse.list).map(row => ({ + code: scalar(row.kcdm), name: text(row.kcmc), ...numericProperty("credits", row.xf), + })).filter(row => row.code); + const uniquePrerequisites = [...new Map(prerequisites.map(row => [row.code, row])).values()]; + if (sources.prerequisites.state === "available" && !uniquePrerequisites.length) sources.prerequisites = { state: "empty" }; + const description = text(descriptionResponse.xxkcms) || text(basic.xxkcms); + if (sources.prerequisiteDescription.state === "available" && !text(descriptionResponse.xxkcms)) sources.prerequisiteDescription = { state: "empty" }; + + const live = available ?? enrolled ?? cart; + const course = normaliseCourse(task); + // A live teaching task can differ from the course-library description. + if (live) { + const normalized = normaliseCourse(live); + for (const field of ["language", "category", "nature"] as const) if (normalized[field]) course[field] = normalized[field]; + } + const population = live ?? task; + const counts = populationCounts(population, ["yxzrs", "bksyxrs", "yjsyxrs", "nansyxrs", "nvsyxrs", "dnyxrs", "dwyxrs"]); + const quotas = populationCounts(population, ["zrl", "bksrl", "yjsrl", "nansrl", "nvsrl", "dnrl", "dwrl"]); + const quotaCounts = populationCounts(population, ["yxzrlrs", "bksyxrlrs", "yjsyxrlrs", "nansyxrlrs", "nvsyxrlrs", "dnyxrlrs", "dwyxrlrs"]); + const notices: TisCourseDetail["notices"] = []; + for (const [field, kind] of [["xkyq", "selection-note"], ["ctkcxx", "time-conflict"], ["zyslxzqk", "preference-summary"]]) { + const message = text(population[field]); + if (message) notices.push({ kind, message }); + } + if (course.language && text(basic.skyymc) && course.language !== text(basic.skyymc)) { + notices.push({ kind: "source-difference", message: `Teaching-task language: ${course.language}; course-library language: ${text(basic.skyymc)}.` }); + } + if (course.category && text(basic.kclbmc) && course.category !== text(basic.kclbmc)) { + notices.push({ kind: "source-difference", message: `Teaching-task category: ${course.category}; course-library category: ${text(basic.kclbmc)}.` }); + } + if (counts.total === undefined) notices.push({ kind: "population-unavailable", message: "Live enrollment counts were not returned. Use --round with the relevant selection type to query availability." }); + if (counts.total !== undefined && quotas.total !== undefined && counts.total > quotas.total) notices.push({ kind: "quota-context", message: "Reported enrollment exceeds nominal capacity; the difference is not a personal remaining-seat count." }); + for (const [source, status] of Object.entries(sources)) { + if (status.state === "unavailable") notices.push({ kind: "source-unavailable", message: `${source}: unavailable (${status.errorCode}).` }); + } + const attachments: TisCourseDetail["attachments"] = []; + for (const [field, kind, flag] of [["kczwdgwjm", "syllabus-zh", "zwfj"], ["kcywdgwjm", "syllabus-en", "ywfj"]] as const) { + const filename = text(syllabus[field]); + if (filename) attachments.push({ kind, filename, url: `https://tis.sustech.edu.cn/kck/kcxxwh/downFj?${new URLSearchParams({ kcid, fjflag: flag, downFlag: "" })}`, authentication: "tis" }); + } + if (attachments.length) notices.push({ kind: "syllabus-version", message: "Syllabus attachments are course-library documents; their applicability to this semester has not been verified." }); + const report: TisCourseDetail = { + semester, course, + catalog: { + ...textProperty("nameEn", basic.kcywmc), ...numericProperty("credits", basic.xf), + hours: { ...numericProperty("total", basic.sjzxs), ...numericProperty("lecture", basic.sjllxs), ...numericProperty("laboratory", basic.sjsyxs) }, + ...textProperty("language", basic.skyymc), ...textProperty("category", basic.kclbmc), + ...textProperty("assessmentMethod", basic.khfsmc), ...textProperty("teachingMethod", basic.skfsmc), + }, + content: { + ...textProperty("introduction", syllabus.kczwjj ?? basic.kczwjj), ...textProperty("introductionEn", syllabus.kcywjj ?? basic.kcywjj), + ...textProperty("objectives", basic.jxmb), ...textProperty("objectivesEn", basic.jxmb_en), + ...textProperty("outcomes", basic.ydxxcg), ...textProperty("outcomesEn", basic.ydxxcg_en), + ...textProperty("outline", basic.kcnr), ...textProperty("outlineEn", basic.kcnr_en), + ...textProperty("readings", basic.jcjqtzl), ...textProperty("readingsEn", basic.jcjqtzl_en), + }, + prerequisites: { ...(basic.sfyxxk === "0" || basic.sfyxxk === "1" ? { enabled: basic.sfyxxk === "1" } : {}), ...(description ? { description } : {}), courses: uniquePrerequisites }, + enrollment: { + source: available ? "available" : enrolled ? "enrolled" : cart ? "cart" : "catalog", + counts, quotas, quotaCounts, + ...percentage("femalePercentage", counts.female, counts.male, counts.total), + ...percentage("undergraduatePercentage", counts.undergraduate, counts.graduate, counts.total), + ...(enrolled && scalar(enrolled.sxbj) ? { status: enrollmentStatus(scalar(enrolled.sxbj)) } : {}), + }, + ...(Object.keys(round).length ? { selection: { + ...textProperty("typeCode", round.xkfsdm), ...textProperty("typeName", round.xkfsmc), + ...textProperty("period", round.lcmc), ...textProperty("startsAt", round.ksrq), ...textProperty("endsAt", round.jsrq), + } } : {}), + attachments, + teachingTeam: records(detail.kctdwhEntityList).map(row => ({ name: text(row.jsxm), ...textProperty("role", row.sffzrmc) })).filter(row => row.name), + notices, sources, reportedAt: new Date().toISOString(), + }; + assertPlanningProjection(report); + return report; +} + +function populationCounts(row: RecordValue, fields: string[]): PopulationCounts { + const names = ["total", "undergraduate", "graduate", "male", "female", "internal", "external"] as const; + return Object.fromEntries(names.flatMap((name, i) => { + const n = number(row[fields[i]]); + return n !== undefined && Number.isInteger(n) && n >= 0 ? [[name, n]] : []; + })); +} +function percentage(key: K, part?: number, other?: number, total?: number): Partial> { + return total !== undefined && total > 0 && part !== undefined && other !== undefined && part + other === total + ? { [key]: Math.round(part / total * 1000) / 10 } as Record : {}; +} +function enrollmentStatus(code: string): { code: string; label: string } { + return { code, label: ({ "0": "待生效", "1": "已生效", A: "候补", "-1": "作废" } as Record)[code] ?? "未知" }; +} +function number(value: unknown): number | undefined { + if (typeof value !== "number" && typeof value !== "string") return undefined; + if (typeof value === "string" && !value.trim()) return undefined; + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : undefined; +} +function scalar(value: unknown): string { return typeof value === "string" || typeof value === "number" ? String(value).trim() : ""; } +function record(value: unknown): RecordValue { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {}; } +function records(value: unknown): RecordValue[] { return Array.isArray(value) ? value.filter(row => row && typeof row === "object" && !Array.isArray(row)) : []; } +function text(value: unknown): string { + const source = scalar(value); + if (!source) return ""; + const $ = load(source); + $("script,style,iframe,object").remove(); + $("br").replaceWith("\n"); + $("p,div,li,tr,h1,h2,h3,h4").append("\n"); + return $.root().text().replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "").replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim(); +} +function textProperty(key: K, value: unknown): Partial> { const result = text(value); return result ? { [key]: result } as Record : {}; } +function numericProperty(key: K, value: unknown): Partial> { const result = number(value); return result !== undefined ? { [key]: result } as Record : {}; } +function protocolError(): CliError { return new CliError("TIS did not return the expected course-detail data.", "TIS_COURSE_DETAIL_PROTOCOL_ERROR"); }