首页
/ Scrapling 与 Scrapy 集成实战:用 scrapling_response 装饰器在现有 Scrapy 项目中直接获得 Scrapling 解析能力

Scrapling 与 Scrapy 集成实战:用 scrapling_response 装饰器在现有 Scrapy 项目中直接获得 Scrapling 解析能力

2026-09-04 11:11:16作者:昌雅子Ethen

如果你维护着一个既有的 Scrapy 项目,又不想推翻重写,Scrapling 的 Scrapy 集成(docs/integrations/scrapy.md)提供了一条平滑路径:通过 scrapling_response 装饰器,Scrapy 回调函数里的 response 参数会被就地转换为 Scrapling 的 Response 对象,爬虫调度、请求队列仍由 Scrapy 全权负责,而解析环节则无缝切换到 Scrapling 的完整选择器 API。读完本文,你将掌握三种接入方式(装饰器、参数化装饰器、直接调用 convert_response)、字段映射与 Cookie 解析等底层实现细节,以及四条必须知道的使用注意事项。

安装要求:默认安装即可,无需额外 extras

该集成基于 Scrapling 的默认安装(pip install scrapling)即可工作,不需要安装 fetchersshell 等任何可选依赖组,唯一要求是环境中已安装 Scrapy——对于既有 Scrapy 项目来说这自然满足。这一结论可以从 依赖声明 得到印证:核心依赖只有 lxmlcssselectorjsontldw3libtyping_extensions,Scrapy 并不在其中。

集成模块的导入方式也体现了"不强制依赖"的设计。scrapling/integrations/init.py 明确说明每个第三方框架集成都是独立模块、显式导入:

from scrapling.integrations.scrapy import scrapling_response

scrapling/integrations/scrapy.py 在模块顶层用 try/except 捕获 Scrapy 的导入:

try:
    from scrapy.http import Response as ScrapyResponse
except (ImportError, ModuleNotFoundError) as e:
    raise ModuleNotFoundError(
        "This integration requires Scrapy installed, please install it first with `pip install scrapy`"
    ) from e

也就是说,只有真正 import 这个集成模块时才要求 Scrapy 存在,未安装时会抛出带明确提示的 ModuleNotFoundError,Scrapy 永远不会成为 Scrapling 的硬性依赖。适用前提方面,pyproject.toml 声明 requires-python = ">=3.10",因此该集成运行在 Python 3.10 及以上环境。

核心用法:用 scrapling_response 装饰回调

最典型的接入方式是把 @scrapling_response 装饰器加在任意 spider 回调上,回调收到的 response 就变成了 Scrapling Response。官方文档给出的完整示例是一个引用语(quotes)爬虫:

import scrapy
from scrapling.integrations.scrapy import scrapling_response


class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = ["https://quotes.toscrape.com"]

    @scrapling_response
    def parse(self, response):  # `response` is now a Scrapling Response
        first_quote = response.find_by_text("The world as we have created it", partial=True)
        for quote in [first_quote, *first_quote.find_similar()]:
            card = quote.parent
            yield {
                "text": quote.get_all_text(strip=True),
                "author": card.find("small", class_="author").text,
                "tags": [tag.text for tag in card.find_all("a", class_="tag")],
            }
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield scrapy.Request(response.urljoin(next_page), callback=self.parse)

示例中用到的几个 Scrapling 解析 API 都定义在 Selector 类 中,值得逐个对照源码确认其行为:

  • find_by_text(text, partial=True):按文本内容查找元素,partial=True 表示包含式匹配而非精确匹配;实现 还支持 first_match(默认 True,返回单个元素)、case_sensitiveclean_match(忽略空白差异)参数。
  • find_similar():基于"相同树深度 + 相同标签名 + 相同父/祖父标签 + 属性相似度阈值(默认 0.2)"寻找相似元素,实现 默认忽略 hrefsrc 这类易变的 URL 属性。这也是示例中能从一个 quote 卡片批量定位其余卡片的关键。
  • get_all_text(strip=True):收集元素下所有可见文本并用换行符拼接(默认忽略 script/style 标签),实现Separator="\n"ignore_tags=("script", "style") 等默认值。
  • find_all("a", class_="tag"):按标签名与属性查找,class_class 的合法别名(源码注释 解释了为何要白名单处理 Python 保留字)。
  • css(...)urljoin(...):Scrapling 的 Response 继承自 Selector,因此保留了对熟悉 Scrapy CSS 语法的开发者的兼容性,示例末尾仍用 response.css("li.next a::attr(href)") 取下一页链接。

Response 类的定义位于 scrapling/engines/toolbelt/custom.py,它的文档字符串说明这是"所有引擎统一返回的响应类型",额外携带 statusreasoncookiesheadersrequest_headershistorymeta 等 HTTP 元信息,并在构造时记录一条便于调试的日志(Fetched ({status}) <{method} {url}>)。

支持全部四种回调形态,且保留函数内省信息

