☰
urllib库爬虫核心用法:从urlopen到Request的全面解析
2026/10/15 14:55:39 网站建设 项目流程

写爬虫到底用什么库?网上铺天盖地的答案几乎都是requests,这不算错。但你用requests跑通一百个网站之后,回头看Python标准库里的urllib,会发现很多基础概念被重新点亮。这篇文章准备把urllib里最常用的几个成员彻底讲清楚:发请求的urlopen、直接下载文件的urlretrieve、拼查询参数的urlencode和parse_qs、拆URL的urlparse与urlsplit,还有专门用来制作请求头的request.Request类。刚入门爬虫的读者能从这里建立对HTTP请求最朴素的认知,已经会requests的读者也能补上“为什么”的那一层理解。

1. 我为什么还在用urllib:标准库才是最好的入门教材

1.1 零依赖带来的安全感

urllib最大的优势不是性能,而是它根本不需要安装。Python装好,它就躺在标准库里。公司内网环境、离线服务器、连pip都用不了的虚拟机,这些场景里你没法敲一行pip install requests,但urllib却能开箱即用。

这一点在写爬虫的时候特别现实。曾经我在一个只开放了80端口的测试环境里做数据抓取,requests装不上,第三方库没戏,最后就是靠urllib完成了整个采集任务。从那之后我的态度就变成了:requests是好用,但urllib是兜底的本事,不能没有。

从学习角度讲,urllib的代码更“啰嗦”,每一步都要你手动处理编码、构造参数、拿对象。可正是这种啰嗦,逼着你把HTTP请求的流程走一遍:URL要怎么拼、参数要怎么编码、请求头是什么、响应对象里有哪些字段。把这些弄明白了,再看requests的高级封装,就成了透明的纸。

1.2 urllib包里的四块积木

urllib不是一个大单体,它实际上是四个子模块的组合体,每个各管一段:

  • urllib.request:负责打开URL、发送请求、处理Cookie和代理,是爬虫用得最多的部分。
  • urllib.parse:负责URL的解析、拼接、编码解码,啥叫query、啥叫path,都归它管。
  • urllib.error:定义异常类,最常用的就是URLError和HTTPError。
  • urllib.robotparser:解析robots.txt,判断哪些页面能爬哪些不能爬。

这一篇里,request和parse是主角,error会在讲urlopen时顺带展开,robotparser这个偏门先不聊。把前三个弄扎实,你手搓一个抓取脚本的基本能力就齐了。

2. urlopen:爬虫第一次和服务器握手

2.1 一个最简单的请求

urlopen是urllib.request模块里最直白的函数,它的任务就一句话:给我一个URL,我把服务器的响应拿回来。看个最基础的例子:

from urllib.request import urlopen resp = urlopen("https://example.com", timeout=5) print(resp.status) # 200 print(resp.geturl()) # 实际请求的URL,会看到重定向后的地址 print(resp.getheaders()) # 响应头列表 html = resp.read().decode("utf-8") print(html[:200])

第一次用的时候不少人会懵:urlopen返回的到底是什么?它返回的是一个HTTPResponse对象,别把它当成纯文本。这个对象至少记住三个有用的能力:

  • read():把响应体读出来,得到bytes类型,所以要手动decode。
  • status:HTTP状态码,200是成功,404是没找到,403是拒绝访问。
  • getheaders():响应头,里面通常有Content-Type、Set-Cookie这些关键信息。

有个细节容易踩坑:resp.read()只能调用一次。第一次调用把内容读完后,再调用只能拿到b'',因为文件指针已经走到头了。如果你后面需要反复用这段内容,建议先存到变量里。

2.2 timeout:给网络请求上根保险丝

新手写爬虫最容易犯的错就是不设超时。服务器响应慢一点,程序就在那儿一直挂着,看起来像死机了。尤其批量抓取的时候,一个卡住的请求能拖垮整个任务。

urlopen的timeout参数单位是秒,类型是浮点数,传多少都可以:

resp = urlopen("https://example.com", timeout=3)

