首页
/ Scrapling 解析器性能基准测试:从 5000 元素文本提取到自适应元素相似度搜索的实测方法与源码剖析

Scrapling 解析器性能基准测试:从 5000 元素文本提取到自适应元素相似度搜索的实测方法与源码剖析

2026-09-04 21:41:48作者:牧宁李

本文以仓库文档 docs/benchmarks.md 为核心,完整解读 Scrapling 解析器与其他主流 HTML 解析库的基准测试结果与测试方法,并结合 benchmarks.pyscrapling/parser.py 等源码,说明 Scrapling 的 Selector 是如何通过 lxml 直连、预编译 XPath 与惰性属性缓存等设计实现毫秒级解析的。读完后你将能够复现这套基准测试、理解每项测试的公平性设计,并从实现层面掌握 css()find_by_text()find_similar() 的性能特征。

一、基准测试结果总览

仓库文档对 Scrapling 的 Selector 解析器与多个人气 Python 解析库做了两组对比。所有数据均为 100 次以上运行的平均值,测试脚本与完整方法论见 benchmarks.py

1. 文本提取速度(5000 个嵌套元素)

# 耗时 (ms) 相对 Scrapling
1 Scrapling 1.99 1.0x
2 Parsel/Scrapy 2.06 1.035
3 Raw Lxml 2.56 1.286
4 PyQuery 23.98 ~12x
5 Selectolax 197.02 ~99x
6 MechanicalSoup 1545.15 ~776.5x
7 BS4 with Lxml 1562.1 ~785.0x
8 BS4 with html5lib 3412.73 ~1714.9x

从结果结构看,测试把各库分成了三个梯队:Scrapling、Parsel(Scrapy 官方解析层)与裸 Lxml 同处毫秒级(1.99~2.56 ms),PyQuery、Selectolax 处于百毫秒以内,而 MechanicalSoup、BeautifulSoup(无论配 lxml 还是 html5lib)都在 1.5~3.4 秒量级——这与这些库面向"页面交互模拟"或"通用容错解析"的设计目标有关。

2. 元素相似度搜索与按文本查找

Scrapling 的自适应元素定位能力(adaptive element finding)在第二组测试中与 AutoScraper 对比:

耗时 (ms) 相对 Scrapling
Scrapling 2.3 1.0x
AutoScraper 12.58 5.47x

这两组测试对应的正是 Scrapling 相对普通 CSS 选择器的两个差异化能力:find_by_text() 按文本内容定位元素、find_similar() 以某个元素为起点找出结构相似的兄弟元素。下一节将拆解这两项测试的具体任务定义。

二、测试方法论:benchmarks.py 的完整设计

理解结果之前先看测试是怎么做的——这也是这套文档最有价值的部分:所有数字都可以按脚本复现。

2.1 测试数据

第一组测试使用本地构造的大页面,共 5000 个嵌套元素:

large_html = (
    "<html><body>" + '<div class="item">' * 5000 + "</div>" * 5000 + "</body></html>"
)

第二组测试则在 __main__ 中通过 requests 拉取 https://books.toscrape.com/index.html 的真实书页作为输入(这也是运行脚本需要网络的原因)。

2.2 计时装饰器:预热 + 进程时间平均

所有测试函数都由同一个 @benchmark 装饰器包裹(见 benchmarks.py),其流程是:

  1. 预热阶段:先用 timeit.repeat(..., number=2, repeat=2) 跑 2 轮,避免冷启动、JIT 之外的首次开销污染数据;
  2. 正式测量timeit.repeat(..., number=1, repeat=100, timer=time.process_time)——即单次执行、重复 100 次,用 time.process_time(只计 CPU 时间,不含 I/O 等待)采样;
  3. 取 100 次样本的平均值并换算为毫秒输出。

这解释了文档中"All benchmarks represent averages of 100+ runs"的说法:样本量 ≥100,且计时基于进程时间而非墙钟。

2.3 各库的任务定义(对比公平性关键)

第一组测试的统一任务:解析 5000 元素页面并取出 .item 的全部文本。各库的调用方式(摘自 benchmarks.py):

# Raw Lxml —— 注意解析器配置与 Scrapling 保持一致以保证公平
def test_lxml():
    return [
        e.text
        for e in etree.fromstring(
            large_html,
            # Scrapling and Parsel use the same parser inside, so this is just to make it fair
            parser=html.HTMLParser(recover=True, huge_tree=True),
        ).cssselect(".item")
    ]

# Parsel/Scrapy
def test_parsel():
    return Selector(text=large_html).css(".item::text").extract()

# Scrapling:不需要像 parsel 那样再调 .extract()
def test_scrapling():
    return ScraplingSelector(large_html, adaptive=False).css(".item::text").getall()

