> ## Content Index
> Fetch the complete content index at: https://blog.vercanti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# BeautifulSoup 与 lxml 完全指南
- URL: https://blog.vercanti.com/beautifulsoup-yu-lxml-wan-quan-zhi-nan/
- Published: 2026-08-28T14:34:38.000Z
- Updated: 2026-08-28T14:57:00.000Z
- Description: BeautifulSoup4 和 lxml 是 Python 爬虫中最常用的两个 HTML/XML 解析库。BeautifulSoup 提供简洁的 Python 风格 API，lxml 基于 C 语言实现，性能极高，二者常配合使用。 创建 BeautifulSoup 对象时必须指定解析器。不同解析器在速度、容错性和依赖上有所差异。 推荐在生产爬虫中统一使用 lxml 解析器。 返回第一个匹配的 Tag 对象，未找到返回 None。 返回所有匹配的 Tag 列表，未找到返回空列表 。 tag.string 和 tag.text 行为有细微差别，需注意区分。
- Author: yellowdog
- Tags: Python, 框架与库

> 官方文档：<https://www.crummy.com/software/BeautifulSoup/bs4/doc/> | <https://lxml.de/>  
> 适用版本：beautifulsoup4 4.12+，lxml 5.x（2026-05-07 整理）

BeautifulSoup4 和 lxml 是 Python 爬虫中最常用的两个 HTML/XML 解析库。BeautifulSoup 提供简洁的 Python 风格 API，lxml 基于 C 语言实现，性能极高，二者常配合使用。

## 安装

```bash
pip install beautifulsoup4
pip install lxml
pip install html5lib   # 可选，用于 html5lib 解析器

```

---

## BeautifulSoup4

### 解析器选择

创建 `BeautifulSoup` 对象时必须指定解析器。不同解析器在速度、容错性和依赖上有所差异。

| 解析器       | 用法字符串          | 速度 | 容错性   | 依赖           | 适用场景       |
| --------- | -------------- | -- | ----- | ------------ | ---------- |
| Python 内置 | html.parser    | 中  | 中     | 无（标准库）       | 轻量脚本，无额外依赖 |
| lxml HTML | lxml           | 快  | 高     | 需安装 lxml     | 生产环境首选     |
| lxml XML  | lxml-xml / xml | 快  | 低（严格） | 需安装 lxml     | 解析标准 XML   |
| html5lib  | html5lib       | 慢  | 极高    | 需安装 html5lib | 需还原浏览器解析行为 |

推荐在生产爬虫中统一使用 `lxml` 解析器。

### 创建对象

```python
from bs4 import BeautifulSoup

html = "<html><body><p class='title'>Hello</p></body></html>"

# 从字符串创建
soup = BeautifulSoup(html, "lxml")

# 从文件对象创建
with open("page.html", "r", encoding="utf-8") as f:
    soup = BeautifulSoup(f, "lxml")

# 从 requests 响应创建
import requests
resp = requests.get("https://example.com")
resp.encoding = resp.apparent_encoding  # 修正编码
soup = BeautifulSoup(resp.text, "lxml")

```

---

### 查找元素

#### find()

返回第一个匹配的 `Tag` 对象，未找到返回 `None`。

| 参数         | 类型                             | 默认值  | 说明                        |
| ---------- | ------------------------------ | ---- | ------------------------- |
| name       | str / list / re.Pattern / True | —    | 标签名，如 "a"、\["a","p"\]、正则  |
| attrs      | dict                           | {}   | 属性过滤，如 {"class": "title"} |
| recursive  | bool                           | True | 是否递归搜索所有后代；False 只搜索直接子节点 |
| string     | str / re.Pattern               | None | 匹配标签的文本内容                 |
| \*\*kwargs | —                              | —    | 快捷属性过滤，如 class\_="title"  |

```python
# 按标签名查找
tag = soup.find("p")

# 按属性查找（class 是保留字，用 class_）
tag = soup.find("p", class_="title")
tag = soup.find("p", attrs={"class": "title"})

# 按 id 查找
tag = soup.find(id="main")

# 只在直接子节点中查找
tag = soup.body.find("p", recursive=False)

# 按文本内容查找
import re
tag = soup.find("p", string=re.compile(r"Hello"))

```

