
大一那年我接到过一个课程项目题目就叫“大数据—疫情防控项目数据爬取一”。当时第一反应是“这不是一晚上就能写完的作业吗”真正动手以后才发现这类“看起来很简单的爬虫任务”背后藏着大量和大数据沾边的脏活累活。疫情数据每天都在更新而且分散在公告、统计表、动态接口里如果不做规范化采集和清洗后续的分析、可视化、报告全部都是空谈。这篇文章就把这个项目从立项、选型到最终跑通的完整过程摊开来讲。内容主要覆盖如何挑选合适的数据源、如何定位动态页面背后的数据接口、怎么设计一张既能支撑每日更新又不至于频繁改结构的表以及爬虫跑起来之后的各种“体检”手段。适合两类人看一类是刚学爬虫想找个真实项目练手的学生另一类是想把数据采集做成长期可维护小系统的数据分析爱好者。这个项目我前后迭代了三版踩了不少坑写出来的每一条都来自实际运行不是那种“一行代码搞定”的空话。1. 项目立项从一句任务描述到完整技术方案1.1 这个项目为什么值得做疫情数据有一个特点它既高度结构化又散落在不同来源里。每天的累计确诊、新增、治愈、无症状感染者、中高风险地区名单如果要人工复制粘贴到表格里少说也要半小时更别说后面还要做趋势变化、地区对比、环比增量这些分析。由此引出整个项目的核心价值用程序把“每天重复的取数动作”自动化把数据沉淀成一份可持续更新的数据集为后续数据分析提供原料。我当时还发现一个很实际的问题很多平台在疫情过后会整理页面但原始接口并不一定永久开放。与其依赖别人提供的Excel导出不如自己每天把原始JSON快照存下来。哪怕将来平台调整了字段手里还有一份历史存档可以用来回放和比对。这也是“大数据”项目中数据资产积累的常见思路先有存量才能谈及增量处理。1.2 技术选型为什么没用Scrapy而是requests听到爬虫很多人第一反应是Scrapy。我之前也在几个小项目里用过Scrapy确实很强大。但这个项目我最终选的是requests pandas openpyxl这套组合理由有三个。第一数据源结构太规整了。疫情平台基本都有明确的数据接口返回的是标准JSON字段命名也规范这种情况下用Scrapy去搭一堆Spider、Middleware反而显得笨重。数据量不大每天几十条记录requests的轻量方式足够用。第二调试成本低。数据分析场景下绝大多数时间是花在清洗合并上的而不是爬虫本身。用requests可以直接在Jupyter Notebook里调试接口拿到的数据立刻丢进DataFrame里看结构整个思考链路是连续的。第三部署环境简单。只需要一台小服务器跑Python脚本配合系统cron或者任务管理器就能按时触发。Scrapy的部署通常要考虑各种依赖和扩展比较复杂。当然如果原始页面是复杂的层层跳转、需要模拟点击或者有强反爬策略那Scrapy仍然是好选择。每种工具都有自己的适用场景这里的关键是“够用且好维护”。下面这张表是我当时做的一个粗略对比方案优点缺点本次是否选择requests pandas轻量、调试直观、安装简单不适合复杂翻页与强反爬是Scrapy框架高并发、组件丰富、可扩展项目结构重、部署成本高否Selenium/Playwright能模拟真实浏览器、穿透部分反爬资源占用大、速度慢、维护难备用方案第三方封装库开箱即用字段不灵活、接口不稳定否1.3 整体流程规划与系列文章边界这个项目的完整链路可以分为四段数据采集、数据清洗、数据入库、定时更新。因为标题里写着“一”我这一篇主要把前两段讲透同时把定时更新的基础方案带出来。可视化、地区关联分析、预测建模这类内容放到系列后续再展开。关于数据清洗多说一句爬虫只是把数据拿到手能不能用还得看清洗。比如“新增”字段可能同时存在“当日新增”和“较上日新增”两种含义不同来源对“治愈”的口径也不一样有人算出院、有人算解除隔离。这些如果不在一开始就约定清楚后期的分析结论很容易被口径差异带偏。2. 数据源摸底与接口定位2.1 页面初探先看静态页面还是直接找接口我第一次拿到任务时先打开某公开数据平台的“全球新冠疫情数据”页面肉眼看到顶部有三张统计卡片累计确诊、累计死亡、累计治愈中间是每日新增数右侧是一个国家/地区列表下面还有一张趋势折线图。大多数人此时会打开浏览器开发者工具去Elements面板里翻DOM结构试图用XPath或CSS选择器去提取数字。这个做法不能说错但对动态加载页面来说效率太低而且页面结构一旦小幅改动选择器就全废了。正确的做法是先去Network面板里看XHR请求。我习惯的路径是打开开发者工具切到Network选项卡勾选Fetch/XHR筛选刷新页面观察弹出的请求列表点开可疑的JSON请求在Preview里看是否有页面上的数据确认后把请求URL复制出来单独用requests重放一遍检查是否有签名参数或时效性限制。实际操作中发现页面核心数据来自一个名为summary/latest.json的接口返回结构大致长这样{ status: 0, updateTime: 2023-05-01 10:20:30, data: { total: { confirmed: 73182391, deaths: 698214, cured: 64128301 }, today: { confirmed: 1702, deaths: 5, cured: 1240 } }, regionList: [ { regionName: 某州, confirmed: 5853210, deaths: 82103, cured: 5332401 } ], trend: [ { date: 2023-04-30, confirmed: 1002 } ] }这个JSON结构比我预想的还要规整直接决定了后续解析方案只需要requests.get然后按JSON里的key取值就行不需要任何网页解析库。2.2 字段确认别急着写代码先把所有可能性列出来拿到第一次接口结果后我没有立刻写爬虫而是花了两小时把页面上每一个显示出来的数字和接口字段做了对应。重点确认三件事累计值是否包含当日增量有的平台“累计确诊”是截止到昨天的数字今天的要等到凌晨出公告才更新也有平台实时更新。这两种口径不能混着用否则今天的数据和明天数据拼接时会有一个批次差。“今日新增”到底是新增确诊还是包含无症状感染者的混合数字如果接口只给一个total那后续做环比分析会有偏差。我最终选择了有明确分类的接口把“确诊”“无症状““疑似”分列存储。区域列表是扁平结构还是树形结构有些平台是省-市-区三层嵌套解析起来要递归。好在这个公开平台返回的是扁平列表字段里面有地区级别标识降低了不少工作量。我把所有字段都记录下来列出了一张字段规划表这也成为后面对接数据库时的建表依据。2.3 字段设计与数据表结构规划考虑到这是一个持续运行的数据采集项目而不是一次性跑批我决定不用临时文件来存数据而是建一张真正的数据表。字段设计如下字段名类型说明idINTEGER PK AUTOINCREMENT自增主键data_dateTEXT业务日期来自接口updateTime的日期部分snapshot_timeTEXT抓取快照时间程序写入时自动生成confirmed_totalINTEGER累计确诊deaths_totalINTEGER累计死亡cured_totalINTEGER累计治愈today_confirmedINTEGER当日新增确诊today_deathsINTEGER当日新增死亡today_curedINTEGER当日新增治愈raw_jsonTEXT完整原始JSON留作备份create_timeTEXT记录创建时间这里有几个地方值得解释。data_date是业务时间对应平台每天发布的数据日期snapshot_time是程序抓取的时间。这两个分开存是刻意为之平台可能因为网络延迟或公告发布时间变化导致某天的数据在22点才能拉到如果只存一个时间就没法区分“数据属于哪一天”和“我什么时候拉的”。raw_json字段也很关键哪怕后续接口改版导致字段映射断了还可以从原始JSON里恢复数据。3. 核心爬虫实现从请求到入库3.1 环境准备与依赖安装这个项目用到的依赖很少核心就三个requests、pandas、openpyxl。外加Python自带的sqlite3。我建议先建一个独立的虚拟环境不要污染全局环境。python -m venv venv source venv/bin/activate # Linux / macOS # 或者 venv\Scripts\activate # Windows pip install requests pandas openpyxl数据存储上我选的是SQLite而不是MySQL或CSV。原因很简单单文件、零配置、对后续教学演示也友好查询能力完全够用。后续如果要做成可视化大屏直接连接SQLite取数即可。3.2 基础请求函数把重复逻辑收敛成一个方法写爬虫的第一步先写一个带异常处理和时间戳参数的请求函数。别把请求逻辑散落在各个代码段里不然后面加统一请求头、加超时时间会改到崩溃。import requests import time import json HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://some-public-site.example/ } def fetch_json(url, retries3): for i in range(retries): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.json() else: print(f[attempt {i1}] 状态码异常: {resp.status_code}) except requests.RequestException as e: print(f[attempt {i1}] 请求失败: {e}) time.sleep(2 * (i 1)) # 退避等待 return None这个函数虽然小但有三个细节重试采用指数退避避免快速重试时仍撞上源站限流请求头里带了Referer很多数据接口会校验来源不带Referer直接请求虽然偶尔也能成功稳定性差很多设了timeout防止某个请求把整个任务挂死。3.3 数据解析与清洗规整字段处理空值拿到JSON以后解析逻辑不要太变形直接按映射取值即可。但要注意并不是每个字段每次请求都能取到值尤其是疫情数据平台在凌晨更新期间today模块可能整体缺失。def parse_payload(payload): if not payload or payload.get(status) ! 0: return None data payload.get(data, {}) total data.get(total, {}) today data.get(today, {}) or {} record { data_date: payload.get(updateTime, )[:10], snapshot_time: time.strftime(%Y-%m-%d %H:%M:%S), confirmed_total: total.get(confirmed), deaths_total: total.get(deaths), cured_total: total.get(cured), today_confirmed: today.get(confirmed), today_deaths: today.get(deaths), today_cured: today.get(cured), raw_json: json.dumps(payload, ensure_asciiFalse) } return record解析时最怕碰到None和空字符串后续SQL里写统计会很别扭。我的做法是统一在入库之前做一次“把None转成0或空串”的清洗但不直接改原JSON因为原JSON是原始凭证。清洗逻辑用pandas来处理比较直观import pandas as pd records [record] df pd.DataFrame(records) df df.fillna(0) df[data_date] pd.to_datetime(df[data_date]).dt.strftime(%Y-%m-%d)这一步做完DataFrame的结构就完全确定下来可以直接写入SQLite了。这里还有个判断是否值得入库的细节如果同一data_date已经存在快照且今天的值比已有值小通常是平台数据回修我会选择保留数值较大的一条同时把两条都记到日志里备查。这类数据回修问题在疫情这类实时播报型数据里非常常见。3.4 入库与去重策略入库用SQLite的INSERT OR REPLACE并给data_date和snapshot_time建一个唯一索引这样即使定时任务因为网络抖动重跑也不会产生重复记录。我建索引的SQL长这样CREATE TABLE IF NOT EXISTS covid_daily ( id INTEGER PRIMARY KEY AUTOINCREMENT, data_date TEXT, snapshot_time TEXT, confirmed_total INTEGER, deaths_total INTEGER, cured_total INTEGER, today_confirmed INTEGER, today_deaths INTEGER, today_cured INTEGER, raw_json TEXT, create_time TEXT, UNIQUE(data_date, snapshot_time) );写入时用pandas的to_sql或直接用原生sqlite3都行。第一次跑通的时候从请求到入库总共用了不到三分钟那一刻这个项目就算是真正“立住了”。3.5 动态渲染页面的备用方案如果某个数据源没有现成JSON接口只能在页面里看到数字怎么办我当时准备了一条备用方案用Playwright渲染页面后提取文本。这里不展开讲代码因为整个系列的主线是API型爬虫。只提一个关键教训用浏览器渲染方案时别等整个页面全部加载完再操作正确姿势是监听某个网络事件或者等待目标元素出现否则速度会很慢而且容易把无关资源也加载下来。4. 定时调度、日志与连续运行保障4.1 定时任务方案选择与触达时间设计项目如果只跑一次那不算完整必须让它每天自动更新。Linux环境下首选cronWindows环境下可以用任务计划程序。但选cron之前先要解决一个问题几点跑最合适疫情数据的更新节奏一般是每天早上8点到10点之间有些平台会在中午回修数据。我参考了几个数据源的更新日志最终把采集时间定在每天的11:05和20:05两个节点。11:05跑一次拿到当日最新数据20:05再跑一次更新可能发生的回修。两个时间点都刻意避开了整点原因是整点时会有大量定时任务同时触发服务器的瞬时负载偏高接口响应也会变慢。cron表达式类似这样5 11 * * * cd /path/to/project python main.py 5 20 * * * cd /path/to/project python main.py4.2 日志记录与数据一致性体检跑定时任务最怕“静默失败”数据库没有新数据程序日志也没写任何东西。为此我加了三层防护每次请求都记录日志包括尝试次数、耗时、状态码每天抓取后自动执行体检SQL统计当天是否已有数据、和前一条记录对比增量是否合理如果当天两次抓取都失败了触发第三层发送告警消息到自己的群机器人。体检SQL的核心逻辑是查出最近三天的累计确诊数并用前一天做差值计算当日新增。如果接口返回的today本来就等于0而体检算出来差值不为0说明数据不一致需要人工介入。def daily_check(conn): rows conn.execute( SELECT data_date, confirmed_total FROM covid_daily WHERE snapshot_time ( SELECT MAX(snapshot_time) FROM covid_daily ) ORDER BY data_date DESC LIMIT 3 ).fetchall() if len(rows) 2: return False for i in range(len(rows) - 1): diff rows[i][1] - rows[i 1][1] if diff 0: return False return True这个函数不检查绝对值是否合理只检查“累计值不回退”。因为疫情数据的累计确诊数只增不减一旦出现负的差值说明数据源口径变化或者抓错了字段。4.3 数据量增长后的归档意识运行一段时间后SQLite文件会越来越大但表本身只有几千行谈不上压力。真正的压力来自raw_json字段每条原始消息几千字符累积一年也就几十MB问题不大。但如果后续要分析更细粒度的数据比如按国家、按省份拆分单表设计就不太够用了需要考虑分区表或者单独建明细表。我把这个话题留到系列后续这里先提醒一句建表时预留region字段哪怕现在用不到也不至于后面要加时还得重新导数据。5. 常见故障与排查实录5.1 请求被限流与临时封禁第一次连续跑了两天第三天早上发现某次请求返回了403。排查日志发现原因很简单我的定时任务设置了每10分钟跑一次而源站对单IP每分钟的请求数做了限制。解决方案有两个一个是拉长间隔把两次采集的最小间隔控制在30分钟以上另一个是为每个请求加随机User-Agent从常见浏览器UA池里随机选一个。实测下来这两招组合后连续两个月没有再触发过限流。遇到403或者429千万别反复重试。最稳妥的做法是“退避切换备用接口”。我当时准备了一个备用接口主接口挂了以后会自动切换。5.2 站点改版与字段漂移跑了一个半月后平台低调地调整了接口返回结构把原来的cured字段改名成了healed同时增加了一个countryType字段。如果爬虫代码是硬编码写死的这时候肯定直接报错。好在我在解析前加了一个“字段预期检查”发现预期的字段缺失时不会直接抛异常而是先把原始JSON写入一个broken/目录再在日志里打上WARN级别提示。这个设计帮我省了很多事。因为脚本没有中断仍然把每天的数据快照存了下来我只需要在下一次版本更新时补上新的字段映射历史数据也没有丢失。做长期爬虫项目一定要有“未来字段会变”的预期而不是假设接口永远稳定。5.3 脏数据与空值的边界处理除了缺字段还有一类问题更隐蔽接口返回了数据但值明显不合理。比如某天区域列表里出现了一条confirmed为负数的记录或者today_confirmed突然比昨天翻了十倍。针对这类情况我加了一个简单规则任何负值直接置为None并记录原始JSON当前一天为0而当天突然暴增超过某阈值时不阻断采集但在日志里标红提示人工复核。数据清洗的原则是宁可多留原始证据也不要轻易丢弃或者修改。因为清洗规则可能会出错但原始JSON不会说谎。5.4 排查故障时我常用的SOP把遇到的所有问题归结成一套排查流程我会按这个顺序来先看当日日志确认是请求阶段失败还是解析阶段报错用curl手动重放一次请求确认接口是否仍然存活比较当前返回的JSON结构和最早记录里备份的JSON结构看字段是否有变化检查数据库里最近一次快照时间判断数据是否新鲜若以上全没问题检查服务器时间和时区设置是否正确。其中第五点看着好笑但真的遇到过服务器时区没设对导致每天定时任务总是在晚上7点才触发数据虽然入库了但snapshot_time记录的是UTC时间和业务日期差了一天后面做趋势分析时发现日期对不上。6. 写在第一阶段末尾的几句经验这个项目的第一阶段我从任务描述里提炼出可执行的技术方案又把方案落地成了一天两跑、自动入库、数据体检的完整小系统。整个过程最值钱的不是那一百多行代码而是对“口径”这两个字的理解疫情数据也好其他业务数据也好先搞清楚每个字段的计算口径再开始动手能帮你省掉后面至少两周返工时间。另一个体会是爬虫项目做的时间越长越会发现“稳定”比“功能多”重要。一个每天定时稳定跑一年的简单脚本价值远大于一个功能花哨但半年后就因为站点改版而瘫痪的复杂系统。我后来对这个项目的所有改动都是围绕“如何在数据源变化时保持稳定”来做的。下一篇我会重点讲地区级数据的拆解与可视化部分包括如何把扁平接口转成省市级联数据、如何设计适合地图展示的聚合表以及用哪类图表最能反映趋势变化。如果你也在跑类似的数据采集项目建议先把这一篇里的字段设计、原始JSON备份和体检逻辑搭好后面做分析时会顺手很多。