Scrapling Spider 框架深入解析:Request 对象、response.follow() 与去重指纹机制
【免费下载链接】Scrapling🕷️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl!项目地址: https://gitcode.com/GitHub_Trending/sc/Scrapling
在 Scrapling 的 spiders(爬虫框架)模块中,Request对象是整个爬取流程的调度单元:它决定访问哪个 URL、由哪个回调处理响应、以什么优先级入队、以及是否会被去重过滤器丢弃。本文基于仓库中的参考文档 requests-responses.md,结合 Request 类源码、Response.follow() 实现 与 Scheduler 调度器,完整讲解请求的构造、链接跟随、回调约定、优先级调度与基于指纹的去重机制,帮助你写出可控、可预测、可断点恢复的爬虫。
Request 对象:爬取流程的调度单元
一个Request表示一个待抓取的 URL。你可以直接构造,也可以在回调中通过response.follow()派生:
from scrapling.spiders import Request # 直接构造 request = Request( "https://example.com/page", callback=self.parse_page, priority=5, ) # 通过 response.follow(在回调中推荐的方式) request = response.follow("/page", callback=self.parse_page)Request的全部构造参数如下(与 源码 中的__init__签名一致):
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
url | str | 必填 | 要抓取的 URL |
sid | str | "" | 会话 ID,将请求路由到指定会话(见 Sessions 文档) |
callback | callable | None | 处理响应的异步生成器方法,默认为parse() |
priority | int | 0 | 值越大越先被处理 |
dont_filter | bool | False | 为True时跳过去重(允许重复请求) |
meta | dict | {} | 任意元数据,会透传到对应的 response 上 |
**kwargs | 额外关键字参数,直接传给会话的 fetch 方法(如headers、method、data、proxy等) |
从源码看,**kwargs被存为self._session_kwargs(见 request.py#L51),这是后续两件事的基础:一是它们会被原样转发给底层 fetcher;二是参与请求指纹计算,从而影响去重行为(后文详述)。
由于 kwargs 直接透传,发起 POST 请求只需多传两个参数:
yield Request( "https://example.com/api", method="POST", data={"key": "value"}, callback=self.parse_result, )此外,Request还内置了几个值得了解的细节:
domain属性:从 URL 解析域名(request.py#L67-L69),引擎用它来做allowed_domains过滤和每域名并发限制;copy()方法:复制请求(meta做浅拷贝),被引擎用于被封锁请求的重试逻辑;- pickle 支持:
__getstate__/__setstate__在序列化时把callback替换为其方法名字符串(request.py#L153-L174),反序列化后通过_restore_callback()从 spider 实例上找回方法——这正是断点(checkpoint)机制能够把待处理请求持久化到磁盘并恢复的关键。
Response.follow():回调中创建后续请求的推荐方式
response.follow()是回调中创建后续请求的推荐方式。相比直接构造Request,它有四个优势:
- 相对 URL自动相对当前页面 URL 解析(源码中通过
self.urljoin(url)实现); - Referer 头默认被设置为当前页面 URL;
- 原请求的会话 kwargs(headers、proxy 等)会被继承;
- callback、sid、priority在未显式指定时从原请求继承。
async def parse(self, response: Response): # 最简形式 - 继承当前请求的 callback、sid、priority yield response.follow("/next-page") # 覆盖特定字段 yield response.follow( "/product/123", callback=self.parse_product, priority=10, ) # 传递额外的元数据 yield response.follow( "/details", callback=self.parse_details, meta={"category": "electronics"}, )response.follow()的参数表如下:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
url | str | 必填 | 要跟随的 URL(绝对或相对) |
sid | str | "" | 会话 ID(为空时继承原请求) |
callback | callable | None | 回调方法(为None时继承原请求) |
priority | int | None | 优先级(为None时继承原请求) |
dont_filter | bool | False | 跳过去重(注意:此参数不继承) |
meta | dict | None | 元数据(与当前 response 的 meta 合并) |
referer_flow | bool | True | 将当前 URL 设置为 Referer 头 |
**kwargs | 与原请求的会话 kwargs 合并(新值优先) |
源码级拆解:follow() 到底做了什么
阅读 custom.py#L88-L144 中Response.follow()的实现,可以看到完整的继承与合并逻辑:
# 合并原会话 kwargs 与新 kwargs(新值优先) session_kwargs = {**self.request._session_kwargs, **kwargs} if referer_flow: # 针对静态请求引擎 headers = session_kwargs.get("headers", {}) headers["referer"] = self.url session_kwargs["headers"] = headers # 针对浏览器引擎 extra_headers = session_kwargs.get("extra_headers", {}) extra_headers["referer"] = self.url session_kwargs["extra_headers"] = extra_headers session_kwargs["google_search"] = False return Request( url=self.urljoin(url), sid=sid or self.request.sid, callback=callback or self.request.callback, priority=priority if priority is not None else self.request.priority, dont_filter=dont_filter, meta={**(self.meta or {})}, **(meta or {})}, **session_kwargs, )这段实现揭示了三个文档层面看不到的细节:
- 双引擎兼容的 Referer 注入:
referer_flow=True时会同时写入headers(供Fetcher静态引擎)和extra_headers(供浏览器引擎)两个字典,把当前响应 URL 作为referer,确保无论底层用哪种会话,请求都会带上正确的 Referer; - 副作用
google_search = False:启用 referer flow 时会强制关闭google_search选项——即不再把带 Referer 的请求先经 Google 中转,避免中转逻辑干扰链接跟随; - 前置校验:如果 response 上没有关联请求(
self.request不是Request实例),follow()会直接抛出TypeError。这意味着follow()只能在 spider 回调中使用,而不能在独立使用 fetcher 拿到 response 后随意调用。
禁用 Referer 流转
默认情况下response.follow()会把Referer头设置为当前页面 URL。如果目标站点对 Referer 敏感(例如某些反爬策略会校验来源页),可以显式关闭:
yield response.follow("/page", referer_flow=False)同时注意,referer_flow=False也不会写入google_search=False,两个行为是绑定在一起的。
回调(Callbacks)约定:异步生成器与三种产出类型
回调是 spider 上处理响应的异步生成器方法,它必须yield以下三种类型之一:
dict:一个抓取到的条目,加入结果集;Request:一个后续请求,加入调度队列;None:被静默忽略。
class MySpider(Spider): name = "my_spider" start_urls = ["https://example.com"] async def parse(self, response: Response): # 产出条目(dict) yield {"url": response.url, "title": response.css("title::text").get("")} # 产出后续请求 for link in response.css("a::attr(href)").getall(): yield response.follow(link, callback=self.parse_page) async def parse_page(self, response: Response): yield {"content": response.css("article::text").get("")}注意:所有回调方法都必须是async def且使用yield(而非return)。即使某个回调只产出条目、没有后续请求,它也必须是异步生成器。Spider 基类 中的抽象方法签名async def parse(...) -> AsyncGenerator[Dict | Request | None, None]正是这一约定的类型化表达。
从引擎侧的实现 engine.py#L154-L183 可以进一步看到回调产出物的完整处理链路:
callback = request.callback if request.callback else self.spider.parse async for result in callback(response): if isinstance(result, Request): if self._is_domain_allowed(result): self._normalize_request(result) await self.scheduler.enqueue(result) else: self.stats.offsite_requests_count += 1 elif isinstance(result, dict): processed_result = await self.spider.on_scraped_item(result) ... elif result is not None: log.error(f"Spider must return Request, dict or None, ...")由此可以确认几处文档未展开但实际会生效的行为:
allowed_domains过滤:产出的Request会先经过域名白名单检查,站外请求会被计入offsite_requests_count而不入队;on_scraped_item钩子:每个dict条目都会先经过该钩子,返回None即可静默丢弃该条目(计入items_dropped);- 异常兜底:回调内部抛出的异常会被捕获、记日志并触发
on_error(request, error),不会中断整个爬取。
另外,_normalize_request(engine.py#L145-L152)会在入队前把空sid解析为会话管理器的默认会话 ID,保证同一会话的请求指纹一致。
请求优先级:值越大越先处理
优先级高的请求先被处理,这在需要"先抓重点页、后翻分页"的场景下非常有用:
async def parse(self, response: Response): # 高优先级 - 先处理商品页 for link in response.css("a.product::attr(href)").getall(): yield response.follow(link, callback=self.parse_product, priority=10) # 低优先级 - 分页链接在商品页之后处理 next_page = response.css("a.next::attr(href)").get() if next_page: yield response.follow(next_page, callback=self.parse, priority=0)使用response.follow()时,若未显式指定priority,它会继承原请求的优先级(源码中是priority if priority is not None else self.request.priority)。
调度器如何实现优先级
Scheduler 基于asyncio.PriorityQueue实现,入队时把优先级取反:
# 优先级取反,使数值更大者先出队;counter 保证同优先级 FIFO item = (-request.priority, counter, request) await self._queue.put(item)配套地,Request定义了__lt__/__gt__比较优先级(request.py#L133-L143),并用单调递增计数器打破同优先级之间的平局,保证先入队者先出队。测试用例 test_request.py#L186-L202 对这两处比较语义有直接验证。
一个容易忽略的细节:当请求被判定为"被封锁"并重试时,引擎会把重试请求的priority -= 1(engine.py#L250),使重试不会插队到同优先级的正常请求之前。
去重与指纹(Fingerprint)机制
spider 会基于一个指纹自动去重请求:指纹由URL、HTTP 方法、请求体与会话 ID计算得出;两个请求指纹相同,第二个会被静默丢弃(Scheduler.enqueue 中通过self._seen集合判断)。
需要允许重复请求时(例如登录后重新访问同一页面),设置dont_filter=True:
yield Request("https://example.com/dashboard", dont_filter=True, callback=self.parse_dashboard) # 或用 response.follow yield response.follow("/dashboard", dont_filter=True, callback=self.parse_dashboard)指纹到底算了什么
Request.update_fingerprint() 展示了完整的指纹算法:
post_data = self._session_kwargs.get("data", {}) body = b"" if post_data: if isinstance(post_data, dict | list | tuple): body = urlencode(post_data).encode() elif isinstance(post_data, str): body = post_data.encode() elif isinstance(post_data, BytesIO): body = post_data.getvalue() elif isinstance(post_data, bytes): body = post_data else: post_data = self._session_kwargs.get("json", {}) body = orjson.dumps(post_data) if post_data else b"" data = { "sid": self.sid, "body": body.hex(), "method": self._session_kwargs.get("method", "GET"), "url": canonicalize_url(self.url, keep_fragments=keep_fragments), } # ...(可选:kwargs / headers) fp = hashlib.sha1(orjson.dumps(data, option=orjson.OPT_SORT_KEYS), usedforsecurity=False).digest()由此可确认几个要点:
- URL 归一化:使用 w3lib 的
canonicalize_url()对 URL 做规范化,因此http://ex.com/a?x=1&y=2与参数顺序不同的等价 URL 会命中同一指纹;默认会丢弃 URL 片段(#section); - 请求体参与计算:
data(dict/list/tuple 会urlencode,str/bytes/BytesIO 原样编码)或在无data时的json参数都会被编码进指纹。也就是说,同一 URL 的 GET 与 POST(或 body 不同的两次 POST)视为不同请求; - 会话隔离:
sid是指纹的一部分,同一 URL 经由不同会话请求时不会互相去重; - 结果缓存:首次计算后缓存在
self._fp,后续比较直接复用;__eq__基于指纹比较(未计算指纹时比较会抛RuntimeError,测试见 test_request.py#L202-L216)。
三个指纹微调开关
可以通过 spider 类属性微调指纹的组成(定义于 spider.py#L96-L98,由 CrawlerEngine 转传给 Scheduler):
| 属性 | 默认值 | 作用 |
|---|---|---|
fp_include_kwargs | False | 把额外请求 kwargs(传给会话 fetch 的参数,如 headers 以外的method、cookies等)纳入指纹 |
fp_keep_fragments | False | 计算指纹时保留 URL 片段(#section) |
fp_include_headers | False | 把请求 headers 纳入指纹 |
例如,需要把https://example.com/page#section1与https://example.com/page#section2视为两个不同 URL 时:
class MySpider(Spider): name = "my_spider" fp_keep_fragments = True # ...fp_include_kwargs的实现细节(request.py#L106-L112)值得注意:它会把除data/json外的 kwargs 键名小写化,值经orjson稳定序列化(键排序),再按排序后的(key, value)元组参与哈希——因此 kwargs 的书写顺序不影响指纹,而data/json因已体现在body中被排除,避免重复计算。fp_include_headers则把headers或extra_headers中的键值各自转 bytes 后取 hex 参与哈希,键名统一小写、但保留值的大小写(见 test_request.py#L127 的验证)。
指纹还有一个工程上的用途:开启development_mode时,响应缓存以指纹为 key 落盘(engine.py#L199-L209),因此同一个请求在开发模式下会直接回放缓存响应而不真正发起网络请求。
用 Request.meta 在回调之间传递上下文
meta字典允许你在回调之间传递任意数据,这在"从列表页提取上下文再到详情页使用"的场景中非常有用:
async def parse(self, response: Response): for product in response.css("div.product"): category = product.css("span.category::text").get("") link = product.css("a::attr(href)").get() if link: yield response.follow( link, callback=self.parse_product, meta={"category": category}, ) async def parse_product(self, response: Response): yield { "name": response.css("h1::text").get(""), "price": response.css(".price::text").get(""), # 从请求中读取 meta "category": response.meta.get("category", ""), }使用response.follow()时,新 meta 会与当前 response 的 meta合并,且新值优先(源码中为{**(self.meta or {}), **(meta or {})},见 custom.py#L142)。这与直接构造Request时 meta 被整体替换的行为不同,是跟随式请求"继承上下文"语义的一部分。
spider 体系还会自动写入一些元数据。例如启用代理轮换(proxy/proxies参数)时,各引擎会在响应上附带所用代理:静态引擎在 static.py#L258 处以meta={"proxy": proxy}构造响应,浏览器引擎在 _controllers.py#L191 与 _stealth.py#L279 处做同样处理。因此可以在回调里通过response.meta["proxy"]读取本次请求实际使用的代理,便于排查"哪个代理触发了封锁"。
小结
Request的七个显式参数加**kwargs透传,覆盖了 URL、会话路由、回调、优先级、去重、元数据与会话参数;response.follow()在相对 URL 解析、Referer 注入、会话 kwargs 继承、meta 合并上省去了大量样板代码,且referer_flow=False可一键关闭 Referer 流转;- 回调必须是
async def+yield,产出dict/Request/None,引擎负责域名过滤、条目钩子与异常兜底; - 优先级数值越大越先调度,底层靠
asyncio.PriorityQueue+ 取反优先级实现,同优先级 FIFO; - 去重指纹 = 归一化 URL + 方法 + 请求体 + sid(SHA-1),可用
dont_filter豁免,可用fp_include_kwargs/fp_keep_fragments/fp_include_headers三个类属性微调; meta是回调间传递上下文的唯一正式通道,同时引擎会自动写入proxy等系统元数据。
完整的 spiders 文档体系见仓库 agent-skill/Scrapling-Skill/references/spiders/ 目录,其中 sessions.md 讲解sid背后的会话机制,architecture.md 讲解引擎整体架构,可与本文互为补充。
【免费下载链接】Scrapling🕷️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl!项目地址: https://gitcode.com/GitHub_Trending/sc/Scrapling
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考