Python Parsel 教程:用 CSS、XPath 提取网页数据
Python Parsel 怎么用?本文通过可运行示例讲清 Selector、CSS、XPath、正则提取、列表页解析及空结果排查,并给出 Parsel 配合 requests 的完整写法。
安装 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 语法。在浏览器控制台测试定位规则时,先测试 h1、a 这部分,回到 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