写爬虫时用 XPath 还是 CSS 选择器常常是习惯之争。这篇用同一个页面量一量:各自能表达什么、哪些写法有陷阱(尤其 class 匹配和 //a[1])、cssselect 把 CSS 翻译成什么,以及耗时到底差多少。
任务 CSS XPath 结果条数 CSS / XPath 所有商品 li li.item ✓ 3 / 3 同时有 item 和 sale 两个 classli.item.sale ✓ 2 / 2 data-id 为 2 的 li li[data-id="2"] ✓ 1 / 1 href 以 /p/ 开头的链接 a[href^="/p/"] ✓ 3 / 3 href 含 other.test 的链接 a[href*="other.test"] ✓ 1 / 1 li 的直接子元素 a li > a ✓ 6 / 6 a 的下一个兄弟 span a + span ✓ 4 / 4 每个 ul 的第 2 个 li ul > li:nth-child(2) ✓ 2 / 2 不含 sale 的商品 li.item:not(.sale) ✓ 1 / 1 价格文字(span.price 的文本) span.price ✓ 4 / 4 链接的 href 属性值 a[href] ✓ 6 / 6 文字是 "Two" 的链接 (CSS 表达不了) ✓ — / 1 包含 a 的 li 的父元素 ul (CSS 表达不了) ✓ — / 2 有 sale 商品的 div(向上找祖先) (CSS 表达不了) ✓ — / 1 最后一个 li li:last-child ✓ 2 / 2 两种元素的并集 h1 和 p.note h1, p.note ✓ 3 / 3 CSS 能表达的 13 个任务,CSS 表达不了的 3 个(按文字内容选、选父元素、选祖先)
CSS 能写 16 个任务中的 13 个;写不了的是按文字内容选、选父元素和选祖先——这些要用 XPath(//a[text()="Two"]、/parent::ul、/ancestor::div[1])。反过来,XPath 里匹配 class 很啰嗦,CSS 的 li.item 更短也更不容易写错。
CSS li.item → 3 个(按空白分隔的"词"匹配,itemized 不算)
XPath //li[@class="item"] → 1 个(整个属性值必须恰好等于 item,漏掉 "item sale")
XPath //li[contains(@class, "item")] → 4 个(子串匹配,把 "itemized" 也选进来了)
XPath //li[contains(concat(" ", normalize-space(@class), " "), " item ")] → 3 个(和 CSS 一致,但写起来很长)CSS 的 .item 是“按空白分隔的词”匹配。XPath 的 @class="item" 要求整个属性值恰好等于 item(漏掉 item sale),contains(@class, "item") 是子串匹配(多选 itemized),正确写法是 contains(concat(" ", normalize-space(@class), " "), " item ")。
//a[1] → 6 个(每个父元素下的第一个 a) (//a)[1] → 1 个(整个文档的第一个 a) CSS a:nth-of-type(1) → 6 个(和 //a[1] 一样,每个父元素下的第一个 a) XPath 的下标从 1 开始://li[1] 选出 ['One¥10'],//li[0] → 0 个
//a[1] 是“每个父元素下的第一个 a”,整个文档的第一个要写成 (//a)[1];XPath 下标从 1 开始。
li.item → descendant-or-self::li[@class and contains(@class, 'item') and contains(concat(' ', normalize-space(@class), ' '), ' item ')]
li.item.sale → descendant-or-self::li[(@class and contains(@class, 'item') and contains(concat(' ', normalize-space(@class), ' '), ' item ')) and (@class and contains(@class, 'sale') and contains(concat(' ', normalize-space(@class), ' '), ' sale '))]
a[href^="/p/"] → descendant-or-self::a[@href and starts-with(@href, '/p/')]
ul > li:nth-child(2) → descendant-or-self::ul/li[count(preceding-sibling::*) = 1]
li.item:not(.sale) → descendant-or-self::li[(@class and contains(@class, 'item') and contains(concat(' ', normalize-space(@class), ' '), ' item ')) and (not(@class and contains(@class, 'sale') and contains(concat(' ', normalize-space(@class), ' '), ' sale ')))]
a + span → descendant-or-self::a/following-sibling::*[(self::span) and (position() = 1)]
p.note → descendant-or-self::p[@class and contains(@class, 'note') and contains(concat(' ', normalize-space(@class), ' '), ' note ')]lxml 的 cssselect 把 CSS 翻译成 XPath 再执行,li.item 展开成带两层 contains 的长表达式,a + span 展开成 following-sibling 加 position() = 1。结果等价,多一层翻译。
方式 每次耗时 XPath 字符串 tree.xpath(…)(每次调用) 3.06 ms XPath 预编译对象 etree.XPath(…) 2.93 ms CSS 字符串 tree.cssselect(…)(每次调用) 2.77 ms CSS 预编译对象 CSSSelector(…) 2.84 ms XPath 精确匹配一个属性(预编译) 1.29 ms CSS 精确匹配一个属性(预编译) 1.10 ms 编译一个 CSSSelector 对象的耗时:17 微秒;编译一个 etree.XPath 对象:3 微秒
5000 个 li 的页面上 XPath 和 CSS 的每次执行耗时都在 2.7–3.1 ms,预编译与否差别不到 10%,编译一个选择器只要几到十几微秒。所以选哪一种应由正确性和可读性决定,不是速度。