#### find\_all()

返回所有匹配的 `Tag` 列表，未找到返回空列表 `[]`。

| 参数         | 类型                             | 默认值  | 说明       |
| ---------- | ------------------------------ | ---- | -------- |
| name       | str / list / re.Pattern / True | —    | 同 find() |
| attrs      | dict                           | {}   | 属性过滤     |
| recursive  | bool                           | True | 是否递归     |
| string     | str / re.Pattern               | None | 文本内容匹配   |
| limit      | int                            | None | 返回结果数量上限 |
| \*\*kwargs | —                              | —    | 快捷属性过滤   |

```python
# 查找所有 <a> 标签
links = soup.find_all("a")

# 查找多种标签
tags = soup.find_all(["h1", "h2", "h3"])

# 用正则匹配标签名（匹配所有 h 开头的标签）
tags = soup.find_all(re.compile(r"^h\d"))

# 限制返回数量
first_five = soup.find_all("p", limit=5)

# 查找含特定 class 的所有元素（多 class 时传列表）
items = soup.find_all("li", class_="item active")

# True 匹配所有带该属性的标签
tags = soup.find_all(href=True)

```

---

### CSS 选择器

#### select()

| 参数       | 类型  | 默认值  | 说明                   |
| -------- | --- | ---- | -------------------- |
| selector | str | —    | CSS 选择器字符串           |
| limit    | int | None | 返回结果数量上限（bs4 4.x 支持） |

#### select\_one()

| 参数       | 类型  | 默认值 | 说明                       |
| -------- | --- | --- | ------------------------ |
| selector | str | —   | CSS 选择器字符串，返回第一个匹配或 None |

```python
# 类选择器
items = soup.select(".item")

# ID 选择器
main = soup.select_one("#main")

# 后代选择器
links = soup.select("div.content a")

# 直接子元素
cells = soup.select("table > tr > td")

# 属性选择器
inputs = soup.select('input[type="text"]')

# 伪类（部分版本支持）
first = soup.select_one("li:first-child")

# 多选择器
tags = soup.select("h1, h2, h3")

```

---

### 属性访问

```python
tag = soup.find("a")

# 标签名
print(tag.name)          # "a"

# 所有属性（dict）
print(tag.attrs)         # {"href": "https://...", "class": ["nav", "link"]}

# 获取属性值（不存在时抛 KeyError）
print(tag["href"])

# 安全获取（不存在返回 None 或默认值）
print(tag.get("href"))
print(tag.get("data-id", ""))

# class 属性始终返回列表
print(tag["class"])      # ["nav", "link"]

# 修改属性
tag["href"] = "https://new-url.com"
del tag["class"]

```

---

### 文本提取

`tag.string` 和 `tag.text` 行为有细微差别，需注意区分。

```python
tag = soup.find("p")

# string：当且仅当标签只有一个直接文本子节点时有值，否则为 None
print(tag.string)

# text（等同于 get_text()）：递归拼接所有文本
print(tag.text)

```

#### get\_text()

| 参数        | 类型    | 默认值                | 说明           |
| --------- | ----- | ------------------ | ------------ |
| separator | str   | ""                 | 多个文本节点之间的分隔符 |
| strip     | bool  | False              | 是否去除每段文本首尾空白 |
| types     | tuple | (NavigableString,) | 参与拼接的节点类型    |

```python
# 用换行符分隔，去除首尾空白
text = tag.get_text(separator="\n", strip=True)

# 只获取直接文本（不含子标签）
from bs4 import NavigableString
direct = "".join(
    str(child) for child in tag.children
    if isinstance(child, NavigableString)
).strip()

```

---

### 树形导航

