前言
用urlopen发过几个请求之后,很快就会撞到墙:需要走代理怎么办?需要保持 Cookie 怎么办?想统一改掉默认的User-Agent怎么办?服务器抽风返回 503,怎么优雅地重试?这些问题都指向同一个东西——urllib.request里的opener 与 handler 机制。
简单说,urlopen()只是一个「用默认配置帮你组装好流水线并跑一遍」的快捷函数。真正干活的是OpenerDirector,它把一串 handler 串起来,每个 handler 负责一类事情:走代理的、管 Cookie 的、处理重定向的、处理错误的。理解了这层结构,就明白为什么加一条能力是「往流水线里塞一个 handler」,而不是「给urlopen多传一个参数」。
本文讲进阶部分:build_opener与自定义 opener、三个最常用的 handler、urllib.error的异常体系、以及超时与重试的写法。urlopen、Request、urlencode这些基础操作不在这里重复。文中以 Python 3 为基准;urllib2是 Python 2 的模块,2.7 已于2020 年 1 月 1 日停止维护,Python 3 把它并进了urllib.request。
一、OpenerDirector 与 handler 链
urllib.request.build_opener([handler, ...])返回一个OpenerDirector。这个函数的官方说明是:它接受 0 个或多个 handler 实例,并返回一个OpenerDirector;即使你一个 handler 都不传,它也会装上默认的那一批,包括ProxyHandler、HTTPDefaultErrorHandler、HTTPRedirectHandler、HTTPErrorProcessor等。
所以「自定义 opener」从来不是「从零搭一条流水线」,而是「在默认流水线上替换或追加一个环节」。你传进去的同类型 handler 会覆盖默认的那一个。
OpenerDirector的关键方法是open(url, data=None[, timeout]),参数含义与urlopen一致——因为urlopen内部就是调用的它。还有一个addheaders属性,是一个(header, value)元组列表,用来改默认请求头:
# 适用于 Python 3.8+
import urllib.request
opener = urllib.request.build_opener() # 不传 handler,用默认那批
opener.addheaders = [
("User-Agent", "my-crawler/1.0 (contact: me@example.com)"),
]
with opener.open("https://example.com/", timeout=10) as resp:
print(resp.status)如果希望此后urlopen()也走这条流水线,可以用install_opener(opener)把它装成全局的。官方文档特意提醒:只有在你确实想让urlopen()用这个 opener 时才需要安装;多数情况下直接调用opener.open()更清晰,副作用也小。
# 适用于 Python 3.8+
import urllib.request
opener = urllib.request.build_opener()
opener.addheaders = [("User-Agent", "my-crawler/1.0 (contact: me@example.com)")]
urllib.request.install_opener(opener) # 之后 urlopen 也用它二、三个最常用的 handler
urllib.request里的 handler 不少,日常真正会用到的集中在这三个。
| Handler | 构造参数 | 解决什么问题 |
|---|
ProxyHandler | proxies=None | 通过指定代理发请求;不传时读环境变量里的小写代理变量(如 http_proxy、https_proxy) |
HTTPCookieProcessor | cookiejar=None | 自动收发 Cookie,配合http.cookiejar.CookieJar |
HTTPRedirectHandler | 无 | 处理 301/302/303/307/308 跳转,可被子类改写以拒绝跳转 |
代理:ProxyHandler接收一个字典,键是协议名,值是代理服务器地址。默认的ProxyHandler读的是环境变量,比如http_proxy;显式传入一个字典就换成你给的地址。把ProxyHandler交给build_opener时,它会替换默认的那个。
# 适用于 Python 3.8+
import urllib.request
proxy = urllib.request.ProxyHandler({"http": "http://127.0.0.1:8080"})
opener = urllib.request.build_opener(proxy)
with opener.open("http://example.com/", timeout=10) as resp:
print(resp.status)Cookie:HTTPCookieProcessor需要一个 cookie 容器,标准库提供的是http.cookiejar.CookieJar。装上它之后,服务器Set-Cookie的值会被存进 jar,后续请求自动带上:
# 适用于 Python 3.8+
import http.cookiejar
import urllib.request
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
opener.addheaders = [("User-Agent", "my-crawler/1.0 (contact: me@example.com)")]
with opener.open("https://example.com/", timeout=10) as resp:
print(resp.status)
for cookie in jar:
print(cookie.name, cookie.value)这里只是演示机制:用 Cookie 保持会话不等于可以拿去绕过登录墙。本文的立场是只用它来维持一个正常访问流程(比如同意过 Cookie 提示的公开页面),不用伪造凭证去访问未授权内容。
重定向:默认的HTTPRedirectHandler会自动跟随跳转。如果你需要控制这个行为,可以继承它并覆写redirect_request()。官方文档说明:redirect_request()在需要重定向时被调用,它应当返回一个新的Request(或None);如果想拒绝某个跳转,就改写成抛异常。
三、自定义 handler
handler 就是普通的类,让它继承BaseHandler并实现对应的方法,就能插进流水线。BaseHandler的官方描述是「所有已注册 handler 的基类,只负责注册的机械部分」。
handler 的方法命名有约定:处理 HTTP 响应的叫http_response(request, response),处理错误状态的叫http_error_404(...)这种形式。下面这个例子统计本次请求最终落在哪个状态码上——纯粹为了看清流水线的形状,不改变原有行为:
# 适用于 Python 3.8+
import urllib.request
class StatusRecorder(urllib.request.BaseHandler):
"""记录最后一次响应的状态码,供调用方查看。"""
last_status = None
def http_response(self, request, response):
StatusRecorder.last_status = getattr(response, "status", None)
return response # 必须原样返回,交回给流水线继续处理
https_response = http_response
recorder = StatusRecorder()
opener = urllib.request.build_opener(recorder)
with opener.open("https://example.com/", timeout=10) as resp:
print(resp.status, StatusRecorder.last_status)要点是http_response必须把 response 返回出去。handler 链是有顺序的,返回值会传给下一个 handler;如果返回None,后续环节(包括最终把响应交还给调用者的那一步)就会出问题。
四、异常体系:URLError 与 HTTPError
urllib.error里的两个异常是爬虫代码里最常见的两个分支。它们的关系是子类关系:
# 适用于 Python 3.8+(这是类型关系示意,不是可执行代码)
Exception
└── OSError
└── urllib.error.URLError
└── urllib.error.HTTPErrorURLError:urllib.request抛出异常的基类。它有一个reason属性,说明失败原因。官方文档说明,从 Python 3.3 起URLError是OSError的子类(在此之前它是IOError的子类,而IOError现在是OSError的别名)。HTTPError(url, code, msg, hdrs, fp):带状态码的 HTTP 错误。它虽然是异常,但同时可以像文件一样使用——因为它保存了响应体,e.read()能拿到服务器返回的内容。
# 适用于 Python 3.8+
import urllib.error
import urllib.request
try:
with urllib.request.urlopen("https://example.com/", timeout=10) as resp:
body = resp.read()
except urllib.error.HTTPError as e:
print(e.code) # 状态码,如 403 / 404
print(e.reason) # 原因短语
print(e.headers.get("Content-Type"))
print(e.read()[:120]) # HTTPError 能当响应对象用
except urllib.error.URLError as e:
print("网络层问题:", e.reason)
except (TimeoutError, OSError) as e:
# 超时在部分场景下会以 socket.timeout / TimeoutError 形式冒出
print("超时或底层 I/O 错误:", e)顺序非常关键:HTTPError必须写在URLError前面。反过来写的话,except URLError会先把子类实例接走,HTTPError那个分支永远不执行。
五、超时与重试
超时由urlopen/open的timeout参数控制,单位是秒。官方文档说得明确:不传时使用全局默认超时设置,写爬虫时应当总是显式指定。timeout只覆盖连接与读取阶段的阻塞等待,覆盖不了 DNS 解析慢、服务器持续返回 503 这类情况,所以它必须和重试配合。
重试这件事,urllib没有内置的重试参数(requests那套HTTPAdapter(max_retries=...)在标准库里没有对应物)。标准做法是自己写一个退避循环。原则只有两条:只重试「值得重试」的错误,并且每次等待更久。
# 适用于 Python 3.8+
import time
import urllib.error
import urllib.request
UA = {"User-Agent": "my-crawler/1.0 (contact: me@example.com)"}
# 4xx 多数是请求本身的问题,重试无意义;5xx 和网络抖动值得再试
RETRYABLE = {429, 500, 502, 503, 504}
def fetch(url, retries=3, base_delay=1.0, timeout=10):
"""带指数退避的 GET,失败时最多重试 retries 次。"""
for attempt in range(retries + 1):
try:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE or attempt == retries:
raise
except urllib.error.URLError:
if attempt == retries:
raise
time.sleep(base_delay * (2 ** attempt)) # 1s, 2s, 4s ...
raise RuntimeError("unreachable")
if __name__ == "__main__":
data = fetch("https://example.com/", retries=2)
print(len(data))这段代码没有引入任何「加速」技巧,等待时间随尝试次数翻倍,正是为了给对面留出恢复的余地。同理,正式抓取时两次成功请求之间也要time.sleep(),并遵守目标站 robots.txt 里的Crawl-delay(urllib.robotparser.RobotFileParser.crawl_delay()可以读出来)。
常见坑点
1. 自定义 handler 的http_response忘了返回值。
❌ 方法体里只记录信息,不return response—— 流水线后续环节拿不到响应,调用者直接报错。 ✅ 处理完原样 return response,把控制权交回流水线。
2.except顺序写反。
❌ 先except URLError再except HTTPError—— 子类永不可达。 ✅HTTPError在前,URLError在后。
3. 以为ProxyHandler是追加而不是替换。
❌ 想着「装了代理 handler,默认的还在,两条路都通」——同类型 handler 是替换默认那一个。 ✅ 明确build_opener(ProxyHandler({...}))会替换默认代理处理,这正是你想要的结果,知道就好。
4.install_opener全局生效,污染整个进程。
❌ 在库代码里随手install_opener,导致同一进程里其他模块的urlopen也被改了行为。 ✅ 优先用opener.open()局部调用;确实要全局安装时,在程序的入口处做,并写清楚目的。
5. 对所有错误一律重试。
❌ 收到 403、404 也傻等重试 —— 浪费自己的时间,也可能被当成异常流量。 ✅ 只重试 429 和 5xx,以及网络层异常;4xx 直接抛出或记日志。
6. 重试间隔固定不变。
❌time.sleep(1)每次都等 1 秒 —— 对方还没恢复就又冲上去。 ✅ 指数退避base * 2 ** attempt,并可加随机抖动。
7.HTTPError只当异常用,丢掉了响应体。
❌except HTTPError as e: print(e.code)—— 服务器返回的说明正文看都没看。 ✅e.read()能拿到正文,很多站点的错误原因就写在里面。
8. 以为标准库有现成的重试参数。
❌ 满世界找urlopen(..., max_retries=3)这种参数 —— 不存在。 ✅ 自己写退避循环;或者改用第三方requests配合HTTPAdapter。
总结
| 需求 | 做法 |
|---|
| 替换默认请求头 | opener.addheaders = [("User-Agent", "...")] |
| 用代理 | build_opener(ProxyHandler({...})) |
| 保持 Cookie | build_opener(HTTPCookieProcessor(http.cookiejar.CookieJar())) |
| 控制重定向 | 继承HTTPRedirectHandler,覆写redirect_request() |
| 插入自定义逻辑 | 继承BaseHandler,实现http_response()并返回response |
| 全局替换流水线 | urllib.request.install_opener(opener)(慎用) |
| 捕获 HTTP 错误 | except HTTPError,可用.code/.read() |
| 捕获网络错误 | except URLError,看.reason |
| 超时 | urlopen(url, timeout=10) |
| 重试 | 无内置,自己写指数退避循环 |
urllib的进阶部分听起来术语多,但骨架就一句话:opener 是一条 handler 流水线,能力靠往流水线里插 handler 获得。搞清这一点,代理、Cookie、重定向、错误处理就不再是四件互不相干的事,而是同一个机制的不同插槽。至于重试和限速,标准库把它们留给了使用者——这是有意为之,因为这两件事的取值应该由你的责任心来定,而不是由库替你决定。