《Python编程实战》12.1 HTTP 客户端与页面解析

从 requests 2.34.2 与 httpx 0.28.1 的取舍讲起,实测连接复用、超时分层与重试策略,再用 http.server 起本地假站点,跑通 BeautifulSoup 4.15.0 + lxml 6.1.3 的 CSS 选择器与 XPath,并处理中文乱码与 httpx 系统代理两个真坑。

本节目标:把「发一个 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.2httpx 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 活,放进事件循环里会阻塞——量大时要丢到线程池。

延伸阅读

小结

  • 选型看能力边界:同步脚本用 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 编排 · 下一节:动态页面与浏览器自动化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时