装饰器能覆盖 Scrapy 支持的所有回调类型:普通函数、生成器、协程和异步生成器。关键在于 scrapling/integrations/scrapy.py 中的分发逻辑——Scrapy 会内省回调函数本身(而不是只看它的返回值)来决定如何处理 yield,因此装饰器必须"以同种形态包裹":

# Each callback kind gets a wrapper of the same kind because Scrapy inspects the
# callback function itself, not just what it returns.
if isasyncgenfunction(func):
    @wraps(func)
    async def async_gen_wrapper(*args, **kwargs):
        args, kwargs = _convert_arguments(args, kwargs)
        async for result in func(*args, **kwargs):
            yield result
    return async_gen_wrapper
elif iscoroutinefunction(func):
    @wraps(func)
    async def async_wrapper(*args, **kwargs):
        args, kwargs = _convert_arguments(args, kwargs)
        return await func(*args, **kwargs)
    return async_wrapper
elif isgeneratorfunction(func):
    @wraps(func)
    def gen_wrapper(*args, **kwargs):
        args, kwargs = _convert_arguments(args, kwargs)
        yield from func(*args, **kwargs)
    return wrapper

同时用 functools.wraps 保留原回调的名称与 docstring,所以 Scrapy 的回调内省与 contracts 测试机制不受影响。测试用例 明确验证了这一点:

spider = TestSpider()
assert inspect.isgeneratorfunction(spider.parse)
assert spider.parse.__name__ == "parse"
assert spider.parse.__doc__ == "Parse the page title"

另外两个值得注意的运行时细节,同样有测试背书:

  • 响应参数位置灵活_convert_arguments 会先扫描位置参数、再扫描关键字参数来定位 ScrapyResponse 实例(实现)。测试验证了 parse(response=...) 关键字传参同样生效(test_response_passed_as_keyword),且位置参数优先于关键字参数(test_positional_response_wins_over_keyword);参数中完全找不到 Scrapy 响应时会抛出 TypeError("No Scrapy response found ...")
  • 额外 kwargs 透传:回调上声明的 cb_kwargs(如 parse(response, category=None))会原样传入,不会被装饰器吞掉(test_cb_kwargs_passthrough)。

参数化形式:向 Response 透传 Selector 配置

装饰器支持带参形式,参数会作为 **selector_config 转发给 Scrapling Response(进而传给底层 Selector)的构造函数:

@scrapling_response(adaptive=True, keep_comments=True)
def parse_product(self, response):
    ...

可透传的配置项在 convert_response 的文档字符串 中列明,与 Selector 构造函数 的参数一一对应:

