每天15分钟玩转Scrapy - 反爬
开场
这一篇讲对方不想让你爬的时候,会发生什么,以及你能做什么。
先划一条线。前面几篇处理的是技术问题,页面怎么渲染、数据怎么落库,这些都有确定答案。这一篇处理的是对抗问题,对方在做判断,而他的判断标准你看不到。所以这里没有通解,只有一堆可以试的手段,和一条必须守住的底。
我的建议是把预期放低一点。你能做的是让自己的请求看起来正常,不是让它变成伪装。说实话这两件事差别很大,前者是减少误伤,后者是一条走不到头的路。
内容分四块。请求头那一块最容易见效,也最容易做过头。被拦之后的应对那一块,重试和 robots 各有各的规矩。代理那一块我给你看一条完整的链路,从中间件写到服务端收到了什么。最后是这一块我踩过的坑。
所有实验我都自己搭了服务端,因为公网没法做这种对照。搭出来的服务端会按我写的规则拦人,这样「因为什么被拦」这件事才有唯一答案。
别用默认 UA 自我介绍
默认的 USER_AGENT 长这样。
| |
这个默认值很坦诚,坦诚到对方一眼就知道来的是爬虫。很多站点就是靠这一行做拦截的,而拦住之后的常见表现是返回一个空页面或者 403,不会给你「因为你自称 Scrapy 所以我拦了」这种提示。其实吧,这一行留着不改,等于每次请求都先自报家门。

换 UA 的活属于下载器中间件。写起来就是三个方法加一个 from_crawler。
| |
⚠️ 2.19 起方法签名里不要再写 spider 参数。以前写 def process_request(self, request, spider) 还能跑,但会打一条 ScrapyDeprecationWarning,官方明说以后不再传。要拿爬虫实例就在 __init__ 里存下 crawler,用 self.crawler.spider。
写完别信自己的代码,信服务端。我起了个本地端点,把收到的 UA 原样回显。
| |
关掉开关再跑同一组请求,六个全一样。
| |
有个细节值得单独拎出来。我在中间件里还写了一行 request.headers.setdefault("Accept-Language", ...),它从来没生效过。DefaultHeadersMiddleware 排在 400 号位,早就把 Accept-Language 塞进去了,setdefault 遇到已有值不覆盖。回显里 accept_language 一直是默认的 en。想真正改掉默认头,只能直接赋值。
请求头不止 User-Agent
换 UA 是第一步,但它不是全部。有些站的判断标准是一整套请求头,缺一个就拦。
我搭了个服务端,两个端点。/gate 只看请求头全不全,不全就 403。/echo 把收到的请求头原样回显,包括数量和顺序。然后拿两个蜘蛛对照,一个什么都不设,一个把请求头补成浏览器的样子。
先看什么都不设的那个。
| |
再看补过的那个。
| |
差别摆在一起看更清楚。

