From cb4c4ca11d1ed701d98bcb068c0c3899aa90ce6c Mon Sep 17 00:00:00 2001 From: KevinHuge <151610589+KevinHuge@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:33:04 +0800 Subject: [PATCH 1/2] feat(talks): add official campus lectures --- CHANGELOG.md | 6 + README.md | 27 +++ package-lock.json | 286 ++++++++++++++++++++++++++++++++ package.json | 2 + src/cli.ts | 31 ++++ src/core/capabilities.ts | 2 + src/core/command-metadata.ts | 2 + src/talks/client.ts | 197 ++++++++++++++++++++++ src/talks/text.ts | 52 ++++++ src/test/official-talks.test.ts | 147 ++++++++++++++++ 10 files changed, 752 insertions(+) create mode 100644 src/talks/client.ts create mode 100644 src/talks/text.ts create mode 100644 src/test/official-talks.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3706383..2893ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +### Added + +- Added public `talks list` and `talks search` commands for official SUSTech + homepage lectures, showing upcoming events by default and all currently + displayed events with `--all`, with source metadata and text/JSON/JSONL output. + ### Fixed - Made `auth status` use a metadata-only macOS Keychain lookup instead of diff --git a/README.md b/README.md index 3e657aa..f002b60 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Public data does not require an account: sustech calendar day 2026-09-01 sustech faculty search "computer vision" sustech online talks list --limit 10 +sustech talks list sustech online contact search "教学" sustech transit lines sustech library search "graph neural networks" --limit 5 @@ -166,6 +167,32 @@ Local state can also change through credential login/logout, persistent and do not overwrite an existing target unless the command explicitly permits and requests it. +## Official campus lectures + +Official university lectures are available without login: + +```bash +sustech talks list +sustech talks list --all +sustech talks list --json --pretty +sustech talks search "物理" --jsonl +sustech talks search "物理" --all +``` + +These commands read the lecture section of the official +[homepage events page](https://www.sustech.edu.cn/zh/home-events.html), excluding +notices. The default view shows lectures whose Beijing start time is still in +the future, ordered from nearest to furthest. Records with an unparseable time +remain visible under “time to confirm” rather than being silently omitted. +`--all` adds lectures whose advertised start time has passed; it means all +lectures currently displayed on the homepage, not the complete historical +archive. Search matches titles, speakers, venues, and time text, and follows the +same upcoming-by-default behavior. Results include title, speaker, venue, +original time text, a normalized Beijing start time when parseable, timing +classification, detail URL, reference time, and official source/fetch metadata. +The existing `online talks` commands continue to use the community-maintained +SUSTech Online source. The new official commands are CLI-only at present. + ## Output contract ```bash diff --git a/package-lock.json b/package-lock.json index 8236a65..51b1f05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/server": "^2.0.0", "@napi-rs/keyring": "1.3.0", + "cheerio": "1.0.0", "playwright-core": "^1.62.1", "zod": "^4.5.2" }, @@ -300,6 +301,54 @@ "undici-types": "~6.21.0" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -315,6 +364,114 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -338,6 +495,37 @@ "node": ">=18.0.0" } }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -355,6 +543,67 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -387,6 +636,12 @@ "node": ">=20" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -424,6 +679,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -431,6 +695,28 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index d909e19..d2679c0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dist/services", "dist/sso", "dist/tis", + "dist/talks", "dist/transit", "dist/wifi", "docs", @@ -75,6 +76,7 @@ "dependencies": { "@modelcontextprotocol/server": "^2.0.0", "@napi-rs/keyring": "1.3.0", + "cheerio": "1.0.0", "playwright-core": "^1.62.1", "zod": "^4.5.2" } diff --git a/src/cli.ts b/src/cli.ts index d6dbb0e..8aafff4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -96,6 +96,8 @@ import { searchOnlineContacts, searchOnlineTalks, } from "./online/index.js"; +import { OfficialTalksClient } from "./talks/client.js"; +import { formatOfficialTalks } from "./talks/text.js"; import { searchResources, type ResourceCategory } from "./resources/catalog.js"; import { formatResources } from "./resources/text.js"; import { @@ -348,6 +350,8 @@ Usage: sustech faculty search QUERY [--department DEPARTMENT] [--limit N] sustech faculty render SLUG sustech online search QUERY [--section talks|contact] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] + sustech talks list [--all] + sustech talks search QUERY [--all] sustech online talks list [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] sustech online talks search QUERY [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] sustech online talks get ID @@ -694,6 +698,33 @@ async function main(argv: string[]): Promise { await runOnline(parsed.positionals, values, output); return; } + if (group === "talks") { + const query = parsed.positionals.slice(2).join(" ").trim(); + if (command !== "list" && command !== "search") throw usageError(`Unknown command: ${parsed.positionals.join(" ")}`); + if (command === "list" && parsed.positionals.length !== 2) throw usageError("talks list does not accept positional arguments."); + if (command === "search" && !query) throw usageError("A talk search query is required."); + const result = await new OfficialTalksClient().list({ + ...(command === "search" ? { query } : {}), + all: values.all, + }); + writeSuccess({ + command: `talks ${command}`, + data: result, + text: formatOfficialTalks(result), + items: result.talks, + summary: { + total: result.total, + sourceTotal: result.sourceTotal, + scope: result.scope, + referenceTime: result.referenceTime, + unknownTimeCount: result.unknownTimeCount, + provenance: result.provenance, + warnings: result.warnings, + }, + meta: result.provenance, + }, output); + return; + } if (group === "context") { await runContext(parsed.positionals, values, output); return; diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index deb89d9..1833a9f 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -23,6 +23,8 @@ export const CAPABILITIES: readonly Capability[] = [ capability("faculty get", "Read one public faculty profile.", "read", { status: "preview" }), capability("faculty search", "Search public faculty profile fields.", "read", { status: "preview" }), capability("faculty render", "Render a public faculty profile as Agent-readable Markdown.", "read", { status: "preview" }), + capability("talks list", "List upcoming official SUSTech homepage lectures, or all currently displayed lectures with --all.", "read", { status: "preview" }), + capability("talks search", "Search upcoming official SUSTech homepage lectures by title, speaker, venue, and time; include started lectures with --all.", "read", { status: "preview" }), capability("online search", "Search selected public community-maintained SUSTech Online content with source and freshness metadata.", "read", { status: "preview" }), capability("online talks list", "List public SUSTech talks indexed by the community-maintained SUSTech Online repository.", "read", { status: "preview" }), capability("online talks search", "Search public SUSTech talks indexed by the community-maintained SUSTech Online repository.", "read", { status: "preview" }), diff --git a/src/core/command-metadata.ts b/src/core/command-metadata.ts index 1f7f931..c8b50b9 100644 --- a/src/core/command-metadata.ts +++ b/src/core/command-metadata.ts @@ -131,6 +131,8 @@ export const COMMAND_OPTIONS: Readonly> "faculty list": ["full", "limit"], "faculty search": ["department", "limit"], "online search": ["section", "since", "until", "limit"], + "talks list": ["all"], + "talks search": ["all"], "online talks list": ["since", "until", "limit"], "online talks search": ["since", "until", "limit"], "online talks get": [], diff --git a/src/talks/client.ts b/src/talks/client.ts new file mode 100644 index 0000000..f1db3ad --- /dev/null +++ b/src/talks/client.ts @@ -0,0 +1,197 @@ +import { load } from "cheerio"; +import { CliError } from "../core/errors.js"; +import { USER_AGENT } from "../core/version.js"; +import { createFetchAdapter, type ServiceAdapter } from "../services/base.js"; + +export const OFFICIAL_TALKS_URL = "https://www.sustech.edu.cn/zh/home-events.html"; +const MAX_BYTES = 2 * 1024 * 1024; + +export interface OfficialTalk { + id: string; + title: string; + speaker?: string; + venue?: string; + timeText: string; + date?: string; + startAt?: string; + detailUrl: string; + timing: "upcoming" | "started" | "unknown"; +} + +export interface OfficialTalksResult { + talks: OfficialTalk[]; + total: number; + sourceTotal: number; + provenance: { + authority: "official"; + sourceUrl: string; + fetchedAt: string; + coverage: "homepage"; + }; + scope: "upcoming" | "all"; + referenceTime: string; + unknownTimeCount: number; + warnings: string[]; +} + +export interface OfficialTalksOptions { + query?: string; + all?: boolean; + now?: Date; +} + +export class OfficialTalksClient { + constructor(private readonly adapter: ServiceAdapter = createFetchAdapter()) {} + + async list(options: OfficialTalksOptions = {}): Promise { + validateOptions(options); + let html: string; + try { + const response = await this.adapter.fetch(OFFICIAL_TALKS_URL, { + headers: { "user-agent": USER_AGENT, accept: "text/html" }, + signal: AbortSignal.timeout(15_000), + redirect: "error", + }); + if (!response.ok) { + await response.body?.cancel(); + throw new CliError("The official talks page returned an HTTP error.", "UPSTREAM_HTTP_ERROR", 1, { status: response.status }); + } + const type = response.headers.get("content-type"); + if (type && !/\b(?:text\/html|application\/xhtml\+xml)\b/i.test(type)) { + await response.body?.cancel(); + throw protocolError("The official talks page did not return HTML."); + } + const reader = response.body?.getReader(); + if (!reader) throw protocolError("The official talks page returned an empty body."); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_BYTES) { + await reader.cancel(); + throw protocolError("The official talks page exceeds the 2 MiB limit."); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + html = Buffer.concat(chunks).toString("utf8"); + } catch (error) { + if (error instanceof CliError) throw error; + throw new CliError("Could not fetch the official talks page.", "NETWORK_ERROR", 1, { sourceUrl: OFFICIAL_TALKS_URL }); + } + const now = options.now ?? new Date(); + return queryOfficialTalks(parseOfficialTalks(html, now.toISOString()), { ...options, now }); + } +} + +export function parseOfficialTalks(html: string, fetchedAt: string): OfficialTalksResult { + const $ = load(html); + const section = $(".item6_slider > ul > li.one"); + if (section.length !== 1 || section.find(".swiper-wrapper").length !== 1) { + throw protocolError("The official talks section was not found; the page structure may have changed."); + } + const talks = new Map(); + const warnings: string[] = []; + section.find(".swiper-slide").each((_, element) => { + const card = $(element); + const href = card.find("a[href]").first().attr("href"); + let url: URL; + try { + url = new URL(href ?? "", OFFICIAL_TALKS_URL); + } catch { + throw protocolError("A lecture card has an invalid detail URL."); + } + const id = /^\/zh\/events\/(\d+)\.html$/.exec(url.pathname)?.[1]; + if (url.origin !== "https://www.sustech.edu.cn" || url.username || url.password || url.search || url.hash || !id) { + throw protocolError("A lecture card has an unsupported detail URL."); + } + const text = (selector: string) => card.find(selector).first().text().replace(/\s+/gu, " ").trim(); + const title = text(".evt_ext p"); + if (!title) throw protocolError("A lecture card is missing its title."); + const timeText = text(".evt_time"); + const match = /^(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})$/.exec(timeText); + const date = match ? `${match[1]}-${match[2]!.padStart(2, "0")}-${match[3]!.padStart(2, "0")}` : undefined; + const validTime = match && date && validDate(date) && Number(match[4]) < 24 && Number(match[5]) < 60; + if (!validTime) warnings.push(timeWarning(id)); + const talk: OfficialTalk = { + id, title, + speaker: text(".evt_peo") || undefined, + venue: text(".evt_adrr") || undefined, + timeText, + ...(validTime ? { date, startAt: `${date}T${match[4]!.padStart(2, "0")}:${match[5]}:00+08:00` } : {}), + detailUrl: url.href, + timing: "unknown", + }; + if (!talks.has(id)) talks.set(id, talk); + }); + return { + talks: [...talks.values()], total: talks.size, sourceTotal: talks.size, + provenance: { authority: "official", sourceUrl: OFFICIAL_TALKS_URL, fetchedAt, coverage: "homepage" }, + scope: "all", + referenceTime: fetchedAt, + unknownTimeCount: warnings.length, + warnings: [...new Set(warnings)], + }; +} + +export function queryOfficialTalks(result: OfficialTalksResult, options: OfficialTalksOptions): OfficialTalksResult { + validateOptions(options); + const now = options.now ?? new Date(result.provenance.fetchedAt); + if (!Number.isFinite(now.getTime())) throw new CliError("The lecture reference time is invalid.", "USAGE", 2); + const referenceTime = now.toISOString(); + const needle = options.query?.trim().toLocaleLowerCase("en-US"); + const classified = result.talks.map((talk) => ({ + ...talk, + timing: talk.startAt === undefined ? "unknown" as const : Date.parse(talk.startAt) > now.getTime() ? "upcoming" as const : "started" as const, + })); + const matches = classified + .filter((talk) => (options.all || talk.timing !== "started") + && (!needle || [talk.title, talk.speaker, talk.venue, talk.timeText].some((field) => field?.toLocaleLowerCase("en-US").includes(needle)))) + .sort((a, b) => compareTalks(a, b, Boolean(options.all))); + return { + ...result, + talks: matches, + total: matches.length, + scope: options.all ? "all" : "upcoming", + referenceTime, + unknownTimeCount: matches.filter((talk) => talk.timing === "unknown").length, + warnings: matches + .filter((talk) => talk.timing === "unknown") + .map((talk) => timeWarning(talk.id)), + }; +} + +function validDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const time = Date.parse(`${value}T00:00:00Z`); + return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value; +} + +function validateOptions(options: OfficialTalksOptions): void { + if (options.query !== undefined && !options.query.trim()) throw new CliError("A talk search query is required.", "USAGE", 2); +} + +function compareTalks(a: OfficialTalk, b: OfficialTalk, all: boolean): number { + const rank = (talk: OfficialTalk) => talk.timing === "upcoming" ? 0 : talk.timing === "unknown" ? 1 : 2; + const rankDifference = rank(a) - rank(b); + if (rankDifference !== 0) return rankDifference; + if (!a.startAt || !b.startAt) return a.id.localeCompare(b.id, "en"); + const difference = Date.parse(a.startAt) - Date.parse(b.startAt); + if (difference !== 0) { + return all && a.timing === "started" ? -difference : difference; + } + return a.id.localeCompare(b.id, "en"); +} + +function protocolError(message: string): CliError { + return new CliError(message, "UPSTREAM_PROTOCOL_ERROR", 1, { sourceUrl: OFFICIAL_TALKS_URL }); +} + +function timeWarning(id: string): string { + return `Talk ${id}: date/time could not be normalized; see timeText and the source page.`; +} diff --git a/src/talks/text.ts b/src/talks/text.ts new file mode 100644 index 0000000..07b7470 --- /dev/null +++ b/src/talks/text.ts @@ -0,0 +1,52 @@ +import type { OfficialTalksResult } from "./client.js"; + +export function formatOfficialTalks(result: OfficialTalksResult): string { + const sections = result.scope === "all" + ? [ + ["尚未开始", result.talks.filter((talk) => talk.timing === "upcoming")], + ["时间待确认", result.talks.filter((talk) => talk.timing === "unknown")], + ["已经开始", result.talks.filter((talk) => talk.timing === "started")], + ] as const + : [ + ["尚未开始", result.talks.filter((talk) => talk.timing === "upcoming")], + ["时间待确认", result.talks.filter((talk) => talk.timing === "unknown")], + ] as const; + const body = sections.flatMap(([heading, talks]) => talks.length === 0 ? [] : [ + `${heading}(${talks.length} 场)`, + "", + ...talks.flatMap((talk) => [ + talk.startAt ? formatStart(talk.startAt) : talk.timeText || "时间待定", + talk.title, + `主讲:${talk.speaker || "未提供"}`, + `地点:${talk.venue || "未提供"}`, + `详情:${talk.detailUrl}`, + "", + ]), + ]); + return [ + `南科大官网学术讲座 · ${result.scope === "all" ? `当前展示 ${result.total} 场` : `尚未开始 ${result.talks.filter((talk) => talk.timing === "upcoming").length} 场`}`, + `北京时间 · ${result.scope === "all" ? "未开始的从近到远,已开始的从新到旧" : "按开始时间从近到远"}`, + "", + ...(body.length ? body : ["没有匹配的讲座。", ""]), + `范围:官网首页当前展示的 ${result.sourceTotal} 条讲座`, + `来源:${result.provenance.sourceUrl}`, + `抓取时间:${result.provenance.fetchedAt}`, + ...(result.scope === "all" ? [] : ["使用 --all 查看包含已开始讲座的全部记录。"]), + ...result.warnings.map((warning) => `注意:${warning}`), + ].join("\n"); +} + +function formatStart(startAt: string): string { + const date = new Date(startAt); + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + month: "2-digit", + day: "2-digit", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""; + return `${value("month")}-${value("day")} ${value("weekday")} ${value("hour")}:${value("minute")}`; +} diff --git a/src/test/official-talks.test.ts b/src/test/official-talks.test.ts new file mode 100644 index 0000000..3c661d9 --- /dev/null +++ b/src/test/official-talks.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { OfficialTalksClient, OFFICIAL_TALKS_URL, parseOfficialTalks, queryOfficialTalks } from "../talks/client.js"; + +const NOW = new Date("2026-09-05T08:00:00.000Z"); + +function card(id: string, title: string, time: string, speaker = "Alice 教授", venue = "理学院 101"): string { + return ``; +} + +function page(cards: string): string { + const notice = card("999", "通知公告", "2026年09月20日 10:00"); + return `
  • ${cards}
  • ${notice}
