ARTICLE DETAIL

资讯详情

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

用Python爬虫抓取高考志愿数据:招生计划与分数线采集实战

用Python爬虫抓取高考志愿数据:招生计划与分数线采集实战 简介这是一套面向高考志愿填报场景的大学数据爬取脚本适合有 Python 基础、想批量获取招生计划、专业分数线、历年录取数据及学校基本信息的考生、家长或数据分析爱好者。脚本基于 requests 与 fake_useragent 实现抓取内容包括专业名称、学科门类、计划招生数、学制、录取批次、招生类型、平均分、最低分/位次、省控线等并整理学校 ID、名称、类型、科类、级别、位置及招生咨询官网字段。压缩包共 3 个文件包含 1 个 Python 脚本、1 个说明文档和 1 个数据文件整体仅 5KB轻量易用README 可辅助快速上手。目前已有 1737 人学习下载。借助脚本可获得结构化的志愿数据便于按院校、年份、分数位次做多维筛选与排序辅助评估不同学校专业的录取可能从而节省手动逐校查询与整理的时间。1. 高考志愿填报季为什么我选择用爬虫统计大学数据每年六月高考出分后到填报截止前那几天是全国家长最焦虑的时刻。面对几百所院校、上千个专业官网数据分散在各省考试院和学校招生网里手动复制到Excel要浪费一整个通宵。这个名叫school_Statistics的Python爬虫项目就是为了把招生计划、专业分数线、历年录取位次和学校基本信息一次性抓到本地。它只依赖requests和fake_useragent两个库代码量不大却覆盖了志愿填报的核心数据维度。如果你也想在最短时间内拿到结构化数据或者想练习真实场景下的requests爬虫这个项目值得拆开看看。2. 拆解school_Statistics目标数据字段与请求构造拿到school_Statistics-main.zip后先看目录结构一个school_Statistics.py主脚本、一个ID.text文本以及README。这个项目不是通用的爬虫框架而是针对特定院校库数据页的定向采集器。在动手改代码前我们需要先搞清楚它要抓什么、从哪里抓、怎么让对方认为我们是正常访客。2.1 从ID.text找到学校ID的加载入口ID.text在项目里承担的是「待抓取学校ID列表」的角色。每行一个学校ID爬虫启动时依次读取后续所有请求的URL都会拼上这个ID。之所以把ID单独放在文件里而不是硬编码在脚本中是因为高考数据每年更新只需要替换这个文件就能让爬虫跟踪新的院校集合。with open(ID.text, r, encodingutf-8) as f: school_ids [line.strip() for line in f if line.strip()]这段代码会过滤掉空行返回字符串列表。注意文件名不是.txt而是.text在Windows上可能被识别为未知类型但Python自带open可以正常读取。我用的时候会顺手改成.txt避免后续编辑器识别混乱。2.2 请求头伪装与requests会话管理直接裸用requests抓网页很容易被服务器拒绝。项目中的解决方案是引入fake_useragent随机生成一个浏览器UA同时封住默认的Python-requests签名。from fake_useragent import UserAgent import requests ua UserAgent() headers { User-Agent: ua.random, Referer: https://www.example.com/ } session requests.Session() session.headers.update(headers)这里必须用ua.random而不是ua.ie或ua.chrome因为随机UA能降低每次请求的指纹一致性。Session对象会复用底层的TCP连接在连续抓取几百个学校时比每次新建requests.get快30%左右。如果你要抓的目标站点有更严格的反爬还需要在headers里补上Accept、Accept-Language并且设置请求间隔。常见做法是每次请求后time.sleep(0.5)把QPS压到2以下。2.3 数据字段映射招生计划、分数线与学校基本信息参考项目摘要中的四个数据块可以整理成下面的字段表数据块字段JSON Key示例类型招生计划专业名称majorNamestr招生计划学科门类subjectCategorystr招生计划计划招生数planCountint招生计划学制eduSystemint专业分数线录取批次batchstr专业分数线平均分/最低分avgScore/minScorefloat专业分数线最低位次minRankint历年分数线年份yearint历年分数线招生类型recruitTypestr历年分数线省控线provinceLineint学校基本信息学校名称namestr学校基本信息类型/科类/级别type/category/levelstr学校基本信息位置locationstr学校基本信息招生咨询官网consultUrlstr抓取时要注意不同接口返回的字段名并不统一。常见做法是先打印一版原始JSON再手动建立映射关系。比如招生计划接口返回的是data.list而分数线接口返回的是data.rows稍不留神就会解析到空列表。另外category和level在部分学校接口里可能为空需要对这些字段做默认值处理否则后续保存时会报字段缺失。3. requests fake_useragent 抓取招生计划与分数线的爬虫实战当目标URL结构和数据字段都摸清后核心的爬虫逻辑就清晰了。下面这段代码被我重组过但保留了原项目的主要流程——先取学校基本信息再抓招生计划和专业分数线。3.1 学校基本信息抓取与JSON解析学校基本信息通常是一个独立的详情接口参数是schoolId。响应体里的内容比较多我们需要用json.loads或response.json()来解析使字段可以直接按字典访问。def fetch_school_info(school_id): url fhttps://api.example.com/school/{school_id}/info data requests.get(url, headersheaders, timeout8).json() info { school_id: school_id, name: data.get(name), type: data.get(type), category: data.get(category, 综合), level: data.get(level, 本科), location: data.get(location), consult_url: data.get(consultUrl) } return info这里用.get(key)而不是data[key]是因为部分学校可能没有设置咨询官网直接按key取会抛出KeyError而.get可以安全返回None。timeout8是给请求一个上限避免某所学校的接口长时间无响应导致主线程卡死。category和level的默认值也提前放好这样后面做统计时不会因为空值中断。3.2 招生计划字段的解析与空值处理招生计划列表里专业名称和学科门类一般都有值但计划招生数可能为null学制也可能是字符串。这时候要做一个类型兜底。def parse_plans(school_id, resp_json): plans [] for major in resp_json.get(list, []): plans.append({ school_id: school_id, major_name: major.get(majorName, ), subject_category: major.get(subjectCategory, 未分类), plan_count: int(major.get(planCount) or 0), edu_system: int(major.get(eduSystem) or 4) }) return plansint(major.get(planCount) or 0)的含义是如果planCount是None、空字符串或0都统一转换成0。学制同理默认给4年。这种容错在真实爬虫里几乎是必需项因为目标站的录入数据并不规范直接强转会直接炸掉循环。3.3 历年分数线的循环抓取与参数变化历年分数线的接口往往比专业分数线多一个year参数。我一般会用当前年份倒推4年逐个请求。years [2024, 2023, 2022, 2021] for year in years: url fhttps://api.example.com/school/{school_id}/score/{year} resp requests.get(url, headersheaders, timeout8).json() for item in resp.get(scoreList, []): score_data { school_id: school_id, year: year, batch: item.get(batch), recruit_type: item.get(recruitType), min_score: item.get(minScore), min_rank: item.get(minRank), province_line: item.get(provinceLine) } all_scores.append(score_data)注意这里的recruitType可能区分「普通类」「中外合作」「民族班」等同一所学校同一年会返回多条记录。如果只保留最后一条会丢失报考口径。所以要把每条都追加进列表后续做数据分析时再按类型过滤。另外年份参数不要硬编码最好从当前日期动态生成否则明年再跑又要改代码。4. 数据落盘与异常重试把爬虫跑稳定只把数据打印出来没有意义关键是要落盘。项目里选择用JSON文件保存而不是CSV是因为招生计划、分数线这些数据天然嵌套JSON能原样保留层级关系。这一步做不好之前爬回来的数据就是一堆废弃变量。4.1 用json.dump保存每次请求结果最简单的保存方式是把所有抓取结果汇总在一个大字典里最后一次性写文件。import json result { school_id: school_id, school_info: info, plans: plans, scores: scores } with open(foutput/{school_id}.json, w, encodingutf-8) as f: json.dump(result, f, ensure_asciiFalse, indent2)ensure_asciiFalse是必须的否则中文会变成\uXXXX转义序列可读性极差。indent2仅用于调试强制缩进会让文件体积变大对后续分析没有帮助。生产环境我一般改用jsonl格式一行一个学校方便增量写入。如果你打算把数据导入数据库jsonl也更容易逐行解析。4.2 异常处理超时、连接失败、JSONDecodeError爬虫跑半小时后最怕遇到某次请求超时导致整个进程退出。项目在循环里加入重试机制对常见的异常做区分处理。def safe_request(url, max_retries3): for attempt in range(max_retries): try: resp requests.get(url, headersheaders, timeout8) resp.raise_for_status() return resp.json() except (requests.exceptions.Timeout, requests.exceptions.ConnectionError): time.sleep(2 * (attempt 1)) except requests.exceptions.HTTPError as e: if resp.status_code 404: return None # 学校不存在跳过 time.sleep(3) return Nonetime.sleep(2 * (attempt 1))是线性退避第一次失败等2秒第二次4秒第三次6秒。这个策略比固定重试要温和能明显减少触发对方WAF的概率。对于404错误直接返回None因为学校ID失效后继续重试只会白白占用线程。如果你发现某次请求返回了200但JSON解析失败多半是响应被压缩或反爬页拦截这时可以检查resp.text前200个字符来判断。4.3 增量抓取借助ID.text实现断点续跑项目直接从头跑到尾一旦中途断掉前面抓的学校还得重新来。我给这个项目加过一个断点逻辑每成功抓完一个学校就把它的ID从ID.text中移除或者把已完成ID追加到一个done.text里。def is_done(school_id): with open(done.text, r) as f: return school_id in f.read() def mark_done(school_id): with open(done.text, a) as f: f.write(school_id \n)启动时先检查is_done若已处理过就跳过。这样即使进程崩溃只要done.text没有损坏就能继续从上次的位置往下跑。这个技巧在长任务里非常实用特别是在志愿填报高峰期数据更新频繁需要隔几个小时就增量刷新一次。5. 从爬虫数据到志愿推荐位次换算与多表关联的技巧数据落地后下一步就是让数据产生真正的价值。高考志愿填报不能只看绝对值尤其不能只看分数。同样的600分在试卷难度极低的年份排位可能在一万名开外在难度极高的年份可能进入前五千。所以爬虫输出的minRank字段比minScore更值得关注。5.1 用位次波动判断院校热度将同一所学校近四年的专业最低位次做成一个序列如果位次逐年上升即数值变大说明录取门槛在降低可能是该校专业遇冷反之则说明竞争加剧。下面这个简单的波动率计算可以直接套用你抓下来的数据。def rank_trend(school_scores): ranks sorted([ item[min_rank] for item in school_scores if item.get(min_rank) ]) if len(ranks) 2: return 0.0 return (ranks[-1] - ranks[0]) / ranks[0] * 100返回值的正负代表位次上升/下降的百分比。注意这里必须过滤掉min_rank为None的数据否则排序会报错或产生虚假结果。如果波动率超过15%说明该专业录取状况极不稳定填报时要格外谨慎。5.2 将招生计划与历年分数线按专业名称合并招生计划抓到的是今年新计划历年分数线是往年的实际录取结果。要预测某个专业的录取位次需要将两表通过school_id major_name关联起来观察但专业名称在不同年份可能微调如「计算机类」时而细分、时而大类招生。merged {} for plan in plans: key (plan[school_id], plan[major_name]) merged.setdefault(key, []) merged[key].append(plan) for score in scores: key (score[school_id], score[major_name]) if key in merged: merged[key].append(score)这种以元组作为字典key的做法比单纯用字符串拼接更严谨可以避免专业名包含特殊字符时产生的拼接冲突。合并后的数据就能用于做差值分析比如招生计划扩招了多少位次可能下降多少。但要注意每年专业目录可能会有调整如果匹配不上就单独保留原始记录。5.3 在填报期间定时刷新数据高考出分到填报截止往往只有一周而院校可能在期间更新招生计划。给school_Statistics.py增加一个定时运行的任务不用复杂框架就用time.sleep(1800)半小时跑一次或者用系统cron调度。在Linux服务器上编辑crontab加入下面的任务即可每天凌晨两点更新一次0 2 * * * cd /path/to/school_Statistics /usr/bin/python3 school_Statistics.py run.log 21将输出重定向到run.log后第二天可以检查日志末尾的异常再配合前面说的断点续跑机制一个能持续运行整个填报季的爬虫服务就完成了。记住任何爬虫都有遇到接口改版的风险在上线前最好先打印一次原始响应体确认字段没有变化。本文还有配套的精品资源点击获取
返回列表