1. 任务拆解与项目背景
做大数据方向的项目,第一步往往不是写算法,也不是搭集群,而是解决最朴素的问题:数据从哪来。之前接了一个模拟项目X,主题是疫情防控数据分析,需要把某一阶段公开的疫情统计信息抓下来,整理成结构化数据,供后续的报表、可视化和趋势预测使用。说白了,这就是一个典型的“大数据—疫情防控项目数据爬取”任务,而“(一)”意味着这只是整个项目的第一个环节:先把数据管道打通。
这类任务在很多高校的数据科学课程、毕业设计里都出现过,甚至一些企业内部的公共卫生分析小组也会用同样的思路去做数据采集。它的核心价值不在爬虫本身,而在于让你体验一条完整的数据链路:从网页或接口中拿到原始数据,清洗成规范表格,再落到数据库里供下游分析使用。数据采集这步做扎实了,后面的分析才有意义,不然都是巧妇难为无米之炊。
这篇文章适合三类人看:第一类是正在做大数据课程设计、需要处理公开数据的学生;第二类是刚开始接触爬虫、想找一个真实业务场景练手的开发者;第三类是在工作中临时接到数据采集任务、需要快速上手的工程师。我会把从方案选型到环境搭建、从代码实现到问题排查的完整过程铺开讲,重点不是Copy一段代码,而是让你理解每一个环节为什么这样做,遇到问题怎么定位。
2. 数据源分析与合规前提
2.1 公开数据从哪找,怎么判断能不能爬
做疫情相关项目,首先要面对的是数据源问题。公开渠道其实不少:各级卫健委发布的统计通报、主流新闻媒体整理的数据页面、一些数据聚合平台的公开接口等等。但每次项目面临的数据源形态完全不一样,有的是一张静态HTML表格,有的是通过JavaScript异步加载的JSON接口,还有的是需要翻页的列表页。
我这次实际采用的是某公开数据平台(某平台)发布的全国疫情汇总数据,页面结构比较稳定,数据通过API接口返回JSON格式,更新频率是每天一次。选择它有几个考虑:数据字段规范、更新时间可预期、接口无需登录,而且覆盖了全国各省份的累计确诊、新增确诊、治愈、死亡等核心指标。这类数据源在技术难度上属于中等偏简单,非常适合作为爬虫项目的入门载体。
不过,拿到数据源之后不能急着写代码,先要确认“能不能爬”的问题。三个标准判断:
- 是否有公开接口或明显可访问的网页内容,而不是需要登录、扫码才能看到的数据
- 网站的robots协议和服务条款是否明确禁止自动化采集
- 数据是否属于个人隐私或涉及敏感信息,如果涉及,直接放弃
注意:爬虫合规是红线。即使技术上能爬到,不等于允许爬。做项目时优选官方公开、接口开放的数据源,控制请求频率,不要给对方服务器造成压力,也不要用于商业用途。
2.2 为什么优先选接口而非网页解析
疫情数据的展现方式通常有两种:一种是通过请求JSON接口拿到结构化数据,另一种是直接在HTML页面中嵌入表格。我强烈建议优先找接口,万不得已再解析HTML。
原因很简单:JSON是Python原生支持的格式,用json模块一解析就是字典或列表,直接转成DataFrame几乎零转换成本。而HTML需要先用BeautifulSoup或XPath定位节点,再处理表格结构中的嵌套、合并单元格、无用标签等问题,解析逻辑容易写、也容易碎。之前做过一个模拟项目,需要爬某市卫健委发布的每日通报,整页都是HTML文本,没有规矩的表格结构,结果光清洗非结构化的文本就花了大半天,后来发现官网其实有一个隐藏的数据接口,只需要带一个channelId参数就能拿到标准JSON。从那以后,我的习惯是:打开浏览器开发者工具,先看Network面板里的XHR请求,把接口找出来,再去考虑页面解析。
找到接口之后,还要确认返回的数据结构。有些接口直接返回完整JSON,有些返回的是加密过的字符串或者需要额外参数拼接。我这次遇到的接口返回的是标准JSON,字段包括更新日期、地区名称、确诊数据、治愈数据等,整体难度不高。但不同平台接口的字段定义经常不同,比如有的用confirmed表示确诊,有的用diagnosed,还有的用中文键名,这个在数据清洗阶段要统一处理。
2.3 请求策略:频率、头部信息、会话管理
爬虫不是一把梭地请求数据,特别是面对企业级数据平台时,频率控制很重要。我这次的数据源虽然维护得不错,但对付这种实时更新的平台,如果并发太高,容易触发风控,返回403或者直接封IP。实际操作中我采用了单线程 + 每次请求间隔1~2秒的策略,跑了大概两分钟就完成了全量数据采集,完全没必要上并发。
请求头信息同样不能裸奔。默认的requests请求头会被很多服务端识别为python-requests,轻则返回异常页面,重则直接拒绝。我一般会构造一组浏览器风格的头部信息:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Referer": "https://example-platform.com/data", "Accept-Language": "zh-CN,zh;q=0.9", }特别说明一下Referer这个字段,某些接口会校验来源页面,如果请求里没有Referer或者来源不对,服务端会判定为跨域请求直接拒绝。把浏览器Network面板里看到的请求头完整复制下来,再精简成必要的字段,是最稳妥的做法。
会话管理也值得一提。如果同一个流程需要先访问页面再请求接口,用requests.Session()统一管理Cookie。虽然我这次的数据源不需要登录,但Session可以帮我们维持连接复用,性能更好,也能避免一些因为Cookie缺失导致的会话丢失问题。
3. 环境搭建与基础方案选型
3.1 工具链选择:Python生态的经典组合
整个项目我用的工具组合是:requests负责网络请求,pandas负责数据清洗和表格化,schedule负责定时增量更新,MySQL作为最终的存储引擎。这套组合几乎是大数据数据采集环节的通用方案,任何一台装了Python的机器都能跑起来。
有一个问题是很多人会纠结的:要不要用Scrapy?我的判断是,如果只是完成一个数据量不大、逻辑简单的采集任务,直接用requests就够了。Scrapy的学习曲线比较长,配置文件、中间件、Item Pipeline这些概念一套下来,反而会分散对数据本身的关注。当然,如果项目是大型爬虫系统、需要分布式采集,那Scrapy肯定是更好的选择。但第一次做数据采集项目,先把基础链路跑通,比纠结框架更重要。
Selenium也没必要轻易上。有些人遇到页面取不到数据就开始上Selenium,实际上是没找到数据背后的接口。无头浏览器开销大、速度慢,而且反爬检测反而更容易触发。除非遇到真正的动态渲染页面、且找不到API接口的情况,否则不建议使用。
3.2 Python虚拟环境与依赖准备
创建虚拟环境是写Python项目的第一道保障,尤其是在不同的项目之间切换时,依赖版本冲突是常态。我的做法是:
python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install requests pandas pymysql schedule这里把schema触发点说一下,如果你用Anaconda环境,也可以用conda create,但本质上是一样的。虚拟环境的好处是,这个项目安装的依赖只在这个环境内生效,不会污染系统级的Python环境。
装完依赖之后,我先花几分钟验证一下能否正常访问数据源,避免把时间浪费在环境和网络问题上:
import requests url = "https://example-platform.com/api/v1/data" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = requests.get(url, headers=headers, timeout=10) print(resp.status_code) print(resp.text[:500])如果状态码是200且返回的是正常的JSON片段,说明链路通畅,可以继续往下走。如果返回的是403、418或者其他异常状态,需要回到请求头或请求频率上排查。这一步相当于“探路”,能把大多数环境配置问题一次性暴露出来。
3.3 为什么选择MySQL而不是CSV
有些同学图省事,爬完之后直接存个CSV文件就完事了。对一次性分析任务,CSV确实够用;但既然是“大数据项目”的前置环节,后面的分析、关联查询、统计汇总都会用到数据库,直接把数据落库才是正解。
MySQL在高校和企业里的广泛程度,决定了它的通用性比SQLite更好。而且MySQL支持标准的SQL语法,方便后续用SQL做聚合查询或者与可视化工具(比如某开源BI平台)对接。表结构上我设计得比较简单:
| 字段名 | 类型 | 说明 |
|---|---|---|
id | INT AUTO_INCREMENT PRIMARY KEY | 自增主键 |
province | VARCHAR(50) | 省份名称 |
confirm_date | DATE | 数据日期 |
confirmed | INT | 累计确诊 |
cured | INT | 累计治愈 |
deaths | INT | 累计死亡 |
created_at | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | 记录入库时间 |
用created_at字段记录每次数据的入库时间,这样即使同一天的数据被重复采集,我们也能通过时间戳区分版本,数据追溯时非常有用。
4. 数据采集与清洗的完整实现
4.1 接口请求与JSON解析实战
数据源摸清之后,就可以写正式的采集逻辑了。这一步我做了一个比较通用的封装,先把请求函数写好:
import requests import json import time session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", }) def fetch_data(url: str, params: dict = None): try: resp = session.get(url, params=params, timeout=15) resp.raise_for_status() return resp.json() except requests.exceptions.RequestException as e: print(f"[请求失败] {url} -> {e}") return None这里用了raise_for_status(),作用是当HTTP状态码不是200时主动抛出异常,方便我们及时发现问题,而不是默默拿一个非200的响应继续处理。超时时间设置成15秒,避免某个接口长时间无响应时程序卡死。
接下来是被很多人忽略的环节:拿到JSON后要做的不是急着一股脑转DataFrame,而是先观察数据结构。打印出JSON的前两层结构,搞清楚哪些字段是我们需要的、哪些字段是嵌套的:
{ "code": 200, "data": { "updateTime": "2024-11-18", "list": [ { "province": "北京市", "confirmed": 3500, "cured": 3400, "deaths": 50 }, ... ] } }拿到这个结构之后,定位到data.list,遍历提取字段,构造一个干净的记录列表。常见的坑是有些字段在部分省份不存在,比如边疆省份的某些统计数据可能缺失,直接用record["confirmed"]会抛KeyError。稳妥的写法是用record.get("confirmed", 0),缺省时填充为0。
data = fetch_data(api_url) if not data or data.get("code") != 200: raise RuntimeError("接口返回异常") records = [] for item in data["data"]["list"]: records.append({ "province": item.get("province", ""), "confirm_date": data["data"].get("updateTime", ""), "confirmed": item.get("confirmed", 0), "cured": item.get("cured", 0), "deaths": item.get("deaths", 0), })这一步的核心是“只取需要的字段”,不要把整块JSON直接塞进数据库。塞原始数据固然省事,但字段冗余会让下游分析变得很痛苦,而且一旦原始接口字段调整,整个存储结构都跟着变。提取和沉淀结构化数据,是大数据流程里最基础也最关键的思维方式。
4.2 数据清洗:从杂乱到规整
爬下来的原始数据很少是完美可用的。比如日期格式可能是2024-11-18 12:00:00,而我们的表结构期望的是2024-11-18;省份字段偶尔会有“北京市”和“北京”两种写法;数值字段偶尔会出现负数或者超大异常值。这些都需要在清洗阶段处理掉。
用pandas处理很方便:
import pandas as pd df = pd.DataFrame(records) # 日期标准化 df["confirm_date"] = pd.to_datetime(df["confirm_date"]).dt.date # 省份去空格、统一全半角 df["province"] = df["province"].str.strip().str.replace("省", "", regex=False) # 数值字段转int,非数值置为0 numerical_cols = ["confirmed", "cured", "deaths"] for col in numerical_cols: df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype(int) # 去重:同一省份同一天只保留一条 df = df.drop_duplicates(subset=["province", "confirm_date"], keep="last") # 异常值过滤:确诊数不可能为负 df = df[df["confirmed"] >= 0] print(df.head()) print(df.info())特别注意pd.to_datetime这个步骤。很多场景下,接口里的时间字段可能带有时区信息或者T分隔符,如果不统一格式,后续按日期分组时会出现意想不到的结果。清洗的目标就一句话:进入数据库的每一行数据,都严格符合表结构的类型定义和数据范围要求。
drop_duplicates(keep="last")也是一种增量更新的策略。后面如果一天内多次执行爬虫,同一个省份同一天可能出现多条记录,保留最后一次写入的记录,就能保证数据的时效性。
4.3 数据落库与增量更新机制
数据清洗完成后,就该写数据库了。我用的连接方式是pymysql,配合pandas的to_sql方法,一行代码实现批量写入:
from sqlalchemy import create_engine engine = create_engine("mysql+pymysql://user:password@localhost:3306/covid_db?charset=utf8mb4") df.to_sql("daily_data", con=engine, if_exists="append", index=False)if_exists="append"表示只追加、不覆盖。配合前面的去重逻辑,重复执行采集任务不会产生脏数据。但这里有一个潜在问题:如果当天采集了两次,drop_duplicates是在内存里做的,第一次的数据已经写进MySQL了,第二次的DataFrame去重后依然包含这条记录,再to_sql又会插入一遍。为了解决这个问题,我在入库前会先查询数据库中的最大日期,然后只把比最大日期新的数据追加进去:
import pandas as pd sql = "SELECT MAX(confirm_date) AS max_date FROM daily_data" max_date = pd.read_sql(sql, con=engine)["max_date"][0] if max_date and str(max_date) >= str(df["confirm_date"].max()): print("当天数据已存在,跳过入库") else: df.to_sql("daily_data", con=engine, if_exists="append", index=False)这就是增量更新的核心思路。对于这类每日更新的数据源,全量重爬再全量覆盖的效率很低,而且在数据量变大之后会越来越慢。增量更新既保证了新数据的及时入库,又避免了对历史数据的重复劳动。
5. 常见问题与排查技巧实录
5.1 状态码异常与页面结构变化
第一个典型问题是请求返回的状态码不是200。最常见的两种情况:返回403,多半是请求头被识别为爬虫,或请求频率太高;返回404,说明接口URL已经变更。403的排查思路是先检查User-Agent是否完整,Referer是否正确,然后适当降低请求频率。如果仍然403,考虑加Cookie或者更换IP,但对我们这个场景,我一般先自查请求头。
第二个典型问题是接口正常但页面结构变了。比如某天运行脚本,发现解析出来的DataFrame行数异常少。我遇到过一次是接口增加了分页参数,如果不带page=1,默认只返回第一页的数据,导致大量数据丢失。排查思路是打印返回JSON的总条数和实际解析条数做对比,不一致时立刻看接口文档或手动请求接口观察变化。
5.2 编码和时区问题
编码问题在爬虫任务里非常常见。首先是网页内容的编码,有些老站是GBK编码,直接用requests.get().text获取的结果会乱码。解决办法是resp.encoding = resp.apparent_encoding,或者直接在响应头上指定编码。其次,如果你用的MySQL表是latin1编码而数据里有中文,写入时会报错或者出现乱码,连接字符串里的charset=utf8mb4不能少。
时区问题则比较隐蔽。有的接口返回的时间是UTC时间,比北京时间慢了8个小时。如果直接用这个时间做分区,当天23点采集的数据会被归到前一天。稳妥的做法是在接口解析阶段就把时区统一转换:
from datetime import datetime, timezone, timedelta utc_time = datetime.strptime("2024-11-18T12:00:00Z", "%Y-%m-%dT%H:%M:%SZ") beijing_time = utc_time + timedelta(hours=8) date_str = beijing_time.date().isoformat()做大数据项目,时间字段的统一性是隐性要求,一旦出问题,后续所有基于时间的聚合都会出错,而且极难发现。
5.3 字段缺失和嵌套结构的解析技巧
第三种典型问题是字段缺失。比如某个省的数据在接口里直接不返回deaths字段,或者返回null。我在代码里用的item.get("deaths", 0)能够应对键不存在的情况,但应对null值时,pandas.to_numeric(errors="coerce")也会自动转换成NaN再补零。这样双保险之后,基本不会因为字段缺失导致程序崩溃。
嵌套结构解析也值得提一句。有些接口返回的不是平铺的列表,而是按照“地区 -> 城市 -> 区县”的层级嵌套。如果我们需要的是省级汇总,可以先遍历外层再从内层取值;如果需要的是明细级别,就要用一个深度遍历逻辑把最内层的数据拍平。写一个简单的递归函数可以通用解决:
def flatten_items(data_list, parent_province=None): result = [] for item in data_list: province = item.get("province") or parent_province if "children" in item: result.extend(flatten_items(item["children"], province)) else: result.append({ "province": province, "confirmed": item.get("confirmed", 0), "cured": item.get("cured", 0), "deaths": item.get("deaths", 0), }) return result这种递归思路在面对“树状结构”的数据源时特别好用,不只是疫情数据,比如部门层级、区域划分、商品类目等场景都能套用。
5.4 反爬机制与频率控制
还有一类问题是反爬策略。这两年主流的数据平台普遍强化了风控机制,高频请求触发后会出现验证码、接口返回加密数据,甚至直接封IP。我在这个项目里总结了一套比较稳妥的节奏:
- 请求间隔固定1.5秒,不规则添加少量随机延迟
- 每次请求前随机轮换几个浏览器User-Agent
- 实时观察响应时间,如果连续几次都超过5秒,主动暂停30秒再进行下一次请求
- 运行过程中出现503或429状态码,先停止请求,等待一段时间后从失败的位置续爬
很多同学认为反爬就是要突破对方的防护,这是个误区。对数据分析项目来说,我们的目标是拿数据,不是和网站对抗。规规矩矩地请求,被拒了停下来想想哪里违反了规则,比硬扛更重要。
6. 项目复盘与后续扩展建议
6.1 从爬虫到大数据链路的整体思考
这个项目虽然名为“数据爬取”,但实际上包含的是完整的大数据采集流程:数据探查、接口请求、JSON解析、数据清洗、增量入库。把这些环节串起来之后,爬虫不再是孤立的脚本,而是整个ETL流程中的Extract环节。
有一个细节是,我在做这个任务时特意把采集逻辑和清洗逻辑拆成两个模块。采集模块只负责把原始数据拿到并保存成中间格式,清洗模块再独立处理。这样设计的原因很简单:数据源一变化,只需要改采集模块;分析逻辑调整,只需要改清洗模块,改动面小,调试成本低。很多工程上的问题,恰恰是模块职责不清造成的。
另外一个容易被忽视的点是数据质量校验。在数据入库之后,最好加一段简单的验证逻辑:
check_sql = """ SELECT confirm_date, COUNT(*) AS total FROM daily_data GROUP BY confirm_date ORDER BY confirm_date DESC LIMIT 5; """ print(pd.read_sql(check_sql, con=engine))每天任务跑完后看一眼这个汇总,能很快判断数据量是否符合预期。比如正常情况下一天应该有31条省级数据,如果突然只有10条,一定有问题需要排查。数据质量不是事后验证出来的,而是靠流程设计保证出来的。
6.2 基于这个项目还能继续扩展什么
因为是系列文章的第一篇,后续其实还有很多方向可以伸展。最直接的扩展是时间序列分析:把每天采集的数据按省份、按时间做趋势图,观察新增确诊量的变化趋势;其次是关联分析,比如把疫情数据和其他公开数据(如某城市出行数据、某地区人员流动指数)做关联,探索相关关系;再往后可以做预测建模,用历史数据训练模型,预测未来一周的疫情发展态势。
这些方向都依赖第一篇搭建的数据采集管道。没有稳定、干净、可持续更新的数据源,再高级的分析和模型都是空中楼阁。这也是为什么我坚持在项目一开始就把数据管道做得规整,宁可多花一点时间在设计上,也不要草草把数据堆进CSV里完事。
6.3 基于项目实践的经验总结
回头来看,这个项目让我体会最深的一点是:真正的技术难点往往不在技术本身,而在对细节的敏感度。一条被忽略的编码字段可能导致整张表乱码;一个时区差异可能导致数据对不上日期;一个没处理的异常值可能导致后续模型训练出诡异结果。这些东西在教科书里很少提到,但遇到一次就会长记性。
如果你也在做类似的大数据采集项目,我的建议是:不要贪快,先把数据源从头到尾捋清楚,看清是接口还是页面、有没有反爬机制、数据更新频率是多少。然后做分层设计,采集、清洗、存储、校验各司其职。最后一定要留出充足的时间做问题排查,因为数据源在项目过程中发生变化几乎是必然的。
最后再分享一个小经验:把代码里的关键日志打印多加几条。很多爬虫脚本跑着跑着突然没有输出,你不知道是网络超时、解析失败还是数据库连接断开。我在关键节点加日志之后,排查问题的时间缩短了很多。比如请求前、拿到JSON后、写入数据库后各打一行,用带时间戳的格式输出,运行时通过日志基本能定位到具体卡在哪一步。这个习惯,用在大大小小的数据采集任务里都非常有价值。