```python
tag = soup.find("p")

# 父节点（单个 Tag）
print(tag.parent)

# 所有祖先（生成器）
for ancestor in tag.parents:
    print(ancestor.name)

# 直接子节点（生成器，包含 NavigableString）
for child in tag.children:
    print(child)

# 所有后代（生成器）
for desc in tag.descendants:
    print(desc)

# 下一个同级节点（可能是 NavigableString 空白）
print(tag.next_sibling)
print(tag.previous_sibling)

# 跳过空白，获取下一个同级 Tag
print(tag.find_next_sibling("p"))
print(tag.find_previous_sibling("p"))

# 文档顺序的下一个节点
print(tag.next_element)
print(tag.previous_element)

```

---

### NavigableString 与 Tag 的区别

| 特性         | Tag                   | NavigableString                   |
| ---------- | --------------------- | --------------------------------- |
| 含义         | HTML/XML 标签节点         | 标签内的纯文本节点                         |
| str()      | 返回含标签的 HTML 字符串       | 返回纯文本                             |
| tag.string | 返回唯一文本子节点             | 本身即字符串内容                          |
| 是否可迭代      | 可（迭代子节点）              | 不可迭代                              |
| 类型判断       | isinstance(node, Tag) | isinstance(node, NavigableString) |

```python
from bs4 import Tag, NavigableString

for child in soup.body.children:
    if isinstance(child, Tag):
        print(f"标签: {child.name}")
    elif isinstance(child, NavigableString):
        text = child.strip()
        if text:
            print(f"文本: {text}")

```

---

### 修改文档树

```python
# append：在子节点末尾追加
new_tag = soup.new_tag("span", attrs={"class": "badge"})
new_tag.string = "NEW"
tag.append(new_tag)

# insert：在指定位置插入（0 为最前）
tag.insert(0, soup.new_tag("b"))

# replace_with：用新内容替换整个节点
tag.replace_with(soup.new_tag("div"))

# decompose：从树中移除并销毁节点
tag.decompose()

# extract：从树中移除并返回节点（可再利用）
removed = tag.extract()

```

---

## lxml

### 解析 HTML

```python
from lxml import html

# 从字符串解析
tree = html.fromstring("<html><body><p>Hello</p></body></html>")

# 从 URL 解析（内部使用 urllib）
tree = html.parse("https://example.com").getroot()

# 配合 requests
import requests
resp = requests.get("https://example.com")
tree = html.fromstring(resp.content)   # 传 bytes，让 lxml 自行处理编码

# 获取根元素
doc = html.document_fromstring(resp.content)

```

### XPath 选择

`element.xpath(expr)` 返回列表，元素可以是 `HtmlElement` 或字符串（取决于表达式）。

#### 常用 XPath 表达式速查