参数 类型 默认值 作用
huge_tree bool True 启用 libxml2 的 huge_tree,解析超大文档时应保持开启
keep_comments bool False 解析时是否保留 HTML 注释
keep_cdata bool False 是否保留 CDATA 段
adaptive bool False 开启自适应(adaptive)选择功能,优先级高于其他 adaptive 相关参数
storage SQLiteStorageSystem adaptive 功能使用的存储类(须为带 lru_cacheStorageSystemMixin 子类)
storage_args dict None 传给存储类的参数;缺省时自动填充默认的 storage_fileurl
adaptive_domain str "" 覆盖记录用的 URL 域名(Response 构造 会用它替代原始 url 传给 Selector

adaptive=True 的语义可以在 Selector.init 中确认:开启后若未显式传入 _storage,则使用默认的 SQLiteStorageSystem(元素存储数据库位于 scrapling/elements_storage.db 对应的默认路径),并在调用存储类前校验其是否为被 lru_cache 包装的 StorageSystemMixin 子类。测试用例 test_parameterized_form 直接验证了 keep_comments=True 生效后 xpath("//comment()") 能够查到注释节点。

回调之外:直接调用 convert_response

在中间件、管道等非回调场景中拿到 Scrapy 响应时,可以绕过装饰器直接转换:

from scrapling.integrations.scrapy import convert_response

scrapling_response = convert_response(scrapy_response, keep_comments=False, keep_cdata=False)

convert_response 的完整实现在 scrapling/integrations/scrapy.py,它做的是逐字段映射:

request = response.request
cookies: Dict[str, str] = {}
for line in response.headers.getlist(b"Set-Cookie"):
    pair = line.split(b";", 1)[0]
    if b"=" in pair:
        name, _, value = pair.decode("latin-1").partition("=")
        cookies[name.strip()] = value.strip()

return Response(
    url=response.url,
    content=response.body,
    status=response.status,
    reason=StatusText.get(response.status),
    cookies=cookies,
    headers=dict(response.headers.to_unicode_dict()),
    request_headers=dict(request.headers.to_unicode_dict()) if request else {},
    encoding=getattr(response, "encoding", "utf-8"),
    method=request.method if request else "GET",
    meta=dict(response.meta) if request else {},
    **selector_config,
)

其中几个设计决策都对应着具体的坑:

  • Cookie 从原始 Set-Cookie 头逐行解析:Scrapy 的 to_unicode_dict() 会用逗号拼接重复头,而 Set-Cookie 的值(如 Expires=Wed, 09 Jun 2027 ...)本身含逗号,拼接后会把一条 Cookie 拆成乱码。因此实现直接遍历 response.headers.getlist(b"Set-Cookie") 原始头行。测试 用两条带 Expires 日期的 Set-Cookie 头验证了解析结果 {"sid": "abc123", "lang": "en"} 的完整性。
  • 无 request 的响应同样可转换meta 属性在响应未绑定 request 时会抛 AttributeError,实现里用 if request else {} 做了防御;编码则用 getattr(response, "encoding", "utf-8") 兜底——基类二进制响应(如 scrapy.http.Response)没有 encoding 属性,测试 验证此时回退为 utf-8 且原始字节 body 原样保留。
  • meta 是浅拷贝dict(response.meta) 复制的是字典本身而非各值,其他中间件放进 meta 的对象(比如 scrapy-playwright 放在 response.meta["playwright_page"] 的页面对象)依旧可达。测试 断言了 response.meta is not scrapy_response.meta 且内容一致。
  • 状态码 reason 映射StatusText.get(status) 内置了完整的 HTTP 状态短语表(实现),未知状态码(如测试中的 599)会回退为 "Unknown Status Code"

转换完成后,对象即拥有 Scrapling Response 的全部能力:测试 断言了 urlbodystatusreasonencodingheadersrequest_headersmeta 的映射,以及 css("h1::text") 选择与 urljoin("/next") 链接拼接均正常工作。

使用注意事项

官方文档在 Notes 一节列出的三条限制,结合源码看都指向同一个核心事实:转换后的对象是 Scrapling 的 Response,但请求调度仍属于 Scrapy 的世界

  1. 翻页要 yield scrapy.Request,不要用 response.follow()Response.follow()实现)生成的是 Scrapling 自带爬虫系统的 scrapling.spiders.Request 对象(它还会合并会话参数、自动带 referer),Scrapy 的引擎并不认识这个类型。而且 follow() 依赖 self.request 是 Scrapling 的 Request 实例,在 Scrapy 集成场景中该属性为 None,直接调用会抛 TypeError("This response has no request set yet.")。正确做法如主示例所示:yield scrapy.Request(response.urljoin(next_page), callback=self.parse)

  2. meta 浅拷贝,跨中间件对象可达:如前述,scrapy-playwright 等浏览器中间件写入 response.meta 的页面对象在转换后仍然可以从 response.meta["playwright_page"] 取出。

  3. Cookies 来自原始 Set-Cookie:如前所述逐行解析,放入 Scrapling 响应的 cookies 字典,可被 find_similar、adaptive 等下游逻辑直接消费。

从源码结构还可以推断一条隐含边界:Scrapy 回调中拿到的 Scrapling Response 主要用于解析,它不会把 self.request 指向任何对象(该字段由 Scrapling 自己的爬虫框架设置,见 构造代码),因此依赖 response.request 的 Scrapling 专有逻辑(如前述 follow)在该集成中不可用。

验证与回归:集成测试覆盖了什么

整个集成的行为边界由 tests/integrations/test_scrapy.py 完整覆盖,分两个测试类:

  • TestConvertResponse:字段映射、Set-Cookie 解析、无 request 响应、二进制响应编码兜底、未知状态码 reason、selector 配置透传(keep_comments=True//comment() 查询的影响);
  • TestScraplingResponseDecorator:四种回调形态(普通/生成器/协程/异步生成器)各一条用例,外加参数化形式、cb_kwargs 透传、关键字传参、位置参数优先、无响应抛 TypeError 等边界。

测试中构造 Scrapy 响应的方式也值得一提——make_response 直接构造 HtmlResponse 并附带 Request实现),全程不触碰网络,说明"转换"这一步与网络抓取彻底解耦:无论 Scrapy 用 httpbin、httpx 还是其他下载器拿到的响应,接入 Scrapling 解析都不需要改动。

小结

Scrapling 的 Scrapy 集成是一个刻意做"薄"的适配层:约 130 行的 scrapling/integrations/scrapy.py 只做两件事——把 ScrapyResponse 逐字段映射为 Scrapling Response(处理 Cookie、meta、编码等易错点),以及按回调形态包裹转换逻辑并保持内省信息。爬虫、调度、去重、重试继续由 Scrapy 承担,而 find_by_textfind_similar、adaptive 选择器等 Scrapling 解析能力则通过一个装饰器或一次函数调用即可在既有项目中落地。如果你正在评估是否迁移,最低成本的验证路径就是:在一个既有 spider 上加上 @scrapling_response,把解析逻辑换成 Scrapling API 跑一轮,再决定后续范围。

登录后查看全文
热门项目推荐
相关项目推荐