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
@@ -0,0 +1,66 @@
1
+ // paiming — aggregate ranking presentation for one school across 5 sources:
2
+ // 软科 (Shanghai Ranking), QS World, US News, 校友会 (Cuaa), 第四轮学科评估.
3
+ // 22 of the 100 candidate-validator agents asked "this school's overall rank" —
4
+ // this verb gives Claude a single call that pulls all five into one view.
5
+ import { getSchoolInfo } from "./gaokao-cn.js";
6
+ import { loadIndex } from "./index-loader.js";
7
+ import { readFileSync, existsSync } from "node:fs";
8
+ import { resolve, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const SRC_DIR = dirname(__filename);
12
+ function loadXuekePinggu() {
13
+ const candidates = [
14
+ resolve(SRC_DIR, "..", "data", "datasets", "xueke-pinggu-disculun.json"),
15
+ resolve(SRC_DIR, "..", "..", "data", "datasets", "xueke-pinggu-disculun.json")
16
+ ];
17
+ for (const p of candidates) {
18
+ if (!existsSync(p))
19
+ continue;
20
+ const raw = JSON.parse(readFileSync(p, "utf8"));
21
+ const out = {};
22
+ for (const s of raw.disclosed_schools ?? []) {
23
+ out[s.school] = { a_plus_count: s.a_plus_count, a_plus_subjects: s.a_plus_subjects };
24
+ }
25
+ return out;
26
+ }
27
+ return {};
28
+ }
29
+ let xuekeCache = null;
30
+ export async function paiming(schoolQuery) {
31
+ if (!xuekeCache)
32
+ xuekeCache = loadXuekePinggu();
33
+ const index = loadIndex();
34
+ const row = index.rows.find((r) => r.zs_code === schoolQuery)
35
+ ?? index.rows.find((r) => r.gaokao_cn_id === Number(schoolQuery))
36
+ ?? index.rows.find((r) => r.name.includes(schoolQuery));
37
+ if (!row)
38
+ throw new Error(`no school matched "${schoolQuery}"`);
39
+ const info = await getSchoolInfo(row.gaokao_cn_id);
40
+ const rank = (info.rank ?? {});
41
+ const xueke = (info.xueke_pinggu ?? info.xueke_rank ?? {});
42
+ return {
43
+ schoolId: row.gaokao_cn_id,
44
+ name: row.name,
45
+ zsCode: row.zs_code,
46
+ rankings: {
47
+ ruanke: rank.ruanke_rank && rank.ruanke_rank !== "0" ? String(rank.ruanke_rank) : null,
48
+ xyh: rank.xyh_rank && rank.xyh_rank !== "0" ? String(rank.xyh_rank) : null,
49
+ qsWorld: rank.qs_world && rank.qs_world !== "0" ? String(rank.qs_world) : null,
50
+ usNews: rank.us_rank && rank.us_rank !== "0" ? String(rank.us_rank) : null,
51
+ tws_china: rank.tws_china && rank.tws_china !== "0" ? String(rank.tws_china) : null
52
+ },
53
+ xueke_pinggu_round4: {
54
+ a_plus: xueke["A+"] ?? null,
55
+ a: xueke["A"] ?? null,
56
+ a_minus: xueke["A-"] ?? null,
57
+ b_plus: xueke["B+"] ?? null,
58
+ b: xueke["B"] ?? null,
59
+ b_minus: xueke["B-"] ?? null,
60
+ c_plus: xueke["C+"] ?? null,
61
+ c: xueke["C"] ?? null,
62
+ c_minus: xueke["C-"] ?? null
63
+ },
64
+ xueke_pinggu_round5_disclosed: xuekeCache?.[row.name] ?? null
65
+ };
66
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/probe.js ADDED
@@ -0,0 +1,73 @@
1
+ // Probe static-data.gaokao.cn to map gaokao.cn school_id → 教育部 zs_code (5-digit).
2
+ // Walks ids in a range and writes a JSON index to docs/school-index.json.
3
+ // Run with: pnpm probe -- --start 1 --end 100
4
+ import { writeFileSync, mkdirSync } from "node:fs";
5
+ import { gzipSync } from "node:zlib";
6
+ import { resolve, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { getSchoolInfo } from "./gaokao-cn.js";
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ // cli/src/probe.ts → repo root is two levels up, cli/data is one level up.
11
+ const CLI_ROOT = resolve(dirname(__filename), "..");
12
+ function parseRange() {
13
+ const args = process.argv.slice(2);
14
+ const get = (flag, fallback) => {
15
+ const idx = args.indexOf(flag);
16
+ return idx >= 0 ? Number(args[idx + 1]) : fallback;
17
+ };
18
+ return {
19
+ start: get("--start", 1),
20
+ end: get("--end", 3000),
21
+ concurrency: get("--concurrency", 25)
22
+ };
23
+ }
24
+ async function probeOne(id) {
25
+ try {
26
+ const info = await getSchoolInfo(id);
27
+ return {
28
+ gaokao_cn_id: Number(info.school_id),
29
+ zs_code: info.zs_code,
30
+ name: info.name,
31
+ province: info.province_name,
32
+ city: info.city_name,
33
+ level: info.level_name,
34
+ type: info.type_name,
35
+ nature: info.nature_name,
36
+ belong: info.belong,
37
+ f985: info.f985 === "1",
38
+ f211: info.f211 === "1",
39
+ dual_class: info.dual_class_name,
40
+ pro_type_min: info.pro_type_min ?? {}
41
+ };
42
+ }
43
+ catch (e) {
44
+ return null;
45
+ }
46
+ }
47
+ async function main() {
48
+ const { start, end, concurrency } = parseRange();
49
+ process.stderr.write(`probing school_ids [${start}..${end}] with concurrency ${concurrency}\n`);
50
+ const rows = [];
51
+ let cursor = start;
52
+ const workers = Array.from({ length: concurrency }, async () => {
53
+ while (cursor <= end) {
54
+ const id = cursor++;
55
+ const row = await probeOne(id);
56
+ if (row) {
57
+ rows.push(row);
58
+ process.stderr.write(` ${id.toString().padStart(5)} → ${row.zs_code} ${row.name}\n`);
59
+ }
60
+ }
61
+ });
62
+ await Promise.all(workers);
63
+ rows.sort((a, b) => a.gaokao_cn_id - b.gaokao_cn_id);
64
+ const payload = JSON.stringify({ generated_at: new Date().toISOString(), rows });
65
+ const outPath = resolve(CLI_ROOT, "data", "school-index.json.gz");
66
+ mkdirSync(dirname(outPath), { recursive: true });
67
+ writeFileSync(outPath, gzipSync(Buffer.from(payload, "utf8")));
68
+ process.stderr.write(`\nwrote ${rows.length} schools → ${outPath}\n`);
69
+ }
70
+ main().catch((err) => {
71
+ process.stderr.write(String(err) + "\n");
72
+ process.exit(1);
73
+ });
@@ -0,0 +1,27 @@
1
+ export type RankTable = {
2
+ province: "guangdong";
3
+ year: number;
4
+ track: "物理类" | "历史类";
5
+ rows: Array<{
6
+ score: number;
7
+ cumulative: number;
8
+ }>;
9
+ };
10
+ export declare const GUANGDONG_SOURCE: {
11
+ readonly province: "广东";
12
+ readonly bureau: "广东省教育考试院";
13
+ readonly url: "https://eea.gd.gov.cn/ptgk/";
14
+ readonly attachments: {
15
+ readonly 2025: "https://eea.gd.gov.cn/attachment/0/583/583759/4734345.zip";
16
+ };
17
+ readonly notes: readonly ["Zip contains 16 PDFs: 1=历史类总表, 2=物理类总表, 3-16=各类艺术/体育子类", "PDF tables are landscape, multi-column, page-paginated — need OCR or tabula", "Refresh cadence: published once per year, ~June 25 after gaokao results"];
18
+ };
19
+ /**
20
+ * Return the 一分一段 table for 广东 / year / track if extracted JSON exists locally.
21
+ * Throws with the official source URL if not — never auto-downloads PDFs.
22
+ */
23
+ export declare function fetchRankTable(year: number, track: "物理类" | "历史类"): RankTable;
24
+ /** score → rank lookup (returns cumulative rank for highest score ≤ input). */
25
+ export declare function scoreToRank(table: RankTable, score: number): number | null;
26
+ /** rank → score lookup (returns lowest score whose cumulative ≥ input rank). */
27
+ export declare function rankToScore(table: RankTable, rank: number): number | null;
@@ -0,0 +1,68 @@
1
+ // 广东省教育考试院 (eea.gd.gov.cn) — 一分一段表 fallback adapter.
2
+ //
3
+ // Status: STUB. The data lives in ZIP-bundled PDFs (e.g.
4
+ // https://eea.gd.gov.cn/attachment/0/583/583759/4734345.zip for 2025),
5
+ // which need OCR/manual extraction before they become queryable JSON.
6
+ //
7
+ // This file is the contract. When extracted JSON data lands at
8
+ // `cli/data/yifenyiduan/guangdong-{year}-{track}.json`, the `fetchRankTable`
9
+ // stub below will read and return it instead of throwing.
10
+ //
11
+ // Why this exists pre-data: it documents the shape and lets `rank` /
12
+ // `--rank` consumers code against the interface without waiting for the
13
+ // data ingest.
14
+ import { readFileSync, existsSync } from "node:fs";
15
+ import { resolve, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const SRC_DIR = dirname(__filename);
19
+ export const GUANGDONG_SOURCE = {
20
+ province: "广东",
21
+ bureau: "广东省教育考试院",
22
+ url: "https://eea.gd.gov.cn/ptgk/",
23
+ // 2025 release index:
24
+ attachments: {
25
+ 2025: "https://eea.gd.gov.cn/attachment/0/583/583759/4734345.zip"
26
+ },
27
+ notes: [
28
+ "Zip contains 16 PDFs: 1=历史类总表, 2=物理类总表, 3-16=各类艺术/体育子类",
29
+ "PDF tables are landscape, multi-column, page-paginated — need OCR or tabula",
30
+ "Refresh cadence: published once per year, ~June 25 after gaokao results"
31
+ ]
32
+ };
33
+ function dataPathFor(year, track) {
34
+ const trackKey = track === "物理类" ? "wuli" : "lishi";
35
+ return resolve(SRC_DIR, "..", "..", "data", "yifenyiduan", `guangdong-${year}-${trackKey}.json`);
36
+ }
37
+ /**
38
+ * Return the 一分一段 table for 广东 / year / track if extracted JSON exists locally.
39
+ * Throws with the official source URL if not — never auto-downloads PDFs.
40
+ */
41
+ export function fetchRankTable(year, track) {
42
+ const path = dataPathFor(year, track);
43
+ if (!existsSync(path)) {
44
+ throw new Error(`广东 ${year} ${track} 一分一段表尚未导入 (looked at ${path}).\n` +
45
+ `下载源: ${GUANGDONG_SOURCE.attachments[year] ?? GUANGDONG_SOURCE.url}\n` +
46
+ `导入步骤详见 docs/data-sources.md → 一分一段表 ingest pipeline.`);
47
+ }
48
+ return JSON.parse(readFileSync(path, "utf8"));
49
+ }
50
+ /** score → rank lookup (returns cumulative rank for highest score ≤ input). */
51
+ export function scoreToRank(table, score) {
52
+ for (const row of table.rows) {
53
+ if (row.score <= score)
54
+ return row.cumulative;
55
+ }
56
+ return null;
57
+ }
58
+ /** rank → score lookup (returns lowest score whose cumulative ≥ input rank). */
59
+ export function rankToScore(table, rank) {
60
+ let best = null;
61
+ for (const row of table.rows) {
62
+ if (row.cumulative >= rank)
63
+ best = row.score;
64
+ else
65
+ break;
66
+ }
67
+ return best;
68
+ }
@@ -0,0 +1 @@
1
+ export * as guangdong from "./guangdong.js";
@@ -0,0 +1,17 @@
1
+ // Province-bureau fallback adapters. Each module exports a stable interface
2
+ // for fetching that province's 一分一段表 from the official 考试院 source
3
+ // (or local extracted JSON, when no API exists upstream).
4
+ //
5
+ // Roadmap (verdicts from docs/data-sources.md):
6
+ // 🟢 广东 (eea.gd.gov.cn) — ZIP+PDF, this round.
7
+ // 🟢 北京 (bjeea.cn) — open-source CSV from ZE3kr/bjeea-bulk-query.
8
+ // 🟢 河北 (hebeea.edu.cn) — Excel direct download.
9
+ // 🟢 江苏 (jseea.cn) — PDF, clean format.
10
+ // 🟢 广西 (gxeea.cn) — fully public HTML.
11
+ // 🟢 黑龙江 (lzk.hl.cn) — no auth, HTML tables.
12
+ // 🟢 西藏 (zsks.edu.xizang.gov.cn)
13
+ // 🟡 + 22 more provinces — login or PDF-only.
14
+ //
15
+ // Only `guangdong` has a written adapter today; the rest are documented
16
+ // pointers in docs/data-sources.md.
17
+ export * as guangdong from "./guangdong.js";
@@ -0,0 +1,31 @@
1
+ import { type ProvinceId } from "./codes.js";
2
+ export type RankTableRow = {
3
+ score: number;
4
+ count: number;
5
+ cumulative: number;
6
+ };
7
+ export type RankTable = {
8
+ province: string;
9
+ province_name: string;
10
+ year: number;
11
+ /** "physics" | "history" for 3+1+2 provinces; "combined" for 3+3; "science" | "liberal" for 老高考. */
12
+ track: string;
13
+ source: string;
14
+ note?: string;
15
+ count: number;
16
+ rows: RankTableRow[];
17
+ };
18
+ /** Load a single rank table. Returns null if not present locally. */
19
+ export declare function loadRankTable(provinceId: ProvinceId, year: number, track: string): RankTable | null;
20
+ /** List all (province, year, track) tuples we have data for. */
21
+ export declare function listRankTables(): Array<{
22
+ province: string;
23
+ year: number;
24
+ track: string;
25
+ }>;
26
+ /** score → cumulative rank lookup (returns rank for highest score row whose score ≤ input). */
27
+ export declare function scoreToRank(table: RankTable, score: number): number | null;
28
+ /** rank → score lookup (lowest score whose cumulative ≥ input rank — i.e. the bar that gets you the rank). */
29
+ export declare function rankToScore(table: RankTable, rank: number): number | null;
30
+ /** Try to pick a sensible default track key when caller omits one. */
31
+ export declare function inferDefaultTrack(provinceId: ProvinceId): string;
@@ -0,0 +1,74 @@
1
+ // Loader for cli/data/yifenyiduan/{province}-{year}-{track}.json files.
2
+ // One file per (province, year, track) — see beijing-2024-combined.json as the canonical shape.
3
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
4
+ import { resolve, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { PROVINCES } from "./codes.js";
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const SRC_DIR = dirname(__filename);
9
+ const CANDIDATE_DIRS = [
10
+ resolve(SRC_DIR, "..", "data", "yifenyiduan"), // cli/src/ → cli/data/yifenyiduan
11
+ resolve(SRC_DIR, "..", "..", "data", "yifenyiduan") // cli/dist/ → cli/data/yifenyiduan
12
+ ];
13
+ function findDataDir() {
14
+ for (const d of CANDIDATE_DIRS) {
15
+ if (existsSync(d))
16
+ return d;
17
+ }
18
+ return null;
19
+ }
20
+ function pinyinFor(provinceId) {
21
+ return PROVINCES[provinceId].pinyin;
22
+ }
23
+ /** Load a single rank table. Returns null if not present locally. */
24
+ export function loadRankTable(provinceId, year, track) {
25
+ const dir = findDataDir();
26
+ if (!dir)
27
+ return null;
28
+ const path = resolve(dir, `${pinyinFor(provinceId)}-${year}-${track}.json`);
29
+ if (!existsSync(path))
30
+ return null;
31
+ return JSON.parse(readFileSync(path, "utf8"));
32
+ }
33
+ /** List all (province, year, track) tuples we have data for. */
34
+ export function listRankTables() {
35
+ const dir = findDataDir();
36
+ if (!dir)
37
+ return [];
38
+ const out = [];
39
+ for (const f of readdirSync(dir)) {
40
+ const m = f.match(/^([a-z]+)-(\d{4})-([a-z]+)\.json$/);
41
+ if (m)
42
+ out.push({ province: m[1], year: Number(m[2]), track: m[3] });
43
+ }
44
+ return out.sort((a, b) => a.province.localeCompare(b.province) || b.year - a.year);
45
+ }
46
+ /** score → cumulative rank lookup (returns rank for highest score row whose score ≤ input). */
47
+ export function scoreToRank(table, score) {
48
+ // rows are sorted descending by score; find first row whose score ≤ input.
49
+ for (const row of table.rows) {
50
+ if (row.score <= score)
51
+ return row.cumulative;
52
+ }
53
+ return null;
54
+ }
55
+ /** rank → score lookup (lowest score whose cumulative ≥ input rank — i.e. the bar that gets you the rank). */
56
+ export function rankToScore(table, rank) {
57
+ let best = null;
58
+ for (const row of table.rows) {
59
+ if (row.cumulative >= rank) {
60
+ best = row.score;
61
+ break;
62
+ }
63
+ }
64
+ return best;
65
+ }
66
+ /** Try to pick a sensible default track key when caller omits one. */
67
+ export function inferDefaultTrack(provinceId) {
68
+ const reform = PROVINCES[provinceId].reform;
69
+ if (reform === "3+3")
70
+ return "combined";
71
+ if (reform === "3+1+2")
72
+ return "physics";
73
+ return "science";
74
+ }
@@ -0,0 +1,34 @@
1
+ import { type IndexFilter } from "./index-loader.js";
2
+ import { type ProvinceId, type Subject } from "./codes.js";
3
+ export type RecommendMajorInput = {
4
+ keyword: string;
5
+ score: number;
6
+ provinceId: ProvinceId;
7
+ subjects: Subject[];
8
+ year: number;
9
+ filter?: IndexFilter;
10
+ limit?: number;
11
+ concurrency?: number;
12
+ };
13
+ export type MajorMatch = {
14
+ spcode: string;
15
+ sp_name: string;
16
+ level3_name: string;
17
+ schools_count: number;
18
+ reachable_count: number;
19
+ schools: Array<{
20
+ schoolId: number;
21
+ name: string;
22
+ city: string;
23
+ is985: boolean;
24
+ is211: boolean;
25
+ baselineMinScore: number;
26
+ delta: number;
27
+ reachable: boolean;
28
+ track: string;
29
+ }>;
30
+ };
31
+ export declare function recommendMajor(input: RecommendMajorInput): Promise<{
32
+ query: object;
33
+ majors_matched: MajorMatch[];
34
+ }>;
@@ -0,0 +1,119 @@
1
+ // recommend-major — interest keyword in, ranked majors out, each with the
2
+ // schools that recruit it in the user's province. Inverse of `recommend`
3
+ // (which is school-centric).
4
+ //
5
+ // Pipeline:
6
+ // 1. Filter index to schools the user can reach (delta ≥ -25).
7
+ // 2. For each, fetch plan/{schoolId}/{year}/{province}.json (concurrent).
8
+ // 3. Match the keyword against sp_name / spname / level3_name.
9
+ // 4. Group hits by spcode, sort by (schools count desc, top school baseline desc).
10
+ import { loadIndex, filterIndex } from "./index-loader.js";
11
+ import { getAdmissionPlan } from "./gaokao-cn.js";
12
+ import { PROVINCES, TRACK_NAMES } from "./codes.js";
13
+ import { inferTrack } from "./recommend.js";
14
+ export async function recommendMajor(input) {
15
+ const index = loadIndex();
16
+ const track = inferTrack(input.provinceId, input.subjects);
17
+ let rows = input.filter ? filterIndex(index, input.filter) : index.rows;
18
+ // Only keep schools with a feasible-ish historical baseline in this province + track.
19
+ const reachableRows = rows.filter((r) => {
20
+ const entries = r.pro_type_min?.[String(input.provinceId)] ?? [];
21
+ if (!entries.length)
22
+ return false;
23
+ const sorted = [...entries].sort((a, b) => b.year - a.year);
24
+ for (const e of sorted) {
25
+ const v = e.type?.[track];
26
+ const n = v ? Number(v) : NaN;
27
+ if (Number.isFinite(n) && n > 0 && input.score - n >= -40)
28
+ return true;
29
+ }
30
+ return false;
31
+ });
32
+ const keyword = input.keyword.toLowerCase();
33
+ const matchHit = (item) => item.sp_name?.toLowerCase().includes(keyword) ||
34
+ item.spname?.toLowerCase().includes(keyword) ||
35
+ item.level3_name?.toLowerCase().includes(keyword) ||
36
+ item.spcode?.toLowerCase().includes(keyword);
37
+ const hits = [];
38
+ const concurrency = input.concurrency ?? 12;
39
+ let cursor = 0;
40
+ await Promise.all(Array.from({ length: concurrency }, async () => {
41
+ while (cursor < reachableRows.length) {
42
+ const r = reachableRows[cursor++];
43
+ if (!r)
44
+ break;
45
+ try {
46
+ const plan = await getAdmissionPlan(r.gaokao_cn_id, input.year, input.provinceId);
47
+ // Most-recent baseline for this row + track
48
+ const entries = r.pro_type_min[String(input.provinceId)];
49
+ const sorted = [...entries].sort((a, b) => b.year - a.year);
50
+ let baseline = 0;
51
+ for (const e of sorted) {
52
+ const v = e.type?.[track];
53
+ const n = v ? Number(v) : NaN;
54
+ if (Number.isFinite(n) && n > 0) {
55
+ baseline = n;
56
+ break;
57
+ }
58
+ }
59
+ const delta = input.score - baseline;
60
+ for (const item of plan) {
61
+ if (!matchHit(item))
62
+ continue;
63
+ const spcode = item.spcode || `${item.level3_name}-${item.sp_name}`;
64
+ hits.push({
65
+ spcode,
66
+ sp_name: item.sp_name,
67
+ level3_name: item.level3_name,
68
+ school: {
69
+ schoolId: r.gaokao_cn_id,
70
+ name: r.name,
71
+ city: r.city,
72
+ is985: r.f985,
73
+ is211: r.f211,
74
+ baselineMinScore: baseline,
75
+ delta,
76
+ reachable: delta >= -15,
77
+ track: TRACK_NAMES[track] ?? track
78
+ }
79
+ });
80
+ }
81
+ }
82
+ catch { /* skip */ }
83
+ }
84
+ }));
85
+ // Group by spcode
86
+ const byMajor = new Map();
87
+ for (const h of hits) {
88
+ const m = byMajor.get(h.spcode) ?? {
89
+ spcode: h.spcode,
90
+ sp_name: h.sp_name,
91
+ level3_name: h.level3_name,
92
+ schools_count: 0,
93
+ reachable_count: 0,
94
+ schools: []
95
+ };
96
+ m.schools.push(h.school);
97
+ m.schools_count++;
98
+ if (h.school.reachable)
99
+ m.reachable_count++;
100
+ byMajor.set(h.spcode, m);
101
+ }
102
+ const majors = Array.from(byMajor.values());
103
+ for (const m of majors) {
104
+ m.schools.sort((a, b) => b.baselineMinScore - a.baselineMinScore);
105
+ }
106
+ majors.sort((a, b) => b.reachable_count - a.reachable_count || b.schools_count - a.schools_count);
107
+ return {
108
+ query: {
109
+ keyword: input.keyword,
110
+ score: input.score,
111
+ province: PROVINCES[input.provinceId].name,
112
+ year: input.year,
113
+ subjects: input.subjects,
114
+ track: TRACK_NAMES[track] ?? track,
115
+ filter: input.filter
116
+ },
117
+ majors_matched: input.limit && input.limit > 0 ? majors.slice(0, input.limit) : majors
118
+ };
119
+ }
@@ -0,0 +1,54 @@
1
+ import { type ProvinceId, type Subject } from "./codes.js";
2
+ import { type IndexFilter } from "./index-loader.js";
3
+ export type Bucket = "保" | "稳" | "冲" | "out";
4
+ export type RecommendInput = {
5
+ score: number;
6
+ provinceId: ProvinceId;
7
+ subjects: Subject[];
8
+ rank?: number;
9
+ schoolIds?: Array<number | string>;
10
+ filter?: IndexFilter;
11
+ limit?: number;
12
+ };
13
+ export type RecommendCandidate = {
14
+ schoolId: number;
15
+ zsCode: string;
16
+ name: string;
17
+ province: string;
18
+ city: string;
19
+ belong: string;
20
+ is985: boolean;
21
+ is211: boolean;
22
+ dualClass: string;
23
+ baselineYear: number;
24
+ baselineMinScore: number;
25
+ baselineTrack: string;
26
+ baselineTrackName: string;
27
+ delta: number;
28
+ bucket: Bucket;
29
+ };
30
+ export type RecommendOutput = {
31
+ query: {
32
+ score: number;
33
+ province: {
34
+ id: ProvinceId;
35
+ name: string;
36
+ reform: string;
37
+ };
38
+ subjects: Subject[];
39
+ track: string;
40
+ trackName: string;
41
+ rank?: number;
42
+ reform_warning?: string;
43
+ };
44
+ evaluated: number;
45
+ buckets: {
46
+ "保": RecommendCandidate[];
47
+ "稳": RecommendCandidate[];
48
+ "冲": RecommendCandidate[];
49
+ out: RecommendCandidate[];
50
+ skipped: number;
51
+ };
52
+ };
53
+ export declare function inferTrack(provinceId: ProvinceId, subjects: Subject[]): string;
54
+ export declare function recommend(input: RecommendInput): RecommendOutput;