公平性细节值得注意:

  • 裸 Lxml 使用了与 Scrapling 内部相同的解析器参数 HTMLParser(recover=True, huge_tree=True),脚本注释明确说明"这是为了公平"——否则 Lxml 默认解析器在大型文档上会因 huge_tree 保护而失败或走不同路径;
  • Scrapling 侧刻意关闭了 adaptiveadaptive=False),因为本测试考察的是纯选择器路径的速度,自适应定位是独立功能(见第二组测试);
  • Scrapling 的调用形态是 .css(".item::text").getall()。脚本注释解释了为什么这样写:"No need to do .extract() like parsel to extract text",并且比 [t.text for t in Selector(large_html, adaptive=False).css('.item')] 更快——前者直接对文本节点取字符串,后者要为每个元素构造 Selector 包装对象。

其余被测库的等价任务:

def test_bs4_lxml():
    return [e.text for e in BeautifulSoup(large_html, "lxml").select(".item")]

def test_pyquery():
    return [e.text() for e in pq(large_html)(".item").items()]

def test_mechanicalsoup():
    browser = StatefulBrowser()
    browser.open_fake_page(large_html)
    return [e.text for e in browser.page.select(".item")]

def test_selectolax():
    return [node.text() for node in HTMLParser(large_html).css(".item")]

第二组测试的任务是:在真实书页中按文本 "Tipping the Velvet" 找到元素,再取出与它相似的兄弟元素的全部文本

def test_scrapling_text(request_html):
    return ScraplingSelector(request_html, adaptive=False) \
        .find_by_text("Tipping the Velvet", first_match=True, clean_match=False) \
        .find_similar(ignore_attributes=["title"])

def test_autoscraper(request_html):
    # autoscraper by default returns elements text
    return AutoScraper().build(html=request_html, wanted_list=["Tipping the Velvet"])

两个对比点:Scrapling 的 find_similar(ignore_attributes=["title"]) 忽略 title 属性(书名的 tooltip),让评分聚焦结构而非标题文案;而 AutoScraper 默认返回匹配元素的文本,脚本注释特意说明这一点对齐,保证两者返回物可比。

最后,display() 函数会把结果按耗时升序排序并计算相对 Scrapling 的倍数,即文档中表格的 vs Scrapling 列(benchmarks.py)。

2.4 如何复现

脚本入口逻辑(benchmarks.py):先本地跑第一组,再请求 books.toscrape.com 跑第二组。运行前需要安装被对比的第三方库(autoscraping/autoscraperbs4mechanicalsouppyqueryselectolax 等,脚本 import 区即完整清单,见 benchmarks.py),Scrapling 核心安装即可(核心依赖见 pyproject.toml,含 lxml>=6.1.1cssselect>=1.5.0 等,支持 Python 3.10~3.13)。需要注意:

  • 第二组测试依赖外网可访问 books.toscrape.com
  • 绝对数值与 CPU、内存、库版本强相关,复现时更应关注相对倍率是否稳定。

三、Scrapling 为什么快:源码级设计解析

有了方法论基础,再看 scrapling/parser.py 中的实现,就能把测试结果落到具体设计上。

3.1 解析层:lxml 直连 + 大文档友好

Selector 的构造直接走 lxml 的 HTMLParserscrapling/parser.py):

_parser_kwargs: Dict[str, Any] = dict(
    recover=True,
    remove_blank_text=True,
    remove_comments=(not keep_comments),
    encoding=encoding,
    compact=True,
    huge_tree=huge_tree,   # 默认 True
    default_doctype=True,
    strip_cdata=(not keep_cdata),
)
parser = HTMLParser(**_parser_kwargs)
self._root = cast(HtmlElement, fromstring(body or "<html/>", parser=parser, base_url=url or ""))

这与基准测试中给裸 Lxml 配置的参数一致——recover=True 提供容错解析,huge_tree=True 解除 libxml2 对大型文档的保护性限制,remove_blank_text/compact 则从源头压缩内存占用。Selector 没有继承 lxml.html.HtmlElement,而是包一层 HtmlElement 引用:源码注释解释这是为了避免 lxml 代理对象不可 pickle 的老问题,同时用 __slots__ 控制实例开销(scrapling/parser.py)。

3.2 选择路径:CSS 编译为 XPath 后原生执行

基准测试里 Scrapling 走 .css(".item::text")。其内部实现(scrapling/parser.py)是把 CSS 选择器转成 XPath 字符串,再交给 lxml 的 xpath() 执行:

xpath_selector = _css_to_xpath(selector)
return self.xpath(xpath_selector, identifier or selector, ...)

