每天15分钟玩转Scrapy - 反爬

开场

这一篇讲对方不想让你爬的时候,会发生什么,以及你能做什么。

先划一条线。前面几篇处理的是技术问题,页面怎么渲染、数据怎么落库,这些都有确定答案。这一篇处理的是对抗问题,对方在做判断,而他的判断标准你看不到。所以这里没有通解,只有一堆可以试的手段,和一条必须守住的底。

我的建议是把预期放低一点。你能做的是让自己的请求看起来正常,不是让它变成伪装。说实话这两件事差别很大,前者是减少误伤,后者是一条走不到头的路。

内容分四块。请求头那一块最容易见效,也最容易做过头。被拦之后的应对那一块,重试和 robots 各有各的规矩。代理那一块我给你看一条完整的链路,从中间件写到服务端收到了什么。最后是这一块我踩过的坑。

所有实验我都自己搭了服务端,因为公网没法做这种对照。搭出来的服务端会按我写的规则拦人,这样「因为什么被拦」这件事才有唯一答案。

别用默认 UA 自我介绍

默认的 USER_AGENT 长这样。

1
Scrapy/2.19.0 (+https://scrapy.org)

这个默认值很坦诚,坦诚到对方一眼就知道来的是爬虫。很多站点就是靠这一行做拦截的,而拦住之后的常见表现是返回一个空页面或者 403,不会给你「因为你自称 Scrapy 所以我拦了」这种提示。其实吧,这一行留着不改,等于每次请求都先自报家门。

默认 UA 与真实浏览器 UA 的对照

换 UA 的活属于下载器中间件。写起来就是三个方法加一个 from_crawler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class RandomUserAgentMiddleware:
    def __init__(self, crawler):
        self.crawler = crawler
        self.stats = crawler.stats
        self.ua_pool = crawler.settings.getlist("UA_POOL") or UA_POOL
        self.enabled = crawler.settings.getbool("RANDOM_UA_ENABLED", True)

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler)

    def process_request(self, request):
        if not self.enabled:
            return None
        request.headers["User-Agent"] = (
            request.meta.get("ua") or random.choice(self.ua_pool)
        )
        self.stats.inc_value("ua_pool/rotated")
        return None

⚠️ 2.19 起方法签名里不要再写 spider 参数。以前写 def process_request(self, request, spider) 还能跑,但会打一条 ScrapyDeprecationWarning,官方明说以后不再传。要拿爬虫实例就在 __init__ 里存下 crawler,用 self.crawler.spider

写完别信自己的代码,信服务端。我起了个本地端点,把收到的 UA 原样回显。

1
2
3
4
5
6
7
0 | Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0
1 | Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0
2 | Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0
3 | Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0
4 | Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0
5 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, l
去重后 UA 种类: 2

关掉开关再跑同一组请求,六个全一样。

