跳转到正文

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 的字段决定,这是最值得记牢的部分。

python
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 metacb_kwargs 的分工

早期代码习惯用 meta 传业务数据,这会与框架保留键冲突。规则

  • meta → 只放框架消费的键:proxydownload_timeoutdont_redirecthandle_httpstatus_listplaywright
  • cb_kwargs → 放业务数据,直接作为回调函数的形参传入,类型安全且不污染 meta

2.2 errback 是必写项

只写 callback 时,网络层异常会被静默吞掉,表现为「爬完了但少了几千条」。

python
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 响应对象的实用方法

python
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 的 Selectorparsel 同源(parsel 就是从 Scrapy 拆出的独立库),XPath / CSS / re 三种语法可混用。


三、去重机制:指纹是怎么算的

Scheduler 的去重不是比较 URL 字符串,而是比较请求指纹(fingerprint)

默认参与计算的部分:请求方法 + 规范化后的 URL + 请求体。不参与:headers、cookies、meta。

这条规则直接决定两类典型 bug:

现象原因解法
同一 URL 用不同 Cookie 请求,第二次被丢弃headers/cookies 不进指纹自定义 fingerprinter,或把区分维度放进 URL 参数
URL 参数顺序不同被当成两个请求规范化只排序不改语义构造请求时统一参数顺序
重试请求被去重挡掉指纹相同重试时必须 dont_filter=True

自定义指纹(需要把某个 header 纳入去重维度时):

python
# settings.py
REQUEST_FINGERPRINTER_CLASS = "myproject.fingerprints.HeaderAwareFingerprinter"
python
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 断点续爬的原理

python
JOBDIR = "crawls/job-001"

设置后,Scrapy 会把待爬队列去重指纹集合序列化到磁盘目录。Ctrl+C(只按一次,让它优雅退出)后重启同一命令即可续爬。

三个限制必须知道:

  1. 只能有一个进程使用同一个 JOBDIR
  2. 修改 Spider 代码后续爬,队列里的旧请求仍指向旧回调名,回调改名会直接报错;
  3. 内存中已出队但未完成的请求会丢失,续爬时这部分需要靠幂等入库兜底。

四、中间件:返回值即控制流

Downloader Middleware 的三个钩子,返回值类型决定框架下一步做什么。这张表是排查中间件问题的核心依据:

方法NoneResponseRequestIgnoreRequest
process_request继续下一个中间件 → 最终发包短路,不发包直接返回响应停止处理,重新入调度队列转交 process_exception
process_response传给下一个中间件停止处理,重新入调度队列交给 Request 的 errback
process_exception继续异常处理链恢复正常响应流程重新入调度队列交给 errback

执行顺序process_request 按优先级数字升序执行,process_response降序执行——即请求出去和响应回来经过的是同一组中间件的镜像顺序

python
# settings.py:数字小的先处理请求、后处理响应
DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.RandomUserAgentMiddleware": 400,
    "myproject.middlewares.ProxyMiddleware": 750,
    # 置 None 可禁用内置中间件
    "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": None,
}

一个体现「返回值即控制流」的典型写法:

python
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:幂等是第一原则

python
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

三条硬性约定:

  1. process_item 必须返回 item 或抛 DropItem,返回 None 会让后续 pipeline 静默收不到数据;
  2. 连接在 open_spider 建、close_spider,不要在 process_item 里反复建连接;
  3. 写入用 upsert 而非 insert。爬虫必然会重跑,幂等是唯一能省掉去重脚本的做法。

六、配置项:真正影响成败的部分

python
# ---- 并发与节流 ----
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 更能兼顾速度和存活率,代价是吞吐不可预测。

调试入口

sh
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:

python
# settings.py
DOWNLOAD_HANDLERS = {
    "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
    "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
}
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
PLAYWRIGHT_MAX_CONTEXTS = 4         # 控制并发浏览器上下文,直接决定内存占用
python
# 逐请求开启,不要全局渲染
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 的本地文件)里,因此:

  1. 进程崩溃 → 队列蒸发(JOBDIR 只能缓解,不能跨机);
  2. 第二台机器无法知道第一台爬过什么 → 重复劳动。

