爬虫学习笔记这个系列,第一篇我主要写的是urllib的安装、urlopen基本用法和read数据的姿势,当时还留了个尾巴,说请求传参、UA伪装、代理这些内容单独开一篇。现在这篇笔记就是把这三个躲不开的问题一次性讲透:请求传参到底应该怎么传、User-Agent为什么一定要设置、IP代理又是怎么挂上去的。对于正在用Python写爬虫、又不太想一上来就上requests库的朋友,这篇应该能帮你把底层逻辑理清楚,看完之后你再回过头看requests,会发现它内部玩的花样,其实全是urllib这套东西。
1. 笔记背景与调试环境准备
1.1 为什么先学urllib
网上大多数爬虫教程一上来就推荐requests,说它比urllib好用不知道多少倍。这话没错,但我个人的学习路径是先把urllib啃下来。原因很简单:requests内部就是对urllib的封装,你把urllib这套Handler、Request、Response的机制搞明白了,requests里那些让人摸不着头脑的session、proxies、headers参数,本质上都是在对相同的底层组件做批量设置。
用urllib写爬虫,代码会稍微啰嗦一点,但每一步都有迹可循。比如你设置一个代理,urllib要求你显式创建ProxyHandler,再通过build_opener构造一个opener,最后用这个opener去open。整个过程非常直白地告诉你:HTTP请求在发出之前,会经过一层层Handler的处理。这种“暴露过程”的学习方式,对建立脑子里的请求模型特别有帮助。
另外一点比较现实:在服务器端看到urllib的默认User-Agent时,几乎所有反爬策略都会直接拉黑。学着设置UA,其实就是在学着理解HTTP请求头的作用,这是后面做任何伪装、破解反爬、模拟浏览器操作的起点。所以这篇笔记写的都是最基础但最根上的东西。
1.2 环境与调试小工具
先说环境。我用的是Python 3.8,urllib是标准库,不需要额外安装,直接import就能用。建议你也在虚拟环境里操作,别把依赖搞乱了。命令行建环境这步略过,不是这篇的重点。
调试工具方面,我强烈建议你准备两个:
- 一个能显示HTTP请求和响应详情的命令行工具,比如curl,方便做对照实验,看看直接发请求和用Python发请求的差异在哪儿。
- 一个能回显请求信息的公共HTTP测试服务。这类服务收到你的请求后,会把请求头、请求参数、来源IP原封不动地返回给你,是验证传参、UA、代理是否生效的神器。
我在整篇笔记里都会用这类回显服务作为观察窗口,因为爬虫最怕的就是“代码没报错,但抓回来的数据不对”。有了回显服务,你能直接看到服务器视角里的你长什么样,非常直观。
1.3 贯穿全文的模拟场景
为了让内容不那么枯燥,我虚构了一个场景来串联三种技术点:假设有一个提供天气查询的开放接口(模拟项目X),它接受城市名和日期两个参数,返回当日气温。再假设有一个需要提交表单才能登录的留言板(模拟项目Y),用来演示POST传参。
整个笔记就是围绕这两个非常简单的模拟目标展开的。你先学会怎么把参数正确地交给服务器,然后学会怎么让服务器觉得你是个正常访客,最后学会怎么让请求从代理IP发出去。三个环节走下来,一个勉强能用的基础爬虫就出来了。
2. 请求传参的完整实现
2.1 GET传参:urlencode的编码原理
GET传参的本质,就是把参数拼在URL的问号后面。第一个念头当然是字符串拼接:
city = "北京" url = f"http://example.com/weather?city={city}&date=20200503"如果你真这么干,大概率会得到一个乱七八糟的URL,或者直接被服务器拒绝。我当时第一次跑就懵了,URL看起来没什么问题,为什么请求失败?后来用回显服务一看,才发现“北京”这两个字在URL里被原样塞进去了,而URL规范里只允许ASCII字符,中文必须先做百分号编码。
手动百分号编码太蠢,urllib里现成的工具就是urlencode。它接受一个字典,自动把键值对拼接好,同时把所有需要编码的字符做转换:
from urllib.parse import urlencode params = { "city": "北京", "date": "20200503" } query_string = urlencode(params) print(query_string) # 输出:city=%E5%8C%97%E4%BA%AC&date=20200503city参数变成了一串%E5%8C%97%E4%BA%AC,这就是“北京”两个字的UTF-8编码结果。服务器拿到后会按照同样的规范把它解码回中文。这个过程不用背,知道原理就行,反正urlencode会帮你处理好。
完整请求代码如下:
import urllib.request from urllib.parse import urlencode base_url = "http://example.com/weather?" params = {"city": "北京", "date": "20200503"} url = base_url + urlencode(params) req = urllib.request.Request(url) resp = urllib.request.urlopen(req, timeout=5) print(resp.read().decode("utf-8"))注意我构造的是Request对象,再交给urlopen。你也可以直接把url字符串传给urlopen,两者都行,但前者是后面设置UA、Cookie的基础,所以建议你从现在就习惯用Request。
2.2 POST传参:bytes与请求头
GET传参适合查询类操作,但登录、提交留言这种改变服务器状态的请求,必须用POST。POST的参数不是放在URL里,而是放在请求体(body)里。
urllib的Request构造方法里有一个data参数,只要给它传了值,请求就会自动变成POST。但这个data必须是bytes类型,不能直接传字符串或字典。所以参数要经历两步转换:先urlencode变成标准查询字符串,再encode成UTF-8编码的bytes。
import urllib.request from urllib.parse import urlencode post_data = urlencode({ "username": "test_user", "message": "hello world" }).encode("utf-8") req = urllib.request.Request( "http://example.com/message", data=post_data ) resp = urllib.request.urlopen(req, timeout=5) print(resp.read().decode("utf-8"))我为什么一开始总在这步翻车?因为urlencode返回的是str,直接塞给data参数会立刻报TypeError。后来养成了习惯:任何发给data的参数,先检查是不是str,是str就先encode。这个习惯帮我省了很多排查时间。
POST请求还有一个容易被忽略的地方:Content-Type请求头。默认情况下,如果不手动指定,urllib不会帮你声明Content-Type。大部分表单处理程序都默认按application/x-www-form-urlencoded来解析请求体,不声明有时候也能工作,但某些严格的框架会认为请求体格式不合法,直接返回400。所以我一般在构造Request时顺便把这块也补上:
req = urllib.request.Request( "http://example.com/message", data=post_data, headers={ "Content-Type": "application/x-www-form-urlencoded" } )2.3 传参中的常见坑
这部分是血泪经验,按出现频率排的。
第一个坑:使用urlencode之后,参数的顺序会按照字典的键名排序。如果你对顺序有严格要求,那就别用字典,直接用列表嵌套元组传进去。绝大多数接口对参数顺序不敏感,所以这个坑踩到的人才少。
第二个坑:重复参数的情况。有些接口允许同一个参数名传递多次,比如tags=a&tags=b。字典结构天然不支持这种场景,list of tuples也支持。遇到这种需求,用列表套元组的写法准没错。
第三个坑:请求体编码不一致。前端页面是GBK编码,你强行用UTF-8的encode去传POST,中文可能变成乱码,服务器拿到后一脸懵。判断编码的方式是看目标页面HTML的charset声明,页面上写GBK就用gb2312或gbk编码去encode,最稳妥的办法是直接用cp936这个别名。
这里多说一句:GET和POST不是互相替代的关系,它们有各自的语义。爬虫里你只需要记住:查询类、无副作用请求用GET;提交类、改变数据状态的请求用POST。这也是HTTP协议本身的设计意图,按着这个规矩来,能避开很多服务器端的校验。
3. 请求头伪装:User-Agent的细节
3.1 服务器怎么判断你是“人”还是“程序”
服务器识别爬虫的方法有很多,但第一个也是最廉价的一个就是看User-Agent。这个请求头用来标识客户端的类型,浏览器会带上完整的浏览器版本、操作系统、内核信息,而Python的urllib默认会发送这样一串:
Python-urllib/3.8任何一个正常的反爬系统,看到这串都会直接拒绝请求,或者返回一个验证页面让你去点选图片。服务器端的逻辑很朴素:真实用户不可能用“Python-urllib”这串字符串去浏览网页,所以见到它就直接当成爬虫处理。
想验证这个说法,用我前面说的回显服务测试一下就知道了。不设置任何请求头直接请求,回显服务显示你User-Agent是Python-urllib;设置了Chrome的UA之后,回显服务看到的就完全是浏览器访客的样子。
UA为什么在一个小地方包含了这么多信息?因为设备型号、操作系统版本、内核版本、浏览器版本都在这一串字符串里编码了。服务器可以用这串信息去适配不同的页面渲染方案,也可以用来做最基本的客户端识别。所以做爬虫的时候,UA是不是真实存在,有没有明显的拼接痕迹,非常容易被识别出来。
3.2 UA的构造与轮换策略
最简单的破解方法,就是从浏览器里复制一份完整的UA字符串,然后用headers参数传进Request对象:
import urllib.request headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36" } req = urllib.request.Request( "http://example.com/weather?city=北京", headers=headers ) resp = urllib.request.urlopen(req, timeout=5) print(resp.read().decode("utf-8"))这里我直接用字符串拼接URL,没走urlencode,是因为中文在代码里手动拼进去也可以,只是不够严谨,正式应用请回到第二部分的正确姿势。
UA不能一直固定用同一个。当你用同一个UA、同一个IP,高频率去抓数据,服务器很快会分析出“这个UA的访问行为不像人类”。人类的特征是:浏览几个页面停留一阵,再看别的;爬虫的特征是:短时间内获取几十上百个页面,间隔固定甚至没有间隔。
轮换UA的标准做法是准备一个UA列表,每次请求随机选一个:
import random UA_LIST = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36", "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/75.0" ] ua = random.choice(UA_LIST)千万别用随机乱拼的字符串,比如“HackBrowser 9.9.9”这种编造的东西。某些WAF会维护已知爬虫工具的UA名单,也会把明显不存在的UA当成恶意特征。用真实的浏览器UA字符串,才是最好的伪装。
3.3 不只是UA:一份完整的请求头清单
设置完UA,你以为能高枕无忧了,但服务器还可以看其他请求头来猜你。最典型的是Accept-Language。一个正常的浏览器访客,大概率会带简体中文和英语的语言偏好设置;一个Python脚本,默认可能什么都不带,或者用开发者的系统语言。
我在调研中发现,很多反爬系统重点看这几个头是否存在、是否合理:
- Referer:当前请求从哪个页面跳转过来的,缺少Referer的请求像“凭空出现”,容易触发怀疑。
- Accept:浏览器能接受的内容类型,一般是text/html、application/xhtml+xml这些。
- Connection:是否保持连接,浏览器一般带keep-alive。
- Accept-Language:浏览器界面使用的语言,中文浏览器一般是zh-CN,zh;q=0.9,en;q=0.8。
把这几项统统补上之后,请求头的完整度才像一个真正的浏览器。我通常准备一个默认的headers字典,每次发起请求直接拿来用,不用每次从头手写:
DEFAULT_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate", "Connection": "keep-alive" }有一点要提醒:Accept-Encoding这里有个暗坑。如果你声明了gzip压缩,但urllib默认不会自动解压响应体,你拿到的可能是压缩后的乱码。urllib不像requests那样自带解压功能。所以对新手阶段,我建议先别加Accept-Encoding,或者接受之后手动用gzip模块去解。不然又多了新的东西要处理。
还有一点是Cookie。Cookie是维持登录状态的核心,但写起来内容比较长,而且跟登录流程强相关,我这里不展开,后续笔记会专门写Cookie和Session。这里先提一句:如果你已经通过浏览器登录过某个网站,可以把浏览器里的Cookie字符串直接复制到headers里,伪装成已登录用户去发起请求。很多返回数据的变化,根源就是缺了这步。
4. IP代理的实现与代理池搭建
4.1 代理的工作方式与适用场景
IP代理,在爬虫领域通常指的是正向代理。客户端不直接连目标服务器,而是先把请求发给代理服务器,由代理服务器转发给目标服务器,目标服务器拿到的来源IP是代理IP,不是你本机的IP。
它的作用在爬虫里很明确:当你用同一个IP高频抓取数据时,服务器会从IP维度做频率统计,超了就拒绝响应甚至封IP。代理把请求分散到多个IP上,每个IP的访问频率都降下来了,被限制的概率也就低了。另外一个作用是把本机真实IP藏起来,减少个人信息暴露。
需要说明一下:我这里说的代理,解决的是高频请求下的身份与频率问题,是常规爬虫开发里的正当需求。使用代理去触犯他人服务条款或做违反法律法规的访问行为,不在这篇笔记的讨论范围内。
4.2 ProxyHandler与opener的配合
urllib设置代理的方式比较独特,它引入了Handler和Opener的概念。前面的基础用法中,urlopen是一个快捷函数,它背后其实是使用默认的OpenerDirector去执行了一次open。而build_opener允许你替换或追加Handler,然后把自定义逻辑打包成一个opener对象。
设置代理的代码很简单:
import urllib.request proxy = { "http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080" } handler = urllib.request.ProxyHandler(proxy) opener = urllib.request.build_opener(handler) resp = opener.open("http://example.com/weather?city=北京", timeout=5)这里的代理格式是协议: 代理服务器地址。字典里的http和https两个key,分别对应不同协议的目标请求走哪个代理。如果代理不需要用户名密码,上面这种写法就够了。需要认证的代理,把地址改成这样:
http://username:password@127.0.0.1:8080看到build_opener是不是有点懵?我当时也懵。换个角度理解:Opener就是你的“请求执行器”,Handler列表就是执行器的零件包。你往零件包里塞了代理处理器,执行器干活的时候就会先把请求转交给代理处理器处理,再发出去。想去掉代理,就用不带ProxyHandler的opener,或者用一个空ProxyHandler替换掉本来设置的那个。
实际上,urllib的urlopen本身是支持一个空代理配置来屏蔽环境变量的,这里不深究,知道opener是更灵活的用法就够用了。
4.3 免费代理池的搭建与质量检测
代理的坑比UA深多了,尤其是免费代理。免费代理的来源我没法放具体站点,你可以自己去搜“免费代理列表”这种关键词,找到一些会实时更新代理IP的列表页。
但免费代理的质量参差不齐,十个里面有八个活不过一下午,剩下两个还可能速度极慢。所以爬虫里常用套路是:抓一批代理列表存下来,然后自己写个探测器,一个个测试可用性,把能用的留下来放进代理池。
探测器的核心思路特别简单:拿这个代理去请求回显服务,如果请求成功并且返回数据里的来源IP恰好是这个代理IP,说明代理生效了。再加上一个超时时间,超过3秒或者5秒就判定不可用:
import urllib.request import socket def test_proxy(ip, port, protocol="http"): proxy_url = f"{protocol}://{ip}:{port}" handler = urllib.request.ProxyHandler({ "http": proxy_url, "https": proxy_url }) opener = urllib.request.build_opener(handler) try: # 这里用一个短超时,太慢的直接淘汰 resp = opener.open("http://example.com/ip", timeout=5) if resp.status == 200: text = resp.read().decode("utf-8") print(f"{proxy_url} 可用,回显IP:{text}") return True except Exception as e: print(f"{proxy_url} 不可用,{e}") return False这个探测器写的代码大概长这样。实际测的时候你会发现,返回状态码200不代表一切正常,因为有些代理会偷偷篡改响应,或者返回一个验证页面让你填验证码。所以更可靠的判定方式是:看回显服务返回的IP是不是你测试的那个代理IP,是才是真正生效。
免费代理池的维护是个体力活,我建议你在爬虫脚本里加一个循环:每隔几十秒重新扫描一批代理,把失效的踢出去,把新的加进来。这样至少能保证代理池不彻底断供。
4.4 代理踩坑与策略
代理这块的坑,我列几个特别典型的。
第一个是代理类型不匹配。HTTP代理只能代理HTTP协议的请求,你拿HTTP代理去请求HTTPS的站点,经常会遇到SSL握手失败或者连接被重置的报错。严谨点的做法是,爬虫脚本里把http和https的代理分开配置,测代理的时候也分协议测。
第二个是代理返回验证页。好一点的代理商会直接转发原始内容,烂一点的代理会在你请求的页面上插入自己的广告。这种“污染”很恶心,你是看不出来的。解决办法是在解析阶段做内容校验,如果发现页面结构和预期不符,就换下一个代理重试。
第三个是免费代理几乎都被用烂了。同一个免费代理IP可能同时被几十个人用,访问量一高,目标服务器早就把这个IP拉黑了,你拿来用也访问不到目标。这就导致免费代理的成功率极低。应对策略就是加大样本量,批量采集几百个代理轮流试,总能遇到几个还能用的。要是经济允许,直接买商业代理服务,稳定性完全不是免费代理能比的。
另外还有个小技巧:代理不要全局使用。有些请求走公共API或者回显服务,根本不需要代理,套上代理反而慢一大截。我通常用opener来区分:需要代理的请求走代理opener,不需要的走带UA的默认opener,两者互不干扰。
5. 高频问题与排查方法速查
5.1 抓包对比与代码定位
写爬虫的时候,代码报错反而不是最难排查的,因为堆栈信息会直接告诉你哪一行出了问题。真正难的是代码不报错,但返回结果不对:比如返回了一个意料之外的HTML页面、一段JSON、一个验证码弹窗。
我排这种问题的固定流程是三步:先在浏览器里手动打开目标页面,确认正常情况下应该长什么样。然后打开开发者工具的网络面板,看浏览器发出的请求头、请求体和参数长什么样。最后把代码里的请求方式和浏览器里的做对比,找差异。
这里强烈建议用回显服务来做对照实验,它会把你发的请求完全展开给你看。如果你在回显服务里发的请求跟浏览器请求基本一致,说明代码没问题,问题出在目标服务器的反爬策略上。如果差异很大,那就继续调代码。
开发者工具里面“复制为cURL”的功能也很常用,能把浏览器当前请求完整转成一条cURL命令,你拿到命令行里执行一下,再看它跟代码版差在什么地方。这种对比做多了,对HTTP请求的理解会突飞猛进。
5.2 常见报错速查表
把我自己实战中撞见过的报错整理成检阅用的速查表:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| HTTP Error 403 | 缺少UA或UA被识别为爬虫 | 设置完整浏览器请求头,检查IP是否被限制 |
| HTTP Error 429 | 请求频率太高触发限流 | 降低抓取频率,换代理,加随机延时 |
| HTTP Error 503 | 服务器过载或主动拒绝 | 等一段时间再试,检查IP是否被封 |
| URLError: <urlopen error [WinError 10054]> | 对方主动断开连接 | 重试,换代理,检查是否触发反爬 |
| 参数传了但服务器不识别 | GET参数没编码 / POST没转bytes | 用urlencode处理,data参数记得encode |
| 中文变乱码 | 编码格式不匹配 | 检查目标页面charset,统一编码 |
| 返回内容与浏览器不一致 | 缺少Referer或者Cookie | 补齐请求头,带上已登录Cookie |
| SSL certificate verify failed | HTTPS证书校验失败 | 临时用context忽略校验,但生产环境不建议 |
最后一行提到SSL证书校验问题,简单解释一句:urllib默认会校验证书,碰到某些自签名证书或者公司内部系统,会直接中断请求。测试阶段可以用SSLContext临时跳过校验,但正式爬虫别这么干,风险很大。这个属于进阶用法,知道有这回事就行。
5.3 爬虫的边界意识
技术能力归技术能力,对待目标网站的态度决定了你能走多远。我自己的习惯是,开工之前先看一眼对方的robots.txt,尽量爬那些明确允许抓取的内容,也严格遵守对方公开接口的使用频率限制。抓取公开数据不算什么见不得人的事,但任何事情一旦把对方的服务器拖垮,性质就变了。
控制频率的方法很简单:每次请求之间加一个随机延时,比如time.sleep(random.uniform(1, 3))。这既降低了服务器压力,也让你的访问模式更接近人类。高并发抓取并不是爬虫能力的体现,能稳定、克制地拿回数据,才是真正考验架构水平的地方。
把这些边界想清楚,再回头去看IP代理、UA轮换这些技能,你就知道它们存在的意义是“让请求更规范地分散负载”,而不是为了搞破坏。这个认知摆正了,后面学任何爬虫进阶技术都不会跑偏。
整理这篇笔记时我最大的感受是:urllib虽然写起来啰嗦,但它把HTTP客户端的那套流程完全暴露在你面前,请求头、参数处理、代理配置这些概念在urllib里都有清清楚楚的对应步骤。等你把这篇笔记里的例子全部跑通,再去看requests库的文档,你会发现大量概念瞬间就对上了号。
我实操中还养成了一个比较受用的习惯:笔记本里专门有一个“报错速查”分区,每次撞见新报错就把它连同上下文抄进去。现在很多问题我都不用去搜索,直接翻自己的笔记就能找到答案。如果这篇笔记对你有用,下一步我准备把Cookie、Session和登录态保持这块写完,这也是爬虫进阶最绕不开的下一道坎。