2.2 Request 与 Response:双程车票的两面 本节摘要:Request 是去程车票:地址、方法、头、回调、行李舱 meta 全印在票面上,下行途中每一道关卡都有权改写它;Response 是回程凭证:状态、头、体加选择器接口,是解析回调的唯一入口。本节把这对对象的字段摊开讲透,并给出 meta 传话与优先级调度的实战用法。 上一节你在车间里印出了票,这一节把票面放大细看。第 2 章的第二个问题——"解析回调拿到的到底是什么"——答案就是 Response;而理解 Response 的最好方式,是先看清它去程的孪生兄弟 Request。
本节摘要:Request 是去程车票:地址、方法、头、回调、行李舱 meta 全印在票面上,下行途中每一道关卡都有权改写它;Response 是回程凭证:状态、头、体加选择器接口,是解析回调的唯一入口。本节把这对对象的字段摊开讲透,并给出 meta 传话与优先级调度的实战用法。
上一节你在车间里印出了票,这一节把票面放大细看。第 2 章的第二个问题——"解析回调拿到的到底是什么"——答案就是 Response;而理解 Response 的最好方式,是先看清它去程的孪生兄弟 Request。
一张 Request 上,真正常打交道的信息有七项:
scrapy.Request( url="https://books.example.com/book/101", # 目的地 callback=self.parse_detail, # 到站后谁接待 method="GET", # 动词:GET / POST headers={"Accept-Language": "zh-CN"}, # 票面附言:请求头 meta={"depth": 1, "category": "fiction"}, # 行李舱:跨回调传话 priority=5, # 候车优先级:数字大的先上车 dont_filter=False, # False 则接受去重审查 errback=self.on_error, # 失联时的联系人 )
这些字段在旅程中并非只读——下行途中每一道下载器中间件都能改写它们。第 4 章的 UA 轮换、代理注入,本质上都是在去程关卡里改这张票。理解"票是可改的",你才能解释很多"我明明写的是 A 怎么发出去成了 B"的现象。
有个字段现在就要点破:priority。它决定候车顺序——同样是待发请求,详情页理应比翻页先走,否则深度优先的默认调度会先把第一页的跟进链全部爬完。数值越大越先出站,第 3 章的调度器一节会展开它的队列实现。
响应回到 Spider 手里时,已是一个内容齐全的对象:
def parse_detail(self, response): response.status # 200,状态码 response.url # 最终地址:重定向后的终点 response.headers # 响应头,类似字典但值是字节串 response.body # 响应体原始字节 response.text # 按推断编码解码后的文本 response.encoding # 本次解码使用的编码 response.css("h1") # 选择器接口,2.3 节的主角 response.xpath("//title") response.meta # 去程塞进行李舱的话,原样带回 response.request # 发出此响应的那张票(可反查回调链)
其中最值得停顿的是 url 与 body 的两个细节。url 显示的是"最终地址":若去程遭遇重定向,你手里的 url 已不是票面上的原始地址,需要回溯时得看 response.request.url。text 与 body 则牵出编码这个经典坑:框架按响应头与页面声明推断编码,推断错了中文会变问号——手工校正用 response.encoding 赋值,或以 body 为准自行解码。
| 维度 | Request(去程) | Response(回程) |
|---|---|---|
| 核心载荷 | 地址与方法 | 状态码与响应体 |
| 头部 | headers 可自由设置 | headers 只读 |
| 传递容器 | meta 行李舱(可写) | meta 原样带回(可读可续写) |
| 中间件介入点 | process_request 改票 | process_response 改凭证 |
| 失败表现 | errback 收到 Failure | 不存在——失败没有回程 |
"失败没有回程"这句值得画重点:你在回调里永远见不到 404 的 Response(默认重试耗尽后请求直接进入失败通道),处理失败必须走 errback。很多新手在 parse 里写"如果 response.status 是 404",实际上那条分支几乎永远不会执行。

GET 之外,POST 请求的票面多了一块 body。构造 POST 票用 FormRequest,表单字段进 formdata:
from scrapy import FormRequest def start_requests(self): yield FormRequest( "https://books.example.com/search", formdata={"keyword": "scrapy", "page": "1"}, # 表单字段进请求体 callback=self.parse_result, )
POST 票与去重机制的互动要特别注意:同一 formdata 的重复提交默认同样会被指纹拦下——搜索翻页这类"每页参数不同"没问题,"每轮都要重查同一关键词"就得 dont_filter(3.1 节的代价计算同样适用)。另外 POST 的失败重试语义与 GET 一致,但对方接口若非幂等,重试可能造成重复提交——对接非幂等接口时,把重试交给业务侧幂等键,而不是盲目依赖框架重试。
把两个最常用的字段组合起来,看一个"广度优先加限深"的实用模式:
def parse(self, response): depth = response.meta.get("depth", 0) # 引擎自动维护的深度 if depth > 3: # 限深:超过三层不再续票 return for href in response.css("a::attr(href)").getall(): yield response.follow( href, callback=self.parse, priority=(100 - depth * 10), # 越浅优先级越高,近似广度优先 )
depth 是引擎免费送的:每续一张票它自动加一。配合 priority 递减,原本"一条道走到黑"的默认深度优先,就被改造成逐层推进——对"先扫全站骨架、再回头要细节"的采集需求,这个模式几乎零成本。
# 运行日志里可见 depth 的踪迹 DEBUG: Crawled (200) books.example.com/ (referer: None) DEBUG: Crawled (200) books.example.com/fiction (referer: books.example.com/) ... 'page_crawled_count': 48, 'max_depth_reached': 3
一个真实案例把本节字段全部用上。背景:详情页抓出来的价格字段大量为空,但站点页面明明显示正常。操作:先在 errback 与回调里同时打印 response.status、response.url 与 len(response.body) 三件套——结果发现"空价格"的响应 url 是另一个地址:请求被服务端重定向到了地区选择页,响应体只有几 KB。解读:不是选择器错了,是票被发到了别的终点——response.url 显示最终地址这一特性在这里立功。处置:在请求头补上地区参数,或改用带地区信息的地址族重新出票。变式:同类"响应不对"问题还有编码错乱(用 response.encoding 赋值校正)与压缩内容异常(交给框架处理,别手工解压),定位手法一致——先看票面事实,再怀疑代码。
票面看完了。下一节到站拆包——选择器登场,这是你写得最多、也最容易出错的一段代码。