`; +} + +const HTML = page([ + card("101", "Past", "2026年09月04日 14:00"), + card("102", "Quantum & Materials", "2026年09月08日 09:05"), + card("103", "Soon", "2026年09月06日 14:00", "Bob", "会议中心"), + card("104", "Unknown", "待定"), + card("102", "Duplicate", "2026年09月08日 09:05"), +].join("")); + +test("homepage parser extracts stable records, normalizes Beijing time, deduplicates, and excludes notices", () => { + const result = parseOfficialTalks(HTML, NOW.toISOString()); + assert.deepEqual(result.talks.map((talk) => talk.id), ["101", "102", "103", "104"]); + assert.equal(result.talks[1]!.title, "Quantum & Materials"); + assert.equal(result.talks[1]!.venue, "理学院 101"); + assert.equal(result.talks[1]!.startAt, "2026-09-08T09:05:00+08:00"); + assert.equal(result.talks[1]!.detailUrl, "https://www.sustech.edu.cn/zh/events/102.html"); + assert.deepEqual(result.provenance, { authority: "official", sourceUrl: OFFICIAL_TALKS_URL, fetchedAt: NOW.toISOString(), coverage: "homepage" }); + assert.equal(result.sourceTotal, 4); +}); + +test("default view keeps upcoming and unknown-time talks and sorts upcoming talks nearest first", () => { + const result = queryOfficialTalks(parseOfficialTalks(HTML, NOW.toISOString()), { now: NOW }); + assert.deepEqual(result.talks.map((talk) => [talk.id, talk.timing]), [["103", "upcoming"], ["102", "upcoming"], ["104", "unknown"]]); + assert.equal(result.scope, "upcoming"); + assert.equal(result.total, 3); + assert.equal(result.sourceTotal, 4); + assert.equal(result.unknownTimeCount, 1); + assert.equal(result.referenceTime, NOW.toISOString()); +}); + +test("--all includes started talks after upcoming and unknown records", () => { + const result = queryOfficialTalks(parseOfficialTalks(HTML, NOW.toISOString()), { all: true, now: NOW }); + assert.deepEqual(result.talks.map((talk) => [talk.id, talk.timing]), [["103", "upcoming"], ["102", "upcoming"], ["104", "unknown"], ["101", "started"]]); + assert.equal(result.scope, "all"); + assert.equal(result.total, 4); +}); + +test("search uses title, speaker, venue, and time and follows the same temporal scope", () => { + const source = parseOfficialTalks(HTML, NOW.toISOString()); + assert.deepEqual(queryOfficialTalks(source, { query: "quantum", now: NOW }).talks.map((talk) => talk.id), ["102"]); + assert.deepEqual(queryOfficialTalks(source, { query: "bob", now: NOW }).talks.map((talk) => talk.id), ["103"]); + assert.deepEqual(queryOfficialTalks(source, { query: "会议中心", now: NOW }).talks.map((talk) => talk.id), ["103"]); + assert.equal(queryOfficialTalks(source, { query: "past", now: NOW }).total, 0); + assert.deepEqual(queryOfficialTalks(source, { query: "past", all: true, now: NOW }).talks.map((talk) => talk.id), ["101"]); + assert.deepEqual(queryOfficialTalks(source, { query: "quantum", now: NOW }).warnings, []); +}); + +test("a talk at the reference instant has already started", () => { + const source = parseOfficialTalks(page(card("101", "Exact", "2026年09月05日 16:00")), NOW.toISOString()); + assert.equal(queryOfficialTalks(source, { now: NOW }).total, 0); + assert.equal(queryOfficialTalks(source, { now: NOW, all: true }).talks[0]!.timing, "started"); +}); + +test("invalid lecture times remain visible with warnings and are never guessed", () => { + for (const time of ["待定", "2026年2月30日 10:00", "2026年9月8日 25:00", "2026年9月8日 10:99"]) { + const source = parseOfficialTalks(page(card("101", "Example", time)), NOW.toISOString()); + const result = queryOfficialTalks(source, { now: NOW }); + assert.equal(result.talks[0]!.startAt, undefined); + assert.equal(result.talks[0]!.date, undefined); + assert.equal(result.talks[0]!.timeText, time); + assert.equal(result.talks[0]!.timing, "unknown"); + assert.equal(result.warnings.length, 1); + } +}); + +test("empty lecture sections differ from broken pages and unsafe detail links", () => { + assert.equal(parseOfficialTalks(page(""), NOW.toISOString()).total, 0); + for (const html of ["

Access denied

", HTML.replaceAll("evt_ext", "changed"), HTML.replace("/zh/events/101.html", "https://example.com/zh/events/101.html")]) { + assert.throws(() => parseOfficialTalks(html, NOW.toISOString()), { code: "UPSTREAM_PROTOCOL_ERROR" }); + } +}); + +test("official lecture transport is bounded and reports HTTP, protocol, and network failures", async () => { + const client = new OfficialTalksClient({ name: "fixture", fetch: async (url, init) => { + assert.equal(url, OFFICIAL_TALKS_URL); + assert.equal(init?.redirect, "error"); + assert.ok(init?.signal); + return new Response(HTML, { headers: { "content-type": "text/html; charset=utf-8" } }); + } }); + assert.equal((await client.list({ all: true, now: NOW })).sourceTotal, 4); + for (const [response, code] of [ + [new Response("unavailable", { status: 503 }), "UPSTREAM_HTTP_ERROR"], + [new Response("{}", { headers: { "content-type": "application/json" } }), "UPSTREAM_PROTOCOL_ERROR"], + [new Response("x".repeat(2 * 1024 * 1024 + 1), { headers: { "content-type": "text/html" } }), "UPSTREAM_PROTOCOL_ERROR"], + ] as const) { + await assert.rejects(new OfficialTalksClient({ name: "fixture", fetch: async () => response }).list(), { code }); + } + await assert.rejects(new OfficialTalksClient({ name: "fixture", fetch: async () => { throw new Error("offline"); } }).list(), { code: "NETWORK_ERROR" }); +}); + +const CLI = fileURLToPath(new URL("../cli.js", import.meta.url)); + +function run(args: string[]) { + const mock = `Date = class extends Date { constructor(...args) { super(...(args.length ? args : [${JSON.stringify(NOW.toISOString())}])); } static now() { return ${NOW.getTime()}; } }; globalThis.fetch = async () => new Response(${JSON.stringify(HTML)}, {headers:{"content-type":"text/html"}});`; + return spawnSync(process.execPath, ["--import", `data:text/javascript,${encodeURIComponent(mock)}`, CLI, ...args], { encoding: "utf8", timeout: 10_000 }); +} + +test("CLI supports upcoming text, --all, search, JSONL, discovery, and strict options", () => { + const text = run(["talks", "list"]); + assert.equal(text.status, 0, text.stderr); + assert.match(text.stdout, /南科大官网学术讲座 · 尚未开始 2 场/); + assert.ok(text.stdout.indexOf("Soon") < text.stdout.indexOf("Quantum & Materials")); + assert.doesNotMatch(text.stdout, /\nPast\n/); + assert.match(text.stdout, /时间待确认(1 场)/); + assert.match(text.stdout, /使用 --all/); + + const all = run(["talks", "list", "--all", "--json"]); + assert.equal(all.status, 0, all.stderr); + const envelope = JSON.parse(all.stdout); + assert.equal(envelope.command, "talks list"); + assert.equal(envelope.data.scope, "all"); + assert.equal(envelope.data.total, 4); + assert.equal(envelope.meta.authority, "official"); + + assert.equal(JSON.parse(run(["talks", "search", "past", "--json"]).stdout).data.total, 0); + assert.equal(JSON.parse(run(["talks", "search", "past", "--all", "--json"]).stdout).data.talks[0].id, "101"); + + const jsonl = run(["talks", "list", "--jsonl"]); + assert.equal(jsonl.status, 0, jsonl.stderr); + assert.deepEqual(jsonl.stdout.trim().split("\n").map((line) => JSON.parse(line).type), ["item", "item", "item", "summary"]); + + const described = run(["describe", "talks", "list", "--json"]); + assert.equal(described.status, 0, described.stderr); + assert.deepEqual(JSON.parse(described.stdout).data.options.map((option: { name: string }) => option.name), ["--all", "--output", "--json", "--jsonl", "--pretty"]); + + for (const args of [["list", "extra"], ["search"], ["list", "--page", "2"], ["list", "--source", "homepage"], ["list", "--limit", "1"], ["list", "--confirm"]]) { + const invalid = run(["talks", ...args, "--json"]); + assert.equal(invalid.status, 2, invalid.stdout); + assert.equal(JSON.parse(invalid.stdout).ok, false); + } +}); From 0421d9fd261d32f9b54e320af16f61834e7294dd Mon Sep 17 00:00:00 2001 From: KevinHuge <151610589+KevinHuge@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:02:16 +0800 Subject: [PATCH 2/2] docs(skill): document official talks commands --- skills/sustech-cli/SKILL.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/skills/sustech-cli/SKILL.md b/skills/sustech-cli/SKILL.md index aa78dc2..d845687 100644 --- a/skills/sustech-cli/SKILL.md +++ b/skills/sustech-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: sustech-cli -description: Use the installed sustech CLI for SUSTech campus reads, planning, guarded exports, and confirm-gated workflows across TIS, Blackboard, profile/context, booking, library booking, PMS, transit, faculty, papers, NCES, and selected SUSTech Online public information. Do not use for unrelated universities or invent commands the installed CLI does not report. +description: Use the installed sustech CLI for SUSTech campus reads, planning, guarded exports, and confirm-gated workflows across TIS, Blackboard, profile/context, booking, library booking, PMS, transit, faculty, official talks, papers, NCES, and selected SUSTech Online public information. Do not use for unrelated universities or invent commands the installed CLI does not report. --- # SUSTech CLI @@ -35,8 +35,9 @@ includes these high-value areas: `resources list`, `resources search`, `wifi status`, `wifi events`, `faculty departments`, `faculty list`, `faculty get`, `faculty search`, `faculty render`, `transit facilities`, `transit find`, `transit lines`, - `transit schedule`, `transit stops`, `transit live`, `online search`, - `online talks list/search/get`, `online contact search/get`. + `transit schedule`, `transit stops`, `transit live`, `talks list`, + `talks search`, `online search`, `online talks list/search/get`, + `online contact search/get`. - Academic profile and audits: `profile show`, `profile export`, `academic snapshot save`, `academic changes`, `academic watch`, `doctor`. - Research helpers: `papers search`, `papers fetch-oa`, `nces browse`, @@ -72,6 +73,12 @@ Some useful routing hints: - For Blackboard timeline questions, prefer `bb calendar` when you need typed `--since`/`--until`/`--type`/`--course-id` filtering. - For “find a Blackboard file/course item”, prefer `bb search` before scraping. +- For current official university lectures, use the direct CLI commands `talks + list` and `talks search`. They show future lectures by default using Beijing + start times; add `--all` for every lecture currently displayed on the + official homepage. Keep unknown-time records visible and preserve the + returned official source metadata. These commands are not exposed through + the local MCP surface. - For timetable exploration, prefer `tis timetable` for one-off solving and `tis plan *` for persistent local planning. - `tis plan explain` and `tis plan recommend` are read-only planning helpers.