| 表达式                          | 说明                    | 示例                                |
| ---------------------------- | --------------------- | --------------------------------- |
| //tag                        | 文档任意位置的标签             | //a                               |
| /tag                         | 根节点的直接子标签             | /html/body                        |
| ./tag                        | 当前节点的直接子标签            | ./td                              |
| .//tag                       | 当前节点的所有后代标签           | .//span                           |
| @attr                        | 属性值                   | //a/@href                         |
| \[@attr\]                    | 含该属性的节点               | //img\[@src\]                     |
| \[@attr='val'\]              | 属性等于某值                | //div\[@id='main'\]               |
| \[contains(@attr,'val')\]    | 属性包含某子串               | //div\[contains(@class,'item')\]  |
| \[starts-with(@attr,'val')\] | 属性以某值开头               | //a\[starts-with(@href,'https')\] |
| text()                       | 文本节点                  | //p/text()                        |
| normalize-space()            | 去除多余空白                | normalize-space(//title/text())   |
| \[position()=1\] / \[1\]     | 第一个元素（XPath 下标从 1 开始） | (//tr)\[1\]                       |
| last()                       | 最后一个                  | //tr\[last()\]                    |
| \[position()>1\]             | 跳过第一个                 | //tr\[position()>1\]              |
| parent::\*                   | 父节点                   | //td\[@class='price'\]/parent::tr |
| following-sibling::tag       | 后续同级节点                | //dt/following-sibling::dd\[1\]   |
| \|                           | 并集（多路径）               | //h1\|//h2                        |

```python
from lxml import html
import requests

resp = requests.get("https://example.com")
tree = html.fromstring(resp.content)

# 获取所有链接的 href
hrefs = tree.xpath("//a/@href")

# 获取含特定 class 的 div 下的文本
texts = tree.xpath("//div[contains(@class,'content')]//p/text()")

# 获取表格所有行（跳过表头）
rows = tree.xpath("//table[@id='data']//tr[position()>1]")
for row in rows:
    cells = row.xpath(".//td/text()")
    print(cells)

# 用 re:test 匹配（需注册命名空间）
# lxml 支持 EXSLT 扩展函数
results = tree.xpath(
    "//a[re:test(@href, r'product/\d+')]",
    namespaces={"re": "http://exslt.org/regular-expressions"}
)

```

### CSS 选择器（lxml.cssselect）

```python
from lxml import html
from lxml.cssselect import CSSSelector

tree = html.fromstring(content)

# 方式一：直接用 cssselect 方法
items = tree.cssselect("div.item > a.title")

# 方式二：预编译选择器（多次使用时性能更好）
sel = CSSSelector("table.data tr td:nth-child(2)")
cells = sel(tree)

```

### etree.tostring()

将 Element 序列化回字符串。

| 参数               | 类型         | 默认值     | 说明                               |
| ---------------- | ---------- | ------- | -------------------------------- |
| element          | Element    | —       | 要序列化的元素                          |
| encoding         | str / type | "ASCII" | 输出编码；传 "unicode" 返回 str 而非 bytes |
| method           | str        | "xml"   | 序列化方式："xml" / "html" / "text"    |
| pretty\_print    | bool       | False   | 是否格式化缩进输出                        |
| with\_tail       | bool       | True    | 是否包含元素的 tail 文本                  |
| xml\_declaration | bool       | False   | 是否添加 XML 声明头                     |

```python
from lxml import etree, html

tree = html.fromstring("<div><p>Hello <b>World</b></p></div>")

# 返回 bytes
raw = etree.tostring(tree, method="html")

# 返回 str（推荐）
text = etree.tostring(tree, encoding="unicode", method="html", pretty_print=True)

# 只提取文本
plain = etree.tostring(tree, method="text", encoding="unicode")

```

### lxml.etree 解析 XML

```python
from lxml import etree

# 从字符串解析
root = etree.fromstring(b"<root><item id='1'>text</item></root>")

# 从文件解析
tree = etree.parse("data.xml")
root = tree.getroot()

# 遍历
for elem in root.iter("item"):
    print(elem.get("id"), elem.text)

# 命名空间处理
ns = {"ns": "http://example.com/ns"}
items = root.xpath("//ns:item", namespaces=ns)

# 构建 XML
builder = etree.TreeBuilder()
builder.start("root", {})
builder.start("item", {"id": "1"})
builder.data("text")
builder.end("item")
builder.end("root")
root = builder.close()

```

---

### 性能对比

下表基于解析同一份约 500KB 的 HTML 文件（实际数据因文档结构而异）。

| 解析器         | 相对速度           | 内存占用 | 容错能力 | 备注           |
| ----------- | -------------- | ---- | ---- | ------------ |
| lxml        | 最快（1x）         | 低    | 高    | C 扩展，生产首选    |
| html.parser | 中（约 3-5x 慢）    | 中    | 中    | 纯 Python，无依赖 |
| html5lib    | 最慢（约 10-20x 慢） | 高    | 极高   | 完全还原浏览器行为    |

---

## 实战场景

### 分页爬取：定位下一页链接

```python
import requests
from bs4 import BeautifulSoup

def get_next_page_url(soup, base_url):
    # 方式一：BeautifulSoup CSS 选择器
    next_btn = soup.select_one("a.next, a[rel='next'], li.next > a")
    if next_btn and next_btn.get("href"):
        href = next_btn["href"]
        if href.startswith("http"):
            return href
        return base_url.rstrip("/") + "/" + href.lstrip("/")
    return None

def crawl_all_pages(start_url):
    url = start_url
    while url:
        resp = requests.get(url, timeout=10)
        soup = BeautifulSoup(resp.content, "lxml")
        # 处理当前页数据
        items = soup.select("div.item")
        for item in items:
            print(item.get_text(strip=True))
        url = get_next_page_url(soup, start_url)

```

### 提取表格数据转为列表

```python
from lxml import html

def parse_table(tree, table_selector="table"):
    tables = tree.cssselect(table_selector)
    if not tables:
        return []

    result = []
    table = tables[0]

    # 提取表头
    headers = [
        th.text_content().strip()
        for th in table.xpath(".//thead//th | .//tr[1]//th")
    ]

    # 提取数据行
    rows = table.xpath(".//tbody//tr") or table.xpath(".//tr[position()>1]")
    for row in rows:
        cells = [td.text_content().strip() for td in row.xpath(".//td")]
        if headers and len(cells) == len(headers):
            result.append(dict(zip(headers, cells)))
        else:
            result.append(cells)

    return result

```

### 处理 HTML 编码问题

```python
import requests
from bs4 import BeautifulSoup

resp = requests.get(url)

# 方式一：让 requests 自动检测编码
resp.encoding = resp.apparent_encoding
soup = BeautifulSoup(resp.text, "lxml")

# 方式二：传 bytes 给 lxml，由 lxml 从 <meta charset> 推断编码（更可靠）
from lxml import html
tree = html.fromstring(resp.content)

# 方式三：手动指定编码
soup = BeautifulSoup(resp.content.decode("gbk", errors="replace"), "lxml")

# 方式四：使用 chardet
import chardet
detected = chardet.detect(resp.content)
encoding = detected.get("encoding", "utf-8")
soup = BeautifulSoup(resp.content.decode(encoding, errors="replace"), "lxml")

```

---

## 踩坑与注意事项

### find\_all 返回空列表的常见原因

1. **动态渲染内容**：页面通过 JavaScript 动态插入的 DOM 节点，requests 获取的原始 HTML 中不存在。解决方式是使用 [Selenium完全指南](https://blog.vercanti.com/selenium-wan-quan-zhi-nan/) 或 [Playwright完全指南](https://blog.vercanti.com/playwright-wan-quan-zhi-nan/) 等浏览器自动化工具。
2. **解析器差异**：不同解析器对 HTML 容错行为不同，同一段 HTML 用 `html.parser` 和 `lxml` 解析后的树结构可能不同，导致选择器路径失效。固定使用同一解析器。
3. **class 多值匹配**：`find("div", class_="a b")` 要求标签同时含有 class `a` 和 `b`，顺序无关；但 `soup.select(".a.b")` 同样有效。若只匹配其中一个用 `soup.select(".a")`.
4. **命名空间干扰**：解析含命名空间的 XML/XHTML 时，标签名会带前缀，需在 XPath 中声明命名空间或用 `local-name()` 忽略。
5. **属性大小写**：HTML 属性名不区分大小写，但解析器通常统一转为小写，使用大写属性名查找会失败。

### JavaScript 渲染内容

BeautifulSoup 和 lxml 均只能解析静态 HTML，无法执行 JavaScript。遇到以下情况说明页面为动态渲染：

- 浏览器中能看到数据，但 `requests` 获取的 HTML 中找不到
- HTML 源码中存在 `<script>` 标签包裹的 JSON 数据或 React/Vue 挂载点

解决方案参见 [Playwright完全指南](https://blog.vercanti.com/playwright-wan-quan-zhi-nan/) 和 [Selenium完全指南](https://blog.vercanti.com/selenium-wan-quan-zhi-nan/)，或分析网络请求直接调用数据接口（参见 [Python爬虫工程师面试题](https://blog.vercanti.com/python-pa-chong-gong-cheng-shi-mian-shi-ti/)）。

### 编码检测

- `resp.text` 使用 requests 内部的编码检测，对 GBK/GB2312 网站常出现乱码。
- 优先传 `resp.content`（bytes）给 lxml，lxml 会读取 `<meta charset>` 自行处理。
- BeautifulSoup 的 `UnicodeDammit` 也可辅助检测编码：

```python
from bs4 import UnicodeDammit
dammit = UnicodeDammit(resp.content)
print(dammit.original_encoding)
soup = BeautifulSoup(dammit.unicode_markup, "lxml")

```

### lxml XPath 下标从 1 开始

XPath 的位置下标从 **1** 开始，不同于 Python 的 0。`//tr[1]` 是第一行，`//tr[0]` 选不到任何元素。

### BeautifulSoup 修改后重新序列化

修改文档树后，用 `str(soup)` 或 `soup.prettify()` 输出 HTML，但 lxml 序列化用 `etree.tostring()`，两者不可混用。

---

## 最佳实践

**用 `lxml` 作为 BeautifulSoup 的解析器以获得最佳性能**：`BeautifulSoup(html, 'lxml')` 比内置的 `html.parser` 快 2-5 倍，同时能处理不规范的 HTML（自动补全标签）。`html5lib` 最兼容但最慢，仅在解析 HTML5 特殊结构时使用。

**批量提取数据用 `find_all` \+ 列表推导，不要逐个 `find`**：`find_all` 一次遍历 DOM 树返回所有匹配节点，逐个 `find` 在大型文档上性能差，且代码冗长。

```python
# 推荐
prices = [tag.text.strip() for tag in soup.find_all('span', class_='price')]

# 不推荐
price1 = soup.find('span', class_='price').text
price2 = soup.find_all('span', class_='price')[1].text

```

**优先用 CSS 选择器（`select`/`select_one`）提取，代码更简洁**：CSS 选择器语法对前端开发者更直观，且支持组合选择器、伪类等复杂条件，比 `find(attrs={...})` 更易读。

```python
# 等价于 find(class_='card') 但更简洁
cards = soup.select('.card')
title = soup.select_one('h2.article-title > a')

```

**lxml XPath 用于结构复杂的 XML/HTML，BeautifulSoup 用于简单 HTML 提取**：XPath 支持轴（ancestor::、following-sibling::）和条件表达式，处理嵌套关系复杂的文档更强大；BeautifulSoup API 更 Pythonic，日常爬虫场景首选。

---

## 常见陷阱

### 陷阱：`find` 返回 `None` 导致 `AttributeError`

**现象：** 代码在某些页面正常，在另一些页面抛出 `AttributeError: 'NoneType' object has no attribute 'text'`。

**原因：** `find()` 在找不到元素时返回 `None`，直接链式访问 `.text` 会报错。页面结构不稳定、A/B 测试变体或反爬导致元素缺失时，就会触发此错误。

**解决：** 始终检查返回值是否为 `None`，或用 `select_one` \+ 条件表达式。

```python
tag = soup.find('span', class_='price')
price = tag.text.strip() if tag else None

```

### 陷阱：lxml XPath 下标从 1 开始

**现象：** `tree.xpath('//tr[0]')` 返回空列表，`//tr[1]` 才是第一行。

**原因：** XPath 位置谓词（position predicate）从 **1** 开始计数，这与 Python 的 0 索引不同，初学者容易混淆。

**解决：** XPath 中用 `[1]` 选第一个，`[last()]` 选最后一个；Python 层面的索引用列表切片 `result[0]`。

### 陷阱：编码问题导致中文乱码

**现象：** 提取的中文字段是乱码，或 `soup.prettify()` 输出包含 `\xa0` 等转义字符。

**原因：** `requests.get(url).text` 使用 `chardet` 自动检测编码，可能检测错误；BeautifulSoup 也可能误判编码。

**解决：** 显式指定编码，或用 `response.content`（bytes）传给 BeautifulSoup，让其自己从 meta charset 检测。

```python
resp = requests.get(url)
resp.encoding = 'utf-8'  # 显式指定
soup = BeautifulSoup(resp.text, 'lxml')

```

---

## 参见

- [Scrapy分布式采集](https://blog.vercanti.com/scrapy-fen-bu-shi-cai-ji-wan-quan-zhi-nan/)
- [httpx完全指南](https://blog.vercanti.com/httpx-wan-quan-zhi-nan/)
- [Playwright完全指南](https://blog.vercanti.com/playwright-wan-quan-zhi-nan/)