Python 爬虫框架应用
请求、解析、存储 是零件。规模上来后失控的从来不是这三件事,而是调度、去重、失败恢复——手写脚本里这三者往往和业务逻辑缠在一起,改一处崩一片。
框架的本质是把调度契约固定下来:你只负责「这个响应里有什么」和「下一步请求谁」,其余交给框架。本篇按这个视角整理 Scrapy 的技术细节,而非使用教程。
🎯 选型判断
| 条件 | 结论 |
|---|---|
| 页面数 < 1000、单层链路 | httpx + 异步脚本,别上框架 |
| 多层链路、需断点续爬、统一入库 | Scrapy |
| 单机带宽/出口 IP 打满 | Scrapy-Redis 分布式 |
| 强渲染依赖为主 | Crawlee 或裸 Playwright,Scrapy 的优势发挥不出来 |
一、架构:组件之间的契约
| 组件 | 职责边界 | 是否需要自己写 |
|---|---|---|
| Engine | 驱动数据流,不含任何业务语义 | 从不 |
| Scheduler | 请求入队出队 + 指纹去重 | 分布式时整体替换 |
| Downloader | 基于 Twisted 的并发发包 | 从不 |
| Spider | 解析响应、产出 Item 与新 Request | 每次 |
| Item Pipeline | 数据清洗、校验、持久化 | 经常 |
| Downloader Middleware | 改写请求/响应:代理、UA、重试、渲染 | 反爬场景必写 |
| Spider Middleware | 改写 Spider 的输入输出、异常兜底 | 少数场景 |
⚠️ 最常见的架构错误
把代理、UA 轮换、重试写进 Spider。这些全部属于 Downloader Middleware。混写的代价是:爬虫无法复用、无法平移到分布式、反爬策略一改要动所有 Spider。判断标准很简单——与"页面里有什么数据"无关的逻辑,都不该出现在 Spider 里。
二、Request 与 Response 的关键字段
框架的调度行为几乎全部由 Request 的字段决定,这是最值得记牢的部分。
scrapy.Request(
url,
callback=self.parse_detail, # 成功回调;不指定则走 parse
errback=self.on_error, # 异常回调,捕获 DNS 失败/超时/连接重置
method="GET",
headers={...},
cookies={...},
meta={"proxy": "http://ip:port"}, # 框架级参数通道
cb_kwargs={"rank": 1}, # 业务级参数通道
priority=10, # 数值越大越先出队,默认 0
dont_filter=False, # True 则跳过去重,重试类请求必须设
)2.1 meta 与 cb_kwargs 的分工
早期代码习惯用 meta 传业务数据,这会与框架保留键冲突。规则:
meta→ 只放框架消费的键:proxy、download_timeout、dont_redirect、handle_httpstatus_list、playwright等cb_kwargs→ 放业务数据,直接作为回调函数的形参传入,类型安全且不污染 meta
2.2 errback 是必写项
只写 callback 时,网络层异常会被静默吞掉,表现为「爬完了但少了几千条」。
from scrapy.spidermiddlewares.httperror import HttpError
from twisted.internet.error import DNSLookupError, TCPTimedOutError
def on_error(self, failure):
if failure.check(HttpError):
self.logger.error("HTTP %s: %s", failure.value.response.status,
failure.value.response.url)
elif failure.check(DNSLookupError, TCPTimedOutError):
self.logger.error("网络层失败: %s", failure.request.url)2.3 响应对象的实用方法
response.urljoin(href) # 基于当前页补全相对链接
response.follow(href, callback=...) # 等价于 urljoin + Request,更简洁
response.follow_all(css="a.next", callback=...)
response.css(".score::text").re_first(r"[\d.]+") # 选择器 + 正则一步到位
response.json() # 直接解析 JSON 响应体Scrapy 的 Selector 与 parsel 同源(parsel 就是从 Scrapy 拆出的独立库),XPath / CSS / re 三种语法可混用。
三、去重机制:指纹是怎么算的
Scheduler 的去重不是比较 URL 字符串,而是比较请求指纹(fingerprint)。
默认参与计算的部分:请求方法 + 规范化后的 URL + 请求体。不参与:headers、cookies、meta。
这条规则直接决定两类典型 bug:
| 现象 | 原因 | 解法 |
|---|---|---|
| 同一 URL 用不同 Cookie 请求,第二次被丢弃 | headers/cookies 不进指纹 | 自定义 fingerprinter,或把区分维度放进 URL 参数 |
| URL 参数顺序不同被当成两个请求 | 规范化只排序不改语义 | 构造请求时统一参数顺序 |
| 重试请求被去重挡掉 | 指纹相同 | 重试时必须 dont_filter=True |
自定义指纹(需要把某个 header 纳入去重维度时):
# settings.py
REQUEST_FINGERPRINTER_CLASS = "myproject.fingerprints.HeaderAwareFingerprinter"from hashlib import sha1
from scrapy.utils.request import RequestFingerprinter
from w3lib.url import canonicalize_url
class HeaderAwareFingerprinter(RequestFingerprinter):
def fingerprint(self, request):
base = f"{request.method}{canonicalize_url(request.url)}"
token = request.headers.get(b"X-Token", b"").decode()
return sha1(f"{base}{token}".encode()).digest()3.1 断点续爬的原理
JOBDIR = "crawls/job-001"设置后,Scrapy 会把待爬队列和去重指纹集合序列化到磁盘目录。Ctrl+C(只按一次,让它优雅退出)后重启同一命令即可续爬。
三个限制必须知道:
- 只能有一个进程使用同一个
JOBDIR; - 修改 Spider 代码后续爬,队列里的旧请求仍指向旧回调名,回调改名会直接报错;
- 内存中已出队但未完成的请求会丢失,续爬时这部分需要靠幂等入库兜底。
四、中间件:返回值即控制流
Downloader Middleware 的三个钩子,返回值类型决定框架下一步做什么。这张表是排查中间件问题的核心依据:
| 方法 | None | Response | Request | 抛 IgnoreRequest |
|---|---|---|---|---|
process_request | 继续下一个中间件 → 最终发包 | 短路,不发包直接返回响应 | 停止处理,重新入调度队列 | 转交 process_exception |
process_response | — | 传给下一个中间件 | 停止处理,重新入调度队列 | 交给 Request 的 errback |
process_exception | 继续异常处理链 | 恢复正常响应流程 | 重新入调度队列 | 交给 errback |
执行顺序:process_request 按优先级数字升序执行,process_response 按降序执行——即请求出去和响应回来经过的是同一组中间件的镜像顺序。
# settings.py:数字小的先处理请求、后处理响应
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.RandomUserAgentMiddleware": 400,
"myproject.middlewares.ProxyMiddleware": 750,
# 置 None 可禁用内置中间件
"scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": None,
}一个体现「返回值即控制流」的典型写法:
class ProxyRotateMiddleware:
def process_request(self, request, spider):
request.meta["proxy"] = self.pool.get()
# 返回 None:继续正常发包
def process_response(self, request, response, spider):
if response.status in (403, 429):
self.pool.report_dead(request.meta.get("proxy"))
# 返回 Request:换 IP 重新调度,必须 dont_filter 否则被去重挡住
return request.replace(dont_filter=True)
return response
def process_exception(self, request, exception, spider):
self.pool.report_dead(request.meta.get("proxy"))
return request.replace(dont_filter=True)代理池的构建见 反爬对抗。
五、Pipeline:幂等是第一原则
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem
class ValidatePipeline:
def process_item(self, item, spider):
adapter = ItemAdapter(item)
if not adapter.get("name"):
raise DropItem("缺少主键字段") # 丢弃,不再往后传
return item # 必须返回,否则后续 pipeline 收不到
class MongoPipeline:
@classmethod
def from_crawler(cls, crawler):
"""从 settings 读配置的标准入口,避免硬编码。"""
return cls(crawler.settings.get("MONGO_URI"))
def open_spider(self, spider):
self.client = pymongo.MongoClient(self.uri)
def close_spider(self, spider):
self.client.close()
def process_item(self, item, spider):
# upsert:重跑不产生重复,分布式并发写也安全
self.db[type(item).__name__].update_one(
{"name": item["name"]},
{"$set": ItemAdapter(item).asdict()},
upsert=True,
)
return item三条硬性约定:
process_item必须返回 item 或抛DropItem,返回None会让后续 pipeline 静默收不到数据;- 连接在
open_spider建、close_spider关,不要在process_item里反复建连接; - 写入用 upsert 而非 insert。爬虫必然会重跑,幂等是唯一能省掉去重脚本的做法。
六、配置项:真正影响成败的部分
# ---- 并发与节流 ----
CONCURRENT_REQUESTS = 16 # 全局并发上限
CONCURRENT_REQUESTS_PER_DOMAIN = 8 # 单域名并发,反爬的关键旋钮
DOWNLOAD_DELAY = 0.5 # 同域名两次请求的基础间隔
RANDOMIZE_DOWNLOAD_DELAY = True # 在 0.5~1.5 倍之间随机,打散固定节奏
AUTOTHROTTLE_ENABLED = True # 自动限速,强烈建议开启
AUTOTHROTTLE_TARGET_CONCURRENCY = 4.0 # 目标并发度
AUTOTHROTTLE_START_DELAY = 1.0
AUTOTHROTTLE_MAX_DELAY = 30.0
# ---- 重试 ----
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]
# ---- 内存与背压 ----
CONCURRENT_ITEMS = 100 # pipeline 并发处理的 item 数
DOWNLOAD_MAXSIZE = 50 * 1024 * 1024 # 超大响应直接丢弃,防止 OOM
DOWNLOAD_TIMEOUT = 30
DEPTH_LIMIT = 0 # 0 为不限;无限翻页站点必须设
# ---- 开发期 ----
HTTPCACHE_ENABLED = True # 本地缓存响应,调试解析器时不再打扰目标站
JOBDIR = "crawls/job-001"
LOG_LEVEL = "INFO"AUTOTHROTTLE 的工作方式:根据每个响应的实际延迟动态调整下次请求的间隔——服务器变慢就自动退让,变快就适度提速。它比固定 DOWNLOAD_DELAY 更能兼顾速度和存活率,代价是吞吐不可预测。
调试入口:
scrapy shell "<url>" # 交互式验证选择器
scrapy parse --spider=<name> -c <callback> <url> # 单独测某个回调
scrapy crawl <name> -O out.json # -O 覆盖写,-o 追加写
scrapy crawl <name> -s LOG_LEVEL=DEBUG # 命令行覆盖任意配置七、对接浏览器渲染
Scrapy 本身不执行 JavaScript。需要渲染时通过替换 Download Handler 接入 Playwright:
# settings.py
DOWNLOAD_HANDLERS = {
"http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
"https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
}
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
PLAYWRIGHT_MAX_CONTEXTS = 4 # 控制并发浏览器上下文,直接决定内存占用# 逐请求开启,不要全局渲染
yield scrapy.Request(url, meta={
"playwright": True,
"playwright_include_page": False, # 需要操作页面时才设 True,用完必须手动 close
"playwright_page_methods": [PageMethod("wait_for_selector", ".item")],
})⚠️ 渲染的真实成本
每个浏览器上下文占用 100~300 MB 内存,吞吐相比纯发包下降一个数量级。必须先确认接口路线不可行(见 基础原理 的 Ajax 分析),且只对需要渲染的 URL 开启。全局开启渲染是压垮爬虫节点最常见的原因。
Selenium 也可对接 Scrapy,但它需要阻塞线程池来桥接同步 API,吞吐显著低于 scrapy-playwright,新项目没有理由选它。
八、分布式:把状态搬出进程
8.1 单机的两个硬限制
Scrapy 的待爬队列和去重指纹都在单机内存(或 JOBDIR 的本地文件)里,因此:
- 进程崩溃 → 队列蒸发(
JOBDIR只能缓解,不能跨机); - 第二台机器无法知道第一台爬过什么 → 重复劳动。
分布式的核心改造只有一句:把队列和去重集合搬到 Redis,让所有节点共享同一份调度状态。
8.2 改造点:只动配置,业务代码不变
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
SCHEDULER_PERSIST = True # 爬完不清空队列与指纹,支持增量续爬
REDIS_URL = "redis://:password@127.0.0.1:6379/0"
SCHEDULER_QUEUE_CLASS = "scrapy_redis.queue.PriorityQueue" # 默认:zset 按优先级
# FifoQueue / LifoQueue 基于 Redis list,分别对应广度优先 / 深度优先Spider 改为从 Redis 读取种子:
from scrapy_redis.spiders import RedisSpider
class MySpider(RedisSpider):
name = "demo"
redis_key = "demo:start_urls" # 取代 start_urls
# parse 等业务方法完全不用改多节点抢任务为什么不会重复:PriorityQueue 用 Redis 的 ZPOPMIN(或事务包裹的 ZRANGE + ZREM)出队,RFPDupeFilter 用 SADD 返回值判断是否首次出现——两者都是 Redis 单线程内的原子操作,天然互斥。
8.3 去重集合的内存账
RFPDupeFilter 把每个指纹以字符串存进 Redis Set。按十六进制 SHA1(40 字节)加上 Set 的元素开销估算:
| URL 量级 | 普通 Set 约需 | 布隆过滤器(误判率 0.01%)约需 |
|---|---|---|
| 100 万 | ~80 MB | ~2.4 MB |
| 1000 万 | ~800 MB | ~24 MB |
| 1 亿 | ~8 GB | ~240 MB |
到亿级时普通 Set 会先于业务把 Redis 撑爆。
8.4 布隆过滤器:用极小漏判换内存
布隆过滤器用一个位数组和 k 个哈希函数记录集合成员:
- 判定不存在 → 一定不存在(无漏判)
- 判定存在 → 可能误判(误判导致该 URL 被当作已爬,即极小概率漏爬)
容量与参数的关系(n 为元素数,p 为误判率):
位数组长度 m = -n·ln(p) / (ln2)²
哈希函数数 k = (m/n)·ln2
例:n = 1 亿,p = 0.0001(万分之一)
m ≈ 1.92e9 bit ≈ 240 MB
k ≈ 13DUPEFILTER_CLASS = "scrapy_redis_bloomfilter.dupefilter.BloomFilter"
SCHEDULER = "scrapy_redis_bloomfilter.scheduler.Scheduler"
BLOOMFILTER_HASH_NUMBER = 6 # 对应上式的 k
BLOOMFILTER_BIT = 30 # 位数组为 2^30 bit = 128 MB💡 选型边界
布隆过滤器只能加不能删,且误判率随实际写入量超过设计容量而急剧上升。适合全网级抓取;对「一条都不能漏」的业务(财务、合规数据),继续用普通 Set,或改用「Redis Set 存近期指纹 + 数据库唯一索引兜底」的两级方案。
8.5 分布式的三个实践要点
- 代理必须按节点独立分配:多节点共用一个出口 IP,等于把并发全部压在同一个 IP 上,比单机更快被封。
- 存储必须支持并发幂等写:MongoDB 用
update_one(upsert=True),MySQL 用INSERT ... ON DUPLICATE KEY UPDATE并建唯一索引。 - Redis 是单点:生产必须配主从 / Sentinel 并开启 AOF;Redis 一挂,所有节点的队列同时归零,且
SCHEDULER_PERSIST也救不回来。
九、部署与管理
9.1 Scrapyd
把爬虫包装成可通过 HTTP 调度的守护进程:
pip install scrapyd scrapyd-client
scrapyd # 默认 6800 端口
scrapyd-deploy <target> -p <project> # 打包 egg 并上传核心接口:schedule.json(启动,返回 jobid)、listjobs.json(查看 pending/running/finished)、cancel.json(取消)、daemonstatus.json(健康检查)。
⚠️ Scrapyd 没有任何认证机制
默认接口可被任意调用,等同于开放远程代码执行。必须限制在内网,或用 Nginx 加 Basic Auth + IP 白名单。切勿直接暴露公网。
9.2 容器化
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # 先装依赖,利用层缓存
COPY . .
CMD ["scrapy", "crawl", "demo"]services:
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
spider:
build: .
depends_on: [redis]
environment:
REDIS_URL: redis://redis:6379/0
deploy:
replicas: 3 # 直接决定分布式节点数扩缩容即 docker compose up -d --scale spider=N,节点数与 Redis 队列天然解耦。
9.3 方案选型
| 方案 | 适合 | 主要代价 |
|---|---|---|
scrapy crawl + cron | 单机、任务少 | 无监控、无重试、日志自理 |
| Scrapyd + scrapyd-client | 少量固定机器 | 需自建鉴权,无可视化 |
| Scrapyd + Gerapy | 需要界面与定时任务 | 多一层组件要维护 |
| Docker Compose | 依赖复杂、需快速扩缩容 | 需要容器知识 |
| K8s + CronJob / Job | 大规模、需弹性与自愈 | 运维成本最高 |
💡 务实建议
中小规模优先 Docker Compose + Redis + 定时触发,跳过 Scrapyd。Scrapyd 的价值在于「远程调度已部署的爬虫」,而在容器时代重新构建并推送镜像的成本已经足够低,这一层往往不再必要。
十、框架横向对比
| 框架 | 技术特征 | 结论 |
|---|---|---|
| Scrapy | Twisted 异步、中间件体系成熟、生态最全 | 通用首选 |
| Scrapy + scrapy-redis | 共享 Redis 调度状态 | 分布式首选 |
| Crawlee for Python | 原生集成 Playwright,API 更现代 | 重渲染场景 |
| feapder | 内置渲染与告警,开箱即用 | 需要现成监控时 |
裸 httpx + asyncio | 无框架心智负担 | 小规模、单层链路 |
📚 模块与官方文档
核心框架
| 模块 | 安装 | 官方文档 |
|---|---|---|
| Scrapy | pip install scrapy | docs.scrapy.org |
itemadapter | 随 Scrapy 安装 | GitHub |
w3lib | 随 Scrapy 安装 | w3lib.readthedocs.io |
parsel | 随 Scrapy 安装 | parsel.readthedocs.io |
Twisted | 随 Scrapy 安装 | twisted.org |
Scrapy 文档中最常查的几页:架构总览、Settings 全量列表、Downloader Middleware、Item Pipeline、Request/Response 对象、信号 Signals。
分布式与去重
| 模块 | 安装 | 官方文档 |
|---|---|---|
scrapy-redis | pip install scrapy-redis | GitHub |
Scrapy-Redis-BloomFilter | pip install scrapy-redis-bloomfilter | GitHub |
redis-py | pip install redis | redis.readthedocs.io |
| Redis 服务端 | — | redis.io/docs |
渲染对接
| 模块 | 安装 | 官方文档 |
|---|---|---|
scrapy-playwright | pip install scrapy-playwright | GitHub |
playwright | pip install playwright | playwright.dev/python |
部署与管理
| 模块 | 安装 | 官方文档 |
|---|---|---|
scrapyd | pip install scrapyd | scrapyd.readthedocs.io |
scrapyd-client | pip install scrapyd-client | GitHub |
gerapy | pip install gerapy | GitHub |
| Docker Compose | — | docs.docker.com/compose |
替代框架
| 框架 | 官方文档 |
|---|---|
| Crawlee for Python | crawlee.dev/python |
| feapder | feapder.com |
工具
| 资源 | 说明 |
|---|---|
| Bloom Filter 参数计算器 | 按 n 和 p 反推位数组长度 m 与哈希函数数 k |