超过3秒还没收到响应,就会抛异常。但要注意,timeout保护的是“连接建立+等待响应”这个过程,不是整个请求的耗时。如果服务器收到请求后一直不分块传输数据,timeout同样会触发异常。

实操建议:批量爬取时,timeout设3~5秒比较合适。太短容易误伤正常网络波动,太长又起不到保护作用。我曾经试过设10秒跑一批数据,结果三分之一的任务挂在半路,后来统一改成4秒,整个采集时间缩了一大截。

2.3 异常处理:URLError和HTTPError别只抓一个

网络请求必然会出幺蛾子,异常处理是爬虫的基本素养。urllib里有两层异常要区分:

from urllib.request import urlopen from urllib.error import URLError, HTTPError try: resp = urlopen("https://example.com/nonexistent", timeout=3) except HTTPError as e: print(f"HTTP错误:{e.code} {e.reason}") except URLError as e: print(f"网络错误:{e.reason}")

这里有个父子关系:HTTPError是URLError的子类。也就是说,服务端返回4xx、5xx状态码时抛的是HTTPError,而DNS解析失败、连接被拒、超时这些抛的是URLError。

有个坑是新手最容易踩的:只写except URLError,结果拿到了页面源码里的404页面而不是异常,因为服务器返回了200状态码但内容是错误提示页。这时候靠状态码判断会失灵,还得结合内容特征判断。

经验:就算你用了requests,异常处理的设计思路也是一样的,都要区分“服务端明确拒绝”和“压根没连上”两种场景。这个思维从urllib时期建立起来,后面很受用。

2.4 data参数:GET和POST的分水岭

urlopen的第二个参数data,决定了请求方式是GET还是POST。不传data是GET,传了data而且data得是bytes类型,就自动变成POST。

from urllib.request import urlopen from urllib.parse import urlencode params = {"username": "testuser", "password": "123456"} data = urlencode(params).encode("utf-8") # 关键一步:转成bytes resp = urlopen("https://example.com/login", data=data)

这个组合技要反复练。urlencode把一个字典变成username=testuser&password=123456这样的字符串,.encode("utf-8")再把它变成bytes,urlopen拿到bytes就知道这是POST请求体了。

顺便说一句,urlopen默认的请求方法是GET,只有data参数出现时才用POST。如果哪天你需要发POST但请求体是空的,requests里有专门的参数,但urllib这里你需要绕过默认逻辑——后面讲Request类的时候我会说怎么手动指定method。

3. urlretrieve:一行代码把文件搬回家

3.1 最基础的下载

如果说urlopen是“拿响应内容”,那urlretrieve就是“直接把内容写到本地文件”。下载图片、附件、静态资源,它是最省事的:

from urllib.request import urlretrieve filename, headers = urlretrieve( "https://example.com/images/cover.png", "cover.png" ) print(filename) # 保存到本地的文件名 print(headers) # 服务器返回的响应头

写完这行代码,文件就躺在你的工作目录里了。它内部帮我们做了两件事:请求URL、把响应体写入文件。返回的元组里,第一个元素是本地文件名,第二个是HTTPMessage对象,可以用它拿到Content-Type、Content-Length等信息。

但用之前想清楚一点:这个函数适合下载“内容明确是文件”的资源。如果它下载的是一个HTML页面,urllib也会老老实实把这个页面写进文件里——不会因为你给的文件名是以.html结尾就聪明地解析内容。

3.2 reporthook回调:自己写一个下载进度条

下载大文件时,没有进度提示会让人心慌。urlretrieve的第三个参数reporthook就是一个回调函数,每下载一块数据就会触发一次。

from urllib.request import urlretrieve def report(blocknum, bsize, totalsize): if totalsize == -1: print(f"已下载 {blocknum * bsize} 字节") return percent = blocknum * bsize / totalsize * 100 print(f"已下载:{percent:.1f}%") urlretrieve( "https://example.com/files/big.zip", "big.zip", report )

回调函数接收三个参数:blocknum是当前块编号,bsize是每块大小,totalsize是文件总大小。blocknum * bsize就是目前已下载的字节数。

