简介这份资源是面向Python爬虫学习者与毕业设计选题学生的Scrapy分布式爬虫实战项目聚焦全国历史天气数据的大规模抓取与存储。项目基于Twisted异步网络库涵盖Spiders、Item、Item Pipeline、Downloader Middleware等核心组件并借助Scrapy Cluster或ScrapyRT等扩展将任务分散到多台机器提升抓取速度与稳定性同时降低对目标站点的访问压力。爬取内容涉及日期、温度、湿度、风向风力等字段的解析与提取数据经Pipeline清洗、验证、格式化后存入MySQL、MongoDB或CSV、JSON文件。压缩包共9个文件以7个py源码为主辅以1个md说明文档和1个gitignore配置整体约8KB已在Windows 10/11环境测试通过并附有示例图片与部署教程。目前已有232人学习适合希望掌握分布式爬虫架构、数据存储设计及真实环境部署的开发者参考借鉴。1. 全国历史天气数据怎么拿从单机 scrapy 到分布式爬虫的落地判断做气象数据分析、农业模型训练或者保险精算的朋友多半绕不开一个需求拿到全国几百个站点、过去十几年的逐日历史天气。单机 scrapy 跑一遍不是不行但站点多、年份长、请求量大单进程串行抓取往往要跑上十几个小时中途还可能因为网络抖动或反爬策略中断。这时候“分布式爬虫”就成了一个很自然的选择——把请求队列抽出来放到 Redis让多台机器或多个进程共享同一个调度器谁空闲谁领任务整体吞吐能翻好几倍。这个方案适合两类人一是已经写过基础 scrapy 爬虫、想把它改造成多机协作的工程师二是手里有历史天气数据需求、愿意花半天搭一套可复用采集管道的开发者。它解决的核心问题不是“能不能抓到”而是“怎么在可控时间内稳定抓完、断点续跑、不重复不遗漏”。下面我会按选型、改造、调度、避坑、验证的顺序把这条链路拆开讲清楚。2. 分布式 scrapy 的骨架Redis 队列、去重指纹与调度器改造2.1 为什么选 scrapy-redis 而不是自己写队列scrapy 原生的调度器是内存级的爬虫一停队列就没了。要做分布式核心是把两个东西外置请求队列queue和去重集合dupefilter。常见做法是用 scrapy-redis 这个组件它把调度器和去重器都换成 Redis 实现多个爬虫实例连同一个 Redis就自动共享任务。选它的理由很直接改动量小只需要在 settings 里换几行配置再让 Spider 继承 RedisSpider 而不是 scrapy.Spider。自己用 kombu 或 rabbitmq 写一套调度器当然也行但你要处理序列化、去重一致性、断线重连工作量不成比例。对于历史天气这种“URL 可枚举、结构规整”的场景scrapy-redis 足够。需要提前想清楚的是Redis 里的队列是持久化的但如果你用spop取任务进程崩溃时正在处理的那条会丢。scrapy-redis 默认用的是zpopmin配合一个正在处理的集合相对安全但也不是严格意义上的 ACK 机制。对天气数据这种允许少量重试的场景够用对金融级去重就不合适了。2.2 改造一个单机天气爬虫的最小步骤假设你原来有一个单机爬虫从某个天气网站按“城市月份”拼 URL 抓取。改造成分布式第一步是安装依赖并配置 Redis 连接。pip install scrapy scrapy-redis redis然后在settings.py里替换调度器和去重类# settings.py # 启用 scrapy-redis 调度器请求队列存到 Redis SCHEDULER scrapy_redis.scheduler.Scheduler # 去重指纹也存 Redis多个实例共享同一份指纹集合 DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter # 允许暂停/恢复爬虫重启后队列不丢 SCHEDULER_PERSIST True # Redis 地址多机部署时改成内网 IP REDIS_URL redis://127.0.0.1:6379/0 # 可选给队列和指纹加前缀避免多个项目互相污染 REDIS_KEY weather:history参数说明SCHEDULER_PERSISTTrue是关键它决定爬虫停止后 Redis 里的队列是否保留。调试阶段可以设 False方便清空重来生产环境一定设 True否则断点续跑无从谈起。REDIS_KEY建议按项目命名我见过有人多个爬虫共用一个 db0结果指纹串了A 项目的 URL 被 B 项目判重排查了半天。接着改 Spider让它从 Redis 读起始 URLfrom scrapy_redis.spiders import RedisSpider class WeatherSpider(RedisSpider): name weather # 不再写 start_urls改为从 Redis 的 weather:start_urls 读取 redis_key weather:start_urls def parse(self, response): # 解析逻辑保持原样 for row in response.css(table.history tr): yield { city: response.meta[city], date: row.css(td:nth-child(1)::text).get(), temp_high: row.css(td:nth-child(2)::text).get(), temp_low: row.css(td:nth-child(3)::text).get(), }逻辑说明redis_key是 scrapy-redis 约定的起始队列名。你不需要在代码里写start_requests爬虫启动后会阻塞等待这个 key 有数据。往 Redis 里推 URL 用lpushredis-cli lpush weather:start_urls https://example.com/history/beijing/202301每推一条任意一个空闲的爬虫实例就会领走。多台机器同时启动同一个 Spider它们会自动分摊队列里的任务这就是分布式的核心机制。2.3 请求指纹与增量抓取别让重复请求吃掉你的带宽历史天气数据有个特点同一个城市不同年份的 URL 结构高度相似如果你在生成 URL 时不小心把月份参数拼错很容易产生大量重复请求。scrapy-redis 的指纹默认基于请求方法、URL、body 做 sha1重复的 URL 会被直接过滤掉不会进入队列。但要注意指纹是在请求入队时计算的如果你先往 Redis 里推了 10 万条 URL其中 3 万条重复这 3 万条在入队时就已经被去重了不会占用队列空间。真正需要担心的是“逻辑重复”——比如北京 2023 年 1 月的数据你从两个不同的入口页面都能点到URL 却不一样。这种情况指纹拦不住得在解析层做二次判断比如把(city, year, month)作为唯一键存到 Redis 的 Set 里解析前先查一下。我一般会在 pipeline 里加一个轻量去重import redis class DedupPipeline: def __init__(self): self.r redis.Redis(host127.0.0.1, port6379, db1) def process_item(self, item, spider): key fseen:{item[city]}:{item[date]} # setnx 返回 1 表示新数据返回 0 表示已存在 if self.r.setnx(key, 1): return item else: raise DropItem(fduplicate: {key})参数说明这里用了独立的 db1和调度队列的 db0 分开避免keys *时互相干扰。setnx是原子操作多实例并发也不会误判。缺点是 Redis 内存会随数据量增长全国站点十几年的逐日数据大概几百万条每条 key 几十字节内存占用在几百 MB 级别可以接受。如果数据量再大一个数量级就得考虑用布隆过滤器或者定期清理。3. 全国站点与年份的 URL 编排把任务拆成可并行的最小单元3.1 按“城市-年份”切片而不是按“城市”整包很多人第一次做分布式习惯按城市切任务一个城市一个 URL爬虫进去翻页。这种切法的问题在于任务粒度太粗一个城市十几年的数据可能几百页某个实例领到这个任务后要跑很久其他实例干等着负载不均。更合理的做法是按“城市-年份”甚至“城市-月份”切片。全国地级市约 300 个每个城市 10 年就是 3000 个任务单元如果按月份切就是 36000 个。任务单元越多分布式调度的优势越明显单个任务失败的影响面也越小。生成 URL 列表的脚本可以这样写import redis r redis.Redis(host127.0.0.1, port6379, db0) cities [beijing, shanghai, guangzhou, shenzhen] # 实际替换为完整城市列表 years range(2014, 2024) count 0 for city in cities: for year in years: for month in range(1, 13): url fhttps://example.com/history/{city}/{year}{month:02d} r.lpush(weather:start_urls, url) count 1 print(fpushed {count} urls)逻辑说明lpush是往队列左侧推scrapy-redis 默认从右侧取rpop所以先推的会先被消费顺序可控。如果你希望打乱顺序避免对同一站点集中请求可以用rpush或者推之前 shuffle 一下城市列表。参数上years的范围根据你的数据需求调整注意目标网站可能只保留最近 N 年的数据推之前最好先手动验证一两个 URL 是否有效。3.2 起始 URL 的验证与失败重推往 Redis 推了几万条 URL 之后最怕的是里面混了一批 404 或者结构错误的链接。这些链接被爬虫领走后要么返回空数据要么触发重试白白消耗调度资源。我的习惯是推之前先抽样验证。写一个小脚本从生成的 URL 里随机抽 20 条用 requests 快速请求一遍看状态码和页面结构import random import requests urls [fhttps://example.com/history/beijing/{y}{m:02d} for y in range(2014, 2024) for m in range(1, 13)] sample random.sample(urls, 20) for url in sample: try: resp requests.get(url, timeout10) # 检查状态码和关键元素是否存在 ok resp.status_code 200 and 历史天气 in resp.text print(f{url} - {resp.status_code} {OK if ok else CHECK}) except Exception as e: print(f{url} - ERROR {e})参数说明timeout10是必须的否则一个慢响应会卡住整个验证脚本。历史天气 in resp.text这种检查比较粗糙更稳妥的是用 parsel 或 lxml 解析后判断目标表格是否存在。如果抽样里有超过 10% 的 URL 有问题说明你的 URL 模板需要调整别急着全量推。对于已经推入 Redis 但发现是坏链接的情况scrapy-redis 没有提供“按 URL 删除”的便捷方法因为队列是 list 结构。常见做法是停掉爬虫用lrange导出全部 URL过滤后再重新lpush。所以推之前验证这一步省的是后面的麻烦。3.3 并发数与下载延迟的平衡分布式环境下每个实例的CONCURRENT_REQUESTS和DOWNLOAD_DELAY需要重新算。单机时你可能设CONCURRENT_REQUESTS16、DOWNLOAD_DELAY1现在 5 台机器同时跑对目标站点的总并发就是 80延迟等效降到 0.2 秒。很多天气网站是中小型站点扛不住这个压力轻则限流重则封 IP。我的经验是先确认目标站点的承受能力。如果对方有 CDN 或者明确允许批量访问总并发控制在 20 以内比较安全如果是小站总并发别超过 5。换算到每个实例就是CONCURRENT_REQUESTS除以实例数。同时开启AUTOTHROTTLE让 scrapy 根据响应时间自动调整# settings.py CONCURRENT_REQUESTS 4 # 单实例并发5 台机器总计 20 DOWNLOAD_DELAY 0.5 # 基础延迟 AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 0.5 AUTOTHROTTLE_MAX_DELAY 10 AUTOTHROTTLE_TARGET_CONCURRENCY 2.0参数说明AUTOTHROTTLE_TARGET_CONCURRENCY是每个实例希望维持的并发数设成 2.0 意味着 scrapy 会动态调整延迟让实际并发趋近这个值。AUTOTHROTTLE_MAX_DELAY10是上限防止某个慢响应把延迟拉到不可接受的程度。这套配置在 5 台机器上跑对目标站点的压力大约相当于单机 10 并发比较温和。4. 分布式爬虫避坑从 Redis 连接池到数据落库的 5 个翻车现场4.1 现象爬虫启动后一直卡在等待Redis 里明明有 URL原因最常见的是redis_key写错或者推 URL 时用的 key 和 Spider 里声明的不一致。scrapy-redis 启动后会blpop这个 key如果 key 不存在它会一直阻塞等待日志里只显示 “Waiting for start urls”。另一个可能是 Redis 的 db 选错了推到了 db0爬虫连的是 db1。解决先用redis-cli keys *start_urls*确认 key 存在再用llen看队列长度。然后检查 Spider 里的redis_key和 settings 里的REDIS_URL是否匹配。如果都正确看 Redis 是否设置了密码而 scrapy 没配REDIS_PARAMS。4.2 现象多个实例同时抓同一个 URL数据重复入库原因去重指纹默认基于请求的 URL 和方法但如果你的 Spider 在start_requests里动态生成了带随机参数的 URL或者用了 POST 请求且 body 里有时间戳指纹就会不同导致重复请求。另一个可能是多个实例用了不同的DUPEFILTER_CLASS配置有的走本地去重有的走 Redis。解决统一所有实例的 settings确保DUPEFILTER_CLASS都是RFPDupeFilter。检查 URL 生成逻辑去掉无意义的随机参数。如果确实需要带时间戳把时间戳精度降到天或者小时并在 pipeline 里做业务层去重兜底。4.3 现象爬虫跑着跑着 Redis 内存暴涨最后 OOM原因去重指纹集合和业务去重集合都在 Redis 里数据量大了之后内存持续增长。尤其是RFPDupeFilter的 key 默认没有过期时间跑完也不清理。另外如果用了SCHEDULER_PERSISTTrue队列里的请求也会一直占内存。解决给指纹 key 设置过期时间比如 7 天通过REDIS_PARAMS传expire参数不太方便更直接的是在爬虫结束后手动expire或者用定时任务清理。业务去重集合如果不需要长期保留也可以设 TTL。如果数据量确实大考虑用布隆过滤器替代 Set内存占用能降一个数量级。4.4 现象部分实例报 “Response never received” 或连接超时原因分布式环境下网络抖动更频繁尤其是多台机器跨机房时。scrapy 默认的重试策略可能不够用或者DOWNLOAD_TIMEOUT设得太短。解决调大DOWNLOAD_TIMEOUT到 30 秒开启RETRY_ENABLED并设置RETRY_TIMES3。如果目标站点有 IP 限流考虑在中间加一层代理池但要注意代理的稳定性不稳定的代理反而会拉低整体效率。另外检查 Redis 连接是否稳定REDIS_PARAMS里可以设socket_timeout和socket_connect_timeout。4.5 现象数据抓完了但入库时发现字段错位或编码乱码原因历史天气页面通常是表格结构不同年份的页面模板可能有细微差异比如列的顺序变了或者表头多了“空气质量”一列。如果你的解析逻辑写死了td:nth-child(2)遇到模板变化就会取错字段。编码问题则多半是响应头里的 charset 和实际内容不一致。解决解析时不要依赖列的位置而是先解析表头建立“列名→索引”的映射再按列名取值。编码方面在 Spider 里显式指定response.encoding utf-8或者用response.text让 scrapy 自动推断。入库前加一层校验比如温度字段必须是数字日期字段必须能解析成日期不符合的记录下来人工排查。5. 验证分布式抓取是否真的生效三个可量化的检查点5.1 用 Redis 监控命令看队列消费速率爬虫跑起来后最直观的验证方式是看 Redis 里队列长度的变化。开一个终端每隔几秒执行一次watch -n 5 redis-cli llen weather:start_urls redis-cli scard weather:dupefilterllen是剩余待抓 URL 数scard是已去重指纹数。如果llen在稳定下降说明消费正常如果长时间不变说明爬虫卡住了或者队列空了。scard的增长速率可以反推抓取速度比如 5 秒涨了 50就是每秒 10 个请求。多实例环境下这个速率应该是单实例的数倍如果没差别说明分布式没生效可能所有实例连的是不同的 Redis或者去重类没配对。5.2 检查各实例的日志分布每个实例的日志里会记录它抓了哪些 URL。把几个实例的日志拉到一起按 URL 去重后统计总数和 Redis 里的指纹数对比。如果日志总数远大于指纹数说明有重复抓取如果远小于说明有实例没在干活。我一般会在 Spider 里加一行日志记录实例 ID 和当前抓取的 URLimport os import logging class WeatherSpider(RedisSpider): name weather redis_key weather:start_urls def parse(self, response): instance_id os.environ.get(HOSTNAME, unknown) self.logger.info(f[{instance_id}] parsing {response.url}) # ... 解析逻辑参数说明HOSTNAME在 Docker 容器里就是容器 ID在物理机上可以用socket.gethostname()。这样日志里能清楚看到哪个实例在处理哪个 URL排查负载不均时很有用。5.3 数据完整性校验抽样比对源站抓完之后别急着关 Redis。随机抽 10 个城市-月份组合手动打开源站页面和你抓到的数据逐条比对。重点看日期是否连续、温度值是否在合理范围比如 -50 到 50 摄氏度、有没有整月缺失。如果发现某个城市的数据大面积缺失先查这个城市的 URL 是否都推入了队列再查解析逻辑是否对该城市的页面模板不兼容。历史天气网站经常改版不同年份的页面结构可能不同这是最容易翻车的地方。我的习惯是每抓完一批就用 pandas 做个简单的完整性报告import pandas as pd df pd.read_csv(weather_history.csv) # 按城市和月份统计记录数正常应该是 28-31 条 report df.groupby([city, year, month]).size().reset_index(namedays) abnormal report[report[days] 28] print(f异常月份数: {len(abnormal)}) print(abnormal.head(20))这个检查能快速定位到哪些月份的数据不完整然后针对性重抓。分布式爬虫的优势在这里也体现出来重抓只需要把对应的 URL 重新推入 Redis不用从头跑一遍。6. 把历史天气数据变成可复用资产增量更新与调度习惯抓到数据只是第一步真正省时间的是建立一套增量更新机制。全国历史天气数据不是抓一次就完了每天都有新数据产生如果你每隔几个月要重新全量抓一遍那分布式搭得再好也是浪费。我的做法是把“历史全量”和“每日增量”分开。历史数据用分布式爬虫一次性抓完存到数据库或者按城市分区的 Parquet 文件里。每日增量用一个轻量脚本只抓昨天和今天的数据追加到对应分区。增量脚本不需要分布式单机 scrapy 或者 requests 就够了关键是稳定和定时。调度上我用 crontab 每天凌晨跑增量每周跑一次完整性校验发现缺失就自动把对应 URL 推入 Redis 队列让分布式爬虫补抓。这样历史数据只会越补越全不会因为某次网络故障留下永久空洞。# 每天凌晨 2 点抓增量 0 2 * * * /usr/bin/python3 /opt/weather/incremental.py /var/log/weather_incr.log 21 # 每周一凌晨 3 点校验完整性缺失的推入 Redis 0 3 * * 1 /usr/bin/python3 /opt/weather/verify_and_repush.py /var/log/weather_verify.log 21这套机制跑了一年多中间经历过目标网站改版、Redis 实例迁移、服务器重启但因为队列和指纹都在 Redis 里持久化每次恢复后都能接着跑没有丢过数据。唯一一次翻车是 Redis 没设密码被内网另一台机器上的测试脚本flushall清空了队列好在数据已经落库只是重新推了一遍 URL。从那以后我给所有生产 Redis 都加了密码并且把flushall命令 rename 掉了。如果你也在做类似的历史数据采集我的建议是先把单机流程跑通确认解析逻辑没问题再改分布式。分布式解决的是吞吐和稳定性不解决解析正确性。解析错了分布式只会让你更快地得到一堆错数据。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?