ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

高考志愿爬虫实战:抓取录取数据并完成位次统计

高考志愿爬虫实战:抓取录取数据并完成位次统计 简介面对高考志愿填报中信息分散、难以对比的痛点这份爬虫工具脚本可帮助考生与家长批量获取目标院校的招生计划、专业分数线、历年录取数据及学校基本信息覆盖专业名称、学科门类、计划招生、学制、录取批次、平均分、最低分/位次、省控线等关键字段也包含招生咨询官网入口适合有Python基础、希望自主整理报考数据的学习者。资源包共三个文件主要由一个Python脚本、一个Markdown说明文档和一个文本文件构成脚本负责核心抓取逻辑文档提供使用说明文本文件用于存放院校列表或参数配置整体仅5KB轻量易用。目前已有1737人学习下载。借助这套脚本读者可获得一套完整的高考志愿数据采集方案既有多维度招生与分数线字段的抓取逻辑也依赖fake_useragent、requests等库实现请求伪装与网页解析脚本结构清晰便于二次修改可扩展至其他省份或年份是高考数据统计分析的有力起点。1. 高考志愿爬虫先定数据边界再写第一行请求高考志愿填报的窗口期只有短短几天而全网流量最大的页面恰好是各省考试院的录取查询和各高校的招生官网。所谓“高考志愿统计大学爬虫”就是把散落在这些站点里的招生计划、历年录取分数、专业组位次抓下来整理成一份可供统计和决策的结构化数据。它解决的痛点很直接官方数据只有查询入口没有下载接口手动复制几百条专业记录既不现实也容易抄错。适合三类人想给孩子做志愿分析的家长、做升学数据产品的开发、以及想用一个真实场景练手 python 爬虫并发与清洗的工程师。这个任务的难点不在请求本身而在数据源的差异和统计口径。2. 大学招生数据源选型考试院、高校官网与第三方汇总站的取舍2.1 三类数据源的时效性与字段完整度对比高考志愿数据没有统一开放 API常见做法是混用三类来源。从网络爬虫原理上看这三类站点的页面结构、更新节奏和反爬设计完全不同抓取策略要分开定。数据源类型数据时效覆盖范围反爬强度字段完整度省教育考试院当天或次日省内全批次高时有验证码与限流高含一分一段与投档线高校本科招生网录取结束后 1-3 天本校全部专业低到中部分有简单防护中专业分数与招生人数齐全第三方志愿平台聚合后定时更新全国强基本不可抓参差需自行核实多数爬虫实战方案会把“考试院 高校官网”作为主数据源两个口径互相校验第三方平台只用来做交叉验证。比如考试院给的是投档最低分高校网站给的是分专业录取平均分两者字段粒度不同如果只取一个源位次计算就会失真。对于想练手的新手这也是一个完整的 python 爬虫入门到数据分析链路。2.2 用 requests 探测站点响应结构写爬虫的第一步不是解析而是探测。先确认目标页面是 HTML、JSON 还是 PDF再决定解析方案。考试院的录取结果页多是 HTML 或静态 JSON高校招生网这些年很多改成了 JS 渲染requests 直接拿不到表格内容需要从接口层面找数据。import requests headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: application/json, text/html, */*, } def probe(url: str) - dict: resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() content_type resp.headers.get(Content-Type, ) return { status: resp.status_code, content_type: content_type, charset: resp.encoding, length: len(resp.text), is_json: json in content_type, is_html: text/html in content_type, } print(probe(https://www.example.edu.cn/zsxx/lqfs.htm))这段探测代码返回状态码、Content-Type 和响应体长度用来判断后续解析走哪条路。is_json字段的意义在于JSON 直接取字段HTML 则需要用选择器提取表格。charset字段也关键很多老站点用 gbk 编码requests 默认按响应头猜编码猜错了中文会乱码可以在拿到响应后强制指定resp.encoding gbk再取 text。2.3 招生计划与录取分数线的字段建模抓之前先建表字段定了爬虫解析就不容易返工。我一般把数据分成两张表招生计划表和录取分数表。招生计划表核心字段省份、院校代码、院校名称、专业组、专业名称、选科要求、计划人数、学费、办学地点。录取分数表核心字段省份、年份、批次、科类、专业组、投档最低分、最低位次、平均分、录取人数。选科要求这个字段容易忽略。新高考按专业组投档同一个大学不同专业组选科要求不同抓取时必须整组保存。这里有个建议把“省份 专业组 选科要求”拼起来做业务主键因为不同省份的专业组编号没有可比性单独存组号无法跨省统计。字段模型定好后再去做选择器解析页面里定位到的每个 td 都知道该落到哪个字段能省大量返工时间。3. 招生数据抓取的请求参数设计与限速策略3.1 高校招生网查询接口的 URL 参数规律高校的招生历史分数页大多有查询功能常见形态是lqfs.jsp?year2024province21type1这类静态参数。year 是年份province 是省份代码type 区分批次或科类。省份代码不统一有的用行政区划码有的用自定义序号需要先用浏览器开发者工具看真实发出的请求不要靠猜。params { year: 2024, province: 21, # 具体省份代码以站点实际请求为准 kelei: physics, # 物理类/历史类部分站点叫 lx 或 kldm page: 1, } resp requests.get(history_url, paramsparams, headersheaders, timeout10)params 的键名每个站点都不一样常见的别名有ssdm省市代码、nf年份、kldm科类代码。遇到分页参数不清楚的情况可以先只请求第一页观察返回内容里有没有总页数或总记录数字段再决定第二页的 page 参数从几开始。常见的分页写法有pageNum、pageNo、curPage和offset四种一种一种试成本太高直接搜响应文本里的“共 X 页”更靠谱。3.2 POST 表单与隐藏令牌考试院站点的典型处理考试院的分页和高校官网是两种风格。高校官网常见pageSize20加当前页考试院的录取结果查询则常返回整张表单再用 POST 提交批次、科类、投档单位代码。POST 方式注意要带data而不是params并且表单里经常有 hidden 字段比如 ASP.NET 站点的__VIEWSTATE或自研框架的csrf_token必须先 GET 一次拿到令牌再提交。from bs4 import BeautifulSoup def fetch_with_token(post_url: str, form_data: dict) - requests.Response: sess requests.Session() get_resp sess.get(post_url, headersheaders, timeout10) soup BeautifulSoup(get_resp.text, html.parser) token soup.find(input, {name: __VIEWSTATE}) if token: form_data[__VIEWSTATE] token.get(value, ) return sess.post(post_url, dataform_data, headersheaders, timeout10)拿到一页数据后立刻检查单页条数和总条数。有的站点一页放 200 条有的只有 10 条。记录数少的页面不必等加载完再解析直接看响应文本里有没有 JSON 数组很多查询页会在script标签里内嵌一段完整数据解析内嵌 JSON 比解析表格稳定得多因为表格结构改动频繁而内嵌数据的键名往往多年不变。3.3 限速、重试与失败隔离爬虫稳定运行的基础高密度请求很容易触发站点限流。常见的防护手段是单位时间请求次数限制和基于 UA 与访问频率的封禁。驯服它的核心是控制节奏把并发降下来同时把失败请求隔离出去不要因为一次超时拖垮整批任务。import random import time from requests.adapters import HTTPAdapter sess requests.Session() sess.mount(https://, HTTPAdapter(max_retries0)) def crawl_with_retry(url, params, max_attempts3): for attempt in range(1, max_attempts 1): try: resp sess.get(url, paramsparams, timeout10) if resp.status_code 200: return resp if resp.status_code in (403, 429): wait 10 * attempt random.uniform(0.5, 2.0) else: wait 2 * attempt time.sleep(wait) except requests.exceptions.RequestException: time.sleep(5 * attempt) return None这里的重试策略分两种情况403 或 429 表示被限流等待时间要成倍拉长普通 5xx 错误属于服务器临时抖动短等待即可。HTTPAdapter(max_retries0)是把 urllib3 自带的自动重试关掉避免它抢在自定义循环之前重试导致节奏完全失控。random.uniform给等待时间加抖动是为了避免多个线程同时醒来、又同时发起请求形成新的尖峰流量。3.4 并发控制线程数不能一拍脑袋定爬虫并发设计到底哪个好其实没有统一答案。对高校招生和考试院这类数据量级单机用ThreadPoolExecutor就够不需要分布式更不用上消息队列。以全国 31 个省份、每省 500 所院校计算全部专业组记录也就十几万条单机几小时能跑完。线程数的设定依据是目标站点的响应时间和限速阈值。from concurrent.futures import ThreadPoolExecutor, as_completed def run_batch(task_items, workers4, delay0.5): results [] with ThreadPoolExecutor(max_workersworkers) as pool: future_map {pool.submit(crawl_with_retry, *t): t for t in task_items} for future in as_completed(future_map): resp future.result() if resp is not None: results.append(resp) time.sleep(delay) return results建议从workers4、delay0.5起步先观察单位时间内完成的请求数和失败率。如果失败率超过 5%先降线程数而不是加出口 IP 切换多数站点对低并发请求是宽容的。只有确认对方有严格的单 IP 频率限制、业务又确实需要更高吞吐时才考虑维护一个出口地址池在每次请求前从中取一个地址失败后剔除。就高考数据这类低频任务先把限速和重试做好比折腾 IP 池实在得多。4. 录取数据的统计口径位次、冲稳保与专业组匹配4.1 分数到位次的换算裸分没有可比性位次才有。同一所大学同一个专业组两年的最低分可能涨 10 分但位次变化往往很小。所以统计前必须先做“分数 → 位次”映射。各省考试院每年公布一分一段表格式就是两个字段分数段、累计人数。import pandas as pd rank_df pd.read_csv(yfyd_2024.csv) rank_map dict(zip(rank_df[score], rank_df[count])) def score_to_rank(score: int) - int: # 分数段表里每个分数对应的累计人数即该分数的最低排名 return rank_map.get(score, int(rank_map[list(rank_map)[0]] * 1.2))这个实现直接取整分对应的累计人数。如果目标分数不在表里说明该分数段人数极少用相邻分数插值或按缺省系数放大估算。这里要注意缺省值只用于没有历史数据的冷门专业组用* 1.2表示“排名比已知最低位次更靠后”给冲稳保判断留出缓冲。实际项目中这列应该标成is_estimatedTrue避免和真实位次混在一起计算。4.2 冲稳保区间与专业组匹配的量化逻辑把考生的位次和院校专业组往年的录取位次放到同一尺度就能划分三档考生位次比录取位次高 15% 以上算“稳”高 5% 到 15% 算“冲”低于录取位次 10% 以上算“保”。这个比例不是拍脑袋定的它对应的是不同分数段考生的密度差异。def classify(candidate_rank: int, school_min_rank: int) - str: ratio (school_min_rank - candidate_rank) / candidate_rank if ratio 0.15: return 稳 if ratio 0.05: return 冲 return 保比值计算的是考生位次相对录取位次的领先幅度。举例考生位次 20000某专业组去年录取最低位次 17500说明去年排在 17500 名的同学也有机会进组考生领先 2500 名同比领先 12.5%算冲。这个比例法只适合初筛。考生人数大的省份2000 位的差距和 5000 位的差距意味着完全不同的竞争密度初筛之后还要把波动超过 10% 的专业组标记出来人工复核。4.3 去重、缺失值与同校异地校区清洗数据抓下来后常见三类问题同校异地校区名称不一致、专业同名不同组、分数表与计划表年份错位。清洗时先按业务主键省份 院校代码 专业组做一次 groupby再对专业名做标准化。df[major_std] df[major].str.replace(r[(].*?[)], , regexTrue) duplicated_key df.duplicated( subset[province, school_code, major_std, plan_year] ) df df.loc[~duplicated_key].copy()这里把括号内容去掉是为了把“计算机类含 AI 方向”和“计算机类”合并成同一专业统计。去重后还要检查每个专业组的录取人数是否缺失缺失的用该组三年历史平均值填充并在结果表里加一个is_estimated标记列统计时单独处理。分数表与计划表年份错位是最隐性的问题——高校官网的年度页有时挂着上一年的数据最稳妥的办法是让爬虫把页面自带的年份字段一并抓下来不要用请求参数里的年份做唯一判断。5. 高考季的数据保鲜增量更新与结构回归技巧5.1 用年份字段做幂等增量高考数据每年 6 月底到 7 月中旬集中更新不需要常驻调度每天跑一次增量就够了。增量更新的做法是以“省份 年份”为维度先查本地库里该年份是否已有数据有就只补缺失专业组没有就全量重抓。这条 SQL 可以当幂等判断用SELECT COUNT(DISTINCT major_group_id) FROM admission_scores WHERE province 21 AND year 2024;这个查询返回该省份该年份已入库的专业组数量和源站记录数比对一致就跳过不一致才进入抓取流程。入库时用INSERT INTO ... ON CONFLICT或者先 DELETE 再 INSERT保证重复跑任务不会产生脏数据。5.2 选择器结构变化的回归检测页面结构一改爬虫就断这是所有爬虫项目里最消耗维护成本的问题。常见做法是给每个解析函数配一个断言解析完必须拿到指定数量的字段否则报错并输出当前页面快照。快照文件可以命名成snapshot_{host}_{timestamp}.html放在独立目录里方便本地复现。def parse_score_rows(html: str): rows extract_rows(html) # 用你的选择器提取行 assert len(rows) 0, score table empty assert len(rows[0]) 6, fcolumn count changed: {len(rows[0])} return rows保存页面快照的意义在于站点结构变更后可以直接用快照在本地跑解析器不用重新请求线上地址快速定位是解析问题还是网络问题。跑完一轮更新后把所有异常快照收集起来按域名和错误类型归档下一次结构再变时可以直接在旧快照上做选择器回归。5.3 校验数据完整性的统计技巧抓完一批后用最简单的统计就能发现抓漏。检查每个省份应覆盖多少个院校专业组、每年录取分数记录的均值与极值。数值突变通常不是分数变化而是抓漏了某个专业。把这部分异常写进独立报告输出格式可以是 CSV标记异常原因人工复核一遍即可。把这段校验逻辑挂到爬虫入口每次跑完自动生成一份异常清单比盯着日志高效得多。整个流程跑顺后一次完整更新只需要改年份参数和省份列表重跑校验脚本剩下就是等报告。本文还有配套的精品资源点击获取
返回列表