
前言用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, dataNone[, timeout])参数含义与urlopen一致——因为urlopen内部就是调用的它。还有一个addheaders属性是一个(header, value)元组列表用来改默认请求头# 适用于 Python 3.8import urllib.requestopener urllib.request.build_opener() # 不传 handler用默认那批opener.addheaders [(User-Agent, my-crawler/1.0 (contact: meexample.com)),]with opener.open(https://example.com/, timeout10) as resp:print(resp.status)如果希望此后urlopen()也走这条流水线可以用install_opener(opener)把它装成全局的。官方文档特意提醒只有在你确实想让urlopen()用这个 opener 时才需要安装多数情况下直接调用opener.open()更清晰副作用也小。# 适用于 Python 3.8import urllib.requestopener urllib.request.build_opener()opener.addheaders [(User-Agent, my-crawler/1.0 (contact: meexample.com))]urllib.request.install_opener(opener) # 之后 urlopen 也用它二、三个最常用的 handlerurllib.request里的 handler 不少日常真正会用到的集中在这三个。Handler构造参数解决什么问题ProxyHandlerproxiesNone通过指定代理发请求不传时读环境变量里的小写代理变量如 http_proxy、https_proxyHTTPCookieProcessorcookiejarNone自动收发 Cookie配合http.cookiejar.CookieJarHTTPRedirectHandler无处理 301/302/303/307/308 跳转可被子类改写以拒绝跳转代理ProxyHandler接收一个字典键是协议名值是代理服务器地址。默认的ProxyHandler读的是环境变量比如http_proxy显式传入一个字典就换成你给的地址。把ProxyHandler交给build_opener时它会替换默认的那个。# 适用于 Python 3.8import urllib.requestproxy urllib.request.ProxyHandler({http: http://127.0.0.1:8080})opener urllib.request.build_opener(proxy)with opener.open(http://example.com/, timeout10) as resp:print(resp.status)CookieHTTPCookieProcessor需要一个 cookie 容器标准库提供的是http.cookiejar.CookieJar。装上它之后服务器Set-Cookie的值会被存进 jar后续请求自动带上# 适用于 Python 3.8import http.cookiejarimport urllib.requestjar http.cookiejar.CookieJar()opener urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))opener.addheaders [(User-Agent, my-crawler/1.0 (contact: meexample.com))]with opener.open(https://example.com/, timeout10) as resp:print(resp.status)for cookie in jar:print(cookie.name, cookie.value)这里只是演示机制用 Cookie 保持会话不等于可以拿去绕过登录墙。本文的立场是只用它来维持一个正常访问流程比如同意过 Cookie 提示的公开页面不用伪造凭证去访问未授权内容。重定向默认的HTTPRedirectHandler会自动跟随跳转。如果你需要控制这个行为可以继承它并覆写redirect_request()。官方文档说明redirect_request()在需要重定向时被调用它应当返回一个新的Request或None如果想拒绝某个跳转就改写成抛异常。三、自定义 handlerhandler 就是普通的类让它继承BaseHandler并实现对应的方法就能插进流水线。BaseHandler的官方描述是「所有已注册 handler 的基类只负责注册的机械部分」。handler 的方法命名有约定处理 HTTP 响应的叫http_response(request, response)处理错误状态的叫http_error_404(...)这种形式。下面这个例子统计本次请求最终落在哪个状态码上——纯粹为了看清流水线的形状不改变原有行为# 适用于 Python 3.8import urllib.requestclass StatusRecorder(urllib.request.BaseHandler):记录最后一次响应的状态码供调用方查看。last_status Nonedef http_response(self, request, response):StatusRecorder.last_status getattr(response, status, None)return response # 必须原样返回交回给流水线继续处理https_response http_responserecorder StatusRecorder()opener urllib.request.build_opener(recorder)with opener.open(https://example.com/, timeout10) as resp:print(resp.status, StatusRecorder.last_status)要点是http_response必须把 response 返回出去。handler 链是有顺序的返回值会传给下一个 handler如果返回None后续环节包括最终把响应交还给调用者的那一步就会出问题。四、异常体系URLError 与 HTTPErrorurllib.error里的两个异常是爬虫代码里最常见的两个分支。它们的关系是子类关系# 适用于 Python 3.8这是类型关系示意不是可执行代码Exception└── OSError└── urllib.error.URLError└── urllib.error.HTTPErrorURLErrorurllib.request抛出异常的基类。它有一个reason属性说明失败原因。官方文档说明从 Python 3.3 起URLError是OSError的子类在此之前它是IOError的子类而IOError现在是OSError的别名。HTTPError(url, code, msg, hdrs, fp)带状态码的 HTTP 错误。它虽然是异常但同时可以像文件一样使用——因为它保存了响应体e.read()能拿到服务器返回的内容。# 适用于 Python 3.8import urllib.errorimport urllib.requesttry:with urllib.request.urlopen(https://example.com/, timeout10) as resp:body resp.read()except urllib.error.HTTPError as e:print(e.code) # 状态码如 403 / 404print(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.8import timeimport urllib.errorimport urllib.requestUA {User-Agent: my-crawler/1.0 (contact: meexample.com)}# 4xx 多数是请求本身的问题重试无意义5xx 和网络抖动值得再试RETRYABLE {429, 500, 502, 503, 504}def fetch(url, retries3, base_delay1.0, timeout10):带指数退避的 GET失败时最多重试 retries 次。for attempt in range(retries 1):try:req urllib.request.Request(url, headersUA)with urllib.request.urlopen(req, timeouttimeout) as resp:return resp.read()except urllib.error.HTTPError as e:if e.code not in RETRYABLE or attempt retries:raiseexcept urllib.error.URLError:if attempt retries:raisetime.sleep(base_delay * (2 ** attempt)) # 1s, 2s, 4s ...raise RuntimeError(unreachable)if __name__ __main__:data fetch(https://example.com/, retries2)print(len(data))这段代码没有引入任何「加速」技巧等待时间随尝试次数翻倍正是为了给对面留出恢复的余地。同理正式抓取时两次成功请求之间也要time.sleep()并遵守目标站 robots.txt 里的Crawl-delayurllib.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_retries3)这种参数 —— 不存在。 ✅ 自己写退避循环或者改用第三方requests配合HTTPAdapter。总结需求做法替换默认请求头opener.addheaders [(User-Agent, ...)]用代理build_opener(ProxyHandler({...}))保持 Cookiebuild_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, timeout10)重试无内置自己写指数退避循环urllib的进阶部分听起来术语多但骨架就一句话opener 是一条 handler 流水线能力靠往流水线里插 handler 获得。搞清这一点代理、Cookie、重定向、错误处理就不再是四件互不相干的事而是同一个机制的不同插槽。至于重试和限速标准库把它们留给了使用者——这是有意为之因为这两件事的取值应该由你的责任心来定而不是由库替你决定。