Python Parsel 教程:用 CSS、XPath 提取网页数据

Python Parsel 怎么用?本文通过可运行示例讲清 Selector、CSS、XPath、正则提取、列表页解析及空结果排查,并给出 Parsel 配合 requests 的完整写法。

5 分钟阅读 CSS选择器爬虫入门requestsPython爬虫xpathlxmlpython-crawlerdata-extraction

安装 Parsel

pip install parsel

如果示例还要发送 HTTP 请求,再安装 requests

pip install requests

从 Selector 开始

Selector 用来包装待解析的文本。调用 .css().xpath() 后,会得到一个 SelectorList,它可以继续调用选择器方法,也可以直接遍历。

from parsel import Selector

html = """
<html>
  <body>
    <div class="article">
      <h1>Python 爬虫入门</h1>
      <p class="summary">学习网页请求与数据提取。</p>
      <a href="/start">开始阅读</a>
    </div>
  </body>
</html>
"""

selector = Selector(text=html)

后面的 CSS、XPath 和正则示例都使用这个 selector

用 CSS 选择器提取数据

CSS 语法短,页面结构不复杂时很好读。

如果你对类选择器、属性选择器和后代选择器还不熟,可以先看 CSS 选择器入门:从改样式到爬虫数据定位

# 提取文本
title = selector.css("h1::text").get()
print(title)  # Python 爬虫入门

# 提取属性
link = selector.css("a::attr(href)").get()
print(link)  # /start

# 提取全部匹配结果
paragraphs = selector.css("p::text").getall()
print(paragraphs)  # ['学习网页请求与数据提取。']

::text::attr(name) 是 Parsel/Scrapy 扩展的伪元素:

  • ::text 获取当前元素的直接文本节点
  • ::attr(href) 获取元素的 href 属性

它们不是浏览器原生 CSS 语法。在浏览器控制台测试定位规则时,先测试 h1a 这部分,回到 Parsel 后再补上 ::text::attr()

还有一个容易忽略的细节:::text 只取直接文本节点。遇到下面这种结构:

<p class="desc">价格 <strong>299</strong> 元</p>

如果想取出元素内部的全部文本,可以使用 XPath:

texts = selector.css("p.desc").xpath(".//text()").getall()
description = "".join(texts).strip()

用 XPath 提取数据

XPath 更适合按属性、层级关系或文本条件筛选节点。

# 提取文本
title = selector.xpath("//h1/text()").get()

# 提取属性
link = selector.xpath("//a/@href").get()

# 按 class 属性筛选
summary = selector.xpath('//p[@class="summary"]/text()').get()

CSS 和 XPath 可以混用。例如先用 CSS 找到文章卡片,再用 XPath 提取卡片中的全部文本。

循环内要使用相对 XPath

这可能是 Parsel 新手最容易踩的坑。

for item in selector.css("div.item"):
    # 正确:从当前 item 内部继续查找
    name = item.xpath(".//span[@class='name']/text()").get()

这里的 .// 表示“从当前节点向下查找”。如果写成 //span,查询会回到整份文档,每次循环都可能拿到同一批结果。

.get().getall() 有什么区别

两者的区别不复杂:

first = selector.css("li::text").get()
items = selector.css("li::text").getall()
  • .get() 返回第一个匹配值;没有匹配时返回 None
  • .getall() 返回所有匹配值;没有匹配时返回空列表 []

可以给 .get() 设置默认值,避免字段缺失后调用字符串方法时报错:

price = selector.css(".price::text").get(default="").strip()

旧代码中常见的 .extract().extract_first() 仍可使用,但新代码更建议写 .getall().get()

用正则表达式做二次提取

正则适合处理已经定位好的短文本,不建议直接拿它解析整页 HTML。

from parsel import Selector

price_html = "<span class='price'>价格:¥128.00</span>"
price_selector = Selector(text=price_html)

price = price_selector.css("span.price::text").re_first(r"\d+(?:\.\d+)?")
print(price)  # 128.00

.re() 返回所有匹配结果,.re_first() 只返回第一个结果。调用正则后得到的是字符串,不再是可以继续调用 .css().xpath() 的选择器。

提取列表页数据

列表页不要分别提取全部标题和全部链接后再 zip()。只要某条记录缺少一个字段,数据就可能错位。更稳妥的写法是先遍历每个卡片,再在卡片内部取字段。

from parsel import Selector

html = """
<div class="product-list">
  <div class="item">
    <a class="name" href="/product/1">机械键盘</a>
    <span class="price">299</span>
  </div>
  <div class="item">
    <a class="name" href="/product/2">无线鼠标</a>
    <span class="price">159</span>
  </div>
</div>
"""

selector = Selector(text=html)
products = []

for item in selector.css("div.item"):
    name = item.css("a.name::text").get(default="").strip()
    link = item.css("a.name::attr(href)").get(default="")
    price = item.css("span.price::text").get(default="").strip()

    if not name or not link:
        continue

    products.append({
        "name": name,
        "link": link,
        "price": price,
    })

print(products)

这个写法有两个好处:字段属于哪条记录一目了然,个别卡片缺字段时也不会影响后面的数据。

