简介面向高考志愿系统数据获取的Python爬虫代码包适合需要批量采集院校信息、录取分数线、省控线等公开数据的研究者、数据分析师或爬虫入门者可用于高考志愿填报研究、数据展示与二次分析。整套资源共1445个文件、压缩包约27MB内部包含10个py爬虫脚本按学院信息、录取分数、省控线等模块拆分便于按需调用1432个txt文件为抓取结果或中间数据可直接进行文本检索或二次处理2个sql文件预置了数据表结构导入数据库即可查询readme给出基本使用说明。已有282人浏览学习。代码覆盖从URL管理、请求网页到解析存储的主要爬虫环节部分脚本采用并发设计可提高采集效率配置常量集中管理整体结构清晰既适合爬虫项目入门也可作为快速搭建高考数据采集管线的参考方案。读者可按需调整请求参数、解析规则与存储逻辑灵活扩展到其他教育数据场景。1. 分数线数据不好拿这套 code.zip 的爬虫先把“壳”打好高考志愿系统数据获取爬虫 code.zip 解压后是 10 个 Python 文件覆盖了从院校信息、录取分数线、省控线到招生计划的主体数据链路。它不是临时抓一把的小工具而是按“请求-解析-入库-并发”拆开的工程雏形allCollegeInfo.py 负责院校列表allCollegeScoreLine.py 和 insertCollegeCutOff.py 负责历年录取分数线getProviceSLine.py 抓各省省控线allCollegeSL.py 处理招生计划threadD1.py 和 threadD2.py 给出了两种并发方案。如果手里的爬虫项目已经过了单页面调试阶段或者正好需要把高考志愿这类公开数据变成结构化表格这份代码可以当成一个可运行的骨架。2. requests 爬虫的请求层URL 队列与响应解析爬虫能不能长时间稳定跑请求层比解析层的决定权重更大。requests 库虽然简单但超时、重试、请求头配得不合适再好的解析逻辑也会被 403 或 429 打断。这套代码里的 AppConstant.py 就是为了避免站点参数散落各处而存在的。2.1 AppConstant.py把站点参数集中管理用每个脚本时都去改 url 和 headers是入门阶段最常踩的坑。AppConstant.py 的核心价值是把站点域名、User-Agent、超时、重试次数、数据库连接这些容易变的东西收拢到一处后续改站点或调频率只需要动这一个文件。# AppConstant.py BASE_URL https://example.eol.cn # 站点根地址 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.eol.cn/, Accept-Language: zh-CN,zh;q0.9, } TIMEOUT (3.05, 10) # (连接超时, 读取超时) RETRY_TIMES 3 # 单页失败后重试次数 MAX_WORKERS 8 # 线程池大小 DB_CONFIG { host: 127.0.0.1, port: 3306, user: crawler, password: ******, database: gaokao, charset: utf8mb4, }这里 TIMEOUT 用元组分别表示连接超时和读取超时比单一数值更贴合实际网络情况。HEADERS 里的 User-Agent 和 Referer 同时配置因为不少站点会校验 Referer为空直接拒绝请求。RETRY_TIMES 不是越大越好3 次足够多了会拖慢整体抓取节奏。2.2 allCollegeInfo.py列表页到详情页的 URL 循环从文件名看allCollegeInfo.py 主要抓取院校基础信息。这个过程通常是先请求列表页拿到所有详情页 URL构成一个待抓取队列再逐个请求详情页。下面这段代码是这类爬虫的公共骨架。import time import requests from bs4 import BeautifulSoup from AppConstant import BASE_URL, HEADERS, TIMEOUT, RETRY_TIMES def get_session(): s requests.Session() s.headers.update(HEADERS) return s def fetch(session, url, retriesRETRY_TIMES): for i in range(retries): try: r session.get(url, timeoutTIMEOUT) r.raise_for_status() return r.text except (requests.RequestException, OSError): if i retries - 1: return time.sleep(0.5 * (i 1)) def parse_college_list(html): soup BeautifulSoup(html, lxml) items [] for a in soup.select(ul.college-list li a): href a[href] items.append({ name: a.get_text(stripTrue), url: href if href.startswith(http) else BASE_URL href, }) return itemsSession 会复用底层 TCP 连接比每次新建 requests.get 更快。fetch 里捕获 RequestException 和 OSError避免超时或连接重置直接中断抓取。重试等待用了 0.5、1.0、1.5 秒递增属于最简单的退避策略。parse_college_list 里有一个容易忽略的细节页面里的详情链接经常是相对路径比如/college/1024.html必须判断后拼接 BASE_URL否则请求会直接落到站点根路径。2.3 限速、重试与异常兜底反爬不一定是验证码最常见的其实是状态码反馈。给每个响应状态码设计对应处理策略是爬虫工程师的基础工作。HTTP 状态码含义处理建议200正常直接解析403被反爬拒绝检查 User-Agent、Referer、Cookie降低频率404页面不存在跳过并记录缺失 URL429请求太频繁按 Retry-After 响应头等待5xx服务端异常等待几秒后重试针对 429建议显式读取响应头里的等待时间而不是写死 sleep 数值。if r.status_code 429: wait int(r.headers.get(Retry-After, 5)) time.sleep(wait) return retry_fetch(session, url)有的站点会在 429 响应头里指明“多久后再来”这个值写死为 5 秒会显得很业余。读取 Retry-After 还有一个额外好处当对方设置了动态限速策略时能自动适配。注意如果连续多次 429说明并发数设高了不要只靠重试硬怼应该调低线程数或增加请求间隔。3. 数据入库与字段标准化insertCollege.py 的深水区抓下来的页面只是字符串真正费时间的是把 HTML 里的“最低分”“位次”“计划数”清洗成能直接落库的字段。insertCollege.py 和 insertCollegeCutOff.py 做的就是这层转换。很多爬虫能抓但不能跑第二次问题都出在这一步的数据去重和类型处理上。3.1 先看字段再建表录取分数线表设计高考志愿数据里有两类线省控线和院校录取分数线。allCollegeScoreLine.py 抓的是院校历年录取数据insertCollegeCutOff.py 负责写入。建表时建议把院校、年份、专业组、批次作为唯一键用来承接增量更新。CREATE TABLE college_cutoff ( id INT AUTO_INCREMENT PRIMARY KEY, provice_code VARCHAR(8) NOT NULL, -- 省份代码保留原工程拼写 year INT NOT NULL, -- 年份 college_id INT NOT NULL, -- 院校ID major_group VARCHAR(32), -- 专业组/科类 batch VARCHAR(32), -- 批次 min_score DECIMAL(5,1), -- 最低分 min_rank INT, -- 最低位次 plan_count INT, -- 计划人数 data_source VARCHAR(64), -- 来源页面 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_college_year (college_id, year, major_group, batch) );字段里的 provice_code 其实是 province 的拼写错误但原工程文件里已经把这个拼写带到了命名习惯中建议保留而不是强行改掉否则后续对不上原脚本的写入字段。DECIMAL(5,1) 是为了兼容 750 分制下的 0.5 分比如某些省份的投档分会出现 623.5。plan_count 可以单独拆表但放在这个表里能直接支撑“招生计划 vs 录取人数”对比。3.2 insertCollege.py 的去重与增量更新第一次全量抓完第二次再跑同一批 URL 时不能重复插入。最稳妥的写法是数据库层面做 upsert而不是先 SELECT 再决定 INSERT。这个方案在并发场景下不会产生竞态问题。def upsert_cutoff(conn, rows): sql INSERT INTO college_cutoff (provice_code, year, college_id, major_group, batch, min_score, min_rank, plan_count, data_source) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE min_score VALUES(min_score), min_rank VALUES(min_rank), plan_count VALUES(plan_count), data_source VALUES(data_source) cursor conn.cursor() cursor.executemany(sql, rows) conn.commit()这里用 executemany 一次绑定多行减少 Python 与 MySQL 之间的往返次数。注意VALUES(min_score)是 MySQL 8.0.20 之前的老写法如果数据库版本较新建议改为INSERT AS new ON DUPLICATE KEY UPDATE min_score new.min_score否则会有弃用告警。UNIQUE KEY 里包含 major_group是因为同一个院校同一年可能会有物理类、历史类或不同专业组单独用 college_id year 做去重会误删数据。3.3 allCollegeScoreLine.py 中批量写入的常见坑页面上的“--”或空字符串代表没有数据入库前先做类型转换是最容易被跳过的步骤。allCollegeScoreLine.py 这类脚本如果直接把字符串拼进 SQL会出现字符串与 INT 字段隐式转换的问题。def to_int(v): if v is None: return None s str(v).replace(,, ).strip() return int(s) if s.isdigit() else None def clean_rows(rows): cleaned [] for row in rows: try: row[min_score] to_int(row.get(min_score)) row[min_rank] to_int(row.get(min_rank)) row[plan_count] to_int(row.get(plan_count)) except (ValueError, TypeError): continue if row[min_score] is None: row[min_score] 0 # 或标记为缺失值 cleaned.append(row) return cleaned批量写入的耗时差距在实际抓取中会被放大逐条 commit 一万行可能要十几秒executemany 加事务批量提交通常不到 1 秒。建议每 500~1000 行 commit 一次避免长事务持有锁也减少异常回滚时丢失全部数据的风险。写入方式1 万行耗时参考风险逐条 commit8~15 秒中途出错要回滚大量数据executemany 每 1000 行 commit0.3~0.8 秒出错最多回滚当前批次如果抓取中途崩溃已经 commit 的批次还会保留配合检查点就能做到断点续抓。4. 爬虫并发设计threadD1.py 和 threadD2.py 的两种线程模型单线程抓几千个页面可能感觉还行但高考录取数据按省份、年份、批次、科类展开后页面数量很容易上万。threadD1.py 和 threadD2.py 正好对应两种不同的并发设计任务并行和生产者-消费者。4.1 为什么单线程太慢耗时模型一个页面从请求到解析平均按 0.3 秒算1 万页就是 3000 秒接近 50 分钟。加上重试、限速和网络波动实际耗时超过 1 小时很常见。threadD1.py 用 8 个线程理论上能把时间压到 7~8 分钟但受目标站点带宽和反爬频率限制实际会在 15~20 分钟左右。这里讨论的“爬虫并发设计到底哪个好”核心是先想清楚任务边界是固定的一批院校还是一个数量未知的 URL 流。4.2 threadD1.py线程池按院校分流threadD1.py 的思路是把所有院校 ID 放进一个任务列表每个任务独立完成“请求详情页 解析 入库”交给 ThreadPoolExecutor 并发执行。from concurrent.futures import ThreadPoolExecutor, as_completed from AppConstant import MAX_WORKERS def crawl_one_college(college_id): session get_session() # 每个线程独立 Session url f{BASE_URL}/college/{college_id}.html html fetch(session, url) rows parse_college_detail(html, college_id) upsert_cutoff(conn, rows) with ThreadPoolExecutor(max_workersMAX_WORKERS) as pool: futures { pool.submit(crawl_one_college, cid): cid for cid in college_ids } for fut in as_completed(futures): cid futures[fut] try: fut.result() except Exception as e: log_error(cid, e)这里的核心参数就是 MAX_WORKERS8它代表同时打开 8 个 HTTP 连接。requests.Session 不是严格线程安全的如果在多线程里共用同一个 Session经常会出现偶发的 ConnectionError所以 get_session() 要在每个任务内部调用。as_completed 的作用是“谁先完成先处理谁”适合任务耗时不均匀的场景如果改成按 submit 顺序取结果前一个慢任务会阻塞后面已完成的处理。4.3 threadD2.py生产者-消费者队列threadD2.py 换了一个思路URL 由生产者扔进队列多个 worker 线程从队列里取 URL 抓取和解析。这种设计把“任务分发”和“任务处理”解耦适合 URL 数量很大且需要动态产生的场景。import queue from concurrent.futures import ThreadPoolExecutor task_queue queue.Queue(maxsize100) def producer(url_list): for url in url_list: task_queue.put(url) for _ in range(worker_count): # 发送停止信号 task_queue.put(None) def worker(): session get_session() while True: url task_queue.get() if url is None: break html fetch(session, url) rows parse(html) upsert_cutoff(conn, rows) task_queue.task_done()maxsize100 是限流的关键当队列满时producer 的 put 会阻塞避免一次性把几十万 URL 全塞进内存。worker 里的停止信号用 None 而不是特殊字符串是为了避免和合法 URL 冲突。注意所有 worker 应该使用独立的数据库连接不能共用一个 conn否则 commit 和游标状态会乱。对比项threadD1.pythreadD2.py任务划分按院校维度预先拆分按 URL 队列动态分发内存控制任务列表全量载入队列 maxsize 限流适用场景院校列表固定且量小分页/来源数量大且未知实现复杂度低中threadD2 里用的是本地队列换成 Redis 列表或消息队列后就变成了分布式爬虫的雏形。但单机多线程能解决 90% 的问题贸然上分布式会引入重复抓取、任务分发、结果汇总等一堆新问题。这套代码把 threadD1 和 threadD2 同时放进包里就是在提示你按数据量选择粒度。5. 省控线数据的一致性校验与断点续抓省控线是省级数据每年发布的时间不一样页面经常出现“暂无数据”或者只有部分批次。getProviceSLine.py 处理的就是这种边界情况。5.1 getProviceSLine.py把“空结果”也当成一条记录只检查 HTTP 状态码会漏掉很多问题。页面可能返回 200但内容是“暂无数据”或一个空表格。这类脚本里解析结果为空本身就是一个有效状态。def fetch_provice_line(province, year): url f{BASE_URL}/sline/{province}/{year}.html html fetch(session, url) rows parse_line(html) if 暂无数据 in html or len(rows) 0: return {province: province, year: year, status: missing} return {province: province, year: year, status: ok, rows: rows}抓取时把“没有数据”也写进结果表后续做数据补全时才知道哪些省份、哪些年份需要重跑。否则第二次全量抓取时只能靠日志回忆。5.2 temp.py用检查点做断点续抓抓取到一半断网或进程被杀是爬虫的常态。不要指望异常处理能覆盖所有情况最稳妥的手段是维护一个 done_set 记录已成功的 URL。temp.py 里值得复用的是保存和恢复检查点的逻辑。import os import pickle DONE_FILE done_set.pkl def load_done(): if os.path.exists(DONE_FILE): with open(DONE_FILE, rb) as f: return pickle.load(f) return set() def save_done(done): tmp DONE_FILE .tmp with open(tmp, wb) as f: pickle.dump(done, f) os.replace(tmp, DONE_FILE) # 原子替换防止写一半损坏每抓完一个 URL就把它加入 done_set但不要每次保存。建议每攒 50 个或 100 个保存一次避免频繁写盘。os.replace 是原子操作即使程序在写入过程中崩溃也不会留下半个文件。5.3 用 SQL 核查缺失再定向补抓抓完之后不能直接信任数据用 SQL 检查缺失是最快的验证方法。上面提到字段里保留了 provice_code这里正好用上。SELECT provice_code, year, COUNT(*) AS cnt FROM college_cutoff GROUP BY provice_code, year HAVING cnt 10;如果某省某年只有个位数记录基本可以判断是漏抓或页面结构变了。把 SQL 查出来的省份和年份拼成 URL定向补跑会比全量重抓节省大量时间。我会在 temp.py 里扩展一个--province和--year参数配合刚才的 done_set 检查点只跑缺失任务。比如python getProviceSLine.py --province 河南 --year 2022 --force这样整个补数据流程就收口到“查询缺失 SQL → 执行定向脚本 → 重新计数验证”三个步骤上。本文还有配套的精品资源点击获取