☰
Python爬虫实战:抓取山东新高考选考科目要求数据
2026/10/12 17:27:31 网站建设 项目流程

2020年山东新高考第一年落地后,高校专业的选考科目要求成了考生和家长最关心的数据之一。当时招考信息网站提供了一个在线查询系统,按学校逐页展示专业(类)的选考要求,但页面没有导出功能,翻上几百页复制粘贴显然是体力活。我接到一个整理全量数据的小任务,把拟在山东招生的普通高校专业(类)选考科目要求全部抓下来,做成一张能筛选的本地表格。文章里没有复杂的框架,就是Python加requests加BeautifulSoup的一整套实战记录,适合刚接触爬虫、想拿真实站点练手的朋友参考。

1. 需求拆解:选考科目要求数据到底长什么样

1.1 先理解“选考科目要求”的业务含义

山东新高考实行“3+3”模式,除了语数外三门必考,考生要从思想政治、历史、地理、物理、化学、生物六门里选三门参加等级考。而高校在招生时,会针对每个专业(类)提出选考科目要求。常见的有几种形式:不限选科、必须选考物理、化学和生物均须选考、物理或化学任选一门等。

这直接决定考生能不能报某个专业,所以数据本身的准确性特别关键。爬虫爬下来之后,如果字段错位、漏行、专业名称被截断,那整理出来的表就没法用。做这个项目,第一步不是写代码,而是把手上的资料先理清楚:需要保存哪些字段、页面上的展示规则是什么、选考科目要求有哪些固定写法。

我当时列了六个核心字段,分别是年份、院校代码、院校名称、专业(类)名称、选考科目要求、备注。年份单独拎出来,是因为查询系统里可能默认展示当年数据,保存下来方便以后做多年度对比。备注字段不是每个页面都有,但有些专业会写明“按大类招生”或“其中某方向有特殊要求”,这种补充信息不能丢。

1.2 观察页面结构:先花十分钟,省掉两小时

拿到查询地址后,我没急着写爬虫,而是先用浏览器打开页面,按F12打开开发者工具,看两个东西:页面上的表格结构,以及翻页时发送的请求。

这类查询系统分为几种典型形态。第一种是表格数据直接渲染在HTML里,翻页链接就是带page参数的普通网页地址,这种最简单。第二种是页面先给一个框架,数据通过AJAX接口返回JSON,再动态填充到表格,这种就比解析HTML麻烦一些,但接口返回的数据往往更规整。第三种是点击查询后才出数据,而且用的是POST请求,参数藏在请求体里,需要从网络面板里找Form Data。

2020年这个查询系统属于第一种和第二种混合的形态:首屏能看到表格,但翻页是通过一个固定地址带page和pageSize参数完成的。我在Network面板里选中XHR和Doc两个过滤条件,刷新页面后找到真实请求,确认返回的是HTML片段而不是JSON。既然返回的是HTML,就直接用BeautifulSoup解析,比处理嵌套JSON还直观。

2. 动手前的准备:技术选型与字段规划

2.1 为什么是requests加BeautifulSoup,而不是Scrapy

做这个爬虫之前,我顺手评估过工具。Scrapy功能确实强,自带并发调度、去重、中间件,适合做大规模分布式采集。但这个任务的数据量顶多几千条记录、几十个页面,用Scrapy有点小题大做,而且调试起来不如脚本直观。

requests负责发HTTP请求,BeautifulSoup负责解析HTML,两者配合非常轻。对于新手来说,这套组合的学习曲线也最平缓,代码量小,哪里出错看哪里。最关键的是,这类教育类查询网站结构相对稳定,不会频繁调整页面,不需要Scrapy那种强大的健壮性设计。

另外,我特意没有用Selenium。当时有同事建议用浏览器自动化模拟点击,因为觉得页面是动态的。但实际看Network面板之后,发现数据内容就在HTML里,没必要启动一个浏览器进程去渲染,那样反而慢,还要处理等待时间、浏览器版本兼容的问题。能不用浏览器就不用浏览器,这是小项目提效的第一原则。

2.2 环境准备与数据字段设计

本机环境是Python 3.8,Windows系统,用的库只有requests和beautifulsoup4。安装命令很简单:

pip install requests beautifulsoup4

如果你想在原有环境里不污染全局,可以先建一个虚拟环境:

python -m venv venv venv\Scripts\activate pip install requests beautifulsoup4