这里有个必须处理的坑:totalsize可能是-1。如果服务器响应头里没带Content-Length,或者是chunked传输,总大小就是未知数。这时候再计算百分比会出现除以零或者得出负数,所以一定要先判断。

注意:不要在这个回调里做太多耗时操作,它是在下载循环里同步调用的,写日志、发网络请求都会让整个下载过程变慢。

3.3 urlcleanup和它的小脾气

urlretrieve还有一个配套函数urlcleanup()。它清理的是urllib内部缓存的一些临时文件。早期版本的urllib在下载时可能生成临时文件,占着磁盘不释放,必要时可以调用urlcleanup()做一次清理。

实际用下来我一般这么取舍:几十MB的小文件、一次性下载任务,直接用urlretrieve,简洁。但如果你需要下载大文件还要断点续传、要拿到响应体内容做进一步处理,还是用urlopen加文件写入更靠谱:

from urllib.request import urlopen resp = urlopen("https://example.com/files/big.zip", timeout=10) with open("big.zip", "wb") as f: while True: chunk = resp.read(1024 * 128) if not chunk: break f.write(chunk)

这种手动分块写文件的方式,好处是可控。你可以控制每次读多少、限制下载总大小、中途退出也不会留下半个文件,这些urlretrieve都做不到。

4. urlencode与parse_qs:URL里的中文密码

4.1 到底什么是URL编码

HTTP协议诞生时,URL只允许可打印的ASCII字符。中文、空格、特殊符号直接放在URL里是不合法的。那怎么办?把它们转成二进制字节,每个字节用%XX表示。

比如“爬虫”这两个字,按UTF-8编码后被转成了%E7%88%AC%E8%99%AB。空格比较特殊,在query字符串里会变成+,在path里变成%20。这就是为什么你打开任何搜索引擎的地址栏,看到搜索词都是一堆神秘的百分号。

urllib.parse模块就是专门干这活的。它的urlencode负责把参数编码进query部分,parse_qs负责把query解码回字典。

4.2 urlencode:把字典变成查询字符串

写爬虫查数据时,参数是中文是很常见的事。手搓查询字符串当然可以,但一旦参数多了、还要处理特殊字符,手搓就是事故高发区。用urlencode才是正确姿势:

from urllib.parse import urlencode params = { "keyword": "Python爬虫", "page": 1, "tag": "" } qs = urlencode(params) print(qs) # keyword=%E7%88%AC%E8%99%AB&page=1&tag=

输出结果里中文被自动编码,数字保持原样,空字符串后面直接跟着&。这个字符串可以直接拼到URL后面:

base_url = "https://search.example.com/s" final_url = f"{base_url}?{qs}"

urlencode还有一个参数doseq=False,当某个键对应的值是一个列表时用得着:

params = {"tag": ["python", "spider"]} print(urlencode(params, doseq=True)) # tag=python&tag=spider

如果不设置doseq,列表会被直接str成"['python', 'spider']",那就完全不是预期的效果了。

4.3 parse_qs和parse_qsl:把查询串绕回来

有编码就有解码。当你拿到一个URL想取出里面的参数,parse_qs就派上用场了:

from urllib.parse import parse_qs, parse_qsl query = "keyword=%E7%88%AC%E8%99%AB&page=1&page=2" print(parse_qs(query)) # {'keyword': ['Python爬虫'], 'page': ['1', '2']} print(parse_qsl(query)) # [('keyword', 'Python爬虫'), ('page', '1'), ('page', '2')]

这里有一个必须理解的设计:parse_qs返回的字典,每个键对应的值都是列表,而不是单个字符串。原因是查询字符串里同一个键可能多次出现,比如page=1&page=2,如果只保留一个是丢信息的,所以统一用列表存。

parse_qsl则返回元组列表,每个元组是一对键值,它保留了参数的原始顺序。如果你要把同一个query转成列表数据结构,parse_qsl会更直观。实际工程里这两个都常用,区别只在你要“去重取单值”还是“保留全部”。

4.4 顺手记一下:编码解码要配对

