CSS 选择器与 XPath 实战

解析库把 HTML 变成了树,真正决定你能否取到数据的还是选择器写得稳不稳。本章先给出两套语法的速查对照与取舍,再用同一段 HTML 分别跑 lxmlparsel,最后说清怎么写选择器才不容易被页面改版打断。

两套语法的分工

CSS 选择器短、可读性好,适合「按结构取节点」;XPath 表达力更强,能按文本内容、兄弟关系、祖先关系定位,还能取属性与文本节点。parsel.Selector 同时提供 response.cssresponse.xpathlxml.cssselect 则把 CSS 编译成 XPath,两者结果可以互相印证。

装齐依赖:pip install lxml cssselect parsel

CSS 选择器速查

写法示例含义
标签div所有 div
.class.titleclass 中含 title 的元素
#id#mainid 为 main,页面内唯一,通常最稳
[attr="v"]a[data-id="9"]属性精确匹配
[attr^=]a[href^="/p/"]前缀匹配,另有 $= 后缀、*= 包含
后代div.list a空格表示任意层级后代
直接子元素ul > li只选直接子节点,不加 > 会连更深层一起选
相邻兄弟h2 + p紧跟在 h2 之后的那个 p
通用兄弟h2 ~ ph2 之后同级的所有 p
:nth-child(n)li:nth-child(2)同级第 2 个,按位置不按标签类型
:first-childli:first-child同级第一个,:first-of-type 才按同标签算
:not()li:not(.ad)排除匹配项,常用来跳过广告位

XPath 轴与函数对照

写法示例含义
////div文档中任意层级的 div
//html/body绝对路径,单斜杠在中间表示直接子节点
text()//h1/text()取文本节点
@attr//a/@href取属性值
contains()//div[contains(@class,"item")]属性包含子串,适合含多个 class 的元素
starts-with()//a[starts-with(@href,"/p/")]属性前缀匹配
normalize-space()normalize-space(//h1)压掉首尾空白与连续空白
following-sibling:://h2/following-sibling::p[1]之后的同级节点
ancestor:://span/ancestor::li[1]向上找最近的祖先,适合「从标签反查所在条目」
position()//li[position()<=3]按位置过滤,可做范围筛选
[1]//li[1]每个父节点下的第一个,不是整个结果集的第一个
[last()](//li)[last()]加括号才是整个结果集的最后一个

[1](//li)[1] 的区别是新手最常踩的坑:前者按父节点分组取第一个,后者才是全局第一个。

实战一:lxml.html 加 cssselect

"""离线跑通 CSS 与 XPath:python css_demo.py"""
from lxml import html
from lxml.cssselect import CSSSelector

HTML = """<ul class="list">
  <li class="item"><a class="title" href="/p/1">文章 A</a><span class="tag hot">热门</span></li>
  <li class="item ad"><a href="/ad/1">广告位</a></li>
  <li class="item"><a class="title" href="/p/2">文章 B</a></li>
</ul>"""

tree = html.fromstring(HTML)

# CSSSelector 会把 CSS 编译成 XPath,打印 path 能看清它到底怎么选的
sel = CSSSelector("li.item:not(.ad) > a.title")
print(sel.path)                                    # 编译后的 XPath 表达式
print([a.text for a in sel(tree)])                 # ['文章 A', '文章 B']
print(len(tree.cssselect("ul.list > li")))         # 3,同一棵树也能直接 .cssselect()
print(tree.xpath("//a[@class='title']/@href"))     # ['/p/1', '/p/2']
print(tree.xpath("//span[contains(@class,'hot')]/ancestor::li[1]/a/text()"))  # ['文章 A']
print(tree.xpath("normalize-space(//a[@class='title'][1])"))                  # 文章 A

实战二:parsel 在同一段 HTML 上跑两套

"""同一段 HTML 跑 CSS 与 XPath:python parsel_demo.py"""
from parsel import Selector

HTML = """<ul class="list">
  <li class="item"><a class="title" href="/p/1">文章 A</a><span class="tag hot">热门</span></li>
  <li class="item ad"><a href="/ad/1">广告位</a></li>
  <li class="item"><a class="title" href="/p/2">文章 B</a></li>
</ul>"""

sel = Selector(text=HTML)
print(sel.css("li.item:not(.ad) a.title::text").getall())       # ['文章 A', '文章 B']
print(sel.css("li.item:not(.ad) a.title::attr(href)").getall())  # ['/p/1', '/p/2']
print(sel.xpath("//li[contains(@class,'item') and not(contains(@class,'ad'))]/a/text()").getall())
print(sel.xpath("(//a[@class='title'])[last()]/@href").get())    # /p/2,括号决定是全局最后一个
print(sel.xpath("//a[@class='title'][1]/@href").getall())        # ['/p/1', '/p/2'],每个父节点下第一个
print(sel.css("li.item:not(.ad) a::attr(href)").get())
# 两套写法结果必须一致;对不上说明选择器写歪了,先用浏览器控制台定位

parsel::text::attr(href) 是伪元素语法,lxml 没有,对应的是 XPath 里的 text()@href

常见坑与调试方法

表现处理
长链位置选择器div > div:nth-child(3) > span 页面一改就失效iddata-*、语义化 class
[1] 当成全局第一个取回多条或取错需要全局用 (//x)[1]
contains(@class,"item") 误伤item-ad 也被选中not(contains(...)) 或精确比较
空白与换行文本里混进 \n、多个空格CSS 侧 get_text(strip=True),XPath 侧 normalize-space()
选择器命中 0 条却不报错getall() 返回空列表,任务静默丢数据断言数量,或先打印 len() 再决定是否继续

浏览器是最快的调试场:F12 控制台里 $$(".list > li") 走 CSS,$x("//li[1]") 走 XPath,两者返回的都是元素而不是字符串。选中后写 $$(".list > li").length 看条数,能立刻判断页面是否被前端渲染成了空壳。

选择器维护与要点对照

需求推荐写法
页面内唯一锚点#main[data-testid="list"]
稳定属性data-*、语义化 class,避免依赖 nth-child
排除干扰项li:not(.ad)[contains(@class,"item")]
从标签反查条目ancestor::li[1]
按文本定位//a[normalize-space(text())="下一页"]
结果集取值.get() 取第一个,.getall() 取全部并判空

写选择器前先问一句:如果页面顶部加了一个广告位或者新增一列,这个选择器还能选到原来的数据吗?答不上来就说明它绑得太死。同理,抓取范围也要设边界:遵守 robots.txt 与服务条款,控制请求间隔与并发上限,不采集个人隐私与付费内容,不绕过登录、付费墙、验证码等访问控制,数据使用遵循《个人信息保护法》《数据安全法》《著作权法》。选择器再稳,越界采集也不会因此变得正当。

小结:CSS 负责按结构快速取节点,XPath 负责按文本、兄弟与祖先关系精确定位,parsel 让两套语法在同一棵树上共存;选择器优先挂 iddata-* 和语义化 class,别写长链 nth-child,调试交给浏览器的 $$$x,采集范围则守好 robots.txt 与站点条款。

笔记加载中…