Parsel 配合 requests

下面是一个更接近实际项目的例子:请求新闻列表,检查响应,再提取标题和链接。

from urllib.parse import urljoin

import requests
from parsel import Selector


def scrape_news(url: str) -> list[dict[str, str]]:
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/124.0 Safari/537.36"
        )
    }

    try:
        response = requests.get(url, headers=headers, timeout=(5, 15))
        response.raise_for_status()
    except requests.RequestException as exc:
        print(f"请求失败:{exc}")
        return []

    selector = Selector(text=response.text)
    news_list = []

    for article in selector.css("article.news-item"):
        title = article.css("h2 a::text").get(default="").strip()
        href = article.css("h2 a::attr(href)").get(default="")
        date = article.css("time::attr(datetime)").get(default="")
        summary = " ".join(
            text.strip()
            for text in article.css("p.summary").xpath(".//text()").getall()
            if text.strip()
        )

        if not title or not href:
            continue

        news_list.append({
            "title": title,
            "link": urljoin(response.url, href),
            "date": date,
            "summary": summary,
        })

    return news_list


if __name__ == "__main__":
    for news in scrape_news("https://example.com/news"):
        print(news["title"], "->", news["link"])

这里统一捕获了 RequestException。需要区分连接失败、超时和 HTTP 状态码异常时,可以继续看 Python 爬虫 requests 异常处理完全指南

示例中有几个值得保留的习惯:

  • 请求必须设置超时,避免程序一直等下去
  • 使用 raise_for_status() 处理 4xx、5xx 响应
  • 在每个卡片内部提取字段,避免列表错位
  • 使用 urljoin() 把相对链接补成绝对链接
  • 对可能缺失的字段设置默认值

解析 XML 和命名空间

解析 XML 时显式设置 type="xml"

from parsel import Selector

xml_data = """
<root>
  <item id="1"><name>商品 A</name><stock>50</stock></item>
  <item id="2"><name>商品 B</name><stock>0</stock></item>
</root>
"""

selector = Selector(text=xml_data, type="xml")

for item in selector.xpath("//item"):
    item_id = item.xpath("@id").get(default="")
    name = item.xpath("name/text()").get(default="")
    stock = item.xpath("stock/text()").get(default="")
    print(item_id, name, stock)

带命名空间的 XML 不能总靠普通标签名匹配。数据结构简单、也不需要区分同名命名空间时,可以先移除命名空间:

selector = Selector(text=xml_with_namespace, type="xml")
selector.remove_namespaces()
items = selector.xpath("//item").getall()

如果文档里存在多个同名标签,移除命名空间可能让它们混在一起。这时应保留命名空间,并在 XPath 中注册、使用对应前缀。

为什么选择器明明正确,却取不到数据

遇到空结果时,先别急着换 CSS 或 XPath。按下面的顺序检查,通常更快。

1. 响应里根本没有目标内容

先检查服务端返回的原始 HTML:

print(response.status_code)
print(response.url)
print(response.text[:1000])

浏览器里看得到、response.text 里没有,常见原因是页面由 JavaScript 渲染。Parsel 不会执行 JavaScript,需要找到页面背后的数据接口,或者使用 Playwright 等浏览器自动化工具拿到渲染后的 HTML。

2. 请求被重定向或返回了验证页

登录页、验证码页和风控提示也可能返回 200。只看状态码不够,还要检查最终 URL、页面标题和正文片段。

3. 页面编码判断错误

如果内容存在但中文乱码,可以先检查响应头和 response.apparent_encoding,确认后再指定编码:

response.encoding = "gb18030"
selector = Selector(text=response.text)

不要见到中文站点就固定写 gbk。编码应根据响应头、页面声明或实际字节内容判断。

4. XPath 查询范围写错

在子节点中继续查询时,优先检查有没有把 .// 写成 //。后者会从整份文档开始匹配。

5. 文本藏在子标签里

p::text 取不到 <p>文字 <strong>重点</strong></p> 中的全部内容。需要改用 .//text(),再把文本片段清洗、拼接。

CSS 还是 XPath

没有必要二选一。

页面结构简单时,CSS 通常更短:

selector.css("article.news-item h2 a::text").getall()

需要按文本、父子关系或复杂条件筛选时,XPath 更顺手:

selector.xpath('//article[contains(@class, "news-item")]//a/text()').getall()

实际项目里常见的做法是:CSS 负责定位重复卡片,XPath 处理卡片内部不规则的文本结构。哪种写法更清楚,就用哪种。

常用方法速查

需求 写法
创建 HTML 解析器 Selector(text=html)
创建 XML 解析器 Selector(text=xml, type="xml")
CSS 选择节点 .css("div.item")
XPath 选择节点 .xpath("//div")
获取第一个结果 .get()
获取全部结果 .getall()
获取文本 .css("h1::text")
获取属性 .css("a::attr(href)")
正则提取首个结果 .re_first(pattern)
正则提取全部结果 .re(pattern)
移除 XML 命名空间 .remove_namespaces()

Practice

读完这一节,去靶场里验证一下。

去挑战广场练习