字段设计这块,我一开始就在代码里写了一个字段列表,后面解析和写入都用它做标准。这样即使网站显示顺序变了,只要调整解析逻辑,最终输出的一致性不会乱。

字段名类型说明
yearstr招生年份,固定为2020
school_codestr院校代码,原始文本保留
school_namestr院校名称
major_namestr专业(类)名称
subject_requirementstr选考科目要求
remarkstr备注,可能为空

字段类型统一用字符串是刻意的。后续如果要做筛选和统计,院校代码、院校名称这些本质上就是字符串,虽然年份看着像数字,但保存成文本可以避免导入Excel时开头0被吞掉的问题。备注字段可能存在缺失,写入CSV时留空即可。

3. 爬虫核心实现:请求、解析、落库全流程

3.1 请求构造与分页规则确认

第一步是确认查询地址和参数格式。假设查询页面的地址是:

https://example.com/xkkm/query

页面默认每页显示20条记录,翻页时URL变成了:

https://example.com/xkkm/query?page=2&pageSize=20

这说明是一个标准的GET分页查询。我直接用requests的params参数来组织请求,参数会自动拼接到URL后面,比手写字符串方便,也避免中文编码出错。

import requests from bs4 import BeautifulSoup import csv import time import random BASE_URL = "https://example.com/xkkm/query" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/87.0.4280.88 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Referer": "https://example.com/" } def fetch_page(page, page_size=20): params = { "page": page, "pageSize": page_size } resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=10) resp.encoding = "utf-8" resp.raise_for_status() return resp.text

这里有几个细节值得注意。第一是resp.encoding,查询网站返回的是UTF-8编码,但如果不显式设置,requests有时会根据响应头里的charset来推断,偶尔会推断错误,就会导致中文全部乱码。第二是timeout时间的设置,必须写,否则网络异常时请求会一直卡住,拖垮整个采集流程。第三是User-Agent,默认的python-requests标识容易被服务端拦截,伪装成浏览器是基本礼仪。

分页总页数怎么确定?我在实际解析中发现,页面底部有一个分页栏,里面显示“共XX页”。先请求第一页,然后从HTML里提取总页数,再做循环。提取方式可以灵活处理,一般是找一个class为pagination或者page-info的节点,然后用正则或者字符串截取数字。

def get_total_pages(html): soup = BeautifulSoup(html, "html.parser") page_info = soup.select_one(".page-info") if not page_info: return 1 # 比如文本是“共 123 页”,提取数字 import re match = re.search(r"共\s*(\d+)\s*页", page_info.get_text()) return int(match.group(1)) if match else 1

3.2 表格解析与字段清洗

拿到HTML文本后,先用BeautifulSoup解析,然后定位到数据表格。这个网站的表格结构很标准,外层是table标签,表头是thead,数据行都在tbody里。每一行有六个单元格,分别对应院校代码、院校名称、专业(类)名称、选考科目要求、备注和其他信息。

解析代码写起来不算难,真正的坑在于单元格内部的隐藏结构。有的单元格里不只是纯文本,可能嵌套了span、a标签,甚至还有换行。直接用get_text()拿到的是所有子标签文本的拼接,必须用strip=True去除首尾空白,同时把内部的换行和多余空格替换掉。

def parse_table(html): soup = BeautifulSoup(html, "html.parser") rows = soup.select("table tbody tr") data = [] for row in rows: cells = row.find_all("td") if len(cells) < 6: continue school_code = cells[0].get_text(strip=True) school_name = cells[1].get_text(strip=True) major_name = cells[2].get_text(strip=True) subject_req = cells[3].get_text(strip=True) remark = cells[4].get_text(strip=True) # 如果单元格内有多个换行,统一替换成空格 import re subject_req = re.sub(r"\s+", " ", subject_req) major_name = re.sub(r"\s+", " ", major_name) data.append({ "year": "2020", "school_code": school_code, "school_name": school_name, "major_name": major_name, "subject_requirement": subject_req, "remark": remark }) return data

为什么用CSS选择器table tbody tr而不是find_all("tr")?因为如果表格里还有嵌套表格,find_all("tr")会把内层行也找出来,造成解析错乱。用tbody限定最外层数据体,干净利落。

