拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python解析一分一档表:构建高考位次查询与冲稳保推荐系统

简介面向广西高考考生及家长的志愿填报辅助系统源码基于Python与HTML构建适用于分析历年录取数据、生成一分一档排名参考帮助考生科学定位院校与专业。Python脚本负责解析历史数据、构建排名与分数分布统计HTML文件呈现一分一档表、本科一批及二批投档线等查询页面形成从数据清洗到前端展示的完整链路。文件包共54个文件以HTML页面为主42个另含4个Python处理脚本、2个TXT说明文档及LICENSE、Makefile等基础配置压缩包约12.94MB结构紧凑便于本地部署与二次开发。内容覆盖2011—2023年广西文史类与理工类一分一档数据、近年本科一批和本科二批投档线页面并附USTC校徽图片等静态资源。目前已有473人浏览学习可快速搭建本地志愿填报参考系统查看历年分数分布亦能借助脚本对录取数据进行批量整理与排名分析适合作为课程设计、毕业设计或自学Python数据可视化的实践参考。1. 排名比分数更值得参考这套源码把一分一档 HTML 当成主数据源排名通常比分数更值得参考这一点在广西尤其明显。同一分数在不同年份能落到的学校可能差出两个档次同一排名对应的高校层次则基本稳定。广西考试院每年公布的文史类、理工类一分一档表记录每个分数的人数和累计人数这是志愿填报最核心的位次数据。这套基于 Python 与 HTML 的高考志愿填报系统源码没有接商业志愿库而是把 2011 到 2023 年的一分一档表 HTML 全部保留在 raw_pages 里用 parse_data.py 做表格抽取、build_history_rank.py 做跨年对齐、score_distribution.py 做分布输出前端用 HTML 页面完成位次查询和冲稳保联动。对想自己动手做志愿工具、或需要从真实考试院页面提取数据的开发者来说这份源码最大的参考价值在于它处理的是原始 HTML不是整理好的 CSV坑都留在细节里。2. parse_data.py 拆解一分一档 HTML 的表格定位与结构清洗2.1 从文件命名能读出的数据口径差异拿到源码先别急着跑在 raw_pages 里翻一遍文件名就能读出至少三层信息。2016 年之前的文件名是“2015年普通高考文史类一分一档表.html”这种朴素写法2016 年开始陆续出现“总分总成绩全国性加分”的后缀括号有时候是全角有时候是半角同一个目录下两套写法并存。这三层差异分别对应年份解析、科类判断和加分口径识别。文件范围命名特征解析时要处理的点2011-2015普通高考文史类 / 理工类一分一档表表头行混用 td 和 th表格中间夹空白行2016-2020多数带“总分总成绩全国性加分”后缀括号全半角混排页脚出现补充说明表容易把目标表定位错2021-2023与 2016 年之后的命名结构基本一致个别页面存在嵌套 table需要按列名过滤加分口径这层很容易被忽略。同一名考生裸分和加上全国性加分后的排名可能差出几千甚至上万名而投档线页面用的是“投档分”不是裸分。所以 parse_data.py 在处理 2016 年后的表格时必须核对列名是否带“总分总成绩全国性加分”字样并在输出的 DataFrame 里把年份、科类、加分口径三个字段都显式保留。后面接 build_history_rank 做跨年对比时入口标准不一致整个序列就歪了。2.2 用 BeautifulSoup 定位目标表格避免 read_html 踩坑pandas.read_html 确实能解析表格但它的行为是把页面里所有 table 都返回成 DataFrame 列表。考试院页面结构不稳定页头有导航表页尾有说明表中间偶尔还夹着嵌套表。直接取 list[0] 会拿到页头导航取错了后面一年数据全错。源码里 parse_data.py 自己写解析器用表头特征定位目标表这个思路是对的。from pathlib import Path import pandas as pd from bs4 import BeautifulSoup RAW_DIR Path(raw_pages) def parse_score_table(html_path: Path) - pd.DataFrame: with open(html_path, r, encodingutf-8) as f: soup BeautifulSoup(f.read(), html.parser) target None # 多张表里按表头特征锁目标表避免拿到页头导航表 for table in soup.find_all(table): cells table.find_all([th, td]) headers [c.get_text(stripTrue) for c in cells[:5]] if 总分 in headers and 累计人数 in headers: target table break if target is None: raise ValueError(f未定位到一分一档表: {html_path.name}) # 行内可能混有空白行、分隔行和无效字符转型失败直接跳过 records [] for row in target.find_all(tr): cells [td.get_text(stripTrue) for td in row.find_all([td, th])] if len(cells) 3: continue try: score int(cells[0]) count int(cells[1]) cum int(cells[2]) except ValueError: continue records.append({score: score, count: count, cum: cum}) return pd.DataFrame(records)逻辑说明soup.find_all(table)拿到页面全部表格逐个检查前五个单元格的文本只有同时包含“总分”和“累计人数”的表才被选中。解析行数据时统一用find_all([td, th])因为 2011 到 2013 年的页面里有几行用 th 存分数只用 td 会静默丢数据。参数说明函数接收 Path 对象后面存储RAW_DIR.glob(*.html)返回的也是 Path免去字符串和 Path 来回转换。len(cells) 3处理表格中间夹带的空白行、合并行这类行在旧页面里经常出现不拦掉会导致 DataFrame 尾部多出一堆空记录。int(cells[0])转型失败就 continue比直接抛异常更适合批量处理个别行数据坏掉不影响整年数据入库。2.3 批量解析时把年份、科类、口径一起带上单表解析拿到的是纯分数序列还要知道这是哪一年的、哪一科的后面才能做跨年对比。文件名里已经带全这些信息批量解析时直接复用 parse_score_tabledef load_score_tables(raw_dir: Path RAW_DIR) - pd.DataFrame: frames [] for path in sorted(raw_dir.glob(*.html)): name path.name if 文史 in name: category history elif 理工 in name: category science else: continue # 年份取文件名前四位数字兼容各种括号和特殊字符 year_chars [c for c in name if c.isdigit()] if len(year_chars) 4: continue year int(.join(year_chars[:4])) df parse_score_table(path) df[year] year df[category] category df[file] name frames.append(df) return pd.concat(frames, ignore_indexTrue)科类判断用“文史”“理工”两个关键词覆盖 2011 到 2023 的全部文件年份提取的方法是把文件名里的数字全部挑出来取前四位拼成 int对“2016年普通高考文史类一分一档表 (总分总成绩全国性加分.html”这种带括号带空格的文件名同样有效。末尾保留 file 列排查问题时能快速定位是哪张表出了问题。清洗阶段有个高频问题值得单独说部分年份表格里累计人数在分数断层处会直接跳到下一个值跳变幅度很大这是正常现象。但如果同一分数出现了多行累计人数却先涨后降那就是解析时把行顺序打乱了二维数组的行顺序必须严格保持 HTML 原序不能做排序否则累计人数的单调性直接破掉。这个不变量在第 5 章的回归测试里会再见到。提示如果解析后得到的 DataFrame 行数只有官方表格的一半先检查是不是把find_all([td, th])写成了find_all(td)这是让 th 行数据静默丢失的最常见原因。3. build_history_rank.py跨年位次索引与投档线对齐3.1 位次法 vs 线差法为什么排名序列更适合做决策线差法的思路是拿“考生分数 - 当年本科线”和“往年投档分数 - 往年本科线”做比较。这个方法计算简单但有个前提本科线本身受试卷难度和考生人数双重影响线差并不稳定。换一套难度差异明显的试卷同一个考生用线差法推算出的院校层次可能完全失真。位次法则绕开这个变量。无论当年试卷难还是简单考生排在全省前 N 名对应的竞争者范围就是那一批人。一分一档表里“累计人数”就是位次某理科考生考了 600 分当年 600 分对应的累计人数是 18000位次就是 18000。把这个位次拿到前一年查到 18000 名对应的是 595 分再用 595 分去比前一年的高校投档线得到的选校区间比直接用 600 分去比可靠得多。还有一层原因和录取机制直接相关高校按投档分排序录取投档分和位次之间存在稳定映射。投档线页面的本质是“某校最低投档分”用位次法可以把历年投档分全部换算成历年入学难度排名再和考生当年的位次比较。这就是为什么 build_history_rank.py 的产物是一张“分数-位次-等效分数-投档线”四合一索引表而不是简单保存几个分数。3.2 构建分数到位次的双向索引每个年份、每个科类都需要两个方向的查询输入分数得到位次输入位次得到往年等效分数。前者直接查累计人数后者要把“位次区间 → 分数”的反向映射建好因为同分考生共享同一位次一个分数对应一段位次区间。def build_rank_index(df: pd.DataFrame): # 返回两个索引分数-位次位次-分数 score_to_rank {} rank_to_score {} for (year, category), g in df.groupby([year, category]): g g.sort_values(score, ascendingFalse).reset_index(dropTrue) key (year, category) score_to_rank[key] dict(zip(g[score], g[cum])) reverse {} for row in g.itertuples(): # 同分考生共享同一个 cum反向索引取首次出现作为该位次段起点 reverse[row.cum] row.score rank_to_score[key] reverse return score_to_rank, rank_to_score逻辑说明正向索引是“分数 → cum 列”反向索引则是“cum → 分数”。累计人数在分数降序下递增每个行里 cum 覆盖一段位次范围把 cum 作为键查询位次 8000 时找到的就是首个累计人数不少于 8000 的分数。同分并列天然成立所有同分考生共享同一个 cum反向查到的分数完全一致。参数说明按[year, category]分组键是二元组后续查询传 (2023, science) 就能锁定单张表。这种字典做索引的方式在数据量约四十万行的场景下查询是 O(1) 级别的没必要上数据库。真正要在意的是缺失 key如果某年缺了文史或理工直接查字典会抛 KeyError这个问题交给 utils.py 里的安全包裹统一处理。3.3 近三年加权策略与投档线对齐位次法解决了“同一把尺子量不同年份”的问题但选校还要面对另一个问题参考哪一年没有任何一个年份能完全代表今年的竞争环境常见做法是取近三年数据做加权。三种典型策略的取舍如下策略权重分配适用场景仅看最近一年w11, w20, w30政策或批次设置有大调整旧数据失效等权平均w1w2w31/3考生人数和命题难度总体平稳指数衰减w10.5, w20.3, w30.2既要近期趋势又要保留横向参照投档线参与的方式比较直接项目里 2014 到 2023 年的本科第一批、本科第二批投档分数线 HTML 也在 raw_pages 里用和 parse_data.py 类似的方法解析拿到“学校 → 投档分”再结合当年的一分一档表把投档分换算成位次得到“学校 → 最低录取位次”。考生当年位次和这组历史位次做比较就形成了冲、稳、保三个候选集def match_schools(rank: int, school_rank_history: dict): result {chong: [], wen: [], bao: []} for school, history in school_rank_history.items(): # 近三年取最小值代表该校的守门位次 recent sorted(history.values())[:3] min_rank min(recent) ratio (rank - min_rank) / min_rank if min_rank else 1 if ratio 0.05: result[chong].append(school) elif ratio 0.20: result[wen].append(school) else: result[bao].append(school) return result这里的 0.05 和 0.20 是示例阈值实际使用时要拿历史数据做回测把上一年的候选结果和当年真实投档线对比统计命中率再反推阈值。阈值定太窄冲的学校明显够不着列表没有参考价值定太宽稳的学校比考生水平低出一截保的学校又过于保守整个工具就变成心理安慰了。4. score_distribution.py 与前端从分数分布到冲稳保联动4.1 输出数据契约JSON 而非图片score_distribution.py 在这条链路里的作用是把解析出来的分数分布转成前端能直接消费的数据。把 matplotlib 图片嵌进 HTML 当然可以但图片不能交互用户没法点选、没法查位次。更合理的做法是输出一份静态 JSON和一个包含查询逻辑的 HTML 页面浏览器本地就能直连运行不需要起后端服务。import json def export_distribution(df: pd.DataFrame, out_path: str) - None: payload {} for (year, category), g in df.groupby([year, category]): g g.sort_values(score).reset_index(dropTrue) payload[f{year}_{category}] { scores: g[score].tolist(), counts: g[count].tolist(), cum: g[cum].tolist(), } with open(out_path, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse, indent1)输出结构是{ 2023_science: { scores, counts, cum } }年份和科类拼进 key 里。前端拿到后直接画图不需要再调 Python。代价是数据快照式每年新数据发布后要重新执行一次脚本更新 JSON但对志愿填报这个场景来说完全够用。注意排序方向这里按分数升序排序前端画折线时直接按数组顺序连线查位次时也能用顺序查找不用再做一次排序。4.2 浏览器端渲染分数分布HTML 端只依赖浏览器自带的能力不引第三方库。分布曲线用折线或者柱状排行曲线用一根单独的折线两根线共用横轴分数纵轴分别对应人数和累计位次div classchart-controls select idyear-select option value2023_science2023 理工/option option value2023_history2023 文史/option /select input idscore-input typenumber min0 max750 placeholder输入分数 button idquery-btn查位次/button /div canvas iddist-chart width800 height320/canvas div idrank-result/div script // dist.json 由 score_distribution.py 导出和一分一档表严格对应 fetch(dist.json).then(res res.json()).then(data { function drawDistribution(key) { const { scores, counts, cum } data[key]; const canvas document.getElementById(dist-chart); const ctx canvas.getContext(2d); const maxCount Math.max(...counts); // 横轴把分数区间映射到画布纵轴按人数最大值缩放 scores.forEach((s, i) { const x (s - 200) / 600 * 700 50; const h counts[i] / maxCount * 260; ctx.fillRect(x, 280 - h, 3, h); }); } document.getElementById(query-btn).onclick () { const key document.getElementById(year-select).value; const score document.getElementById(score-input).value; const { scores, cum } data[key]; // 分数升序数组取第一个满足 s score 的下标 const idx scores.findIndex(s s score); const rank idx 0 ? cum[idx] : null; document.getElementById(rank-result).textContent rank ? 位次约 ${rank} : 分数不在表内; }; }); /script这段代码把一件事说得很清楚前端不重新算数据只从 JSON 里找索引。scores.findIndex(s s score)返回第一个大于等于输入分数的下标对应的 cum 就是该分数的位次逻辑和 Python 端的分数段查找一致。如果两边结果对不上说明 JSON 生成时排序方向变了或者某个年份的数据清洗出了问题。这里数据加载用的是 fetch用浏览器直连本地文件时需要注意部分浏览器对 file:// 协议下的 fetch 有限制。如果打不开起个最简单的静态服务就行python3 -m http.server 8080。项目本来就是 Python 环境这个命令不需要额外安装任何东西。4.3 冲稳保的阈值设定与联动代码冲稳保联动是前端交互里最核心的模块。考生输入位次后系统把学校投档位次表拉出来和考生位次比较按档位染色。几个档位的判定口径直接影响最终推荐列表的质量档位判定口径界面表现冲考生位次低于近三年最低投档位次 5% 以内黄色标注“风险选”稳考生位次低于近三年最低投档位次 5%-20%绿色标注“适中”保考生位次低于近三年最低投档位次 20% 以上蓝色标注“稳妥”这里的“低于”指位次数值更大即考生排名更靠后。实现时用一个本地数组保存学校历史位次前端不用请求接口const schools [ { name: 桂林电子科技大学, history: [18000, 17500, 19000] }, { name: 广西大学, history: [22000, 21000, 23500] } ]; function classify(rank) { return schools.map(s { // history 取最小值代表守门位次即该校近年最好一次录取到的最后排位 const minRank Math.min(...s.history); const ratio (rank - minRank) / minRank; const level ratio 0.05 ? chong : ratio 0.20 ? wen : bao; return { ...s, level }; }); }考生排名数值小于守门位次时说明历史上分数等效后大概率能进归入稳或保考生排名比守门位次靠后就往冲的方向靠。阈值 5% 和 20% 是经验值换省份、换科类都要重新标定。这段代码本身很薄但因为直接面向考生输出结论对数据源的准确性要求反而最高一个学校的历史位次填错整行推荐都不可信。5. utils.py 收口位次边界校验、回归测试与 make 链路5.1 安全包裹函数隔离脏数据build_history_rank 里构建的索引查询全部通过 utils.py 做一层包裹不放裸字典查询。用户输入分数可能越界、年份可能缺失、科类名大小写可能不一致裸查询会把 KeyError 直接抛给页面用户看到的是一行报错而不是提示语。def safe_score_to_rank(index, year: int, category: str, score: int) - int: if not (0 score 750): raise ValueError(fscore out of range: {score}) key (year, category) if key not in index: raise KeyError(fno data for {year} {category}) rank index[key].get(score) if rank is None: raise ValueError(fscore {score} not found in {year} {category}) return rank三个 guard 对应三类问题分数范围检查拦截明显错误输入年份科类检查在数据缺失时报错清晰分数不存在说明该分数段在当年没有记录。调用方拿到异常后可以统一转成页面提示不必再去猜问题出在数据还是逻辑。5.2 给解析管线加一条回归锁数据清洗脚本最怕改坏历史逻辑。在项目里加一个最小的 pytest 用例锁定两个不变量解析出的分数表累计人数必须单调递增构建索引后任意分数反查位次再反查分数应该回到原值。def test_cum_is_monotonic(): df parse_score_table( RAW_DIR / 2023年理工类一分一档表总分总成绩全国性加分.html ) # 累计人数只能递增解析行顺序错乱或漏行时这里最先暴露 assert df[cum].is_monotonic_increasing def test_roundtrip(): df parse_score_table( RAW_DIR / 2023年理工类一分一档表总分总成绩全国性加分.html ) index, reverse build_rank_index(df) # 抽 50 个分数做往返校验覆盖抽样而不是全量保证秒级执行 for score in df[score].sample(50, random_state1): rank index[(2023, science)][score] assert reverse[(2023, science)][rank] scoreis_monotonic_increasing是 pandas 内置方法快速判断序列是否单调。往返测试只抽 50 个分数够覆盖分散在各个分数段的脏数据。这两个用例不依赖浏览器、不依赖外部接口任何机器上跑 pytest 就能验证。只要 2023 年原表不被擅自修改断言永远应该通过一旦有人动了解析逻辑导致行错位第一个报错的就是累计人数单调性。5.3 Makefile 串起完整链路源码里带 Makefile说明希望前面的步骤能一键复现而不是靠 readme 里手抄命令。常见做法是拆成三个目标make parse # 解析 raw_pages 下全部一分一档表输出中间 JSON/CSV make dist # 调用 score_distribution.py 生成前端需要的 dist.json make test # 跑 pytest验证解析结果和索引正确性执行的先后顺序建议是 test 最先跑因为解析脚本在快速迭代期先保证测试绿再进入数据产出流程。后面补新一年数据时结构顺序就是“放 HTML → make test → make dist”整个更新流程可以控制在一小时内。志愿填报这类工具对数据准确性的要求比普通信息站高一个量级一个位次算错就影响一批学校的推荐结果。把校验逻辑收进 utils.py、把回归测试和 make target 前置就是为了让“数据有问题”在命令执行阶段就暴露而不是等考生填完分数看到错误结果再回头排查。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门