Python 爬虫(二):解析的取舍——CSS、XPath 与三种解析器

Posted on Wed 14 October 2026 in 技术 • Sucre Liu

解析的本质是"抗变化"

爬虫最容易坏的地方不是网络,是解析。目标站改一次版式,你的选择器就可能全废。

所以解析代码的质量标准不是"现在能跑",而是"改版后还能不能修得快"。选择器写得越贴近页面结构(div > div:nth-child(3)),越容易碎;越贴近语义([data-testid="price"]、#price),越耐用。

本文基于 BeautifulSoup 4.14、lxml 6.1、parsel 1.11。

三种解析器,各有位置

工具 解析方式 适合
BeautifulSoup CSS 选择器(select)+ 树遍历 一次性脚本、结构不规整、需要容错
lxml XPath、极快 结构规整、量大、需要 XPath 能力
parsel CSS 和 XPath Scrapy 生态、需要两种选择器混用

它们不是竞争关系。常见的组合是 BeautifulSoup + lxml 解析器:

from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "lxml")   # 用 lxml 做底层,比内置 html.parser 快

html.parser 容错好但慢;lxml 快且能"修复"残缺 HTML,但对畸形标签更严格。注意:不同解析器对同一段坏 HTML 的结果可能不同,别在开发用 html.parser、上线换 lxml——那会引入"换了解析器就少了几条数据"的诡异 bug。统一一种。

CSS 还是 XPath

两者都能定位元素,区别在表达力和可读性:

  • CSS 选择器:更短、更直观,前端一看就懂。但不能按文本内容选,也没有"父节点/祖先节点"这种轴。
  • XPath:能做 CSS 做不到的事——按文本选、取祖先、用函数。代价是更长、更容易写错。

几个 XPath 独占的场景:

# 按文本内容选(CSS 做不到)
soup.select_one("//a[normalize-space(text())='Next']")
# 从子节点反查父节点
soup.select_one("//span[@class='price']/..")
# 选所有包含某 class 的 div(部分匹配)
soup.select_one("//div[contains(@class,'product')]")

而 CSS 这边,soup.select() 就够用:

for card in soup.select("article.product_pod"):
    title = card.select_one("h3 a")["title"]          # 属性
    price = card.select_one("p.price_color").get_text(strip=True)

一个常被忽略的细节:get_text(strip=True) 只去掉首尾空白,不会合并中间的多余空格和换行。稳妥做法是再 re.sub(r"\s+", " ", text).strip()。

实战:同一页,两种写法

以练习站 books.toscrape.com 的列表页为例。每本书是一个 article.product_pod。

BeautifulSoup(CSS)

import re
from bs4 import BeautifulSoup

def parse_list_bs(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    items = []
    for card in soup.select("article.product_pod"):
        a = card.select_one("h3 a")
        price_el = card.select_one("p.price_color")
        if not a or not price_el:
            continue                       # 缺节点就跳过,别让它崩
        rating_el = card.select_one("p.star-rating")
        rating = rating_el["class"][-1] if rating_el else None
        items.append({
            "title": a.get("title") or a.get_text(strip=True),
            "price": re.sub(r"\s+", " ", price_el.get_text(strip=True)),
            "rating": rating,              # 如 "Three"
            "url": a["href"],
        })
    return items

parsel(CSS + XPath 混用)

from parsel import Selector

def parse_list_parsel(html: str) -> list[dict]:
    sel = Selector(text=html)
    items = []
    for card in sel.css("article.product_pod"):
        items.append({
            "title": card.css("h3 a::attr(title)").get(),
            "price": card.css("p.price_color::text").get(),
            # 用 XPath 取 class 的最后一个词(评分)
            "rating": card.xpath("p[contains(@class,'star-rating')]/@class").get(),
            "url": card.css("h3 a::attr(href)").get(),
        })
    return items

parsel 的 ::text / ::attr() 语法来自 Scrapy 的选择器,在 Scrapy 项目里是原生能力,response.css(...) 返回的就是 Selector。这也是为什么写 Scrapy 时几乎不用 BeautifulSoup。

抗变化:选择器怎么写得耐用

这是本篇最想让你记住的一节。

  1. 优先用稳定的锚点:id、data-*、语义化的 class(price、product-title)比 nth-child 稳。前端改样式常常只改布局类名,不动语义类。
  2. 少用绝对路径:body > div:nth-child(2) > ... 一次改版就废。用 soup.select_one("div.product .price") 这种"从语义锚点出发"的写法。
  3. 能取数据接口就别解析 DOM:很多页面把数据放在 <script> 里(如 window.__NEXT_DATA__ = {...})。直接抽 JSON 比解析 HTML 稳得多:

python import json, re m = re.search(r"window\.__NEXT_DATA__\s*=\s*(\{.*?\});", html, re.S) data = json.loads(m.group(1)) if m else {} 4. 永远假设节点可能不存在:select_one 返回 None。用 .get()/判空,而不是 [0] 硬取。 5. 别用正则解析 HTML。正则适合清洗文本(价格、日期),不适合当解析器——HTML 不是正则语言。 6. 把选择器集中管理。一个页面一个 SELECTORS = {...} 常量表,改版时只改一处。

一个判断标准

解析写得好不好,用一句话检验:"如果明天这个页面加了两个无关的 <div>,我的代码还准吗?" 如果你的选择器依赖了那个位置的层级,答案就是"不准"。

小结

  • 解析的目标是抗变化,不是"现在能跑"。
  • BeautifulSoup 适合脚本与容错;lxml 快且能 XPath;parsel 让 CSS 与 XPath 混用,是 Scrapy 的原生选择。
  • 统一解析器,别开发/上线用不同的。
  • CSS 直观,XPath 能按文本/轴定位;按需选,不要迷信其一。
  • 优先语义锚点、data-*、甚至页面内 JSON;永远判空;别用正则解 HTML。

下一篇讲会话与登录:Cookie/Session 到底存了什么、CSRF token 怎么处理、以及"抄下浏览器真实请求"这件事为什么比想象中重要。