另外,解析时一定不要把表头行混进去。表头通常是thead中的tr,用table tbody tr选择器就已经天然过滤了表头。但如果网站结构不规范,需要额外判断第一个单元格的内容是否为“院校代码”之类的文本,做好二次过滤。

3.3 数据落地:CSV写入与去重

数据最终要交到别人手里,Excel是最通用的格式。我选择用CSV存储,然后用一个特殊编码处理中文问题。

Python自带的csv模块写入中文时,如果用默认编码,Excel打开会乱码。解决方案是写入时使用utf-8-sig编码,它会自动添加BOM头,Excel识别就没问题。

def save_to_csv(all_data, output_file="xuangao_2020.csv"): with open(output_file, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=[ "year", "school_code", "school_name", "major_name", "subject_requirement", "remark" ]) writer.writeheader() writer.writerows(all_data)

去重逻辑是在循环里维护一个已见集合,用院校代码加专业(类)名称加选考科目要求拼接作为键。理论上同一个院校的同一个专业只出现一次,但查询系统偶尔会有重复展示。如果不去重,后续做统计分析时计数就会偏高。

def collect_all_pages(total_pages): all_data = [] seen = set() for page in range(1, total_pages + 1): html = fetch_page(page) page_data = parse_table(html) for item in page_data: key = (item["school_code"], item["major_name"], item["subject_requirement"]) if key not in seen: seen.add(key) all_data.append(item) print(f"第 {page} 页完成,累计 {len(all_data)} 条") time.sleep(random.uniform(0.5, 1.5)) return all_data

这里每抓完一页,强制休息0.5到1.5秒。别小看这个sleep,它一方面控制请求频率,避免给服务器造成压力,另一方面也能降低被临时限流的概率。later如果你要抓更频繁,可以把间隔压缩到0.3秒,但我不建议。

3.4 完整的采集循环

把上面几个函数串起来,主流程就是先获取总页数,然后循环抓取、解析、去重、保存。直接运行这个脚本,就能在当前目录生成xuangao_2020.csv。

def main(): first_html = fetch_page(1) total_pages = get_total_pages(first_html) print(f"检测到总页数:{total_pages}") all_data = collect_all_pages(total_pages) save_to_csv(all_data) print(f"采集完成,共 {len(all_data)} 条记录") if __name__ == "__main__": main()

在实际跑的时候,我记得总共跑了不到两百个页面,最后落地的有效记录大概几千条,全程耗时大概十几分钟。这个速度完全够用,而且不会对目标网站造成明显影响。如果数据量比这个大一个量级,再考虑并发优化也不迟。

4. 踩坑实录:翻页丢失、乱码与数据重复怎么处理

4.1 翻页后数据没有变化是怎么回事

这是我第一次跑脚本时遇到的最诡异的问题。第一页正常,第二页返回的内容和第一页一模一样。当时我没有看URL,而是直接用了response的URL做检查,发现requests的params参数确实变了,但返回内容还是第一页。

后来才意识到,这种查询系统对分页参数的处理依赖某个隐藏字段,比如页面里的requestId或者sessionId,首次请求时服务器分配了一个会话标识,翻页时必须带上这个标识才能继续取数。解决办法很简单,先用requests.Session()创建一个会话对象,让cookie自动管理。

session = requests.Session() def fetch_page_with_session(page, page_size=20): params = {"page": page, "pageSize": page_size} resp = session.get(BASE_URL, params=params, headers=HEADERS, timeout=10) resp.encoding = "utf-8" resp.raise_for_status() return resp.text

改成Session之后,翻页正常了。这种坑在表单类网站里非常常见,尤其是登录后查询、分页显示的系统。以后遇到翻页异常,先检查是不是cookie或者会话状态没维持住。

4.2 中文乱码与字段内容碎片化

乱码问题多半是编码判断错误。第一版代码没写resp.encoding,结果抓回来的院校名称变成一堆“锟斤拷”。requests的推测机制并不总是准确,尤其在响应头缺失charset时。显式指定编码是最保险的。

字段碎片化则是单元格内存在不规整结构造成的。比如有的院校名称很长,页面里用了换行展示,get_text(strip=True)之后变成了名字中间带空格。还有的选考科目要求是“物理、化学”并分成两行,直接拼接就变成“物理化学”中间没有分隔符。我用re.sub(r"\s+", " ", text)把所有连续空白字符统一替换成单个空格,再手动把逗号、顿号规范一下。这类清洗逻辑不复杂,但一定要在爬取阶段做掉,否则数据进了CSV再处理会非常被动。

