编码问题:最常见的第一个坑
requests拿到响应后,resp.text的解码行为取决于resp.encoding。默认值来自HTTP响应头的Content-Type字段里的charset声明。问题在于,不少站不返回charset,或者返回的charset和实际编码不一致。
# 不靠谱:依赖HTTP头
resp = requests.get(url)
text = resp.text # 可能乱码
# 稍好:用chardet嗅探
resp.encoding = resp.apparent_encoding
text = resp.text
# 最稳:已知目标站编码时直接指定
resp.encoding = "gbk"
text = resp.text
apparent_encoding底层用chardet,准确率大约九成以上,但碰到短文本或混编码页面会判错。我在采集老站时习惯先试apparent_encoding,看到乱码就手动指定GBK或GB2312。大部分国内老站用GBK,新站用UTF-8。
还有一种隐蔽的编码问题:页面编码正确但HTML实体没有解码。&、 、中这类实体在.text里会被自动转义成对应字符,但偶尔碰到不规范的自定义实体(比如&xxx不是标准实体),BeautifulSoup会原样保留。这种情况用html.unescape()补一手:
import html
text = html.unescape(tag.get_text())
BeautifulSoup不执行JavaScript
这是新手最容易误解的一点:BeautifulSoup是HTML解析器,不是浏览器引擎。它拿到什么HTML就解析什么,不会执行页面里的<script>。如果目标数据是JS渲染后插入DOM的,BeautifulSoup拿到的HTML里对应位置是空的。
判断方法:浏览器里查看页面源代码(右键→查看网页源代码),如果源代码里有你要的数据,说明是服务端渲染,BeautifulSoup能拿到。如果源代码里没有但页面显示有,说明是JS动态渲染,需要换方案。
# 方案一:找接口
# F12 Network面板里找XHR/Fetch请求,直接请求API拿JSON
api_url = "https://example.com/api/list?page=1"
resp = requests.get(api_url, headers=headers)
data = resp.json() # 多数现代站返回JSON
# 方案二:Selenium/Playwright渲染后解析
from selenium import webdriver
from bs4 import BeautifulSoup
driver = webdriver.Chrome()
driver.get("https://example.com")
driver.implicitly_wait(5) # 给JS执行时间
soup = BeautifulSoup(driver.page_source, "lxml")
# 后续正常用BeautifulSoup提取
driver.quit()
方案一优先,直接请求API最干净,速度也快。Playwright比Selenium新,API更稳定,但Selenium的生态更成熟。我们工作室两者都备着,按目标站情况选。
残缺HTML的容错
真实世界的HTML不保证规范。标签未闭合、嵌套混乱、属性值没引号——这些在BeautifulSoup里大多能自动处理。不同解析器容错能力不同:
# 一段残缺HTML
broken = "<p>段落一<p>段落二<div>未闭合"
# lxml:尝试修复结构
BeautifulSoup(broken, "lxml")
# <html><body><p>段落一</p><p>段落二<div>未闭合</div></p></body></html>
# html5lib:按浏览器标准容错
BeautifulSoup(broken, "html5lib")
# 修复结果更接近浏览器实际渲染
# html.parser:标准库自带
BeautifulSoup(broken, "html.parser")
# 容错中等,介于两者之间
lxml和html5lib对同一段残缺HTML的修复结果可能不同。碰到提取结果和浏览器显示不一致时,先换解析器试。html5lib最接近浏览器行为,但速度最慢,只在排查问题时临时用。
NoneType异常的防护
BeautifulSoup找不到元素时返回None。对None调用.text或.get()会抛AttributeError。批量采集中最常见的崩溃原因就是这个。
# 危险写法
price = soup.find("span", class_="price").text
# 如果没有匹配元素,None.text直接报错
# 安全写法
price_tag = soup.find("span", class_="price")
price = price_tag.get_text(strip=True) if price_tag else "N/A"
批量循环里更要注意:
for item in soup.select("div.item"):
name = item.select_one(".name")
price = item.select_one(".price")
# 任一为None就跳过这条
if not name or not price:
continue
results.append({
"name": name.get_text(strip=True),
"price": price.get_text(strip=True),
})
解析性能注意
BeautifulSoup解析大HTML文档(超过5MB)时会比较慢,内存占用也高。几个实际经验:
# 只需要部分数据时,先截取再解析
# 比如只需要table部分,用正则先切出来
import re
table_html = re.search(r'<table.*?</table>', html, re.S)
if table_html:
soup = BeautifulSoup(table_html.group(), "lxml")
用lxml解析器比html.parser快三到五倍,这是最简单的提速手段。如果同一段HTML要反复查询,考虑把解析结果缓存:
soup = BeautifulSoup(html, "lxml") # 只解析一次
# 后续所有find/select都复用这个soup对象
不要在循环里反复构造BeautifulSoup对象。同一页面解析一次就够了。
小结
采集踩坑无非几类:编码不对、JS渲染没处理、None没判空、解析器选错。编码和None是最高频的两个,编码问题排查靠apparent_encoding加手动指定,None防护靠每个取值点判空。碰到诡异问题先换解析器试一把,经常能省下大半排查时间。