Python爬虫实战:抓取山东新高考选考科目要求数据 2020年山东新高考第一年落地后高校专业的选考科目要求成了考生和家长最关心的数据之一。当时招考信息网站提供了一个在线查询系统按学校逐页展示专业类的选考要求但页面没有导出功能翻上几百页复制粘贴显然是体力活。我接到一个整理全量数据的小任务把拟在山东招生的普通高校专业类选考科目要求全部抓下来做成一张能筛选的本地表格。文章里没有复杂的框架就是Python加requests加BeautifulSoup的一整套实战记录适合刚接触爬虫、想拿真实站点练手的朋友参考。1. 需求拆解选考科目要求数据到底长什么样1.1 先理解“选考科目要求”的业务含义山东新高考实行“33”模式除了语数外三门必考考生要从思想政治、历史、地理、物理、化学、生物六门里选三门参加等级考。而高校在招生时会针对每个专业类提出选考科目要求。常见的有几种形式不限选科、必须选考物理、化学和生物均须选考、物理或化学任选一门等。这直接决定考生能不能报某个专业所以数据本身的准确性特别关键。爬虫爬下来之后如果字段错位、漏行、专业名称被截断那整理出来的表就没法用。做这个项目第一步不是写代码而是把手上的资料先理清楚需要保存哪些字段、页面上的展示规则是什么、选考科目要求有哪些固定写法。我当时列了六个核心字段分别是年份、院校代码、院校名称、专业类名称、选考科目要求、备注。年份单独拎出来是因为查询系统里可能默认展示当年数据保存下来方便以后做多年度对比。备注字段不是每个页面都有但有些专业会写明“按大类招生”或“其中某方向有特殊要求”这种补充信息不能丢。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.8Windows系统用的库只有requests和beautifulsoup4。安装命令很简单pip install requests beautifulsoup4如果你想在原有环境里不污染全局可以先建一个虚拟环境python -m venv venv venv\Scripts\activate pip install requests beautifulsoup4字段设计这块我一开始就在代码里写了一个字段列表后面解析和写入都用它做标准。这样即使网站显示顺序变了只要调整解析逻辑最终输出的一致性不会乱。字段名类型说明yearstr招生年份固定为2020school_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?page2pageSize20这说明是一个标准的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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Referer: https://example.com/ } def fetch_page(page, page_size20): params { page: page, pageSize: page_size } resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) 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 13.2 表格解析与字段清洗拿到HTML文本后先用BeautifulSoup解析然后定位到数据表格。这个网站的表格结构很标准外层是table标签表头是thead数据行都在tbody里。每一行有六个单元格分别对应院校代码、院校名称、专业类名称、选考科目要求、备注和其他信息。解析代码写起来不算难真正的坑在于单元格内部的隐藏结构。有的单元格里不只是纯文本可能嵌套了span、a标签甚至还有换行。直接用get_text()拿到的是所有子标签文本的拼接必须用stripTrue去除首尾空白同时把内部的换行和多余空格替换掉。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(stripTrue) school_name cells[1].get_text(stripTrue) major_name cells[2].get_text(stripTrue) subject_req cells[3].get_text(stripTrue) remark cells[4].get_text(stripTrue) # 如果单元格内有多个换行统一替换成空格 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_filexuangao_2020.csv): with open(output_file, w, newline, encodingutf-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_size20): params {page: page, pageSize: page_size} resp session.get(BASE_URL, paramsparams, headersHEADERS, timeout10) resp.encoding utf-8 resp.raise_for_status() return resp.text改成Session之后翻页正常了。这种坑在表单类网站里非常常见尤其是登录后查询、分页显示的系统。以后遇到翻页异常先检查是不是cookie或者会话状态没维持住。4.2 中文乱码与字段内容碎片化乱码问题多半是编码判断错误。第一版代码没写resp.encoding结果抓回来的院校名称变成一堆“锟斤拷”。requests的推测机制并不总是准确尤其在响应头缺失charset时。显式指定编码是最保险的。字段碎片化则是单元格内存在不规整结构造成的。比如有的院校名称很长页面里用了换行展示get_text(stripTrue)之后变成了名字中间带空格。还有的选考科目要求是“物理、化学”并分成两行直接拼接就变成“物理化学”中间没有分隔符。我用re.sub(r\s, , text)把所有连续空白字符统一替换成单个空格再手动把逗号、顿号规范一下。这类清洗逻辑不复杂但一定要在爬取阶段做掉否则数据进了CSV再处理会非常被动。4.3 请求失败与中断恢复跑长任务最怕中途挂掉。网络抖动、服务器超时、偶发的状态码异常都可能导致脚本中断。我在代码里加了简单的重试机制遇到请求异常就等待一段时间后再试最多重试三次。def fetch_with_retry(page, retries3): 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(stripTrue)后再用正则清洗跑到一半被限流请求过于频繁增加随机延时sleep数据重复写入未做唯一性校验维护已见集合按唯一键去重程序中断后从头再跑缺少断点记录写页面断点文件5. 数据到手之后统计与扩展玩法5.1 用CSV数据做选考要求分布统计爬虫跑完手里攒下几千条高校专业记录单纯交给需求方还不够。我用pandas做了个快速统计看看选考科目要求大致是什么分布哪些专业几乎都要求物理哪些专业完全不限。import pandas as pd df pd.read_csv(xuangao_2020.csv, encodingutf-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加了采集日期列后来再做年度对比时直接按年份和采集日期两个维度筛选非常有用。