gaokao-pro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +109 -0
  2. package/data/datasets/gaoshui-yundongdui-2024.json +33 -0
  3. package/data/datasets/junjing-jingxiao.json +75 -0
  4. package/data/datasets/provinces-specialty-2024.json +139 -0
  5. package/data/datasets/qiangji-2024.json +52 -0
  6. package/data/datasets/schools-adapters-2024.json +123 -0
  7. package/data/datasets/sushe-shitang.json +40 -0
  8. package/data/datasets/tijian-shouxian-zhuanye.json +29 -0
  9. package/data/datasets/xiaoyuzhong-zhaosheng.json +18 -0
  10. package/data/datasets/xueke-pinggu-disculun.json +28 -0
  11. package/data/datasets/zhongwai-hezuo-2024.json +36 -0
  12. package/data/datasets/zonghepingjia-2024.json +53 -0
  13. package/data/school-index.json.gz +0 -0
  14. package/data/yifenyiduan/_ocr-pipeline/extract-pdf.py +117 -0
  15. package/data/yifenyiduan/beijing-2023-combined.json +1 -0
  16. package/data/yifenyiduan/beijing-2024-combined.json +1 -0
  17. package/data/yifenyiduan/beijing-2025-combined.json +1 -0
  18. package/data/yifenyiduan/henan-2024-liberal.json +1 -0
  19. package/data/yifenyiduan/hunan-2024-history.json +1 -0
  20. package/dist/aliases.d.ts +2 -0
  21. package/dist/aliases.js +120 -0
  22. package/dist/chart-check.d.ts +20 -0
  23. package/dist/chart-check.js +99 -0
  24. package/dist/codes.d.ts +162 -0
  25. package/dist/codes.js +59 -0
  26. package/dist/compare.d.ts +39 -0
  27. package/dist/compare.js +112 -0
  28. package/dist/datasets.d.ts +65 -0
  29. package/dist/datasets.js +82 -0
  30. package/dist/find.d.ts +48 -0
  31. package/dist/find.js +87 -0
  32. package/dist/format.d.ts +17 -0
  33. package/dist/format.js +109 -0
  34. package/dist/gaokao-cn.d.ts +122 -0
  35. package/dist/gaokao-cn.js +49 -0
  36. package/dist/index-loader.d.ts +33 -0
  37. package/dist/index-loader.js +59 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +727 -0
  40. package/dist/match.d.ts +40 -0
  41. package/dist/match.js +118 -0
  42. package/dist/mcp.d.ts +1 -0
  43. package/dist/mcp.js +535 -0
  44. package/dist/memory.d.ts +31 -0
  45. package/dist/memory.js +69 -0
  46. package/dist/paiming.d.ts +29 -0
  47. package/dist/paiming.js +66 -0
  48. package/dist/probe.d.ts +1 -0
  49. package/dist/probe.js +73 -0
  50. package/dist/provinces/guangdong.d.ts +27 -0
  51. package/dist/provinces/guangdong.js +68 -0
  52. package/dist/provinces/index.d.ts +1 -0
  53. package/dist/provinces/index.js +17 -0
  54. package/dist/rank-table.d.ts +31 -0
  55. package/dist/rank-table.js +74 -0
  56. package/dist/recommend-major.d.ts +34 -0
  57. package/dist/recommend-major.js +119 -0
  58. package/dist/recommend.d.ts +54 -0
  59. package/dist/recommend.js +147 -0
  60. package/dist/selftest.d.ts +11 -0
  61. package/dist/selftest.js +48 -0
  62. package/dist/top.d.ts +29 -0
  63. package/dist/top.js +66 -0
  64. package/dist/xuanke.d.ts +8 -0
  65. package/dist/xuanke.js +35 -0
  66. package/package.json +40 -0