即 CSS 只是入口,最终执行的是 libxml2 的 C 实现 XPath 引擎,这与 Parsel 的路径一致——这也是两者(1.99 ms vs 2.06 ms)处于同一梯队的原因。Scrapling 的领先来自包装层的更少开销:

  • 惰性属性缓存tagtextattrib 等属性被实现为带缓存的 property,源码注释直言动机——"So they don't slow down the process of initializing many instances of the class... Doing that only made the library performance test sky rocketed multiple times faster"(scrapling/parser.py)。批量 css() 时每个结果都会构造 Selector,避免初始化期急切求值收益显著;
  • 预编译 XPath 常量:模块级预编译了高频表达式,如 _find_all_elements = XPath(".//*")_find_all_elements_with_spaces = XPath(".//*[normalize-space(text())]")scrapling/parser.py),避免每次查找重复编译;
  • 文本节点直接取值::text 选择的结果是 _ElementUnicodeResultgetall() 对文本节点直接 str() 序列化(scrapling/parser.py),省去了"元素对象逐个取 .text"的包装与属性查找——这正是测试注释中 Scrapling 快于列表推导写法的原因。

3.3 第二组测试的主角:find_by_text 与 find_similar

find_by_text()scrapling/parser.py)的性能路径很直接:

  1. 用预编译 XPath .//*[normalize-space(text())] 一次性取出所有带文本的元素;
  2. 对每个节点取 text 属性(内部是惰性缓存的 TextHandler),若 clean_match=True 先做 clean()(空白折叠,基于 str.translate + 预编译正则,见 scrapling/core/custom_types.py);
  3. 大小写不敏感时统一 lower() 后做包含/全等比较;
  4. first_match=True 命中即 break 提前退出。

基准测试里 clean_match=False 关掉了清洗步骤,进一步贴近裸比较速度。

find_similar()scrapling/parser.py)则是"结构剪枝 + 属性相似度评分"的两段式算法,这是它相对 AutoScraper 快 5.47x 的核心:

  1. XPath 剪枝:先用当前元素的深度、自身及上两级祖先的标签拼出路径,再执行 root.xpath(f"{xpath_path}[count(ancestor::*) = {current_depth}]") 只留下"同深度、同 parent/tag 路径"的候选,把全页扫描压缩成小集合;
  2. 属性相似度评分:对候选逐个调用私有方法 __are_alike(),用 difflib.SequenceMatcher 逐属性计算比值并取平均,>= similarity_threshold(默认 0.2)判为相似(scrapling/parser.py);ignore_attributes 默认忽略 hrefsrc——因为 URL 在同类元素间变化大、不可靠;若元素本身无属性且候选也无属性,直接记 100% 匹配。

这套算法不依赖元素类型(docstring 明确说明"works in any case without depending on the element type"),且行为有完整测试覆盖,例如 find_similar 的默认行为、阈值过滤、match_text、属性数量不匹配时的评分等用例见 tests/parser/test_find_similar_advanced.py

3.4 返回值类型:为速度定制的 str 子类

解析结果统一包装在 TextHandler/TextHandlers/AttributesHandler 中(scrapling/core/custom_types.py):

  • TextHandler 继承 str(零拷贝包装)并重写 clean()re() 等热路径方法,返回链式可用的同类型对象;
  • AttributesHandler 内部用 MappingProxyType 存储,源码注释称其为"Fastest read-only mapping type",读取属性免加锁、免拷贝;
  • Selectors 继承 List,支持 += 与切片,便于 css() 内多选择器分支的结果拼接(scrapling/parser.py)。

这些类型不增加运行时对象层级,却在提取后仍保留正则、实体替换(replace_entities 基于 w3lib)、JSON 解析(json() 基于 orjson)等能力。

四、实践建议与适用边界

结合基准测试的调用方式与源码实现,几条可直接落地的建议:

  1. 批量取文本优先 ::text + getall()Selector(html, adaptive=False).css(".item::text").getall() 是最快的提取形态;只有需要逐个元素做属性/子树操作时才用 .css(".item") 再遍历;
  2. 大页面保持 huge_tree=True(默认即是),避免 libxml2 保护性中断;
  3. find_by_text(..., first_match=True) 做"以已知文本为锚点"的定位,命中即停;若页面有较多空白噪音可开 clean_match,代价是每节点一次清洗;
  4. find_similar 的阈值默认 0.2 即可覆盖绝大多数同构列表场景(docstring 建议"don't play with this number unless you are getting the results you don't want");跨列表定位时按需传 ignore_attributes 过滤高变属性,如基准测试中的 ignore_attributes=["title"]
  5. 纯性能场景显式 adaptive=False:自适应功能(元素指纹存储、页面改版后的 relocate 重定位)会引入 SQLite 存储与评分开销,基准测试正是在关闭该功能的条件下测得的纯选择器速度。

最后说明适用范围:本文所有数据引自仓库 docs/benchmarks.md 的既有记录,绝对耗时随硬件与依赖版本变化;复现时请以 benchmarks.py 的输出为准,并优先核对相对倍率。Scrapling 当前版本为 0.4.13(见 scrapling/init.py),基准脚本依赖 Python 3.10~3.13 环境及 lxml 6.1.1+。

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