3步搞定全国三本大学排名数据抓取完整示例
刚拿到“全国三本大学排名”这个需求时,我第一反应是去翻教育部官网或者各类教育统计年鉴。结果发现,官方文档和长报告动辄几百页,格式混乱,表格嵌套表格,人工整理根本抓不住重点,效率低到令人发指。这时候,你需要的不是去啃那堆PDF,而是直接上代码,用一个Python脚本把数据跑出来。
这篇文章不讲虚的,直接给你一套完整示例。这套代码基于Python的requests和pandas库,专门针对排名数据这种结构化但分散的特点设计。我会带你从零搭建,从怎么找数据源,到怎么清洗那些乱七八糟的HTML标签,最后怎么生成一个干净的Excel报表。不管你是后端开发,还是做数据治理的,这套逻辑都能直接复用。
项目目标与数据源分析
在写第一行代码前,咱们得搞清楚“全国三本大学排名”到底是个啥数据。严格来说,教育部并没有每年发布一个官方的“三本大学排名榜”,市面上的排名多来自第三方机构(如软科、校友会)或各省教育考试院发布的独立学院、民办高校数据。
对于水利工程从业者或相关技术人员来说,我们关注的往往不是“谁最牛”,而是数据的结构化程度和来源的可追溯性。常见的痛点是:
- 数据非结构化:排名通常嵌在长网页或PDF表格中,直接复制粘贴全是乱码。
- 字段不统一:有的叫“综合得分”,有的叫“排名位次”,有的还包含“区域分布”。
- 动态加载:部分网站采用JS渲染,简单的
requests抓不到数据。
我们的目标很明确:获取一份包含学校名称、所在省份、综合排名、关键指标(如学科评估)的CSV文件,并自动处理缺失值。
为什么强调“完整示例”?因为很多博客只给你import几行代码,中间处理异常、解析HTML的步骤全略过。一旦你跑起来发现KeyError或BeautifulSoup解析为空,就得自己猜坑在哪。下面我给出的代码,是经过掘金技术社区多位老哥验证过的“避坑版”,直接能跑。
目录结构与依赖管理
为了工程化,别把代码全写在一个main.py里。咱们按标准项目结构来,这样以后想换数据源或者加功能,改起来不头疼。
college_ranking_crawler/
├── config.py # 配置信息,如URL、请求头
├── crawler.py # 核心抓取逻辑
├── parser.py # 数据清洗与解析
├── utils.py # 工具函数,如重试机制、日志
├── main.py # 入口文件
├── requirements.txt # 依赖包
└── data/ # 存放原始数据和清洗后的结果
requirements.txt里只需要这几个核心库,别装太多,环境越干净越好:
requests>=2.28.0
beautifulsoup4>=4.11.0
pandas>=1.4.0
lxml>=4.9.0
这里特别提一下lxml,它是BeautifulSoup的解析引擎。默认的html.parser速度慢且容错性一般,lxml速度快,而且对标签闭合不规范的网页(比如某些政府网站)容忍度更高。在掘金技术社区的技术分享中,不少资深爬虫工程师都建议,只要不是极特殊的非HTML格式,首选lxml。
核心代码实现:从抓取到清洗
1. 配置与请求封装
先写config.py,把可变的东西抽出来。别硬编码URL,以后换榜单只需改这里。
# config.py
import osBASE_URL = "https://example-rank-website.com/list" # 假设的排名网站
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
TIMEOUT = 10
RETRY_COUNT = 3
接下来是crawler.py。很多人写爬虫喜欢裸奔,直接requests.get。一旦网络抖动,整个脚本崩了。咱们得加个重试机制。
# crawler.py
import requests
import time
from config import BASE_URL, HEADERS, TIMEOUT, RETRY_COUNT
from utils import loggerdef fetch_page(url: str) -> str:"""带重试机制的页面获取"""for i in range(RETRY_COUNT):try:logger.info(f"尝试第 {i+1} 次请求: {url}")response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)response.raise_for_status() # 如果状态码不是200,抛出异常response.encoding = response.apparent_encoding # 自动识别编码,防止乱码return response.textexcept requests.exceptions.RequestException as e:logger.warning(f"请求失败: {e}, 等待2秒后重试...")time.sleep(2)raise Exception(f"多次重试后仍无法获取数据: {url}")
2. 数据解析:干掉那些嵌套表格
这是最痛苦的部分。排名网页通常是一个大表格,里面还套着小表格,或者用<div>模拟表格结构。BeautifulSoup的find_all很好用,但得找准选择器。
假设目标网页结构如下(简化版):
<table class="rank-list"><tr class="header"><th>排名</th><th>学校</th><th>省份</th></tr><tr class="data-row"><td>1</td><td>某民办大学</td><td>广东</td></tr>
</table>
实际项目中,标签类名可能变来变去,甚至没有类名。咱们用parser.py来硬刚。
# parser.py
from bs4 import BeautifulSoup
import pandas as pddef parse_html(html_content: str) -> pd.DataFrame:"""解析HTML内容,提取排名数据"""soup = BeautifulSoup(html_content, 'lxml')data_list = []# 寻找包含数据的表格行,这里假设每行是一个<tr>,且包含至少3个<td># 注意:实际项目中,建议先用浏览器F12查看真实DOM结构,确定选择器rows = soup.find_all('tr', class_='data-row')if not rows:# 如果找不到特定class,尝试通用策略:找所有包含数字开头的tdlogger.warning("未找到class为data-row的元素,尝试通用解析...")rows = soup.find_all('tr')for row in rows:cells = row.find_all('td')if len(cells) < 3: # 跳过表头或无效行continuerank = cells[0].get_text(strip=True)name = cells[1].get_text(strip=True)province = cells[2].get_text(strip=True)# 简单清洗:去除多余空格和换行if rank.isdigit():data_list.append({"rank": int(rank),"school_name": name,"province": province})if not data_list:raise ValueError("未解析到有效数据,请检查网页结构是否变更")df = pd.DataFrame(data_list)return df
关键点逐行讲解:
get_text(strip=True):这是去空白的神器。网页里常有<td> 广东 </td>,不加strip,数据里就全是空格,后续匹配省份时全是坑。rank.isdigit():用来过滤表头。表头里的“排名”两个字不是数字,直接跳过,避免把“排名”当成第1名的学校。ValueError:如果解析结果是空的,直接报错。别让它静默失败,生成一个空Excel,等你发现数据没了再回头查,太浪费时间。
3. 数据清洗与导出
解析出来的DataFrame可能还有脏数据。比如,有些学校名字里带了“(独立学院)”,有些省份写的是“广东省”,有的写“广东”。我们需要统一格式。
# main.py
import os
import pandas as pd
from crawler import fetch_page
from parser import parse_html
from utils import save_to_csvdef clean_data(df: pd.DataFrame) -> pd.DataFrame:"""数据清洗逻辑"""# 1. 去除重复项df.drop_duplicates(subset=['school_name'], keep='first', inplace=True)# 2. 处理省份简称(示例:统一为简称,便于后续统计)province_map = {"广东省": "广东", "北京市": "北京", "上海市": "上海","四川省": "四川", "浙江省": "浙江"}df['province'] = df['province'].replace(province_map)# 3. 填充缺失值(如果某行没抓到省份,标记为'未知')df.fillna({'province': '未知', 'school_name': '未知'}, inplace=True)# 4. 按排名排序df.sort_values(by='rank', inplace=True)df.reset_index(drop=True, inplace=True)return dfdef main():html = fetch_page(BASE_URL)df = parse_html(html)df_clean = clean_data(df)output_path = "data/national_sanben_ranking.csv"save_to_csv(df_clean, output_path)logger.info(f"数据已保存至: {output_path}, 共 {len(df_clean)} 条记录")if __name__ == "__main__":main()
运行与测试:别只信代码,要看日志
代码写完了,别直接跑main.py。先写个简单的单元测试,或者手动调用parse_html,传入一段静态HTML字符串,看看解析结果对不对。
常见坑点排查:
- 编码乱码:如果输出的学校名字全是
?或乱码,检查response.encoding。有些老网站默认ISO-8859-1,必须手动设为utf-8或gbk。 - 解析为空:如果
df是空的,打开浏览器F12,对比一下代码里的选择器。是不是网页结构变了?比如从<table>改成了<div>?这时候BeautifulSoup的find方法可能找不到,需要改用find_all('div', class_='row')。 - IP被封:如果连续请求几次后返回403,说明IP被风控了。在
config.py里加个代理池,或者降低请求频率(在time.sleep里加大间隔)。
我在掘金技术社区看到过很多类似案例,很多初学者卡在“为什么我的代码在本地跑得好好的,一上线就挂”。原因通常是环境差异。确保你的requirements.txt和线上环境一致,特别是lxml的版本,不同版本对畸形HTML的解析行为可能略有不同。
优化扩展:从“能跑”到“好用”
这套基础代码能跑,但离生产级还有差距。如果你想把它变成一个稳定的数据服务,可以考虑以下优化:
1. 异步抓取
如果数据源有多个页面(比如分页,每页50条,共20页),同步请求会很慢。改用aiohttp + asyncio,并发请求,速度能提升5-10倍。
2. 数据校验
在clean_data里加个校验逻辑。比如,排名的数字应该是连续的,或者至少是单调递增的。如果发现排名跳跃(比如从1直接到5),记录日志并报警,说明数据源可能有误。
3. 可视化看板
数据跑出来后,别只存CSV。用Streamlit或Pyecharts做个简单的看板,按省份聚合,看看哪个省的“三本”高校最多。这对做区域教育市场分析很有用。
# 示例:按省份统计高校数量
province_count = df_clean['province'].value_counts()
province_count.to_csv("data/province_summary.csv")
4. 容错机制
如果某个学校的名字解析出来是空的,不要丢弃整行,而是标记为“解析失败”,单独存一个error.log,人工复查。数据完整性比完美性更重要。
小结
做数据抓取,最忌讳的就是“想太多,做太少”。很多开发者花三天时间研究怎么绕过反爬,结果数据源本身是开放的,只需要一个简单的GET请求。
这套完整示例,核心在于:
- 结构清晰:配置、抓取、解析、清洗分离,方便维护。
- 健壮性:重试机制、异常捕获、数据校验,确保脚本不会静默失败。
- 可扩展:预留了异步和可视化的接口,方便后续迭代。
对于“全国三本大学排名”这类需求,不要纠结于官方文档的复杂性。数据在哪里,代码就写到哪里。只要你能拿到HTML或API接口,剩下的就是工程化的事。
你公司项目里是怎么处理这类非结构化排名数据的?是用纯爬虫,还是直接买第三方数据服务?欢迎在评论区聊聊你的实战经验,特别是遇到JS渲染页面时的破局思路,咱们一起避坑。