1
2
3
0 | Scrapy/2.19.0 (+https://scrapy.org)
1 | Scrapy/2.19.0 (+https://scrapy.org)
2 | Scrapy/2.19.0 (+https://scrapy.org)

有个细节值得单独拎出来。我在中间件里还写了一行 request.headers.setdefault("Accept-Language", ...)它从来没生效过DefaultHeadersMiddleware 排在 400 号位,早就把 Accept-Language 塞进去了,setdefault 遇到已有值不覆盖。回显里 accept_language 一直是默认的 en。想真正改掉默认头,只能直接赋值。

请求头不止 User-Agent

换 UA 是第一步,但它不是全部。有些站的判断标准是一整套请求头,缺一个就拦。

我搭了个服务端,两个端点。/gate 只看请求头全不全,不全就 403。/echo 把收到的请求头原样回显,包括数量和顺序。然后拿两个蜘蛛对照,一个什么都不设,一个把请求头补成浏览器的样子。

先看什么都不设的那个。

1
2
3
4
2026-09-13 05:39:47 [bare] INFO: [bare] /gate 状态码 = 403,正文 = <html><body>403 blocked: User-Agent 自报家门:Scrapy/2.19.0 (+https://scrapy.org)</body></html>
2026-09-13 05:39:47 [bare] INFO: [bare] /echo 收到的请求头共 3 个:User-Agent, Accept-Encoding, Host
2026-09-13 05:39:47 [bare] INFO: [bare] UA = Scrapy/2.19.0 (+https://scrapy.org)
2026-09-13 05:39:47 [bare] INFO: [bare] Accept-Language = ''

再看补过的那个。

1
2
3
4
2026-09-13 05:39:49 [headed] INFO: [headed] /gate 状态码 = 200,正文 = <html><body>200 passed gate</body></html>
2026-09-13 05:39:49 [headed] INFO: [headed] /echo 收到的请求头共 6 个:User-Agent, Accept, Accept-Language, Accept-Encoding, Upgrade-Insecure-Requests, Host
2026-09-13 05:39:49 [headed] INFO: [headed] UA = Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
2026-09-13 05:39:49 [headed] INFO: [headed] Accept-Language = 'zh-CN,zh;q=0.9,en;q=0.8'

差别摆在一起看更清楚。

三个请求头与六个请求头的两种结局

项目什么都不设补过请求头
/gate 状态码403200
请求头数量36
Accept-Language空字符串zh-CN,zh;q=0.9,en;q=0.8
请求字节数260734

差的那三个是 AcceptAccept-LanguageUpgrade-Insecure-Requests真实浏览器发请求时这三个是必带的,一个正常的 HTTP 客户端不会只发三个头。 Accept-Language 为空尤其扎眼,它等于告诉对方这个客户端不在乎语言。

补全这件事写成中间件最省事。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class BrowserHeadersMiddleware:
    """把请求头补成浏览器的样子。只补「缺的」,不覆盖调用方显式设过的值。"""

    def __init__(self, crawler):
        self.crawler = crawler
        self.stats = crawler.stats
        self.headers = crawler.settings.getdict("BROWSER_HEADERS")

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler)

    def process_request(self, request):
        added = 0
        for name, value in self.headers.items():
            # 这里必须判断存在性再赋值。写成 headers.setdefault(name, value) 之后
            # 以为改了值是不对的:更早的中间件(DefaultHeadersMiddleware 在 400 号位)
            # 可能已经填过,setdefault 遇到已有值不会覆盖。
            if name not in request.headers:
                request.headers[name] = value
                added += 1
        if added:
            self.stats.inc_value("headers/filled")
            self.stats.inc_value("headers/filled_total", added)
        return None

池子写在 settings 里,好处是改起来不用碰代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
BROWSER_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
    "Accept-Encoding": "gzip, deflate, br",
    "Upgrade-Insecure-Requests": "1",
}

DEFAULT_REQUEST_HEADERS = dict(BROWSER_HEADERS)

DEFAULT_REQUEST_HEADERS 那一行让全局默认头也变成浏览器样式,中间件就成了兜底。两层一起上,可以覆盖到那些绕过默认头的情况。

⚠️ 补请求头有个度。Accept-Encoding 里写 br 之后,服务端可能真的返回 Brotli 压缩的内容,而你的环境不一定装了对应的解压库。稳妥的写法是只声明 gzip, deflate,或者确认自己那套依赖齐全。

还有一个反过来的坑。有些站会检查请求头的一致性,比如 UA 说是 Chrome 126,却没有 Sec-Fetch-* 那一组头,或者 Accept-Language 只有一个值。这种不一致比少几个头更容易被认出来。伪装的目标是自洽,不是凑数量。

被拦之后:重试与守规矩

请求头补好了,还是有被拦的时候。这一节讲两种拦法下的应对。

一种是软拦,服务端不拒绝你,但它不高兴。表现是 429 或者 503,意思是「你太频繁了,缓缓再来」。这类情况该重试,但重试有预算,花完了就得认。

另一种是硬的规矩,robots.txt。它不拦你,它是跟你商量。遵不遵守由你决定,但这件事你应该知道自己在做什么选择。

下面三个小节,前两个是框架自带的能力,第三个是 429 这个码的特殊之处。

1. 出错要重试

网络请求失败是常态,不是异常。所以重试不该由你在 errback 里手写,框架本来就带着。

默认 RETRY_TIMES = 2,也就是一次失败再补两次,总共敲三次门。默认重试的响应码是这一串。

1
[500, 502, 503, 504, 522, 524, 408, 429]

⚠️ 这个默认值跟老版本不一样。429(请求过多)是后加的,而它恰好是最该重试的一个码。你要是照着老教程抄了一份自己的 RETRY_HTTP_CODES 列表,就把 429 漏掉了。

我起了个本地服务,让它在同一个 URL 上前两次返回 503、第三次返回 200。默认配置跑下来。

1
2
3
最终状态 200,正文 <html><body>ok after 3 attempts</body></html>
'retry/count': 2,
'retry/reason_count/503 Service Unavailable': 2,

retry/count 是 2,加上最初那一次正好三次,与服务端记的「第三次才成功」对上了。

RETRY_TIMES 压到 1,放到一个前五次都失败的 URL 上,就能看到放弃长什么样。

1
2
3
4
ERROR: Gave up retrying <GET http://127.0.0.1:8412/flaky?fail=5&tag=B> (failed 2 times): 503 Service Unavailable
最终状态 503,正文 <html><body>503 attempt 2</body></html>
'retry/count': 1,
'retry/max_reached': 1,

「放弃」这条日志的级别由 RETRY_GIVE_UP_LOG_LEVEL 控制,默认就是 ERROR,所以它一定会出现在你眼前。retry/max_reached 是统计里专门数放弃次数的键,跑完扫一眼这个值,比翻日志快。

写蜘蛛的时候有件事别忘。503 不在「允许的状态码」里,响应会被 HttpErrorMiddleware 过滤掉,parse 根本收不到。想让回调看到重试耗尽之后那个页面,得显式放开。

1
"HTTPERROR_ALLOWED_CODES": [500, 502, 503, 504],

2. 守着 robots 的规矩

ROBOTSTXT_OBEY 默认是关的,但 scrapy startproject 生成的项目模板里已经帮你打开了。这个改动挺好的,新项目默认守规矩。

它做的事不只是「读一下 robots.txt」。只要这个开关是开的,每个新域名都会先请求一次 /robots.txt,解析出规则,之后每条请求都过一遍判定。

判定结果不需要猜。2.18 加了 robots_parsed 信号,解析完就通知你,回调里能拿到解析器,直接问它。

1
2
3
4
5
6
7
    def on_robots_parsed(self, robotparser, request):
        ua = self.crawler.settings["USER_AGENT"]
        for path in ("/ok", "/private/secret", "/private/public-ok"):
            verdict = robotparser.allowed(HOST + path, ua)
            self.logger.info(
                "robots 判定 %-20s%s", path, "允许" if verdict else "禁止"
            )

⚠️ 参数名必须是 robotparserrequest,这是信号签名。写错会在回调里抛 TypeError,而 signals 的兜底会把它吃掉,只在日志里留一行 ERROR,很容易漏看。

规则、开关两种状态各跑一次,对照很直观。

1
2
3
4
5
robots 判定 /ok                  → 允许
robots 判定 /private/secret      → 禁止
robots 判定 /private/public-ok   → 允许
robots 声明的 Crawl-delay:0.0
'robotstxt/forbidden': 1,

三条路径的结果值得琢磨一下。Allow 能把 Disallow 覆盖回来,/private/public-ok 就是这么被放行的。抓到的 item 只有两条,被拦的那条没有进管道。

把开关关掉,三条全过,包括那条本该被拦的。

1
2
3
拿到 http://127.0.0.1:8412/ok → 200
拿到 http://127.0.0.1:8412/private/secret → 200
拿到 http://127.0.0.1:8412/private/public-ok → 200

顺带说个反直觉的点。公网那两个演示站都没有 robots.txt,请求返回 404。404 的语义是「全部放行」,不是「拒绝访问」,所以拿它们练手看不出这个开关的任何效果。想看真实行为,得找一个真有 robots.txt 的站点,或者像我这样自己搭一个。

3. 429 有它自己的节奏

前面那个 503 的例子是「偶发失败」,重试就行。429 不是这个性质,它的含义是「你太快了」,重试得太勤快反而更糟。

我搭的限流端点带一个窗口参数,前 n 次返回 429 并附上 Retry-After。先把重试整个关掉,看原始行为。

1
2
3
4
2026-09-13 05:39:57 [ratenoretry] INFO: [no-retry] 状态码 429,Retry-After = b'1',正文 = <html><body>429 too many requests (第 1 次)</body></html>
2026-09-13 05:39:57 [ratenoretry] INFO: [no-retry] 状态码 429,Retry-After = b'1',正文 = <html><body>429 too many requests (第 2 次)</body></html>
2026-09-13 05:39:57 [ratenoretry] INFO: [no-retry] 状态码 429,Retry-After = b'1',正文 = <html><body>429 too many requests (第 3 次)</body></html>
2026-09-13 05:39:57 [ratenoretry] INFO: [no-retry] 状态码 200,Retry-After = None,正文 = <html><body>200 ok (第 4 次)</body></html>

四次请求分别拿到 429、429、429、200。Retry-After 那个头是服务端给的提示,意思是「等 1 秒再来」。这个头 Scrapy 不会自动遵守,它只是把响应交给你。你的蜘蛛连续发了四次,中间没有任何间隔,靠的是窗口刚好走到第四位。

把重试打开,看框架怎么处理这个码。

1
2
3
4
5
6
2026-09-13 05:39:59 [scrapy.downloadermiddlewares.retry] ERROR: Gave up retrying <GET http://127.0.0.1:8416/ratelimit?n=3&who=wr> (failed 3 times): 429 Unknown Status
2026-09-13 05:39:59 [ratewithretry] INFO: [with-retry] 最终状态码 429,正文 = <html><body>429 too many requests ( 3 )</body></html>
2026-09-13 05:39:59 [ratewithretry] INFO: [with-retry] retry/count = 2retry/max_reached = 1retry/reason_count/429 = None
 'retry/count': 2,
 'retry/max_reached': 1,
 'retry/reason_count/429 Unknown Status': 2,

RETRY_TIMES 是 2,加上最初那一次正好三次。窗口是 3,三次全用完了,最后还是 429。这就是重试预算和限流窗口错位的典型结果,预算花光,数据没拿到。

把窗口改成 1 再跑同一个蜘蛛,配置一个字没动。

1
2
3
4
2026-09-13 05:40:40 [rateretryok] INFO: [retry-ok] 最终状态码 200,正文 = <html><body>200 ok (第 2 次)</body></html>
2026-09-13 05:40:40 [rateretryok] INFO: [retry-ok] retry/count = 1,retry/max_reached = None
 'retry/count': 1,
 'retry/reason_count/429 Unknown Status': 1,

第二次就成功了,retry/max_reachedNone,没有放弃。

⚠️ 这两组对照的结论是,重试能不能救你,取决于你的重试节奏跟对方的窗口对不对得上,跟配置写得多激进没关系。 默认的重试是立刻重发,没有退避。面对 429 这种做法等于把预算一次性烧掉。这事儿我一开始也没转过弯,总觉得次数开大点总归没坏处。

想让重试真的有用,得给它加退避。最简单的改法是在 process_response 里读 Retry-After,让这个请求延迟后再回队列。Scrapy 的重试中间件留了 RETRY_PRIORITY_ADJUST 这类旋钮,但按响应头做退避要自己写,这也是为什么很多团队干脆在下载器中间件里加一层自己的限速器。

还有一个统计上的坑顺便说掉。retry/reason_count/429 读出来是 None,真实的键名是 '429 Unknown Status'。原因是 429 这个码不在 Twisted 的状态短语表里,Scrapy 拼键名的时候把整个字符串用上了。想统计重试原因就别按精确键名取,遍历所有 retry/reason_count 开头的键更稳。

对比一下 403 就很清楚。403 是「你就是不受欢迎」,重试没有意义,框架默认也不重试它。

1
2
2026-09-13 05:40:02 [forbidden] INFO: [forbidden] 状态码 403,正文 = <html><body>403 you are not welcome</body></html>
2026-09-13 05:40:02 [forbidden] INFO: [forbidden] retry/count = None,重试原因计数 = {}

retry/countNone,重试原因计数是空字典。一次都没试。这是对的行为,把重试预算浪费在一个明确的拒绝上,除了加重对方的判断没有别的作用。

走代理换出口

请求头补得再像,有一个东西你改不了,出口 IP。同一个 IP 一天来几十万次请求,这件事光靠 UA 掩饰不掉。代理解决的正是这个。

Scrapy 内置了一个 HttpProxyMiddleware,号位是 750。它不看你的配置,只看 request.meta["proxy"]。所以做代理轮换不用重写下载逻辑,只要在更早的位置往那个键里写一个地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class RotateProxyMiddleware:
    """每个请求从池子里挑一个代理,写进 request.meta["proxy"]。

    真正把代理用起来的是内置的 HttpProxyMiddleware(默认 750 号位),
    它读的正是 request.meta["proxy"]。所以本中间件的号位必须比 750 小。
    """

    def __init__(self, crawler):
        self.crawler = crawler
        self.stats = crawler.stats
        self.pool = crawler.settings.getlist("PROXY_POOL")
        self.enabled = crawler.settings.getbool("ROTATE_PROXY_ENABLED", False)

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler)

    def process_request(self, request):
        if not self.enabled or not self.pool:
            return None
        # 已经指定过代理的请求不要覆盖(比如某个请求必须走固定出口 IP)
        if request.meta.get("proxy"):
            return None
        request.meta["proxy"] = random.choice(self.pool)
        self.stats.inc_value("proxy/assigned")
        return None

    def process_exception(self, request, exception):
        """代理挂了就把这个请求记一笔,真实项目里应该把它从池子里剔除。"""
        if request.meta.get("proxy"):
            self.stats.inc_value("proxy/failed")
        return None

挂上去的时候号位要挑对,比 750 小就行,我选的是 610。

1
2
3
4
DOWNLOADER_MIDDLEWARES = {
    "antiban.middlewares.BrowserHeadersMiddleware": 543,
    "antiban.middlewares.RotateProxyMiddleware": 610,
}

⚠️ 那个号位不是随便挑的。写在 750 之后你的赋值永远不会生效,因为内置中间件已经先跑过了。这类静默失效很难查,表现是「配置看起来完全正确,但请求还是从本机出去的」。

代理池里的地址可以带凭据,写成 http://user:pass@host:port。内置中间件会替你拆开拼成 Proxy-Authorization 头,不用自己算 base64。

验证这件事不能只看自己的日志。我写了一个最小的正向代理,它在转发的时候往请求里塞一个 X-Forwarded-For,在响应里盖一个 X-Proxied-By 的章,然后再起一个蜘蛛去请求回显端点。两个蜘蛛跑同一个 URL,一个开着轮换,一个关着。

开着轮换的那次。

1
2
3
2026-09-13 05:40:04 [proxy] INFO: [proxy] 服务端看到的 X-Forwarded-For = '203.0.113.8'
2026-09-13 05:40:04 [proxy] INFO: [proxy] 响应头里代理的印章 X-Proxied-By = b'labserver8-proxy'
2026-09-13 05:40:04 [proxy] INFO: [proxy] proxy/assigned = 1,proxy/failed = None

关掉的那次。

1
2
2026-09-13 05:40:06 [direct] INFO: [direct] 服务端看到的 X-Forwarded-For = ''
2026-09-13 05:40:06 [direct] INFO: [direct] 响应头里 X-Proxied-By = None
项目走代理直连
服务端看到的 X-Forwarded-For203.0.113.8空字符串
响应里的 X-Proxied-Bylabserver8-proxy不存在
proxy/assigned1不存在

这两行证据是分开的两条链路。X-Forwarded-For 证明服务端看到的是代理的地址;X-Proxied-By 证明那次响应确实是从代理那边回来的,不是本机直接拿到的缓存。两个方向都对上了,链路才算通。 只看自己这一侧的日志,很可能是「写了 meta 但没走代理」,这种情况日志里什么异常都没有。你想想看,如果只在本地看 proxy/assigned 这个计数,它写对了也只能说明那一行代码执行过,说明不了请求真的绕了路。

代理出口的双向验证

真实项目里代理有几个层次。数据中心代理便宜、快,但 IP 段是公开的,容易被整段封。住宅代理贵,IP 看起来像普通用户,但速度不稳、有并发限制。我自己的感受是,选择取决于对方查得有多细,以及你的时间预算,没有通用的答案。

⚠️ 代理池不是越大越好。一个池子里混进几个慢的,整体速度会被这几个拖住,而且表现是间歇性的偶发超时,比全挂还难查。上线之前先给池子里的每个地址做一轮体检,把响应时间和成功率记下来,把明显不合格的剔掉再进生产。

最后说一句立场问题。代理能让你绕过 IP 层面的限制,但它不改变一件事,对方在 robots.txt 或者服务条款里表达过的意愿。技术上能做和该不该做,是两件事。

容易栽的跟头

坑 1:只改 USER_AGENT,剩下的头一个不管。 这是最常见的一种「我以为我伪装了」。实测里最少的一套请求头只有三个,User-AgentAccept-EncodingHostAccept-Language 甚至是空字符串。真实浏览器不会只发三个头。想快速自查,把自己请求的头打成一行,跟浏览器开发者工具里的请求头对一下,差多少一眼就看出来了。

坑 2:中间件里写 request.headers.setdefault() 以为改了默认头。 这个我之前专门验证过,它从来没生效过。DefaultHeadersMiddleware 排在 400 号位,比你的中间件早,等你的代码跑到的时候那些键已经有值了,setdefault 遇到已有值不覆盖。要真正改掉默认头只有两条路,要么直接赋值,要么改 DEFAULT_REQUEST_HEADERS 把默认值换成你想要的。这个坑的特征是「代码看着完全合理,日志里也看不出问题,只有对端的回显能证明它没生效」。

坑 3:代理中间件的号位写在 750 之后。 内置的 HttpProxyMiddleware 在 750,它读 request.meta["proxy"]。你的号位比它大,就等于在它跑完之后才改这个值,这一轮的请求已经发出去了。表现和坑 2 一样,配置全对,行为完全不符合预期。

坑 4:把 429 当成普通的失败码来重试。 Scrapy 的默认重试是立刻重发,中间没有退避。面对 429 这种做法等于把重试预算一口气烧光。实测里 RETRY_TIMES=2 配一个窗口为 3 的限流端点,三次请求全部撞在窗口里,以 retry/max_reached=1 收场。同一份配置换到窗口为 1 的端点上就成功了。重试能不能救你,看的是节奏对不对,不是次数够不够。

坑 5:按 retry/reason_count/429 去取统计。 取出来是 None,真实的键名是 '429 Unknown Status'。429 这个码不在 Twisted 的状态短语表里,Scrapy 拼键名的时候把码和占位短语一起用上了。稳妥的写法是遍历所有以 retry/reason_count 开头的键,别按精确名字取。

坑 6:以为 ROBOTSTXT_OBEY 打开就万事大吉。 它做的不只是读一下那个文件。开关一开,每个新域名都会先请求一次 /robots.txt,多一次往返。更值得留意的是 404 的语义,robots.txt 返回 404 表示「没有规则」,也就是全部放行,不是拒绝。想验证这个开关到底有没有在工作,得找一个真有 robots.txt 的站点,公网那两个演示站都没有。说到底这个开关管的是「要不要看那份文件」,不是「看完之后怎么抓」,两件事别混在一起。

坑 7:把 robots.txt 里的 Crawl-delay 当成会被自动遵守。 Scrapy 会把它解析出来,但要不要按它降速是你自己的事。实测里那个值为 0.0,日志里能看到解析结果,可蜘蛛该跑多快还是跑多快。想真的按它来,得在 robotparser 拿到的值上自己接一段逻辑。

坑 8:代理池里混进慢节点。 这件事的麻烦在于它的表现是间歇性的。十个地址里有一个响应要 30 秒,整体平均耗时会被它拖上去,而且是随机出现,看日志像偶发故障。上线前给池子做一轮体检,把响应时间和成功率记下来再筛一遍,比事后查快得多。

小结

反爬这一块的难点不在技术,在判断。

请求头那一块是可以确定的。三个头对六个头,403 对 200,这个差距是硬的,补上就有效。但补到什么程度为止,这条线由对方画。补得过头,比如 UA 说是 Chrome 126 而 Sec-Fetch-* 那一组一个都没有,反而更像假的。

重试那一块让我改了原来的想法。以前我以为重试次数开大一点总没坏处,实测之后发现不是。面对 429,立刻重发等于把预算一口气烧光,RETRY_TIMES=2 配窗口 3 的结果是一次都没成功。同一份配置换个窗口就够用了,区别只在节奏。 所以重试这件事要跟限速一起设计,单独调一个没有意义。

代理那一块,我最想让你记住的是那个双向验证。只看自己这边写了 meta["proxy"] 是没有意义的,得让服务端告诉你它看到的地址,再让响应头告诉你这一趟确实绕了路。两个方向都对上,才算链路是通的。

最后是立场。robots.txt 是对方写给爬虫看的,它表达的是一个意愿。技术上有能力绕过去,不代表就应该绕。我在这一篇里把 ROBOTSTXT_OBEY 的行为讲清楚了,包括 404 的语义和 Crawl-delay 其实不会被自动遵守,但那个开关该不该打开,是你自己要回答的问题。

我自己的做法是,公开数据、频率可控、对方没有明确反对的,正常抓;对方写了 Disallow 的目录,不碰。这条线不一定适合所有人,但它至少是条线。

这篇里要是有哪里讲得不对,欢迎拍砖。