分布式的核心改造只有一句:把队列和去重集合搬到 Redis,让所有节点共享同一份调度状态。

8.2 改造点:只动配置,业务代码不变

python
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 读取种子:

python
from scrapy_redis.spiders import RedisSpider

class MySpider(RedisSpider):
    name = "demo"
    redis_key = "demo:start_urls"      # 取代 start_urls
    # parse 等业务方法完全不用改

多节点抢任务为什么不会重复PriorityQueue 用 Redis 的 ZPOPMIN(或事务包裹的 ZRANGE + ZREM)出队,RFPDupeFilterSADD 返回值判断是否首次出现——两者都是 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 为误判率):

text
位数组长度  m = -n·ln(p) / (ln2)²
哈希函数数  k = (m/n)·ln2

例:n = 1 亿,p = 0.0001(万分之一)
   m ≈ 1.92e9 bit ≈ 240 MB
   k ≈ 13
python
DUPEFILTER_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 分布式的三个实践要点

  1. 代理必须按节点独立分配:多节点共用一个出口 IP,等于把并发全部压在同一个 IP 上,比单机更快被封。
  2. 存储必须支持并发幂等写:MongoDB 用 update_one(upsert=True),MySQL 用 INSERT ... ON DUPLICATE KEY UPDATE 并建唯一索引。
  3. Redis 是单点:生产必须配主从 / Sentinel 并开启 AOF;Redis 一挂,所有节点的队列同时归零,且 SCHEDULER_PERSIST 也救不回来。

九、部署与管理

9.1 Scrapyd

把爬虫包装成可通过 HTTP 调度的守护进程:

sh
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 容器化

dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt   # 先装依赖,利用层缓存
COPY . .
CMD ["scrapy", "crawl", "demo"]
yaml
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 的价值在于「远程调度已部署的爬虫」,而在容器时代重新构建并推送镜像的成本已经足够低,这一层往往不再必要。


十、框架横向对比

框架技术特征结论
ScrapyTwisted 异步、中间件体系成熟、生态最全通用首选
Scrapy + scrapy-redis共享 Redis 调度状态分布式首选
Crawlee for Python原生集成 Playwright,API 更现代重渲染场景
feapder内置渲染与告警,开箱即用需要现成监控时
httpx + asyncio无框架心智负担小规模、单层链路

📚 模块与官方文档

核心框架

模块安装官方文档
Scrapypip install scrapydocs.scrapy.org
itemadapter随 Scrapy 安装GitHub
w3lib随 Scrapy 安装w3lib.readthedocs.io
parsel随 Scrapy 安装parsel.readthedocs.io
Twisted随 Scrapy 安装twisted.org

Scrapy 文档中最常查的几页架构总览Settings 全量列表Downloader MiddlewareItem PipelineRequest/Response 对象信号 Signals

分布式与去重

模块安装官方文档
scrapy-redispip install scrapy-redisGitHub
Scrapy-Redis-BloomFilterpip install scrapy-redis-bloomfilterGitHub
redis-pypip install redisredis.readthedocs.io
Redis 服务端redis.io/docs

渲染对接

模块安装官方文档
scrapy-playwrightpip install scrapy-playwrightGitHub
playwrightpip install playwrightplaywright.dev/python

部署与管理

模块安装官方文档
scrapydpip install scrapydscrapyd.readthedocs.io
scrapyd-clientpip install scrapyd-clientGitHub
gerapypip install gerapyGitHub
Docker Composedocs.docker.com/compose

替代框架

框架官方文档
Crawlee for Pythoncrawlee.dev/python
feapderfeapder.com

工具

资源说明
Bloom Filter 参数计算器按 n 和 p 反推位数组长度 m 与哈希函数数 k

← 返回 Python 深度研究