用urlencode编码,用parse_qs解码,这是配套的。有个细节:手动拼接查询串时,空格如果按肉眼写进去再传给parse_qs,解析结果很可能不对,因为空格应该编码成+或%20。所以凡是参数可能含空格、中文、特殊符号,一律走urlencode,别手动拼。

你可以试着自己写一个完整的请求拼接工具:

from urllib.parse import urlencode, parse_qs def make_query(params: dict) -> str: return urlencode(params) def get_param(query: str, key: str) -> str | None: parsed = parse_qs(query) values = parsed.get(key, []) return values[0] if values else None

这样把编码和解码包成两个函数,爬虫脚本里用起来就很顺手了。

5. urlparse与urlsplit:把URL大卸八块

5.1 URL的六个零件

拿到一条URL,有时候需要从中提取域名、路径、参数,这时候手动切片太蠢了。urlparse就是干这个的:

from urllib.parse import urlparse url = "https://example.com:8080/path/to/page;param?key=value#fragment" result = urlparse(url) print(result.scheme) # https print(result.netloc) # example.com:8080 print(result.path) # /path/to/page print(result.params) # param print(result.query) # key=value print(result.fragment) # fragment

它把一条URL拆成六个字段。scheme是协议,netloc是域名加端口,path是路径,params是路径参数(这个比较冷门,用的频率很低),query是查询字符串,fragment是片段标识符(就是页面里#后面的锚点)。

实际抓取中,我最常用的是netloc和query。拿到一个列表页URL,先用netloc判断要不要请求,再用query解析出分页参数,这两个字段基本够用。

5.2 urlsplit比urlparse少了什么

urlsplit是urlparse的“简化版”。区别只有一个:urlsplit不解析params字段。看代码:

from urllib.parse import urlsplit url = "https://example.com/path/to/page;param?key=value#fragment" result = urlsplit(url) print(result.scheme) # https print(result.netloc) # example.com print(result.path) # /path/to/page;param print(result.query) # key=value print(result.fragment) # fragment

看到区别了吧?urlparse把;param单独拆成params字段,但urlsplit把整段/path/to/page;param都留在path里。对现代Web应用来说,路径参数这个设计基本被弃用了,绝大多数URL连分号都不会出现。

所以日常爬虫场景,urlsplit够用,还更简洁。只有当你处理老式系统、或者需要严格区分路径和路径参数时,urlparse才有不可替代的优势。官方文档里也建议:除非你真的需要params,否则优先用urlsplit。

5.3 实战:从URL里快速拿host和参数

把urlsplit和parse_qs拼在一起,就是爬虫脚本里常见的“URL拆解组合拳”:

from urllib.parse import urlsplit, parse_qs def extract_url_info(url: str) -> dict: parts = urlsplit(url) return { "host": parts.netloc, "path": parts.path, "params": parse_qs(parts.query), "scheme": parts.scheme, } info = extract_url_info("https://example.com/list?page=1&size=20") print(info["host"]) # example.com print(info["params"]) # {'page': ['1'], 'size': ['20']}

这个函数看起来简单,但实际可复用性很高。判断外部链接、解析分页、统计域名,都能用它。我之前做站点采集时,就是用这个思路快速过滤掉非目标域名的外链,再顺着列表里的分页参数抓完整站。

还有一个常被人忽略的操作:urljoin。它能把相对路径拼接成绝对URL。同一个域名下面,页面里的链接经常是/news/123这种相对路径,直接用urljoin补全:

from urllib.parse import urljoin base = "https://example.com/news/" relative = "../image/cover.jpg" print(urljoin(base, relative)) # https://example.com/image/cover.jpg

这一节讲的都是parse模块的“读”操作。读懂了URL,你的爬虫才知道自己要去哪里、请求什么资源。

6. request.Request类:让请求带上“人味”

6.1 为什么裸奔会被拒

如果你直接用urlopen请求某些网站,对方可能返回200,但内容里写着“访问被拒绝”或者直接给你返回验证码页面。为什么?因为你的请求头太明显了。

urllib默认发送的User-Agent是Python-urllib/3.x。这个标识就像你穿着一件写着“我是机器人”的衣服走进超市,稍微有点防护意识的网站一眼就能认出来。更正式一点的站点会直接对这种UA返回403。

Request类就是为了解决这类问题而生。它允许你在发请求前,把请求头、请求方法、请求体都定制好。

6.2 Request的基本用法

Request的构造参数核心就三个:URL、data、headers。看例子:

from urllib.request import Request, urlopen req = Request( "https://example.com/api/search", data=b"", headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json", "Referer": "https://example.com/", }, method="POST", ) resp = urlopen(req)

注意两个细节。第一,data=b""不是没传,而是传了空bytes,加上method="POST",这就会发出一个POST请求。如果你不传data,即使指定了method也可能会被某些场景忽略。

第二,headers是一个普通字典,但它是整个反爬对抗的第一道防线。UA伪装成浏览器只是最基础的,Referer表示你从哪个页面跳转过来,Accept告诉服务器你能接收什么类型,这三件套已经是爬虫请求的基本礼仪了。

6.3 add_header:动态添加请求头的正确姿势

已经创建好的Request对象,还能继续追加请求头。方法就是add_header:

req = Request("https://example.com/profile") req.add_header("User-Agent", "Mozilla/5.0 ...") req.add_header("Cookie", "sessionid=abc123; token=xyz") req.add_header("Accept-Language", "zh-CN,zh;q=0.9")

这个手段在抓需要登录后访问的页面时尤其有用。有些站点校验Cookie,你只要提前从浏览器里把Cookie字符串复制出来,手动塞进请求头,就能绕过登录态的限制。

但这里有个度的问题:请求头不要加得太满。我曾经见过有人一口气加了十几个自定义Header,结果触发了服务端的反爬策略,因为正常人不会发这么厚的头。按需添加,像浏览器,才是伪装的核心逻辑。

6.4 再进一步:怎么让Request和opener配合干活

urlopen是一个“快捷入口”,它的背后其实有一个opener在做实际请求。当你需要更复杂的网络设置时,比如HTTP代理、自定义HTTPS证书校验、CookieJar,就要绕开urlopen直接操作opener。

以代理为例:

from urllib.request import ProxyHandler, build_opener proxy = ProxyHandler({"http": "http://127.0.0.1:7890", "https": "http://127.0.0.1:7890"}) opener = build_opener(proxy) req = Request("https://example.com") resp = opener.open(req, timeout=5)

build_opener把多个handler组装成opener,opener.open(req)和urlopen(req)返回的对象是同一类型,所以后面处理响应内容的方式完全一致。

代理在爬虫里的身份很单纯:一个转发的跳板。当你的请求量上来了,目标站点会按IP统计请求频率,同一IP短时间请求太多就会被限流。通过代理IP池轮换出口IP,是最直接的规避手段。代理的格式就是协议://IP:端口,基本概念就这么简单,难的是IP池的质量和调度策略。

还有一种常见的handler是HTTPCookieProcessor,配合http.cookiejar.CookieJar来自动管理Cookie。爬虫抓取会话类数据时,用它可以省去手工处理Cookie的麻烦。

从第一行urlopen开始的体会

回头再看这六个工具,其实背后是一条完整的请求链路:urlencode把参数变成合法的查询串,urlopen或Request把请求发出去,urlretrieve把结果落盘,urlparse和urlsplit帮你看清URL结构,parse_qs再把参数从URL里抠出来。每一个函数解决一个具体问题,拼起来就是一个不用任何第三方库的爬虫核心。

个人建议,你可以试试手写一个页面下载器:用urlsplit解析目标URL,用urlencode拼接带中文的搜索词,用Request带上浏览器请求头,最后用urlretrieve把结果页面存到本地。这一套流程走完,标准库爬虫这块就算是真正入门了。再去翻requests的源码,你会立刻发现里面很多函数名、参数命名都留着urllib的影子,因为requests本来就是在urllib的基础上长出来的。地基打扎实了,上面盖什么楼都稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询