4.3 请求失败与中断恢复

跑长任务最怕中途挂掉。网络抖动、服务器超时、偶发的状态码异常,都可能导致脚本中断。我在代码里加了简单的重试机制:遇到请求异常就等待一段时间后再试,最多重试三次。

def fetch_with_retry(page, retries=3): for attempt in range(retries): try: return fetch_page_with_session(page) except requests.RequestException as e: print(f"第 {page} 页请求失败,第 {attempt + 1} 次重试:{e}") time.sleep(2 ** attempt) return None

但重试只能解决临时故障,如果程序中断后重新再跑,还是会从头开始。更稳妥的做法是记录已经完成的分页。我用一个简单的断点文件,每页成功解析后就写入当前页数,下次启动时从断点页继续。这样即使跑了半小时断掉,也不需要重头再来。

def load_last_page(): try: with open("checkpoint.txt", "r") as f: return int(f.read().strip()) except (FileNotFoundError, ValueError): return 1 def save_checkpoint(page): with open("checkpoint.txt", "w") as f: f.write(str(page))

这种断点方式比数据库快照轻量得多,对于小规模采集已经足够。如果是更正式的工程,建议把采集进度写到SQLite或单独的进度表里。

4.4 常见问题速查表

我把这次实战里最典型的几个问题整理成一张表,后面遇到类似情况可以直接对照。

现象可能原因解决办法
中文乱码响应编码推断错误显式设置resp.encoding = "utf-8"
第二页内容和第一页一样分页依赖会话Cookie改用requests.Session()
部分数据缺失表格结构不规整,tr嵌套用table tbody tr限定范围
字段间有多余空格或换行HTML源码内部有标签嵌套get_text(strip=True)后再用正则清洗
跑到一半被限流请求过于频繁增加随机延时sleep
数据重复写入未做唯一性校验维护已见集合,按唯一键去重
程序中断后从头再跑缺少断点记录写页面断点文件

5. 数据到手之后:统计与扩展玩法

5.1 用CSV数据做选考要求分布统计

爬虫跑完,手里攒下几千条高校专业记录,单纯交给需求方还不够。我用pandas做了个快速统计,看看选考科目要求大致是什么分布,哪些专业几乎都要求物理,哪些专业完全不限。

import pandas as pd df = pd.read_csv("xuangao_2020.csv", encoding="utf-8-sig") # 统计选考科目要求的分布 req_counts = df["subject_requirement"].value_counts() print(req_counts.head(20))

统计结果里占比最高的通常是“物理必选”“不限”和“化学或生物任选一门”这几类。物理必选的专业大多是工科、计算机、电子信息相关,不限选科的多是文科、艺术类、部分管理类专业。这个分布对考生选科策略很有参考意义,也方便招生部门做数据复盘。

如果想把数据变成一张方便筛选的表,可以直接用Excel打开CSV,选中年份列和选考科目要求列,加筛选按钮。之后按“不限选科”“物理必选”等关键词逐项筛选,效率比在网页上翻页高得多。

5.2 这个爬虫还能迁移到哪些场景

这次任务的思路不局限于高考选考数据。凡是“列表页+分页+表格HTML”结构的网站,都可以用同一套代码逻辑快速改造。比如高校招生计划查询、职业资格目录查询、企业资质公示列表,结构上都大同小异。

迁移时主要改三个地方:查询地址BASE_URL、表格行的CSS选择器、字段清单fieldnames。其他像请求头、会话保持、翻页循环、去重写入这些逻辑,基本可以原封不动复用。甚至可以把脚本封装成一个通用函数,传入不同的URL、选择器和字段名,就变成了一个小型通用列表采集器。

如果目标网站的数据不是HTML表格而是JSON接口,就把parse_table换成json解析,整体架构不需要变。requests拿到的还是同样的响应,只是解析方式不同。掌握这一个套路,遇到类似场景基本能直接套模板。

最后再分享一个小技巧:这类数据爬完以后,最好在文件里额外增加一列采集时间,记录数据快照的抓取时间。以后数据源更新了,你能清楚分辨哪些是旧数据、哪些是新数据,也方便做对比。我当时给CSV加了采集日期列,后来再做年度对比时,直接按年份和采集日期两个维度筛选,非常有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询