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/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();
|