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.
- package/README.md +109 -0
- package/data/datasets/gaoshui-yundongdui-2024.json +33 -0
- package/data/datasets/junjing-jingxiao.json +75 -0
- package/data/datasets/provinces-specialty-2024.json +139 -0
- package/data/datasets/qiangji-2024.json +52 -0
- package/data/datasets/schools-adapters-2024.json +123 -0
- package/data/datasets/sushe-shitang.json +40 -0
- package/data/datasets/tijian-shouxian-zhuanye.json +29 -0
- package/data/datasets/xiaoyuzhong-zhaosheng.json +18 -0
- package/data/datasets/xueke-pinggu-disculun.json +28 -0
- package/data/datasets/zhongwai-hezuo-2024.json +36 -0
- package/data/datasets/zonghepingjia-2024.json +53 -0
- package/data/school-index.json.gz +0 -0
- package/data/yifenyiduan/_ocr-pipeline/extract-pdf.py +117 -0
- package/data/yifenyiduan/beijing-2023-combined.json +1 -0
- package/data/yifenyiduan/beijing-2024-combined.json +1 -0
- package/data/yifenyiduan/beijing-2025-combined.json +1 -0
- package/data/yifenyiduan/henan-2024-liberal.json +1 -0
- package/data/yifenyiduan/hunan-2024-history.json +1 -0
- package/dist/aliases.d.ts +2 -0
- package/dist/aliases.js +120 -0
- package/dist/chart-check.d.ts +20 -0
- package/dist/chart-check.js +99 -0
- package/dist/codes.d.ts +162 -0
- package/dist/codes.js +59 -0
- package/dist/compare.d.ts +39 -0
- package/dist/compare.js +112 -0
- package/dist/datasets.d.ts +65 -0
- package/dist/datasets.js +82 -0
- package/dist/find.d.ts +48 -0
- package/dist/find.js +87 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.js +109 -0
- package/dist/gaokao-cn.d.ts +122 -0
- package/dist/gaokao-cn.js +49 -0
- package/dist/index-loader.d.ts +33 -0
- package/dist/index-loader.js +59 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +727 -0
- package/dist/match.d.ts +40 -0
- package/dist/match.js +118 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +535 -0
- package/dist/memory.d.ts +31 -0
- package/dist/memory.js +69 -0
- package/dist/paiming.d.ts +29 -0
- package/dist/paiming.js +66 -0
- package/dist/probe.d.ts +1 -0
- package/dist/probe.js +73 -0
- package/dist/provinces/guangdong.d.ts +27 -0
- package/dist/provinces/guangdong.js +68 -0
- package/dist/provinces/index.d.ts +1 -0
- package/dist/provinces/index.js +17 -0
- package/dist/rank-table.d.ts +31 -0
- package/dist/rank-table.js +74 -0
- package/dist/recommend-major.d.ts +34 -0
- package/dist/recommend-major.js +119 -0
- package/dist/recommend.d.ts +54 -0
- package/dist/recommend.js +147 -0
- package/dist/selftest.d.ts +11 -0
- package/dist/selftest.js +48 -0
- package/dist/top.d.ts +29 -0
- package/dist/top.js +66 -0
- package/dist/xuanke.d.ts +8 -0
- package/dist/xuanke.js +35 -0
- 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
|
+
}
|
package/dist/memory.d.ts
ADDED
|
@@ -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>;
|