Scrapling 解析器性能基准测试:从 5000 元素文本提取到自适应元素相似度搜索的实测方法与源码剖析
本文以仓库文档 docs/benchmarks.md 为核心,完整解读 Scrapling 解析器与其他主流 HTML 解析库的基准测试结果与测试方法,并结合 benchmarks.py、scrapling/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),其流程是:
- 预热阶段:先用
timeit.repeat(..., number=2, repeat=2)跑 2 轮,避免冷启动、JIT 之外的首次开销污染数据; - 正式测量:
timeit.repeat(..., number=1, repeat=100, timer=time.process_time)——即单次执行、重复 100 次,用time.process_time(只计 CPU 时间,不含 I/O 等待)采样; - 取 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 侧刻意关闭了 adaptive(
adaptive=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/autoscraper、bs4、mechanicalsoup、pyquery、selectolax 等,脚本 import 区即完整清单,见 benchmarks.py),Scrapling 核心安装即可(核心依赖见 pyproject.toml,含 lxml>=6.1.1、cssselect>=1.5.0 等,支持 Python 3.10~3.13)。需要注意:
- 第二组测试依赖外网可访问
books.toscrape.com; - 绝对数值与 CPU、内存、库版本强相关,复现时更应关注相对倍率是否稳定。
三、Scrapling 为什么快:源码级设计解析
有了方法论基础,再看 scrapling/parser.py 中的实现,就能把测试结果落到具体设计上。
3.1 解析层:lxml 直连 + 大文档友好
Selector 的构造直接走 lxml 的 HTMLParser(scrapling/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 的领先来自包装层的更少开销:
- 惰性属性缓存:
tag、text、attrib等属性被实现为带缓存的 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选择的结果是_ElementUnicodeResult,getall()对文本节点直接str()序列化(scrapling/parser.py),省去了"元素对象逐个取.text"的包装与属性查找——这正是测试注释中 Scrapling 快于列表推导写法的原因。
3.3 第二组测试的主角:find_by_text 与 find_similar
find_by_text()(scrapling/parser.py)的性能路径很直接:
- 用预编译 XPath
.//*[normalize-space(text())]一次性取出所有带文本的元素; - 对每个节点取
text属性(内部是惰性缓存的TextHandler),若clean_match=True先做clean()(空白折叠,基于str.translate+ 预编译正则,见 scrapling/core/custom_types.py); - 大小写不敏感时统一
lower()后做包含/全等比较; first_match=True命中即break提前退出。
基准测试里 clean_match=False 关掉了清洗步骤,进一步贴近裸比较速度。
find_similar()(scrapling/parser.py)则是"结构剪枝 + 属性相似度评分"的两段式算法,这是它相对 AutoScraper 快 5.47x 的核心:
- XPath 剪枝:先用当前元素的深度、自身及上两级祖先的标签拼出路径,再执行
root.xpath(f"{xpath_path}[count(ancestor::*) = {current_depth}]")只留下"同深度、同parent/tag路径"的候选,把全页扫描压缩成小集合; - 属性相似度评分:对候选逐个调用私有方法
__are_alike(),用difflib.SequenceMatcher逐属性计算比值并取平均,>= similarity_threshold(默认 0.2)判为相似(scrapling/parser.py);ignore_attributes默认忽略href、src——因为 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)等能力。
四、实践建议与适用边界
结合基准测试的调用方式与源码实现,几条可直接落地的建议:
- 批量取文本优先
::text+getall():Selector(html, adaptive=False).css(".item::text").getall()是最快的提取形态;只有需要逐个元素做属性/子树操作时才用.css(".item")再遍历; - 大页面保持
huge_tree=True(默认即是),避免 libxml2 保护性中断; - 用
find_by_text(..., first_match=True)做"以已知文本为锚点"的定位,命中即停;若页面有较多空白噪音可开clean_match,代价是每节点一次清洗; 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"];- 纯性能场景显式
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+。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00