| 项目 | 什么都不设 | 补过请求头 |
|---|---|---|
/gate 状态码 | 403 | 200 |
| 请求头数量 | 3 | 6 |
Accept-Language | 空字符串 | zh-CN,zh;q=0.9,en;q=0.8 |
| 请求字节数 | 260 | 734 |
差的那三个是 Accept、Accept-Language、Upgrade-Insecure-Requests。真实浏览器发请求时这三个是必带的,一个正常的 HTTP 客户端不会只发三个头。 Accept-Language 为空尤其扎眼,它等于告诉对方这个客户端不在乎语言。
补全这件事写成中间件最省事。
| |
池子写在 settings 里,好处是改起来不用碰代码。
| |
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,也就是一次失败再补两次,总共敲三次门。默认重试的响应码是这一串。
| |
⚠️ 这个默认值跟老版本不一样。429(请求过多)是后加的,而它恰好是最该重试的一个码。你要是照着老教程抄了一份自己的 RETRY_HTTP_CODES 列表,就把 429 漏掉了。
我起了个本地服务,让它在同一个 URL 上前两次返回 503、第三次返回 200。默认配置跑下来。
| |
retry/count 是 2,加上最初那一次正好三次,与服务端记的「第三次才成功」对上了。
把 RETRY_TIMES 压到 1,放到一个前五次都失败的 URL 上,就能看到放弃长什么样。
| |
「放弃」这条日志的级别由 RETRY_GIVE_UP_LOG_LEVEL 控制,默认就是 ERROR,所以它一定会出现在你眼前。retry/max_reached 是统计里专门数放弃次数的键,跑完扫一眼这个值,比翻日志快。
写蜘蛛的时候有件事别忘。503 不在「允许的状态码」里,响应会被 HttpErrorMiddleware 过滤掉,parse 根本收不到。想让回调看到重试耗尽之后那个页面,得显式放开。
| |
2. 守着 robots 的规矩
ROBOTSTXT_OBEY 默认是关的,但 scrapy startproject 生成的项目模板里已经帮你打开了。这个改动挺好的,新项目默认守规矩。
它做的事不只是「读一下 robots.txt」。只要这个开关是开的,每个新域名都会先请求一次 /robots.txt,解析出规则,之后每条请求都过一遍判定。
判定结果不需要猜。2.18 加了 robots_parsed 信号,解析完就通知你,回调里能拿到解析器,直接问它。
| |
⚠️ 参数名必须是 robotparser 和 request,这是信号签名。写错会在回调里抛 TypeError,而 signals 的兜底会把它吃掉,只在日志里留一行 ERROR,很容易漏看。
规则、开关两种状态各跑一次,对照很直观。
| |
三条路径的结果值得琢磨一下。Allow 能把 Disallow 覆盖回来,/private/public-ok 就是这么被放行的。抓到的 item 只有两条,被拦的那条没有进管道。
把开关关掉,三条全过,包括那条本该被拦的。
| |
顺带说个反直觉的点。公网那两个演示站都没有 robots.txt,请求返回 404。404 的语义是「全部放行」,不是「拒绝访问」,所以拿它们练手看不出这个开关的任何效果。想看真实行为,得找一个真有 robots.txt 的站点,或者像我这样自己搭一个。
3. 429 有它自己的节奏
前面那个 503 的例子是「偶发失败」,重试就行。429 不是这个性质,它的含义是「你太快了」,重试得太勤快反而更糟。
我搭的限流端点带一个窗口参数,前 n 次返回 429 并附上 Retry-After。先把重试整个关掉,看原始行为。
| |
四次请求分别拿到 429、429、429、200。Retry-After 那个头是服务端给的提示,意思是「等 1 秒再来」。这个头 Scrapy 不会自动遵守,它只是把响应交给你。你的蜘蛛连续发了四次,中间没有任何间隔,靠的是窗口刚好走到第四位。
把重试打开,看框架怎么处理这个码。
| |
RETRY_TIMES 是 2,加上最初那一次正好三次。窗口是 3,三次全用完了,最后还是 429。这就是重试预算和限流窗口错位的典型结果,预算花光,数据没拿到。
把窗口改成 1 再跑同一个蜘蛛,配置一个字没动。
| |
第二次就成功了,retry/max_reached 是 None,没有放弃。
⚠️ 这两组对照的结论是,重试能不能救你,取决于你的重试节奏跟对方的窗口对不对得上,跟配置写得多激进没关系。 默认的重试是立刻重发,没有退避。面对 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 是「你就是不受欢迎」,重试没有意义,框架默认也不重试它。
| |
retry/count 是 None,重试原因计数是空字典。一次都没试。这是对的行为,把重试预算浪费在一个明确的拒绝上,除了加重对方的判断没有别的作用。
走代理换出口
请求头补得再像,有一个东西你改不了,出口 IP。同一个 IP 一天来几十万次请求,这件事光靠 UA 掩饰不掉。代理解决的正是这个。
Scrapy 内置了一个 HttpProxyMiddleware,号位是 750。它不看你的配置,只看 request.meta["proxy"]。所以做代理轮换不用重写下载逻辑,只要在更早的位置往那个键里写一个地址。
| |
挂上去的时候号位要挑对,比 750 小就行,我选的是 610。
| |
⚠️ 那个号位不是随便挑的。写在 750 之后你的赋值永远不会生效,因为内置中间件已经先跑过了。这类静默失效很难查,表现是「配置看起来完全正确,但请求还是从本机出去的」。
代理池里的地址可以带凭据,写成 http://user:pass@host:port。内置中间件会替你拆开拼成 Proxy-Authorization 头,不用自己算 base64。
验证这件事不能只看自己的日志。我写了一个最小的正向代理,它在转发的时候往请求里塞一个 X-Forwarded-For,在响应里盖一个 X-Proxied-By 的章,然后再起一个蜘蛛去请求回显端点。两个蜘蛛跑同一个 URL,一个开着轮换,一个关着。
开着轮换的那次。
| |
关掉的那次。
| |
| 项目 | 走代理 | 直连 |
|---|---|---|
服务端看到的 X-Forwarded-For | 203.0.113.8 | 空字符串 |
响应里的 X-Proxied-By | labserver8-proxy | 不存在 |
proxy/assigned | 1 | 不存在 |
这两行证据是分开的两条链路。X-Forwarded-For 证明服务端看到的是代理的地址;X-Proxied-By 证明那次响应确实是从代理那边回来的,不是本机直接拿到的缓存。两个方向都对上了,链路才算通。 只看自己这一侧的日志,很可能是「写了 meta 但没走代理」,这种情况日志里什么异常都没有。你想想看,如果只在本地看 proxy/assigned 这个计数,它写对了也只能说明那一行代码执行过,说明不了请求真的绕了路。

真实项目里代理有几个层次。数据中心代理便宜、快,但 IP 段是公开的,容易被整段封。住宅代理贵,IP 看起来像普通用户,但速度不稳、有并发限制。我自己的感受是,选择取决于对方查得有多细,以及你的时间预算,没有通用的答案。
⚠️ 代理池不是越大越好。一个池子里混进几个慢的,整体速度会被这几个拖住,而且表现是间歇性的偶发超时,比全挂还难查。上线之前先给池子里的每个地址做一轮体检,把响应时间和成功率记下来,把明显不合格的剔掉再进生产。
最后说一句立场问题。代理能让你绕过 IP 层面的限制,但它不改变一件事,对方在 robots.txt 或者服务条款里表达过的意愿。技术上能做和该不该做,是两件事。
容易栽的跟头
坑 1:只改 USER_AGENT,剩下的头一个不管。 这是最常见的一种「我以为我伪装了」。实测里最少的一套请求头只有三个,User-Agent、Accept-Encoding、Host,Accept-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 的目录,不碰。这条线不一定适合所有人,但它至少是条线。
这篇里要是有哪里讲得不对,欢迎拍砖。