package/dist/index.js ADDED
@@ -0,0 +1,727 @@
1
+ #!/usr/bin/env node
2
+ import { getSchoolInfo, getAdmissionPlan, getAdmissionScores, extractHistoricalScores } from "./gaokao-cn.js";
3
+ import { PROVINCES, TRACK_NAMES, resolveProvince, ALL_SUBJECTS } from "./codes.js";
4
+ import { recommend } from "./recommend.js";
5
+ import { find } from "./find.js";
6
+ import { top } from "./top.js";
7
+ import { isTty, formatRecommend, formatTop } from "./format.js";
8
+ import { runMcpServer } from "./mcp.js";
9
+ import { loadRankTable, listRankTables, scoreToRank, rankToScore, inferDefaultTrack } from "./rank-table.js";
10
+ import { decodeXuanke } from "./xuanke.js";
11
+ import { loadMemory, setPrefs, addWatched, logEvent, clearMemory, memoryPath } from "./memory.js";
12
+ import { runSelftest } from "./selftest.js";
13
+ import { match } from "./match.js";
14
+ import { recommendMajor } from "./recommend-major.js";
15
+ import { chartCheck } from "./chart-check.js";
16
+ import { readFileSync, existsSync } from "node:fs";
17
+ import { findSchoolAdapter, listSchoolsOfferingProgram, findProvinceSpecialty, listProvinceKeys, getCrossProvincePrograms } from "./datasets.js";
18
+ import { compare } from "./compare.js";
19
+ import { paiming } from "./paiming.js";
20
+ const VERSION = "0.0.1";
21
+ function parseFlags(args) {
22
+ const positional = [];
23
+ const flags = {};
24
+ for (let i = 0; i < args.length; i++) {
25
+ const a = args[i];
26
+ if (a.startsWith("--")) {
27
+ const key = a.slice(2);
28
+ const next = args[i + 1];
29
+ if (next === undefined || next.startsWith("--")) {
30
+ flags[key] = true;
31
+ }
32
+ else {
33
+ flags[key] = next;
34
+ i++;
35
+ }
36
+ }
37
+ else {
38
+ positional.push(a);
39
+ }
40
+ }
41
+ return { positional, flags };
42
+ }
43
+ function printJson(value) {
44
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
45
+ }
46
+ function shouldTable(flags) {
47
+ if (flags.format === "table")
48
+ return true;
49
+ if (flags.format === "json")
50
+ return false;
51
+ return isTty();
52
+ }
53
+ const HELP = `gaokao-pro v${VERSION}
54
+
55
+ Usage:
56
+ gaokao-pro school <schoolId>
57
+ Show metadata for a school (gaokao.cn internal id, e.g. 31 = 北大).
58
+ Includes 985/211 flags, 学科评估, historical min scores per province.
59
+
60
+ gaokao-pro plan <schoolId> --year <year> --province <name|id>
61
+ List admission plan items: 专业代码 / 计划人数 / 学制 / 学费 / 选科要求.
62
+ e.g. gaokao-pro plan 31 --year 2024 --province henan
63
+
64
+ gaokao-pro scores <schoolId> --province <name|id>
65
+ Historical min scores for a (school, province) pair across all years/tracks.
66
+
67
+ gaokao-pro recommend --score <n> --province <name|id> --subjects <list>
68
+ [--schools <id1,id2,...>] [--985] [--211] [--dual-class]
69
+ [--level <本科|专科>] [--type <综合类|理工类|...>]
70
+ [--belong <教育部|工信部|...>] [--limit <n>] [--rank <n>]
71
+ [--explain] [--format table|json]
72
+ Bucket schools into 冲 / 稳 / 保 based on the user's score vs each
73
+ school's most-recent matching-track minimum. Without --schools, scans
74
+ the full index (~2400 schools). All evaluation is local — no network.
75
+ e.g. gaokao-pro recommend --score 660 --province henan \\
76
+ --subjects 物理,化学,生物 --985 --limit 10 --explain
77
+
78
+ gaokao-pro top --score <n> --province <name|id> --subjects <list>
79
+ [--985] [--211] [--dual-class] [--limit <n>]
80
+ [--format table|json]
81
+ Best schools within reach of this score in this province, ranked by
82
+ historical baseline desc. Like recommend, but flat top-N list.
83
+ e.g. gaokao-pro top --score 650 --province henan --subjects 物理 --limit 15
84
+
85
+ gaokao-pro actual <schoolId> --year <year> --province <name|id>
86
+ Per-major actual admission outcomes (vs forward-looking 'plan'):
87
+ max/min/avg score, 录取人数, 最低位次. Use this for rank-based reasoning.
88
+
89
+ gaokao-pro find <keyword> --province <name|id> --year <year>
90
+ [--985] [--211] [--dual-class] [--belong <name>] [--limit <n>]
91
+ Search for a major keyword across schools recruiting in a province.
92
+ e.g. gaokao-pro find "计算机" --province henan --year 2024 --985 --limit 20
93
+
94
+ gaokao-pro rank --province <name|id> --year <year>
95
+ (--score <n> | --rank <n>) [--track <combined|physics|history|...>]
96
+ Look up a (score, rank) pair against the province's official 一分一段表.
97
+ Pass --score to get your 全省位次. Pass --rank to get the score that
98
+ hits that rank. Provinces ingested: see 'gaokao-pro rank-tables'.
99
+ e.g. gaokao-pro rank --province beijing --year 2024 --score 650
100
+
101
+ gaokao-pro rank-tables
102
+ List all (province, year, track) tuples with ingested 一分一段 data.
103
+
104
+ gaokao-pro match --profile profile.json [--limit <n>] [--format table|json]
105
+ Read a student profile (JSON file or stdin) and return ranked schools
106
+ with composite fit scores (interest + baseline + label + city).
107
+ e.g. cat profile.json | gaokao-pro match - --limit 20
108
+
109
+ gaokao-pro recommend-major <interest> --score <n> --province <name|id>
110
+ --subjects <list> --year <year>
111
+ [--985] [--211] [--limit <n>]
112
+ Interest-driven: find majors matching <interest> across schools that
113
+ recruit in your province for the year. Returns schools grouped by 专业.
114
+ e.g. gaokao-pro recommend-major 计算机 --score 660 --province henan \\
115
+ --subjects 物理,化学,生物 --year 2024 --985
116
+
117
+ gaokao-pro chart-check --profile profile.json
118
+ Sanity-check a profile: score range, subject combo for the province's
119
+ 新高考 reform, rank↔score consistency (if 一分一段 exists). 0-100 score.
120
+
121
+ gaokao-pro compare <A> <B> [--province <name|id>]
122
+ Side-by-side: labels (985/211/双一流), 隶属, recent province min scores,
123
+ 招生网 URL, special-program flags, contact. Offline.
124
+ e.g. gaokao-pro compare 复旦 上交 --province henan
125
+
126
+ gaokao-pro paiming <school>
127
+ Aggregate rankings: 软科 / 校友会 / QS / US News / 泰晤士中国 +
128
+ 第四轮学科评估 (A+/A/A- counts) + 第五轮已披露 A+ 学科 (if any).
129
+ e.g. gaokao-pro paiming 清华大学
130
+
131
+ gaokao-pro adapter <name|zs_code>
132
+ Look up one school's 招生网 URL + special-program offer flags + contact.
133
+ Reads cli/data/datasets/schools-adapters-2024.json (80+ schools curated).
134
+ e.g. gaokao-pro adapter 清华 · gaokao-pro adapter 10003
135
+
136
+ gaokao-pro program <type>
137
+ List schools offering a specific program type. Type one of:
138
+ qiangji · zonghepingjia · zhongwai_hezuo · guojia_zhuanxiang ·
139
+ gaoxiao_zhuanxiang · minzu_ban · yuke_ban · gao_shui_yundong · high_art
140
+
141
+ gaokao-pro tiqian <province>
142
+ Per-province 提前批 + 强基/综评 in-province implementing schools.
143
+ Available: tianjin · zhejiang · hunan · shandong · guangdong (verified).
144
+ Pass 'all' to list cross-province programs (国家/高校/地方专项 + 港澳台联招).
145
+
146
+ gaokao-pro xuanke <raw>
147
+ Decode a gaokao.cn selected-subject string (e.g. "70001_70002^70001_70003").
148
+ Returns the human-readable combinations: 物理+化学 或 物理+生物.
149
+
150
+ gaokao-pro memory list
151
+ gaokao-pro memory set <k=v> [<k=v>...]
152
+ gaokao-pro memory watch <schoolId> [--name <name>] [--note <text>]
153
+ gaokao-pro memory event <type> <detail>
154
+ gaokao-pro memory clear
155
+ Local persistent state at ~/.gaokaopro/memory.json — prefs / watched
156
+ schools / event log so Claude can resume across sessions.
157
+
158
+ gaokao-pro selftest
159
+ 3-stage end-to-end smoke: upstream API, local index, 一分一段.
160
+
161
+ gaokao-pro provinces
162
+ List supported provinces with their ids and 新高考 reform mode.
163
+
164
+ gaokao-pro mcp
165
+ Start an MCP server over stdio. Plug into Claude Code with:
166
+ claude mcp add gaokao-pro -- npx -y gaokao-pro mcp
167
+ All verbs above become MCP tools callable by Claude.
168
+
169
+ gaokao-pro help | --help
170
+ Show this help.
171
+
172
+ Notes:
173
+ schoolId is gaokao.cn's internal id (NOT 教育部 5-digit zs_code).
174
+ Run \`gaokao-pro school 31\` once to see the mapping in the 'zs_code' field.
175
+ `;
176
+ const VERBS = {
177
+ async help() {
178
+ process.stdout.write(HELP);
179
+ },
180
+ async "--help"() {
181
+ process.stdout.write(HELP);
182
+ },
183
+ async "-h"() {
184
+ process.stdout.write(HELP);
185
+ },
186
+ async "--version"() {
187
+ process.stdout.write(VERSION + "\n");
188
+ },
189
+ async mcp() {
190
+ await runMcpServer();
191
+ },
192
+ async rank(args) {
193
+ const { flags } = parseFlags(args);
194
+ if (typeof flags.province !== "string")
195
+ throw new Error("--province <name|id> is required");
196
+ const provinceId = resolveProvince(flags.province);
197
+ if (!provinceId)
198
+ throw new Error(`unknown province: ${flags.province}`);
199
+ const year = Number(flags.year);
200
+ if (!Number.isFinite(year))
201
+ throw new Error("--year <year> is required");
202
+ const track = typeof flags.track === "string" ? flags.track : inferDefaultTrack(provinceId);
203
+ const table = loadRankTable(provinceId, year, track);
204
+ if (!table) {
205
+ throw new Error(`no 一分一段 table for ${PROVINCES[provinceId].name} ${year} ${track}. ` +
206
+ `Run \`gaokao-pro rank-tables\` to see what we have. ` +
207
+ `To add this province, drop a JSON file at cli/data/yifenyiduan/${PROVINCES[provinceId].pinyin}-${year}-${track}.json — see cli/src/rank-table.ts for the schema.`);
208
+ }
209
+ const hasScore = flags.score !== undefined;
210
+ const hasRank = flags.rank !== undefined;
211
+ if (!hasScore && !hasRank)
212
+ throw new Error("provide either --score <n> or --rank <n>");
213
+ if (hasScore && hasRank)
214
+ throw new Error("--score and --rank are mutually exclusive");
215
+ const result = {
216
+ province: PROVINCES[provinceId].name,
217
+ year,
218
+ track,
219
+ source: table.source
220
+ };
221
+ if (hasScore) {
222
+ const score = Number(flags.score);
223
+ const rank = scoreToRank(table, score);
224
+ result.score = score;
225
+ result.rank = rank;
226
+ result.summary = rank !== null
227
+ ? `${PROVINCES[provinceId].name} ${year} ${track}: 分数 ${score} → 全省位次 ${rank} 名以内`
228
+ : `分数 ${score} 低于该表覆盖范围`;
229
+ }
230
+ else {
231
+ const rank = Number(flags.rank);
232
+ const score = rankToScore(table, rank);
233
+ result.rank = rank;
234
+ result.score = score;
235
+ result.summary = score !== null
236
+ ? `${PROVINCES[provinceId].name} ${year} ${track}: 位次 ${rank} → 至少需要 ${score} 分`
237
+ : `位次 ${rank} 超出该表覆盖范围`;
238
+ }
239
+ printJson({ ok: true, ...result });
240
+ },
241
+ async "rank-tables"() {
242
+ const items = listRankTables();
243
+ printJson({ ok: true, count: items.length, tables: items });
244
+ },
245
+ async match(args) {
246
+ const { positional, flags } = parseFlags(args);
247
+ let profileJson;
248
+ const src = typeof flags.profile === "string" ? flags.profile : positional[0];
249
+ if (!src || src === "-") {
250
+ const stdin = readFileSync(0, "utf8");
251
+ profileJson = JSON.parse(stdin);
252
+ }
253
+ else {
254
+ if (!existsSync(src))
255
+ throw new Error(`profile not found: ${src}`);
256
+ profileJson = JSON.parse(readFileSync(src, "utf8"));
257
+ }
258
+ const p = profileJson;
259
+ if (typeof p.score !== "number")
260
+ throw new Error("profile.score must be number");
261
+ if (typeof p.province !== "string")
262
+ throw new Error("profile.province must be string");
263
+ const provinceId = resolveProvince(p.province);
264
+ if (!provinceId)
265
+ throw new Error(`unknown province: ${p.province}`);
266
+ if (!Array.isArray(p.subjects))
267
+ throw new Error("profile.subjects must be array");
268
+ for (const s of p.subjects) {
269
+ if (!ALL_SUBJECTS.includes(s))
270
+ throw new Error(`unknown subject: ${s}`);
271
+ }
272
+ const limit = flags.limit !== undefined ? Number(flags.limit) : 20;
273
+ const out = match({
274
+ score: p.score,
275
+ province: provinceId,
276
+ subjects: p.subjects,
277
+ rank: p.rank,
278
+ interests: p.interests,
279
+ constraints: p.constraints
280
+ }, limit);
281
+ printJson({ ok: true, ...out });
282
+ },
283
+ async "recommend-major"(args) {
284
+ const { positional, flags } = parseFlags(args);
285
+ const keyword = positional[0];
286
+ if (!keyword)
287
+ throw new Error("missing <interest>. e.g. `gaokao-pro recommend-major 计算机 ...`");
288
+ const score = Number(flags.score);
289
+ if (!Number.isFinite(score))
290
+ throw new Error("--score <n> is required");
291
+ if (typeof flags.province !== "string")
292
+ throw new Error("--province is required");
293
+ const provinceId = resolveProvince(flags.province);
294
+ if (!provinceId)
295
+ throw new Error(`unknown province: ${flags.province}`);
296
+ if (typeof flags.subjects !== "string")
297
+ throw new Error("--subjects is required");
298
+ const subjects = flags.subjects.split(",").map((s) => s.trim());
299
+ for (const s of subjects) {
300
+ if (!ALL_SUBJECTS.includes(s))
301
+ throw new Error(`unknown subject: ${s}`);
302
+ }
303
+ const year = Number(flags.year);
304
+ if (!Number.isFinite(year))
305
+ throw new Error("--year is required");
306
+ const limit = flags.limit !== undefined ? Number(flags.limit) : 20;
307
+ const filter = {
308
+ f985: flags["985"] === true ? true : undefined,
309
+ f211: flags["211"] === true ? true : undefined,
310
+ dualClass: flags["dual-class"] === true ? true : undefined,
311
+ belong: typeof flags.belong === "string" ? flags.belong : undefined
312
+ };
313
+ const out = await recommendMajor({ keyword, score, provinceId, subjects, year, filter, limit });
314
+ printJson({ ok: true, ...out });
315
+ },
316
+ async "chart-check"(args) {
317
+ const { flags } = parseFlags(args);
318
+ let profileJson;
319
+ const src = typeof flags.profile === "string" ? flags.profile : "-";
320
+ if (src === "-") {
321
+ profileJson = JSON.parse(readFileSync(0, "utf8"));
322
+ }
323
+ else {
324
+ if (!existsSync(src))
325
+ throw new Error(`profile not found: ${src}`);
326
+ profileJson = JSON.parse(readFileSync(src, "utf8"));
327
+ }
328
+ const p = profileJson;
329
+ let province_id;
330
+ if (typeof p.province === "string") {
331
+ const id = resolveProvince(p.province);
332
+ if (id)
333
+ province_id = id;
334
+ }
335
+ const out = chartCheck({ ...p, province_id });
336
+ printJson(out);
337
+ },
338
+ async compare(args) {
339
+ const { positional, flags } = parseFlags(args);
340
+ const a = positional[0];
341
+ const b = positional[1];
342
+ if (!a || !b)
343
+ throw new Error("usage: compare <A> <B> [--province <name|id>]");
344
+ const focusProvince = typeof flags.province === "string"
345
+ ? resolveProvince(flags.province) ?? undefined
346
+ : undefined;
347
+ const out = compare(a, b, focusProvince);
348
+ printJson({ ok: true, ...out });
349
+ },
350
+ async paiming(args) {
351
+ const { positional } = parseFlags(args);
352
+ const q = positional[0];
353
+ if (!q)
354
+ throw new Error("usage: paiming <name|zs_code|schoolId>");
355
+ const out = await paiming(q);
356
+ printJson({ ok: true, ...out });
357
+ },
358
+ async adapter(args) {
359
+ const { positional } = parseFlags(args);
360
+ const query = positional[0];
361
+ if (!query)
362
+ throw new Error("missing <name|zs_code>. e.g. `gaokao-pro adapter 清华` or `gaokao-pro adapter 10003`");
363
+ const adapter = findSchoolAdapter(query);
364
+ if (!adapter)
365
+ throw new Error(`no adapter for "${query}". Try a different name or zs_code, or check the dataset.`);
366
+ printJson({ ok: true, adapter });
367
+ },
368
+ async program(args) {
369
+ const { positional } = parseFlags(args);
370
+ const type = positional[0];
371
+ const valid = ["qiangji", "zonghepingjia", "zhongwai_hezuo", "guojia_zhuanxiang", "gaoxiao_zhuanxiang", "minzu_ban", "yuke_ban", "gao_shui_yundong", "high_art"];
372
+ if (!type || !valid.includes(type)) {
373
+ throw new Error(`type must be one of: ${valid.join(", ")}`);
374
+ }
375
+ const schools = listSchoolsOfferingProgram(type);
376
+ printJson({
377
+ ok: true,
378
+ program: type,
379
+ count: schools.length,
380
+ schools: schools.map((s) => ({
381
+ name: s.name,
382
+ zs_code: s.zs_code,
383
+ zsw_url: s.zsw_url,
384
+ detail: type === "gaoxiao_zhuanxiang" ? s.programs.gaoxiao_zhuanxiang : s.programs[type]
385
+ }))
386
+ });
387
+ },
388
+ async tiqian(args) {
389
+ const { positional } = parseFlags(args);
390
+ const province = positional[0];
391
+ if (!province)
392
+ throw new Error("missing <province>. Try one of: " + listProvinceKeys().join(", ") + " (or 'all')");
393
+ if (province === "all") {
394
+ printJson({ ok: true, cross_province_programs: getCrossProvincePrograms() });
395
+ return;
396
+ }
397
+ const data = findProvinceSpecialty(province);
398
+ if (!data)
399
+ throw new Error(`no specialty plan ingested for ${province}. Available: ${listProvinceKeys().join(", ")}`);
400
+ printJson({ ok: true, ...data });
401
+ },
402
+ async xuanke(args) {
403
+ const { positional } = parseFlags(args);
404
+ const raw = positional[0];
405
+ if (!raw)
406
+ throw new Error("missing raw xuanke string. e.g. `gaokao-pro xuanke 70001_70002`");
407
+ printJson({ ok: true, ...decodeXuanke(raw) });
408
+ },
409
+ async memory(args) {
410
+ const { positional, flags } = parseFlags(args);
411
+ const sub = positional[0] ?? "list";
412
+ if (sub === "list") {
413
+ const state = loadMemory();
414
+ printJson({ ok: true, path: memoryPath(), ...state });
415
+ return;
416
+ }
417
+ if (sub === "set") {
418
+ const pairs = {};
419
+ for (const arg of positional.slice(1)) {
420
+ const idx = arg.indexOf("=");
421
+ if (idx < 0)
422
+ throw new Error(`bad k=v pair: ${arg}`);
423
+ pairs[arg.slice(0, idx)] = arg.slice(idx + 1);
424
+ }
425
+ if (Object.keys(pairs).length === 0)
426
+ throw new Error("memory set needs at least one k=v");
427
+ const state = setPrefs(pairs);
428
+ printJson({ ok: true, prefs: state.prefs });
429
+ return;
430
+ }
431
+ if (sub === "watch") {
432
+ const id = Number(positional[1]);
433
+ if (!Number.isFinite(id))
434
+ throw new Error("memory watch needs <schoolId> as a number");
435
+ const state = addWatched(id, typeof flags.name === "string" ? flags.name : undefined, typeof flags.note === "string" ? flags.note : undefined);
436
+ printJson({ ok: true, watched: state.watched_schools });
437
+ return;
438
+ }
439
+ if (sub === "event") {
440
+ const type = positional[1];
441
+ const detail = positional.slice(2).join(" ");
442
+ if (!type)
443
+ throw new Error("memory event needs <type> <detail>");
444
+ const state = logEvent(type, detail);
445
+ printJson({ ok: true, last_event: state.events[state.events.length - 1] });
446
+ return;
447
+ }
448
+ if (sub === "clear") {
449
+ clearMemory();
450
+ printJson({ ok: true, cleared: true });
451
+ return;
452
+ }
453
+ throw new Error(`unknown memory subcommand: ${sub}. valid: list/set/watch/event/clear`);
454
+ },
455
+ async selftest() {
456
+ const out = await runSelftest();
457
+ for (const r of out.results) {
458
+ const tag = r.ok ? "ok " : "FAIL";
459
+ const ms = r.ms !== undefined ? ` (${r.ms}ms)` : "";
460
+ const reason = r.reason ? `: ${r.reason}` : "";
461
+ process.stdout.write(` ${tag} ${r.stage}${ms}${reason}\n`);
462
+ }
463
+ process.exit(out.ok ? 0 : 1);
464
+ },
465
+ async provinces() {
466
+ const rows = Object.entries(PROVINCES).map(([id, p]) => ({
467
+ id: Number(id),
468
+ name: p.name,
469
+ pinyin: p.pinyin,
470
+ reform: p.reform
471
+ }));
472
+ printJson({ ok: true, count: rows.length, provinces: rows });
473
+ },
474
+ async school(args) {
475
+ const { positional } = parseFlags(args);
476
+ const id = positional[0];
477
+ if (!id)
478
+ throw new Error("missing schoolId. e.g. `gaokao-pro school 31`");
479
+ const info = await getSchoolInfo(id);
480
+ printJson({
481
+ ok: true,
482
+ school: {
483
+ gaokao_cn_id: info.school_id,
484
+ zs_code: info.zs_code,
485
+ name: info.name,
486
+ belong: info.belong,
487
+ location: `${info.province_name} · ${info.city_name} · ${info.town_name}`,
488
+ level: info.level_name,
489
+ type: info.type_name,
490
+ nature: info.nature_name,
491
+ dual_class: info.dual_class_name,
492
+ f985: info.f985 === "1",
493
+ f211: info.f211 === "1",
494
+ rank: info.rank,
495
+ xueke_rank: info.xueke_rank,
496
+ site: info.site,
497
+ phone: info.phone,
498
+ address: info.address,
499
+ intro: info.content?.slice(0, 280)
500
+ }
501
+ });
502
+ },
503
+ async plan(args) {
504
+ const { positional, flags } = parseFlags(args);
505
+ const id = positional[0];
506
+ if (!id)
507
+ throw new Error("missing schoolId");
508
+ const year = Number(flags.year ?? new Date().getFullYear() - 1);
509
+ if (!Number.isFinite(year))
510
+ throw new Error("--year must be a number");
511
+ const provinceArg = flags.province;
512
+ if (typeof provinceArg !== "string")
513
+ throw new Error("--province <name|id> is required");
514
+ const provinceId = resolveProvince(provinceArg);
515
+ if (!provinceId)
516
+ throw new Error(`unknown province: ${provinceArg}`);
517
+ const items = await getAdmissionPlan(id, year, provinceId);
518
+ printJson({
519
+ ok: true,
520
+ query: { schoolId: id, year, province: { id: provinceId, name: PROVINCES[provinceId].name } },
521
+ count: items.length,
522
+ items: items.map((p) => ({
523
+ spcode: p.spcode,
524
+ sp_name: p.sp_name,
525
+ spname: p.spname,
526
+ num: p.num,
527
+ length: p.length,
528
+ tuition: p.tuition,
529
+ batch: p.local_batch_name,
530
+ zslx: p.zslx_name,
531
+ track: TRACK_NAMES[p.type] ?? p.type,
532
+ major_group: p.special_group !== "0" ? p.special_group : null,
533
+ xuanke: {
534
+ first: p.sp_fxk || p.sg_fxk || null,
535
+ reselect: p.sp_sxk || p.sg_sxk || null,
536
+ raw: p.sp_xuanke || p.sg_xuanke || null
537
+ },
538
+ category: `${p.level2_name} · ${p.level3_name}`,
539
+ info: p.info || p.remark || null
540
+ }))
541
+ });
542
+ },
543
+ async recommend(args) {
544
+ const { flags } = parseFlags(args);
545
+ const score = Number(flags.score);
546
+ if (!Number.isFinite(score))
547
+ throw new Error("--score <n> is required");
548
+ if (typeof flags.province !== "string")
549
+ throw new Error("--province <name|id> is required");
550
+ const provinceId = resolveProvince(flags.province);
551
+ if (!provinceId)
552
+ throw new Error(`unknown province: ${flags.province}`);
553
+ if (typeof flags.subjects !== "string")
554
+ throw new Error("--subjects <list> is required (comma-separated, e.g. 物理,化学,生物)");
555
+ const subjects = flags.subjects.split(",").map((s) => s.trim());
556
+ for (const s of subjects) {
557
+ if (!ALL_SUBJECTS.includes(s))
558
+ throw new Error(`unknown subject: ${s} (valid: ${ALL_SUBJECTS.join(", ")})`);
559
+ }
560
+ const schoolIds = typeof flags.schools === "string"
561
+ ? flags.schools.split(",").map((s) => s.trim()).filter(Boolean)
562
+ : undefined;
563
+ const rank = flags.rank !== undefined ? Number(flags.rank) : undefined;
564
+ const limit = flags.limit !== undefined ? Number(flags.limit) : undefined;
565
+ const filter = {
566
+ f985: flags["985"] === true ? true : undefined,
567
+ f211: flags["211"] === true ? true : undefined,
568
+ dualClass: flags["dual-class"] === true ? true : undefined,
569
+ level: typeof flags.level === "string" ? flags.level : undefined,
570
+ type: typeof flags.type === "string" ? flags.type : undefined,
571
+ belong: typeof flags.belong === "string" ? flags.belong : undefined
572
+ };
573
+ const out = recommend({ score, provinceId, subjects, rank, schoolIds, filter, limit });
574
+ if (shouldTable(flags)) {
575
+ process.stdout.write(formatRecommend(out, { explain: flags.explain === true }) + "\n");
576
+ }
577
+ else {
578
+ printJson({ ok: true, ...out });
579
+ }
580
+ },
581
+ async top(args) {
582
+ const { flags } = parseFlags(args);
583
+ const score = Number(flags.score);
584
+ if (!Number.isFinite(score))
585
+ throw new Error("--score <n> is required");
586
+ if (typeof flags.province !== "string")
587
+ throw new Error("--province <name|id> is required");
588
+ const provinceId = resolveProvince(flags.province);
589
+ if (!provinceId)
590
+ throw new Error(`unknown province: ${flags.province}`);
591
+ if (typeof flags.subjects !== "string")
592
+ throw new Error("--subjects <list> is required");
593
+ const subjects = flags.subjects.split(",").map((s) => s.trim());
594
+ for (const s of subjects) {
595
+ if (!ALL_SUBJECTS.includes(s))
596
+ throw new Error(`unknown subject: ${s} (valid: ${ALL_SUBJECTS.join(", ")})`);
597
+ }
598
+ const limit = flags.limit !== undefined ? Number(flags.limit) : 20;
599
+ const filter = {
600
+ f985: flags["985"] === true ? true : undefined,
601
+ f211: flags["211"] === true ? true : undefined,
602
+ dualClass: flags["dual-class"] === true ? true : undefined
603
+ };
604
+ const out = top({ score, provinceId, subjects, limit, filter });
605
+ if (shouldTable(flags)) {
606
+ const rows = out.rows.map((r) => ({
607
+ schoolName: r.name,
608
+ baselineMinScore: r.baselineMinScore,
609
+ delta: r.delta,
610
+ baselineYear: r.baselineYear,
611
+ city: r.city,
612
+ tags: [r.is985 ? "985" : "", r.is211 && !r.is985 ? "211" : "", r.dualClass === "双一流" && !r.is985 && !r.is211 ? "双一流" : ""].filter(Boolean).join(" "),
613
+ belong: r.belong
614
+ }));
615
+ const header = `gaokao-pro top score=${score} province=${PROVINCES[provinceId].name} subjects=${subjects.join("/")} limit=${limit}\n`;
616
+ process.stdout.write(header + formatTop(rows) + "\n");
617
+ }
618
+ else {
619
+ printJson({ ok: true, ...out });
620
+ }
621
+ },
622
+ async actual(args) {
623
+ const { positional, flags } = parseFlags(args);
624
+ const id = positional[0];
625
+ if (!id)
626
+ throw new Error("missing schoolId");
627
+ const year = Number(flags.year);
628
+ if (!Number.isFinite(year))
629
+ throw new Error("--year <year> is required");
630
+ if (typeof flags.province !== "string")
631
+ throw new Error("--province <name|id> is required");
632
+ const provinceId = resolveProvince(flags.province);
633
+ if (!provinceId)
634
+ throw new Error(`unknown province: ${flags.province}`);
635
+ const items = await getAdmissionScores(id, year, provinceId);
636
+ printJson({
637
+ ok: true,
638
+ query: { schoolId: id, year, province: { id: provinceId, name: PROVINCES[provinceId].name } },
639
+ count: items.length,
640
+ items: items.map((it) => ({
641
+ spcode: it.spcode || null,
642
+ sp_name: it.sp_name,
643
+ spname: it.spname,
644
+ max: it.max || null,
645
+ min: it.min || null,
646
+ average: it.average || null,
647
+ min_section: it.min_section && it.min_section !== "-" ? Number(it.min_section) : null,
648
+ lq_num: Number(it.lq_num) || 0,
649
+ diff: it.diff || null,
650
+ batch: it.local_batch_name,
651
+ zslx: it.zslx_name,
652
+ track: TRACK_NAMES[it.type] ?? it.type,
653
+ major_group: it.special_group !== "0" ? it.special_group : null,
654
+ xuanke: {
655
+ first: it.sp_fxk || it.sg_fxk || null,
656
+ reselect: it.sp_sxk || it.sg_sxk || null,
657
+ raw: it.sp_xuanke || it.sg_xuanke || null
658
+ },
659
+ info: it.info || it.remark || null
660
+ }))
661
+ });
662
+ },
663
+ async find(args) {
664
+ const { positional, flags } = parseFlags(args);
665
+ const keyword = positional[0];
666
+ if (!keyword)
667
+ throw new Error("missing keyword. e.g. `gaokao-pro find \"计算机\" --province henan --year 2024`");
668
+ if (typeof flags.province !== "string")
669
+ throw new Error("--province <name|id> is required");
670
+ const provinceId = resolveProvince(flags.province);
671
+ if (!provinceId)
672
+ throw new Error(`unknown province: ${flags.province}`);
673
+ const year = Number(flags.year);
674
+ if (!Number.isFinite(year))
675
+ throw new Error("--year <year> is required");
676
+ const limit = flags.limit !== undefined ? Number(flags.limit) : undefined;
677
+ const filter = {
678
+ f985: flags["985"] === true ? true : undefined,
679
+ f211: flags["211"] === true ? true : undefined,
680
+ dualClass: flags["dual-class"] === true ? true : undefined,
681
+ belong: typeof flags.belong === "string" ? flags.belong : undefined
682
+ };
683
+ const out = await find({ keyword, provinceId, year, filter, limit });
684
+ printJson({ ok: true, ...out });
685
+ },
686
+ async scores(args) {
687
+ const { positional, flags } = parseFlags(args);
688
+ const id = positional[0];
689
+ if (!id)
690
+ throw new Error("missing schoolId");
691
+ const provinceArg = flags.province;
692
+ if (typeof provinceArg !== "string")
693
+ throw new Error("--province <name|id> is required");
694
+ const provinceId = resolveProvince(provinceArg);
695
+ if (!provinceId)
696
+ throw new Error(`unknown province: ${provinceArg}`);
697
+ const info = await getSchoolInfo(id);
698
+ const series = extractHistoricalScores(info, provinceId).map((row) => ({
699
+ ...row,
700
+ trackName: TRACK_NAMES[row.track] ?? row.track
701
+ }));
702
+ printJson({
703
+ ok: true,
704
+ query: { schoolId: id, school: info.name, province: PROVINCES[provinceId].name },
705
+ count: series.length,
706
+ series
707
+ });
708
+ }
709
+ };
710
+ async function main() {
711
+ const [verb, ...rest] = process.argv.slice(2);
712
+ const handler = verb ? VERBS[verb] : VERBS.help;
713
+ if (!handler) {
714
+ process.stderr.write(`unknown verb: ${verb}\n\n`);
715
+ process.stdout.write(HELP);
716
+ process.exit(2);
717
+ }
718
+ try {
719
+ await handler(rest);
720
+ }
721
+ catch (err) {
722
+ const msg = err instanceof Error ? err.message : String(err);
723
+ process.stderr.write(JSON.stringify({ ok: false, error: msg }) + "\n");
724
+ process.exit(1);
725
+ }
726
+ }
727
+ main();