本节目标:把「发一个 HTTP 请求」从「调一次
requests.get」升级为工程能力——选对客户端、复用连接、分层设超时、按状态码重试,并用 BeautifulSoup + lxml 稳定地把 HTML 解析成结构化数据。
适用版本:Python 3.12+(实测 3.14.6);requests 2.34.2、httpx 0.28.1、beautifulsoup4 4.15.0、lxml 6.1.3
12.1 HTTP 客户端与页面解析
第 11 章我们把数据从「文件/数据库」搬来搬去,但真实项目里数据往往在别人的服务器上。采集的第一步永远是「发请求、拿回字节流」,第二步才是「把字节流变成结构化数据」。这一节只做这两件事,但要做到生产可用:超时、重试、连接复用、编码容错,一个都不能少。
本节所有抓取一律打本地假站点(用标准库 http.server 起),绝不碰真实网站——既是合规要求,也让实验结果可复现。
12.1.1 选型:requests 还是 httpx
两者 API 高度相似,差别在能力边界。选型看四件事:
| 维度 | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| 同步 | ✅ 成熟稳定 | ✅ |
| 异步 | ❌ 无原生异步 | ✅ AsyncClient |
| HTTP/2 | ❌ | ✅(需 http2=True) |
| 连接池 | ✅ Session | ✅ Client |
| 超时模型 | 单值或 (连接, 读取) 元组 | Timeout 对象,分四段 |
| 生态惯性 | 最大,第三方库默认依赖 | 较新,FastAPI 测试客户端基于它 |
经验法则:脚本级、同步够用、依赖越少越好 → requests;需要并发抓取、要 HTTP/2、或项目已经是 async(比如第 5 章的 FastAPI 服务里顺带抓数据)→ httpx。不要为了「异步更快」无脑上 httpx:如果只有几十个请求,同步 + 连接复用的收益已经够,异步反而引入事件循环的复杂度。
12.1.2 起一个本地假站点(本机实测)
爬虫代码难测,是因为目标站点会变、会限流、还会封你。工程做法是用 http.server 起一个可控的假站点,把「返回 GBK 编码」「返回 503 再成功」「响应慢 1 秒」这些边界都做进去:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass # 静音访问日志
def do_GET(self):
path = self.path.split("?")[0]
if path == "/gbk-noheader.html":
# 故意不带 charset,模拟 header 不声明编码的站点
body = GBK_HTML.encode("gbk")
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
# ... 其余路由见下文
srv = ThreadingHTTPServer(("127.0.0.1", 8813), Handler)
srv.serve_forever()
ThreadingHTTPServer 每个连接起一个线程,所以能并发响应(异步示例要用到)。上面是节选——完整可运行的 site.py 在本机 http.server 上跑通,本节所有实测输出都来自它。端口 8813 是本机实测用的,你换成任意空闲端口即可。
12.1.3 连接复用:Session / Client 为什么重要
裸 requests.get(url) 每次都新建一个连接:TCP 三次握手 + TLS 握手(HTTPS 时)都要重来。Session 复用底层连接池,第二次起走 keep-alive。真跑对比:
import requests, time
s = requests.Session()
s.headers.update({"User-Agent": "plumephp-scraper/1.0 (+https://plumephp.com/bot)"})
t0 = time.perf_counter()
for _ in range(20):
requests.get(BASE + "/", timeout=5)
print(f"裸 get 20 次: {(time.perf_counter() - t0) * 1000:.1f} ms")
t0 = time.perf_counter()
for _ in range(20):
s.get(BASE + "/", timeout=5)
print(f"Session 20 次: {(time.perf_counter() - t0) * 1000:.1f} ms")
本机实测(本地回环):
裸 get 20 次: 86.4 ms
Session 20 次: 73.9 ms
本地回环 RTT 近乎为零,所以差距只有约 15%——换成跨公网的目标,差距会成倍放大(每次省下的是握手往返)。结论不是「快 15%」,而是「永远用 Session/Client,且把公共请求头挂在它身上」。顺带一提,Session 还会自动保持 Cookie,登录态抓取必需。
12.1.4 超时:必须分层设置
永远不要用「不设超时」的默认值——一个卡住的连接会拖垮整个采集任务。requests 的 timeout 可以给 (连接超时, 读取超时) 元组:
# 连接 3s 内建立、读取阶段每个数据块 5s 内到达
s.get(url, timeout=(3, 5))
给 /slow(服务端睡 1 秒)设 0.5 秒超时,实测:
超时捕获: ReadTimeout
注意异常类型是 ReadTimeout(连接已建立、读数据超时),对应还有 ConnectTimeout(连接阶段超时)。两类要分开处理:连接超时通常意味着网络/目标不可达,读超时可能是对方在忙。httpx 用 Timeout 对象,粒度更细:
import httpx
timeout = httpx.Timeout(connect=3.0, read=5.0, write=5.0, pool=5.0)
with httpx.Client(timeout=timeout) as client:
client.get(url)
pool 超时指「等连接池里空闲连接」的时间——并发高时它才是真正的瓶颈,这是 httpx 比 requests 更值得选的一点。
12.1.5 重试:分清「可重试」与「不可重试」
重试的前提是幂等(GET 天然幂等,POST 要小心)。核心判断:5xx 与超时值得重试,4xx 不该重试(404 重试一百次还是 404,403 重试只会让你更像攻击者)。
requests 用 HTTPAdapter + Retry 声明式配置:
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
s = requests.Session()
retry = Retry(total=3, backoff_factor=0.3, status_forcelist=[503, 502, 504])
s.mount("http://", HTTPAdapter(max_retries=retry))
s.mount("https://", HTTPAdapter(max_retries=retry))
打一个「前两次 503、第三次成功」的 /flaky 端点,实测:
重试后状态: 200 body: ok
backoff_factor=0.3 让等待时间按 0.3 → 0.6 → 1.2 秒递增(指数退避),避免在对方抖动时雪上加霜。若要更精细的控制(比如只对特定异常退避、加随机抖动),就手写循环,见 12.3。
12.1.6 一个真实的坑:httpx 会被系统代理劫持
本机(macOS)配了系统级 HTTP 代理 proxy.nioint.com:8080。用 httpx 打 127.0.0.1 时,默认会走这个代理,结果本地请求被转发出去、返回 503:
import httpx
with httpx.Client(base_url="http://127.0.0.1:8813") as c: # 默认 trust_env=True
print(c.get("/api/items").status_code) # -> 503
with httpx.Client(base_url="http://127.0.0.1:8813", trust_env=False) as c:
print(c.get("/api/items").status_code) # -> 200
原因:httpx 默认 trust_env=True,会读 urllib.request.getproxies(),而后者在 macOS 上通过系统配置(_scproxy)拿到代理——即使 env 里没有任何 *_proxy 变量。requests 不会中招,因为它的 should_bypass_proxies 对 127.0.0.1/localhost 自动返回 True,而 httpx 不会自动绕过回环地址。
修法有三:trust_env=False(推荐,脚本最干净)、设 NO_PROXY=127.0.0.1、或显式传 proxy=None。这个坑只会在「本地测试 + 公司代理」的组合下出现,线上未必暴露——所以本地假站点测试的价值又加一分。
12.1.7 解析:BeautifulSoup + lxml
拿到 HTML 后,BeautifulSoup(html, "lxml") 用 lxml 做后端(比内置 html.parser 快、容错好)。CSS 选择器最直观:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
for li in soup.select("ul#products > li.product"):
print(li["data-sku"],
li.select_one("a.title").get_text(strip=True),
li.select_one("span.price").get_text(strip=True),
li.select_one("a.title")["href"])
真跑本地商品列表页:
A-100 机械键盘 ¥299.00 /item/A-100
B-200 无线鼠标 ¥129.50 /item/B-200
C-300 显示器支架 ¥88.00 /item/C-300
下一页: /?page=2
几个易错点:select_one 返回第一个匹配或 None(一定要判空,否则 None.get_text() 直接崩);get_text(strip=True) 去掉首尾空白;取属性用 tag["attr"],不存在会抛 KeyError,稳妥写法是 tag.get("attr", "")。翻页链接用 soup.select_one("a.next")["href"] 拿到,再 urljoin 拼成绝对地址。
12.1.8 lxml 原生 XPath
CSS 选择器表达力有限(选不了「文本内容」「父节点」),这时直接上 lxml 的 XPath:
from lxml import html as lxml_html
tree = lxml_html.fromstring(html)
print(tree.xpath("//li[@class='product']/@data-sku"))
print(tree.xpath("//a[@class='title']/text()"))
实测输出:
XPath SKU: ['A-100', 'B-200', 'C-300']
XPath 标题: ['机械键盘', '无线鼠标', '显示器支架']
CSS 与 XPath 的取舍:CSS 更短、更易读、更贴近浏览器 F12 的「Copy selector」;XPath 能选文本(/text())、按内容过滤([contains(text(),'价')])、上下翻层。项目里常用组合是——定位元素用 CSS,抠文本/属性用 XPath,或干脆只用 BeautifulSoup(它的 .select() 走 soupsieve,也能 :has()、:contains())。不要两套混着写,维护成本会翻倍。
12.1.9 编码识别与容错
中文站点最容易翻车的就是编码。requests 的 resp.text 解码规则是:先看 HTTP header 的 charset,没有则按 text/html 默认 ISO-8859-1(老 HTTP 规范遗留)。所以 header 不声明 charset 的 GBK 页面会乱码:
r = s.get(BASE + "/gbk-noheader.html", timeout=5)
print("Content-Type:", r.headers["content-type"]) # text/html(无 charset)
print("requests 猜的编码:", r.encoding) # ISO-8859-1
print("直接 resp.text :", r.text.splitlines()[3][:26])
r.encoding = r.apparent_encoding # 用 chardet 风格探测
print("修正后 h1 :", BeautifulSoup(r.text, "lxml").select_one("h1").text)
真跑输出:
Content-Type: text/html
requests 猜的编码: ISO-8859-1
直接 resp.text : <body><h1>ÖÐÎıêÌ⣨GBK ±à
apparent_encoding 后: gbk
修正后 h1 : 中文标题(GBK 编码)
ÖÐÎÄ 就是 中文 被当成 Latin-1 解码的典型乱码。修法:r.encoding = r.apparent_encoding 后重新读 r.text(encoding 改了,text 会重算)。BeautifulSoup 也能直接指定:BeautifulSoup(r.content, "lxml", from_encoding="gbk")——注意传 r.content(字节)而不是 r.text。最稳的策略:能拿到 header 就信 header;拿不到就 apparent_encoding 探测;两者都不确定时,抓一小段试 utf-8/gbk,谁解出合法中文用谁。
12.1.10 异步并发:httpx.AsyncClient
当目标是很多个独立请求(比如逐个抓详情页),httpx 的异步客户端能并发发出。打 5 个各睡 1 秒的 /slow:
import asyncio, httpx
async def main():
async with httpx.AsyncClient(base_url=BASE, timeout=5, trust_env=False) as c:
rs = await asyncio.gather(*[c.get("/slow") for _ in range(5)])
print([r.status_code for r in rs])
asyncio.run(main())
实测:
async 5×/slow(各睡1s): 1.06 s 全部: [200, 200, 200, 200, 200]
5 个各 1 秒的请求总耗时 1.06 秒(串行要 5 秒)——因为 I/O 等待时事件循环去处理别的连接。但要克制:并发度高会瞬间打崩对方(这正是 12.3 要讲限速的原因),而且 asyncio.gather 默认「一个抛错全体取消」,生产里用 return_exceptions=True 或 TaskGroup 收敛异常。解析部分(bs4/lxml)是同步 CPU 活,放进事件循环里会阻塞——量大时要丢到线程池。
延伸阅读
- Python 网络爬虫与自动化:从 requests 到 Playwright —— 完整工具箱与反爬应对速查
- Web 抓取与合规 —— 入门书里的合规底线
- socket、HTTP 与服务端 —— HTTP 协议的底层视角
- 数据管道与 ETL 编排 —— 采集回来的数据如何进管道
小结
- 选型看能力边界:同步脚本用 requests,要并发/HTTP2/已在 async 项目里用 httpx;别为异步而异步。
- 永远用
Session/Client复用连接并挂公共请求头,本地回环差距小,跨公网会成倍放大。 - 超时必须分层:requests 用
(连接, 读取)元组,httpx 用四段Timeout;ReadTimeout与ConnectTimeout分开处理。 - 重试只对幂等请求且只对 5xx/超时,用指数退避;4xx 重试没有意义。
- 本地假站点是最佳测试床:
http.server能把 GBK、503、慢响应都做进去,还顺带暴露了 httpx 走系统代理的坑。 - 解析用 CSS 定位 + XPath 抠文本,别混两套;编码优先信 header,其次
apparent_encoding。
这一节我们只处理服务器直接返回 HTML 的情况。可现实里越来越多页面是 JS 渲染的——HTML 里根本没有数据。下一节就讲:遇到动态页面怎么办,以及浏览器自动化到底解决了什么问题。
阅读导航:上一节:数据管道与 ETL 编排 · 下一节:动态页面与浏览器自动化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。