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/mcp.js ADDED
@@ -0,0 +1,535 @@
1
+ // stdio MCP server — `gaokao-pro mcp` exposes the CLI verbs as Model Context
2
+ // Protocol tools so Claude Code (or any MCP client) can call them directly.
3
+ //
4
+ // Wire up:
5
+ // claude mcp add gaokao-pro -- npx -y gaokao-pro mcp
6
+ //
7
+ // Protocol: JSON-RPC 2.0 over stdio, MCP v2025-06-18 surface.
8
+ // Zero external deps — handle the minimal RPC surface ourselves.
9
+ import { createInterface } from "node:readline";
10
+ import { recommend } from "./recommend.js";
11
+ import { top } from "./top.js";
12
+ import { find } from "./find.js";
13
+ import { getSchoolInfo, getAdmissionPlan, getAdmissionScores, extractHistoricalScores } from "./gaokao-cn.js";
14
+ import { PROVINCES, TRACK_NAMES, resolveProvince, ALL_SUBJECTS } from "./codes.js";
15
+ import { loadRankTable, listRankTables, scoreToRank, rankToScore, inferDefaultTrack } from "./rank-table.js";
16
+ import { decodeXuanke } from "./xuanke.js";
17
+ import { match } from "./match.js";
18
+ import { recommendMajor } from "./recommend-major.js";
19
+ import { chartCheck } from "./chart-check.js";
20
+ import { compare } from "./compare.js";
21
+ import { paiming } from "./paiming.js";
22
+ const SERVER_INFO = { name: "gaokao-pro", version: "0.0.2" };
23
+ const PROTOCOL_VERSION = "2025-06-18";
24
+ function rpcOk(id, result) {
25
+ return { jsonrpc: "2.0", id: id ?? null, result };
26
+ }
27
+ function rpcErr(id, code, message) {
28
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
29
+ }
30
+ // ---- Tool definitions ----
31
+ const TOOLS = [
32
+ {
33
+ name: "recommend",
34
+ description: "Bucket Chinese universities into 冲(reach) / 稳(match) / 保(safety) based on a student's gaokao score, province, and subject combination. Offline (no network). Filters: 985 / 211 / 双一流 / 隶属. Returns up to `limit` schools per bucket.",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {
38
+ score: { type: "number", description: "Student's total gaokao score." },
39
+ province: { type: "string", description: "Province name (e.g. '河南', 'henan', or numeric id like 41)." },
40
+ subjects: {
41
+ type: "array",
42
+ items: { type: "string", enum: ALL_SUBJECTS },
43
+ description: "Selected subjects (3+3 provinces: 3 subjects; 3+1+2: must include 物理 OR 历史 + 2 others). Drives track inference."
44
+ },
45
+ rank: { type: "number", description: "Optional: student's 全省排名 (位次). Not used yet for filtering; reserved for future rank-based mode." },
46
+ f985: { type: "boolean", description: "Filter to 985 universities only." },
47
+ f211: { type: "boolean", description: "Filter to 211 universities only." },
48
+ dualClass: { type: "boolean", description: "Filter to 双一流 universities only." },
49
+ belong: { type: "string", description: "Filter by 隶属 (e.g. '教育部', '工信部')." },
50
+ limit: { type: "number", description: "Cap results per bucket (冲/稳/保/out). Default unlimited." }
51
+ },
52
+ required: ["score", "province", "subjects"],
53
+ additionalProperties: false
54
+ }
55
+ },
56
+ {
57
+ name: "top",
58
+ description: "Top-N best universities a student's score can reach in a province. Like `recommend` but flat list sorted by historical baseline descending. Use when the user wants 'what are the strongest schools I can realistically get into?'",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ score: { type: "number" },
63
+ province: { type: "string" },
64
+ subjects: { type: "array", items: { type: "string", enum: ALL_SUBJECTS } },
65
+ limit: { type: "number", description: "Default 20." },
66
+ f985: { type: "boolean" },
67
+ f211: { type: "boolean" },
68
+ dualClass: { type: "boolean" }
69
+ },
70
+ required: ["score", "province", "subjects"],
71
+ additionalProperties: false
72
+ }
73
+ },
74
+ {
75
+ name: "find",
76
+ description: "Search for a major keyword (e.g. '计算机', '临床医学') across universities recruiting in a specific province for a specific year. Returns schools, plan numbers, 选科 requirements, 学费, batch. Hits gaokao.cn API per candidate school (concurrent).",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ keyword: { type: "string", description: "Major name fragment." },
81
+ province: { type: "string" },
82
+ year: { type: "number", description: "Recruitment year. 2024 is the latest fully-published year." },
83
+ f985: { type: "boolean" },
84
+ f211: { type: "boolean" },
85
+ dualClass: { type: "boolean" },
86
+ belong: { type: "string" },
87
+ limit: { type: "number" }
88
+ },
89
+ required: ["keyword", "province", "year"],
90
+ additionalProperties: false
91
+ }
92
+ },
93
+ {
94
+ name: "school",
95
+ description: "Look up one university's metadata: name, 教育部 code (zs_code), 985/211/双一流 labels, 学科评估 (第四轮) counts, rankings (软科/QS/US News), historical min scores per province per year.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ schoolId: { type: "string", description: "gaokao.cn internal school id (e.g. 31 = 北大, 30 = 北工大). NOT the 5-digit 教育部 code." }
100
+ },
101
+ required: ["schoolId"],
102
+ additionalProperties: false
103
+ }
104
+ },
105
+ {
106
+ name: "plan",
107
+ description: "Forward-looking admission plan for one (school × year × province): list of majors, 计划人数, 学制, 学费, 批次, 选科要求 (新高考). Use when the user asks 'what does Tsinghua recruit in Henan this year?'",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ schoolId: { type: "string" },
112
+ year: { type: "number" },
113
+ province: { type: "string" }
114
+ },
115
+ required: ["schoolId", "year", "province"],
116
+ additionalProperties: false
117
+ }
118
+ },
119
+ {
120
+ name: "actual",
121
+ description: "Backward-looking ACTUAL admissions per major: 实际最高/最低/平均分, 录取人数, 最低位次 (min_section — only populated for 新高考 provinces). Use this to compare 'what got in last year' vs the user's score/rank.",
122
+ inputSchema: {
123
+ type: "object",
124
+ properties: {
125
+ schoolId: { type: "string" },
126
+ year: { type: "number" },
127
+ province: { type: "string" }
128
+ },
129
+ required: ["schoolId", "year", "province"],
130
+ additionalProperties: false
131
+ }
132
+ },
133
+ {
134
+ name: "scores",
135
+ description: "Historical minimum-score time series for a (school × province) pair across all years/tracks gaokao.cn has. Quick way to see the trend without per-major detail.",
136
+ inputSchema: {
137
+ type: "object",
138
+ properties: {
139
+ schoolId: { type: "string" },
140
+ province: { type: "string" }
141
+ },
142
+ required: ["schoolId", "province"],
143
+ additionalProperties: false
144
+ }
145
+ },
146
+ {
147
+ name: "provinces",
148
+ description: "List all 31 supported provinces with their numeric ids, pinyin, and 新高考 reform mode (old / 3+3 / 3+1+2). Useful before calling tools that need a province parameter.",
149
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
150
+ },
151
+ {
152
+ name: "rank",
153
+ description: "Translate between gaokao score and provincial rank (位次) using the official 一分一段表. Pass `score` to get the rank; pass `rank` to get the score that hits that rank. Provinces with ingested data: see `rank_tables` tool first. Use this whenever the user mentions their 位次 — rank-based comparison is much more accurate than raw score across years (since exam difficulty varies).",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ province: { type: "string" },
158
+ year: { type: "number" },
159
+ track: { type: "string", description: "'combined' for 3+3 provinces (北京/上海/天津/山东/海南/浙江); 'physics' or 'history' for 3+1+2; 'science'/'liberal' for 老高考. Omit to use the province default." },
160
+ score: { type: "number", description: "If set, return the rank for this score." },
161
+ rank: { type: "number", description: "If set, return the score that hits this rank." }
162
+ },
163
+ required: ["province", "year"],
164
+ additionalProperties: false
165
+ }
166
+ },
167
+ {
168
+ name: "rank_tables",
169
+ description: "List the (province, year, track) tuples for which we have ingested 一分一段 data. Call this before `rank` to confirm coverage. Beijing is the proof-of-concept; other provinces are being added incrementally.",
170
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
171
+ },
172
+ {
173
+ name: "match",
174
+ description: "Take a complete student profile (score + province + subjects + interests + constraints) and return ranked schools with composite fit scores (interest 0.4 + baseline 0.35 + label 0.15 + city 0.10). Use this when the user has given you enough preferences for a holistic plan; for pure score-based reach/match/safety lists use `recommend` instead.",
175
+ inputSchema: {
176
+ type: "object",
177
+ properties: {
178
+ score: { type: "number" },
179
+ province: { type: "string" },
180
+ subjects: { type: "array", items: { type: "string", enum: ALL_SUBJECTS } },
181
+ rank: { type: "number" },
182
+ interests: { type: "array", items: { type: "string" } },
183
+ constraints: {
184
+ type: "object",
185
+ properties: {
186
+ cities_preferred: { type: "array", items: { type: "string" } },
187
+ cities_avoid: { type: "array", items: { type: "string" } },
188
+ require_985: { type: "boolean" },
189
+ require_211: { type: "boolean" },
190
+ require_dual_class: { type: "boolean" },
191
+ belong: { type: "string" },
192
+ max_tuition_yuan: { type: "number" }
193
+ },
194
+ additionalProperties: false
195
+ },
196
+ limit: { type: "number" }
197
+ },
198
+ required: ["score", "province", "subjects"],
199
+ additionalProperties: false
200
+ }
201
+ },
202
+ {
203
+ name: "recommend_major",
204
+ description: "Interest-driven inverse of `recommend`: given a major keyword (e.g. '计算机', 'AI', '临床医学'), find which schools in the user's province recruit that major, ranked by how many of them the student's score can reach. Use this when the user starts from a major interest instead of a school.",
205
+ inputSchema: {
206
+ type: "object",
207
+ properties: {
208
+ keyword: { type: "string" },
209
+ score: { type: "number" },
210
+ province: { type: "string" },
211
+ subjects: { type: "array", items: { type: "string", enum: ALL_SUBJECTS } },
212
+ year: { type: "number" },
213
+ f985: { type: "boolean" },
214
+ f211: { type: "boolean" },
215
+ dualClass: { type: "boolean" },
216
+ belong: { type: "string" },
217
+ limit: { type: "number" }
218
+ },
219
+ required: ["keyword", "score", "province", "subjects", "year"],
220
+ additionalProperties: false
221
+ }
222
+ },
223
+ {
224
+ name: "chart_check",
225
+ description: "Sanity-check a student profile before sending it into `match` or `recommend`. Validates score range, 选科 vs 新高考 reform, rank↔score consistency (when 一分一段 data exists). Returns ok/health (0-100) + errors + warnings. ALWAYS call this once after collecting the user's profile.",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ score: { type: "number" },
230
+ rank: { type: "number" },
231
+ province: { type: "string" },
232
+ subjects: { type: "array", items: { type: "string" } },
233
+ year: { type: "number" }
234
+ },
235
+ additionalProperties: false
236
+ }
237
+ },
238
+ {
239
+ name: "compare",
240
+ description: "Side-by-side comparison of two schools: labels (985/211/双一流), 隶属, recent 5-province minimum scores, 招生网 URL, special-program flags, contact. Aliases accepted: 清华/北大/复旦/上交/浙大/南大/中科大/哈工大/西交/北航/北理/南开/天大/同济/东南/厦大/山大/海大/武大/华科/中南/中山/华工/川大/重大/电子科大/西工大/西农/兰大/湖大/北邮/央财/贸大/上财/上外/华理/上大/西电/南理工/南航/苏大 etc.",
241
+ inputSchema: {
242
+ type: "object",
243
+ properties: {
244
+ a: { type: "string", description: "School A: name, alias (e.g. 清华), or zs_code (e.g. 10003)" },
245
+ b: { type: "string", description: "School B (same forms)" },
246
+ province: { type: "string", description: "Optional focus province (only this province's score series in output)." }
247
+ },
248
+ required: ["a", "b"],
249
+ additionalProperties: false
250
+ }
251
+ },
252
+ {
253
+ name: "paiming",
254
+ description: "Aggregate rankings for one school: 软科 (Shanghai), 校友会, QS World, US News, 泰晤士中国 + 第四轮学科评估 (A+/A/A-/B+/B/B-/C+/C/C- counts) + 第五轮 disclosed A+ subjects if available. Use this whenever the user asks about a school's 'rank' / '排名' / '学科评估'.",
255
+ inputSchema: {
256
+ type: "object",
257
+ properties: {
258
+ school: { type: "string", description: "School name, alias, or zs_code" }
259
+ },
260
+ required: ["school"],
261
+ additionalProperties: false
262
+ }
263
+ },
264
+ {
265
+ name: "xuanke",
266
+ description: "Decode a gaokao.cn selected-subject requirement string (e.g. '70001_70002^70001_70003') into Chinese subject names. Use this whenever you encounter `sp_xuanke` / `sg_xuanke` fields in plan / actual responses.",
267
+ inputSchema: {
268
+ type: "object",
269
+ properties: {
270
+ raw: { type: "string", description: "Raw xuanke string from gaokao.cn payload, e.g. '70001_70002' (physics AND chemistry) or '70008' (no requirement)." }
271
+ },
272
+ required: ["raw"],
273
+ additionalProperties: false
274
+ }
275
+ }
276
+ ];
277
+ // ---- Tool dispatchers ----
278
+ function getStr(args, key) {
279
+ const v = args[key];
280
+ if (typeof v !== "string")
281
+ throw new Error(`missing or non-string arg: ${key}`);
282
+ return v;
283
+ }
284
+ function getNum(args, key) {
285
+ const v = args[key];
286
+ if (typeof v === "number")
287
+ return v;
288
+ const n = Number(v);
289
+ if (!Number.isFinite(n))
290
+ throw new Error(`missing or invalid number arg: ${key}`);
291
+ return n;
292
+ }
293
+ function getProvinceId(args, key = "province") {
294
+ const raw = args[key];
295
+ if (raw === undefined || raw === null)
296
+ throw new Error("province is required");
297
+ const id = resolveProvince(String(raw));
298
+ if (!id)
299
+ throw new Error(`unknown province: ${raw}`);
300
+ return id;
301
+ }
302
+ function getSubjects(args) {
303
+ const v = args.subjects;
304
+ if (!Array.isArray(v))
305
+ throw new Error("subjects must be an array");
306
+ for (const s of v) {
307
+ if (typeof s !== "string" || !ALL_SUBJECTS.includes(s)) {
308
+ throw new Error(`invalid subject: ${s}`);
309
+ }
310
+ }
311
+ return v;
312
+ }
313
+ function getFilter(args) {
314
+ return {
315
+ f985: args.f985 === true ? true : undefined,
316
+ f211: args.f211 === true ? true : undefined,
317
+ dualClass: args.dualClass === true ? true : undefined,
318
+ belong: typeof args.belong === "string" ? args.belong : undefined
319
+ };
320
+ }
321
+ async function dispatch(name, args) {
322
+ switch (name) {
323
+ case "recommend": {
324
+ return recommend({
325
+ score: getNum(args, "score"),
326
+ provinceId: getProvinceId(args),
327
+ subjects: getSubjects(args),
328
+ rank: args.rank !== undefined ? Number(args.rank) : undefined,
329
+ filter: getFilter(args),
330
+ limit: args.limit !== undefined ? Number(args.limit) : undefined
331
+ });
332
+ }
333
+ case "top": {
334
+ return top({
335
+ score: getNum(args, "score"),
336
+ provinceId: getProvinceId(args),
337
+ subjects: getSubjects(args),
338
+ limit: args.limit !== undefined ? Number(args.limit) : 20,
339
+ filter: getFilter(args)
340
+ });
341
+ }
342
+ case "find": {
343
+ return find({
344
+ keyword: getStr(args, "keyword"),
345
+ provinceId: getProvinceId(args),
346
+ year: getNum(args, "year"),
347
+ filter: getFilter(args),
348
+ limit: args.limit !== undefined ? Number(args.limit) : undefined
349
+ });
350
+ }
351
+ case "school": {
352
+ const info = await getSchoolInfo(getStr(args, "schoolId"));
353
+ return {
354
+ gaokao_cn_id: info.school_id,
355
+ zs_code: info.zs_code,
356
+ name: info.name,
357
+ belong: info.belong,
358
+ location: `${info.province_name} · ${info.city_name} · ${info.town_name}`,
359
+ level: info.level_name,
360
+ type: info.type_name,
361
+ nature: info.nature_name,
362
+ dual_class: info.dual_class_name,
363
+ f985: info.f985 === "1",
364
+ f211: info.f211 === "1",
365
+ rank: info.rank,
366
+ xueke_rank: info.xueke_rank,
367
+ site: info.site,
368
+ phone: info.phone,
369
+ address: info.address,
370
+ intro: info.content?.slice(0, 280)
371
+ };
372
+ }
373
+ case "plan": {
374
+ const items = await getAdmissionPlan(getStr(args, "schoolId"), getNum(args, "year"), getProvinceId(args));
375
+ return { count: items.length, items };
376
+ }
377
+ case "actual": {
378
+ const items = await getAdmissionScores(getStr(args, "schoolId"), getNum(args, "year"), getProvinceId(args));
379
+ return { count: items.length, items };
380
+ }
381
+ case "scores": {
382
+ const info = await getSchoolInfo(getStr(args, "schoolId"));
383
+ const provinceId = getProvinceId(args);
384
+ const series = extractHistoricalScores(info, provinceId).map((row) => ({
385
+ ...row,
386
+ trackName: TRACK_NAMES[row.track] ?? row.track
387
+ }));
388
+ return { school: info.name, province: PROVINCES[provinceId].name, series };
389
+ }
390
+ case "provinces": {
391
+ return Object.entries(PROVINCES).map(([id, p]) => ({
392
+ id: Number(id),
393
+ name: p.name,
394
+ pinyin: p.pinyin,
395
+ reform: p.reform
396
+ }));
397
+ }
398
+ case "rank": {
399
+ const provinceId = getProvinceId(args);
400
+ const year = getNum(args, "year");
401
+ const track = typeof args.track === "string" ? args.track : inferDefaultTrack(provinceId);
402
+ const table = loadRankTable(provinceId, year, track);
403
+ if (!table) {
404
+ throw new Error(`No 一分一段 table for ${PROVINCES[provinceId].name} ${year} ${track}. Call \`rank_tables\` to see what's ingested.`);
405
+ }
406
+ const hasScore = args.score !== undefined;
407
+ const hasRank = args.rank !== undefined;
408
+ if (!hasScore && !hasRank)
409
+ throw new Error("Pass either `score` or `rank`.");
410
+ if (hasScore) {
411
+ const score = Number(args.score);
412
+ return {
413
+ province: PROVINCES[provinceId].name,
414
+ year,
415
+ track,
416
+ source: table.source,
417
+ score,
418
+ rank: scoreToRank(table, score)
419
+ };
420
+ }
421
+ const rank = Number(args.rank);
422
+ return {
423
+ province: PROVINCES[provinceId].name,
424
+ year,
425
+ track,
426
+ source: table.source,
427
+ rank,
428
+ score: rankToScore(table, rank)
429
+ };
430
+ }
431
+ case "rank_tables": {
432
+ return listRankTables();
433
+ }
434
+ case "xuanke": {
435
+ return decodeXuanke(getStr(args, "raw"));
436
+ }
437
+ case "compare": {
438
+ const focusProv = typeof args.province === "string" ? resolveProvince(args.province) ?? undefined : undefined;
439
+ return compare(getStr(args, "a"), getStr(args, "b"), focusProv);
440
+ }
441
+ case "paiming": {
442
+ return await paiming(getStr(args, "school"));
443
+ }
444
+ case "match": {
445
+ return match({
446
+ score: getNum(args, "score"),
447
+ province: getProvinceId(args),
448
+ subjects: getSubjects(args),
449
+ rank: args.rank !== undefined ? Number(args.rank) : undefined,
450
+ interests: Array.isArray(args.interests) ? args.interests : undefined,
451
+ constraints: (args.constraints ?? undefined)
452
+ }, args.limit !== undefined ? Number(args.limit) : 20);
453
+ }
454
+ case "recommend_major": {
455
+ return recommendMajor({
456
+ keyword: getStr(args, "keyword"),
457
+ score: getNum(args, "score"),
458
+ provinceId: getProvinceId(args),
459
+ subjects: getSubjects(args),
460
+ year: getNum(args, "year"),
461
+ filter: getFilter(args),
462
+ limit: args.limit !== undefined ? Number(args.limit) : 20
463
+ });
464
+ }
465
+ case "chart_check": {
466
+ const provinceArg = typeof args.province === "string" ? args.province : undefined;
467
+ const province_id = provinceArg ? resolveProvince(provinceArg) ?? undefined : undefined;
468
+ return chartCheck({
469
+ score: args.score !== undefined ? Number(args.score) : undefined,
470
+ rank: args.rank !== undefined ? Number(args.rank) : undefined,
471
+ province: provinceArg,
472
+ province_id,
473
+ subjects: Array.isArray(args.subjects) ? args.subjects : undefined,
474
+ year: args.year !== undefined ? Number(args.year) : undefined
475
+ });
476
+ }
477
+ default:
478
+ throw new Error(`unknown tool: ${name}`);
479
+ }
480
+ }
481
+ // ---- Server loop ----
482
+ async function handle(req) {
483
+ const { id, method, params = {} } = req;
484
+ try {
485
+ switch (method) {
486
+ case "initialize":
487
+ return rpcOk(id, {
488
+ protocolVersion: PROTOCOL_VERSION,
489
+ capabilities: { tools: {} },
490
+ serverInfo: SERVER_INFO
491
+ });
492
+ case "initialized":
493
+ case "notifications/initialized":
494
+ return null;
495
+ case "tools/list":
496
+ return rpcOk(id, { tools: TOOLS });
497
+ case "tools/call": {
498
+ const name = params.name;
499
+ const args = (params.arguments ?? {});
500
+ const result = await dispatch(name, args);
501
+ return rpcOk(id, {
502
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
503
+ });
504
+ }
505
+ case "ping":
506
+ return rpcOk(id, {});
507
+ default:
508
+ return rpcErr(id, -32601, `Method not found: ${method}`);
509
+ }
510
+ }
511
+ catch (err) {
512
+ const msg = err instanceof Error ? err.message : String(err);
513
+ return rpcErr(id, -32000, msg);
514
+ }
515
+ }
516
+ export async function runMcpServer() {
517
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
518
+ for await (const line of rl) {
519
+ const trimmed = line.trim();
520
+ if (!trimmed)
521
+ continue;
522
+ let req;
523
+ try {
524
+ req = JSON.parse(trimmed);
525
+ }
526
+ catch {
527
+ process.stdout.write(JSON.stringify(rpcErr(null, -32700, "Parse error")) + "\n");
528
+ continue;
529
+ }
530
+ const res = await handle(req);
531
+ if (res !== null) {
532
+ process.stdout.write(JSON.stringify(res) + "\n");
533
+ }
534
+ }
535
+ }
@@ -0,0 +1,31 @@
1
+ export type MemoryPrefs = {
2
+ score?: number;
3
+ rank?: number;
4
+ province?: string;
5
+ subjects?: string[];
6
+ interests?: string[];
7
+ year?: number;
8
+ };
9
+ export type WatchedSchool = {
10
+ school_id: number;
11
+ name?: string;
12
+ added_at: string;
13
+ note?: string;
14
+ };
15
+ export type MemoryEvent = {
16
+ at: string;
17
+ type: string;
18
+ detail: string;
19
+ };
20
+ export type MemoryState = {
21
+ prefs: MemoryPrefs;
22
+ watched_schools: WatchedSchool[];
23
+ events: MemoryEvent[];
24
+ };
25
+ export declare function loadMemory(): MemoryState;
26
+ export declare function saveMemory(state: MemoryState): void;
27
+ export declare function setPrefs(updates: Record<string, string>): MemoryState;
28
+ export declare function addWatched(schoolId: number, name?: string, note?: string): MemoryState;
29
+ export declare function logEvent(type: string, detail: string): MemoryState;
30
+ export declare function clearMemory(): void;
31
+ export declare function memoryPath(): string;
package/dist/memory.js ADDED
@@ -0,0 +1,69 @@
1
+ // memory — local persistent JSON at ~/.gaokaopro/memory.json
2
+ // Stores user prefs + watched schools + simple event log so that Claude can
3
+ // resume context across sessions without re-asking.
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { homedir } from "node:os";
7
+ const MEMORY_DIR = resolve(homedir(), ".gaokaopro");
8
+ const MEMORY_PATH = resolve(MEMORY_DIR, "memory.json");
9
+ export function loadMemory() {
10
+ if (!existsSync(MEMORY_PATH)) {
11
+ return { prefs: {}, watched_schools: [], events: [] };
12
+ }
13
+ try {
14
+ return JSON.parse(readFileSync(MEMORY_PATH, "utf8"));
15
+ }
16
+ catch {
17
+ return { prefs: {}, watched_schools: [], events: [] };
18
+ }
19
+ }
20
+ export function saveMemory(state) {
21
+ mkdirSync(MEMORY_DIR, { recursive: true });
22
+ writeFileSync(MEMORY_PATH, JSON.stringify(state, null, 2));
23
+ }
24
+ export function setPrefs(updates) {
25
+ const state = loadMemory();
26
+ for (const [key, raw] of Object.entries(updates)) {
27
+ if (key === "score" || key === "rank" || key === "year") {
28
+ const n = Number(raw);
29
+ if (Number.isFinite(n))
30
+ state.prefs[key] = n;
31
+ }
32
+ else if (key === "subjects" || key === "interests") {
33
+ state.prefs[key] = raw.split(",").map((s) => s.trim()).filter(Boolean);
34
+ }
35
+ else {
36
+ state.prefs[key] = raw;
37
+ }
38
+ }
39
+ saveMemory(state);
40
+ return state;
41
+ }
42
+ export function addWatched(schoolId, name, note) {
43
+ const state = loadMemory();
44
+ if (!state.watched_schools.some((w) => w.school_id === schoolId)) {
45
+ state.watched_schools.push({
46
+ school_id: schoolId,
47
+ name,
48
+ added_at: new Date().toISOString(),
49
+ note
50
+ });
51
+ saveMemory(state);
52
+ }
53
+ return state;
54
+ }
55
+ export function logEvent(type, detail) {
56
+ const state = loadMemory();
57
+ state.events.push({ at: new Date().toISOString(), type, detail });
58
+ // cap to last 200 events
59
+ if (state.events.length > 200)
60
+ state.events = state.events.slice(-200);
61
+ saveMemory(state);
62
+ return state;
63
+ }
64
+ export function clearMemory() {
65
+ saveMemory({ prefs: {}, watched_schools: [], events: [] });
66
+ }
67
+ export function memoryPath() {
68
+ return MEMORY_PATH;
69
+ }
@@ -0,0 +1,29 @@
1
+ export type PaimingOutput = {
2
+ schoolId: number | string;
3
+ name: string;
4
+ zsCode: string;
5
+ rankings: {
6
+ ruanke: string | null;
7
+ xyh: string | null;
8
+ qsWorld: string | null;
9
+ usNews: string | null;
10
+ tws_china: string | null;
11
+ };
12
+ xueke_pinggu_round4: {
13
+ a_plus: string | null;
14
+ a: string | null;
15
+ a_minus: string | null;
16
+ b_plus: string | null;
17
+ b: string | null;
18
+ b_minus: string | null;
19
+ c_plus: string | null;
20
+ c: string | null;
21
+ c_minus: string | null;
22
+ };
23
+ xueke_pinggu_round5_disclosed: {
24
+ a_plus_count: number;
25
+ a_plus_subjects: string[];
26
+ } | null;
27
+ national_first_class_count?: number;
28
+ };
29
+ export declare function paiming(schoolQuery: string): Promise<PaimingOutput>;