1. 从 urllib 到 requests:为什么爬虫入门总绕不开这一课
如果你刚开始接触 Python 爬虫,大概率会经历这样一个过程:先听说有个叫 Scrapy 的框架很厉害,兴冲冲打开官方文档,结果发现文档里到处都在讲Request、Response、Selector,而你连最基础的 HTTP 请求都还没发明白。于是又回头去查“Python 怎么发请求”,搜出来的答案十有八九会指向两个东西——标准库urllib和第三方库requests。
这个顺序其实是对的。Scrapy 本质上是一个高度封装的异步爬虫框架,它把请求调度、去重、中间件、管道这些工程化问题都替你处理好了,但代价是你必须理解它内部那套基于Request和Response的抽象模型。而requests恰恰是理解这套模型最平滑的入口——它的 API 设计足够直观,直观到你几乎可以凭直觉写出一个可用的请求,同时它又足够完整,覆盖了真实爬虫场景里 90% 以上的需求。
我在带新人的时候经常说一句话:先把 requests 用熟,再去看 Scrapy,你会觉得框架的每一个设计都在帮你解决你曾经手动处理过的麻烦。反过来,如果跳过 requests 直接啃 Scrapy,你大概率会陷入“照着文档能跑,换个网站就懵”的状态,因为你不知道框架底层到底替你做了什么。
这篇笔记就围绕 requests 这个库展开,把它在爬虫场景下的核心用法、容易踩的坑、以及和 Scrapy 之间的衔接关系讲清楚。不管你是完全没写过爬虫的新手,还是已经用过 requests 但总觉得“会用但不懂”的开发者,应该都能从中找到一些之前忽略的细节。
2. requests 到底解决了 urllib 的哪些痛点
2.1 标准库 urllib 的原始面貌
Python 内置的urllib其实功能并不弱,它能发 GET、POST 请求,能处理 cookie,能设置 header。但问题在于它的 API 设计带着浓厚的“标准库风格”——层级深、命名长、参数分散。举个最直观的例子,用 urllib 发一个带参数的 GET 请求,代码大概长这样:
import urllib.request import urllib.parse params = urllib.parse.urlencode({'keyword': 'python', 'page': 1}) url = 'https://example.com/search?' + params req = urllib.request.Request(url) req.add_header('User-Agent', 'Mozilla/5.0') response = urllib.request.urlopen(req) content = response.read().decode('utf-8')这段代码能跑,但读起来很累。参数要手动编码拼接,header 要单独构造 Request 对象再添加,响应内容要自己 decode。如果遇到 POST 请求、文件上传、会话保持,代码复杂度会迅速上升。
2.2 requests 的设计哲学:为人类而写
requests的作者 Kenneth Reitz 有一句很有名的话:“HTTP for Humans”。这个库从诞生之初就瞄准了一个目标——让发 HTTP 请求这件事变得像说话一样自然。同样的 GET 请求,用 requests 写出来是这样的:
import requests response = requests.get( 'https://example.com/search', params={'keyword': 'python', 'page': 1}, headers={'User-Agent': 'Mozilla/5.0'} ) content = response.text参数用字典传,header 用字典传,响应直接.text拿到解码后的文本。没有多余的中间对象,没有手动编码,没有显式 decode。这种差异在简单场景下可能只是“少写几行”,但在复杂场景下——比如需要维持会话、处理重定向、上传文件、设置超时和重试——requests 的优势会被放大到非常明显。
2.3 一个容易被忽略的点:requests 并不是“另一个 urllib”
很多人以为 requests 是 urllib 的替代品,其实更准确的说法是:requests 建立在 urllib3 之上,而 urllib3 又封装了底层的 http.client。也就是说,requests 并没有抛弃标准库,它只是在上层做了一层极其友好的抽象。理解这一点很重要,因为它意味着当你遇到 requests 解决不了的问题时,你依然可以往下钻,去操作 urllib3 甚至 http.client 的层面。
对于爬虫学习来说,这个认知能帮你建立一条清晰的层次链:http.client→urllib3→requests→Scrapy。每一层都在解决上一层暴露出来的工程问题,而 Scrapy 站在最顶端,把并发、调度、去重、持久化这些爬虫特有的问题也一并接管了。
3. 请求发送的核心参数与实战配置
3.1 GET 与 POST:不只是方法名的区别
GET 和 POST 在 HTTP 协议层面的区别,很多文章都讲过——GET 参数放在 URL 里,POST 参数放在请求体里。但在爬虫实战中,真正需要关注的是目标网站期望你用哪种方式。
有些网站的搜索接口,URL 里明明带着?keyword=xxx,看起来是 GET,但实际提交时却要求 POST。这种情况通常是因为前端用 JavaScript 拦截了表单提交,把参数重新打包成了 POST 请求。判断方法很简单:打开浏览器开发者工具的 Network 面板,看实际发出的请求方法是什么。
requests 里发送 POST 请求,参数用data传递:
response = requests.post( 'https://example.com/login', data={'username': 'user', 'password': 'pass'} )如果目标接口期望的是 JSON 格式的请求体,则用json参数:
response = requests.post( 'https://example.com/api/search', json={'keyword': 'python', 'page': 1} )json参数会自动把字典序列化为 JSON 字符串,并设置Content-Type: application/json。这个细节很多人会忽略,手动用data=json.dumps(...)然后忘记设置 header,导致服务端解析失败。
3.2 headers 的取舍:哪些必须带,哪些可以省
请求头是爬虫里最容易“用力过猛”的地方。新手常常从网上抄一大段 header,把 Accept、Accept-Language、Accept-Encoding、Connection、Cache-Control 全带上,结果反而因为某些字段不一致被识别出来。
我的经验是:优先只带 User-Agent,遇到问题再逐个补充。User-Agent 是最基础的标识,不带它很多网站会直接返回 403。但其他字段,比如 Accept-Language,只有在目标网站根据语言返回不同内容时才需要设置。
有一个字段值得特别注意:Referer。有些网站会检查请求来源,如果 Referer 为空或者不是站内地址,就会拒绝请求。这种情况在图片防盗链、API 接口鉴权里很常见。设置方法:
headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'Referer': 'https://example.com/list' }注意:不要伪造 Referer 去访问你无权访问的资源。这里讨论的 Referer 设置,仅用于正常访问公开页面时绕过不合理的防盗链限制。
3.3 超时设置:不设超时的爬虫等于埋雷
requests默认没有超时限制。这意味着如果目标服务器响应极慢或者根本不响应,你的程序会一直卡在那里。在单次请求的脚本里这可能只是让你多等一会儿,但在批量爬取或者 Scrapy 这样的框架里,一个卡住的请求会拖垮整个调度队列。
设置超时很简单:
response = requests.get(url, timeout=10)这里的timeout是连接和读取的总超时时间。更精细的写法是传一个元组:
response = requests.get(url, timeout=(3, 10))第一个数字是连接超时,第二个是读取超时。连接超时指建立 TCP 连接的时间,读取超时指服务器开始返回数据后,两次数据到达之间的最大间隔。分开设置的好处是,对于连接快但响应慢的服务器,你可以给读取阶段更长的容忍时间。
3.4 会话保持:Session 对象的正确用法
如果你需要先登录再访问其他页面,或者需要维持 cookie 状态,就必须用requests.Session()。Session 对象会自动在多次请求之间保持 cookie,就像浏览器一样。
session = requests.Session() session.post('https://example.com/login', data={'username': 'user', 'password': 'pass'}) response = session.get('https://example.com/profile')这里有一个常见的误区:很多人以为 Session 会自动处理所有事情,包括 JavaScript 生成的 cookie。实际上,Session 只能保持服务端通过 Set-Cookie 下发的 cookie,对于前端 JS 动态生成的 token,它无能为力。遇到这种情况,要么分析 JS 逻辑手动构造,要么用自动化浏览器工具。
4. 响应处理:从原始字节到可用数据
4.1 response.text 与 response.content 的选择
response.text返回的是解码后的字符串,response.content返回的是原始字节。什么时候用哪个?
如果你确定响应内容是文本(HTML、JSON、XML),用.text更方便。但.text的解码依赖 requests 对编码的猜测,这个猜测基于 HTTP header 里的Content-Type字段。如果服务端没有正确设置编码,或者编码声明在 HTML meta 标签里而不是 header 里,.text就可能出现乱码。
这时候有两个解决方案。一是手动指定编码:
response.encoding = 'utf-8' content = response.text二是直接用.content拿到字节,自己 decode:
content = response.content.decode('utf-8')对于二进制内容(图片、视频、PDF),必须用.content,因为.text会尝试解码,可能损坏数据。
4.2 JSON 响应的处理
现代网站大量使用 JSON 接口。requests 内置了.json()方法,直接把响应体解析为 Python 字典或列表:
data = response.json() items = data['results']但要注意,.json()在解析失败时会抛出json.JSONDecodeError。如果目标接口偶尔返回 HTML 错误页而不是 JSON,程序就会崩溃。稳妥的做法是加一层判断:
try: data = response.json() except ValueError: print('响应不是合法 JSON:', response.text[:200])4.3 状态码检查与异常处理
requests不会因为 HTTP 状态码是 4xx 或 5xx 就抛出异常,它只是把状态码放在response.status_code里。这意味着如果你不主动检查,可能会把 404 页面的内容当成正常数据解析。
有两种处理方式。一是手动检查:
if response.status_code == 200: # 正常处理 else: # 记录日志或重试二是用raise_for_status():
response.raise_for_status()这行代码会在状态码不是 2xx 时抛出HTTPError异常。配合 try-except 使用,可以统一处理各种网络异常:
try: response = requests.get(url, timeout=10) response.raise_for_status() except requests.exceptions.Timeout: print('请求超时') except requests.exceptions.HTTPError as e: print('HTTP 错误:', e) except requests.exceptions.RequestException as e: print('其他请求异常:', e)RequestException是所有 requests 异常的基类,放在最后兜底。
5. 那些文档里不会写的踩坑经验
5.1 编码问题的完整排查链路
编码乱码是爬虫新手遇到的第一大坑。我见过太多人对着response.text里的乱码发呆,不知道从哪下手。这里给一条完整的排查链路。
第一步,看response.encoding是什么。requests 会根据 header 推断,如果 header 没写,默认是 ISO-8859-1。
第二步,看response.apparent_encoding。这是 requests 用 chardet 库猜测的编码,通常比 header 更准,但计算有开销。
第三步,如果两者都不对,去 HTML 源码里找<meta charset="xxx">,手动设置response.encoding = 'xxx'。
第四步,如果还是乱码,用.content拿字节,尝试用常见编码(utf-8、gbk、gb2312)逐个 decode,看哪个不出错。
for enc in ['utf-8', 'gbk', 'gb2312', 'latin-1']: try: text = response.content.decode(enc) print(f'{enc} 解码成功') break except UnicodeDecodeError: continue这条链路走下来,99% 的编码问题都能定位。
5.2 重定向与历史记录
requests 默认会自动跟随重定向。这通常是好事,但有时候你需要知道最终请求到了哪个 URL,或者需要阻止自动重定向。
查看最终 URL 用response.url,查看重定向历史用response.history。阻止自动重定向则设置allow_redirects=False:
response = requests.get(url, allow_redirects=False) print(response.status_code) # 可能是 301 或 302 print(response.headers['Location']) # 重定向目标这个技巧在分析短链接、登录跳转逻辑时特别有用。
5.3 代理设置的正确姿势
在某些网络环境下,你可能需要通过代理访问目标网站。requests 支持 HTTP 和 SOCKS 代理:
proxies = { 'http': 'http://127.0.0.1:8080', 'https': 'http://127.0.0.1:8080' } response = requests.get(url, proxies=proxies)注意:代理的使用必须遵守目标网站的服务条款和相关法律法规,仅用于合法的网络调试和访问公开资源。
5.4 并发请求的朴素实现与局限
requests 是同步库,一个请求发出去要等响应回来才能发下一个。对于需要爬取大量页面的场景,这个速度是不可接受的。朴素的加速方案是用多线程:
from concurrent.futures import ThreadPoolExecutor def fetch(url): return requests.get(url, timeout=10).text urls = ['https://example.com/page/{}'.format(i) for i in range(1, 21)] with ThreadPoolExecutor(max_workers=5) as executor: results = list(executor.map(fetch, urls))这段代码能把 20 个请求的耗时从“20 倍单次耗时”压缩到“大约 4 倍单次耗时”。但多线程方案有它的局限:线程数量不好控制,太多会拖垮目标服务器也拖垮自己;异常处理变得复杂;cookie 和 session 的共享需要额外小心。
这正是 Scrapy 存在的意义之一。Scrapy 基于 Twisted 实现异步 IO,用单线程事件循环处理并发请求,不需要手动管理线程池,同时内置了下载延迟、自动限速、重试机制这些工程化功能。当你发现 requests 加多线程的代码越来越难维护时,就是时候转向 Scrapy 了。
6. 从 requests 到 Scrapy 的思维转换
6.1 请求对象的抽象升级
在 requests 里,一个请求就是一次函数调用,响应就是返回值。在 Scrapy 里,请求和响应都是对象,通过回调函数串联起来:
import scrapy class ExampleSpider(scrapy.Spider): name = 'example' start_urls = ['https://example.com/list'] def parse(self, response): for link in response.css('a.item::attr(href)').getall(): yield scrapy.Request(link, callback=self.parse_detail) def parse_detail(self, response): yield { 'title': response.css('h1::text').get(), 'content': response.css('div.content::text').get() }这段代码里,scrapy.Request对象被 yield 出去后,Scrapy 的调度器会接管它,负责发送请求、处理重试、调用回调。你不再需要关心“怎么发请求”,只需要关心“请求什么”和“拿到响应后做什么”。
6.2 选择器:从 BeautifulSoup 到 parsel
requests 本身不提供 HTML 解析功能,通常搭配 BeautifulSoup 或 lxml 使用。Scrapy 内置了 parsel 库,支持 CSS 和 XPath 两种选择器,语法和 BeautifulSoup 有相似之处但更简洁。
如果你已经熟悉 BeautifulSoup 的find和find_all,迁移到 parsel 的 CSS 选择器不会太困难。核心差异在于 parsel 的选择器返回的是 Selector 对象列表,需要用.get()或.getall()提取文本:
# BeautifulSoup soup.find('h1').text # parsel response.css('h1::text').get()6.3 中间件与管道:工程化的开始
Scrapy 真正区别于 requests 的地方在于中间件和管道。下载中间件可以在请求发出前和响应返回后插入自定义逻辑,比如随机切换 User-Agent、添加代理、处理异常重试。管道则负责处理爬取到的数据,比如去重、清洗、写入数据库。
这些机制在 requests 里都需要手动实现,而在 Scrapy 里只需要按照约定编写类和方法。这也是为什么我说:requests 让你学会发请求,Scrapy 让你学会做工程。
7. 我个人的一些使用习惯和建议
用了这么多年 requests,有几个习惯我觉得值得分享。
第一,永远设置超时。不管是写脚本还是写正式项目,timeout参数是必须的。我一般设(5, 15),连接 5 秒,读取 15 秒。这个值可以根据目标网站的响应速度调整,但绝不能留空。
第二,用 Session 而不是裸 requests。即使不需要保持登录状态,Session 也能复用 TCP 连接,减少握手开销。对于需要发多个请求到同一个域名的场景,Session 能明显提升速度。
第三,异常处理要分层。超时、HTTP 错误、连接错误分开捕获,分别记录不同的日志。这样出问题的时候能快速定位是网络问题还是目标网站问题。
第四,不要迷信“万能 header”。网上流传的那些几十行的 header 模板,很多字段是浏览器特有的,带上反而容易暴露。从最简配置开始,遇到问题再加,这是更稳妥的策略。
第五,学会看开发者工具。requests 能帮你发请求,但发什么请求、带什么参数、用什么方法,这些信息要从浏览器的 Network 面板里找。把“右键复制为 cURL”这个功能用熟,能省下大量手动分析的时间。
最后说一句关于学习路径的体会。我见过不少人纠结“到底先学 requests 还是先学 Scrapy”,其实这个问题没有标准答案。但如果你问我,我会说:先用 requests 手动爬三五个网站,把编码、cookie、重定向、超时这些坑都踩一遍,然后再去看 Scrapy。到那个时候,你会发现 Scrapy 文档里那些曾经看不懂的设计,突然都变得理所当然了。