CSS 选择器与 XPath 实战
解析库把 HTML 变成了树,真正决定你能否取到数据的还是选择器写得稳不稳。本章先给出两套语法的速查对照与取舍,再用同一段 HTML 分别跑 lxml 与 parsel,最后说清怎么写选择器才不容易被页面改版打断。
两套语法的分工
CSS 选择器短、可读性好,适合「按结构取节点」;XPath 表达力更强,能按文本内容、兄弟关系、祖先关系定位,还能取属性与文本节点。parsel.Selector 同时提供 response.css 与 response.xpath,lxml.cssselect 则把 CSS 编译成 XPath,两者结果可以互相印证。
装齐依赖:pip install lxml cssselect parsel。
CSS 选择器速查
| 写法 | 示例 | 含义 |
|---|---|---|
| 标签 | div | 所有 div |
.class | .title | class 中含 title 的元素 |
#id | #main | id 为 main,页面内唯一,通常最稳 |
[attr="v"] | a[data-id="9"] | 属性精确匹配 |
[attr^=] | a[href^="/p/"] | 前缀匹配,另有 $= 后缀、*= 包含 |
| 后代 | div.list a | 空格表示任意层级后代 |
| 直接子元素 | ul > li | 只选直接子节点,不加 > 会连更深层一起选 |
| 相邻兄弟 | h2 + p | 紧跟在 h2 之后的那个 p |
| 通用兄弟 | h2 ~ p | h2 之后同级的所有 p |
:nth-child(n) | li:nth-child(2) | 同级第 2 个,按位置不按标签类型 |
:first-child | li: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 页面一改就失效 | 换 id、data-*、语义化 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 让两套语法在同一棵树上共存;选择器优先挂 id、data-* 和语义化 class,别写长链 nth-child,调试交给浏览器的 $$ 与 $x,采集范围则守好 robots.txt 与站点条款。