bigywater.tech
Scrapy · 29

Selector:xpath、css 与 re 的底层是什么

response.css() 和 response.xpath() 到底谁在执行?::text 与 //text() 差在哪?坏掉的 HTML 被修成什么样?为什么有些 css 选择器特别慢?这篇从翻译后的 XPath 讲起,再量一遍 1 MB 文档上的耗时。

6 倍
1 MB 文档上等价的 css(178.0 ms)比 xpath(28.9 ms)取 href 慢的倍数
1203.4 ms
css("li:nth-child(100)") 在 20,000 个兄弟节点上的耗时(翻译成逐个数前面的兄弟)
0 个
默认命名空间的 Atom 文档上 //entry 能查到的节点数

底层

实测 · 本机执行输出
  parsel 1.12.1 / lxml (6, 1, 3, 0) / libxml2 2.14.6 / cssselect 1.6.0
  div.item > a                  → descendant-or-self::div[@class and contains(@class, 'item') and contains(concat(' ', normalize-space(@class), ' '), ' item ')]/a
  ul li:nth-child(2)            → descendant-or-self::ul/descendant::li[count(preceding-sibling::*) = 1]
  a[href^="http"]               → descendant-or-self::a[@href and starts-with(@href, 'http')]
  p::text                       → descendant-or-self::p/text()
  a::attr(href)                 → descendant-or-self::a/@href
  li:first-child                → descendant-or-self::li[count(preceding-sibling::*) = 0]
  div#main p.x                  → descendant-or-self::div[@id = 'main']/descendant::p[@class and contains(@class, 'x') and contains(concat(' ', normalize-space(@class), ' '), ' x ')]
  a:not(.skip)                  → descendant-or-self::a[not(@class and contains(@class, 'skip') and contains(concat(' ', normalize-space(@class), ' '), ' skip '))]
  h1 + p                        → descendant-or-self::h1/following-sibling::*[(self::p) and (position() = 1)]
  input[type=checkbox]:checked  → descendant-or-self::input[(@type = 'checkbox') and ((@selected and name(.) = 'option') or (@checked and (name(.) = 'input' or name(.) = 'command')and (@type = 'checkbox' or @type = 'radio')))]

parsel 的 Selector 用 lxml 解析并执行 XPath;css() 经 cssselect 翻译成 XPath 之后执行,所以 css 不比 xpath 快。

取文本

实测 · 本机执行输出
  div::text            = ['head ', ' tail ']
  div ::text           = ['head ', 'bold', ' tail ', 'it']
  //div/text()         = ['head ', ' tail ']
  //div//text()        = ['head ', 'bold', ' tail ', 'it']
  string(//div)        = ['head bold tail it']
  div *::text          = ['head ', 'bold', ' tail ', 'it']
  div.get()            = <div id="a">head <b>bold</b> tail <i>it</i></div>
  div::attr(id)        = a  | @id: a
  不存在时 .get()       = None | .get(default="N/A") = N/A | .getall() = []
  re / re_first        = ['head'] it nodefault

::text 只取直接子文本;要所有后代文本写 ::text 前加空格或用 //text();要拼接成一个字符串用 string()。

坏 HTML

实测 · 本机执行输出
  输入: '<p>one<p>two<table><tr><td>a<td>b</table><b><i>x</b></i>&unknown; &amp; <div'
  lxml 修复后的 HTML: <html><body><p>one</p><p>two</p><table><tr><td>a</td><td>b</td></tr></table><b><i>x</i></b>&amp;unknown; &amp; </body></html>
  p 的个数: 2 ;td 的个数: 2
  type="xml" 也不抛错(lxml 用 recover 模式),修复后: <p>one<p>two<table><tr><td>a<td>b</td><b><i>x</i></b>&unknown;  <div/></td></tr></table></p></p>
  type="xml" 合法文档: ['1', '2']
  空文档 Selector(text="") 的 root: <Element html at 0x…> ;get(): '<html></html>'

libxml2 的 HTML 解析器会补全、理顺标签,所以选择器几乎不会因为 HTML 写得烂而失败;type="xml" 同样是容错模式但修复结果不同。

命名空间

实测 · 本机执行输出
  //entry                         : 0 个
  //a:entry (namespaces={"a": …}) : 2 个
  remove_namespaces() 之后 //entry : 2 个;//link/@href = ['/a', '/b']

XML 带默认命名空间时普通 XPath 查不到元素,需要给前缀或 remove_namespaces()。

性能

实测 · 本机执行输出
  文档大小 1,077,815 字节
  Selector(text=html) 解析:15.8 ms
  s.css("li.item a::attr(href)").getall():178.0 ms
  s.xpath("//li[@class='item']/a/@href").getall():28.9 ms
  s.xpath("//a/@href").getall():23.5 ms
  s.css("li:nth-child(100)").get():1203.4 ms
  s.css("li")(只构造 Selector 列表,不取值):16.9 ms
  s.re(r"/p/(\d+)")(对整个文档做正则):10.2 ms
  HtmlResponse.css 第一次(含解析):53.7 ms;同一个 response 第二次起:37.92 ms(response.selector 已缓存)

解析很快(1 MB 约 16 ms),慢在选择器本身:类选择器翻译出来的 XPath 比 @class 等值判断慢数倍,:nth-child(n) 在大量兄弟上是平方级。解析结果缓存在 response.selector 里。

分步演示

选择器
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Scrapy 2.19.0 / Twisted 26.4.0 / lxml 6.1.3 / parsel 1.12.1(临时虚拟环境);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、重定向、压缩、断连等行为,并记录收到的原始请求;每个实验在临时目录里新建 Scrapy 项目或单文件 spider,命令输出来自真实的 scrapy 进程;计时用 time.perf_counter,数字每次运行会有小幅抖动(cssselect 1.6.0)。除命名空间和耗时外,实验直接在虚拟环境里构造 Selector / HtmlResponse;耗时取 20 次中位数(解析取 5 次),文档是 20,000 个 li 的 1,077,815 字节 HTML
    • 没有验证:Selector 的 type="json"、jmespath 的使用(见后面的文章);xpath 的 EXSLT 正则扩展(re:test);真实网页(嵌套深、脚本多)上的解析耗时;lxml 以外的解析器;css 与 xpath 在小文档上的耗时差(这里只在 1 MB 文档上量过);编码问题(见响应编码那一篇)
    京ICP备2026067502号-1京公网安备11010502063487号