拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

requests高级用法:会话管理、代理配置与反爬实战

requests高级用法:会话管理、代理配置与反爬实战

写过爬虫教程系列第一部分的同学应该都知道,那篇里面我把 requests 的基础用法、请求头和 Cookie 的基本处理都过了一遍,属于"拿着就能用"的程度。但实际跑过一阵子采集任务的人肯定都明白,真正让你头痛的从来不是"发个请求拿到响应",而是站点开始给你颜色看的时候:今天正常返回,明天突然 403,过两天连 SSL 握手都过不去,再往后 IP 直接被封。这些问题单靠基础 API 根本解决不了,需要你对请求库本身的理解上一个台阶。

这篇"爬虫请求库的使用2",就把我这些年用 requests 做采集时攒下来的那些东西一次讲透。内容主要集中在会话复用、请求头伪装、超时重试、代理配置、响应数据处理、SSL 验证边界以及并发选型这几个方面。适合已经能写出简单爬虫、但想要提升稳定性和反反爬能力的同学。全文没有废话,每一条都是能直接写进代码里的经验。

1. Session 会话机制:比手动拼 Cookie 靠谱得多

很多新手写爬虫的时候,习惯的做法是手动从响应头里把 Set-Cookie 抠出来,存到字典里,下一次请求再手动带上。这套玩法在小规模、单次请求的场景下勉强能转,但一旦涉及登录态维持、多次跳转、会话内数据回传,手动管理 Cookie 就是个无底洞。

1.1 Session 究竟替你做了哪些事

requests.Session() 这个东西,本质上是一个持久的连接会话。它的核心能力可以拆成三块:

  • Cookie 自动存储与携带:会话内所有响应返回的 Set-Cookie 会被自动记录,后续请求自动带上,你完全不需要手动处理。
  • 连接池复用:同一个 Session 对象发出的请求,底层 TCP 连接会被复用,而不是每次请求都重新握手。这在高频采集场景下能省掉大量时间,性能差距在几百上千次请求后特别明显。
  • 请求配置的统一继承:在 Session 级别设置的 headers、proxies、timeout 等参数,会被后续所有请求继承,省去重复传入的麻烦。
import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }) # 第一次请求:完成登录或获取 Cookie resp = session.get("https://example.com/login") print(session.cookies.get_dict()) # 后续请求:Cookie 自动携带,无需手动处理 data = session.post("https://example.com/actions", json={"page": 1})

1.2 会话管理的一个反直觉细节

用 Session 的时候有个容易忽略的点:Session 的 headers 更新方式不是"覆盖",而是"合并"。如果你在某次请求里单独传入了一个 headers 字典,这个字典里的字段会和 Session 级别的字段合并,而不是完全替换。这个设计本意是好的,但也导致了一个经典坑——你想在单次请求里临时移除某个请求头,直接传 headers 是做不到的,因为它合并后还是会带上原来的字段。

想要真正移除某个请求头,你得在请求前手动从 session.headers 里删掉,或者直接新建一个请求对象绕过 Session 继承。我在实际项目中就踩过这个坑,当时是要临时去掉 Referer 头,结果调试了半天发现请求里一直带着旧值,白白浪费了一个小时。

1.3 什么时候不该用 Session

Session 也不是万能的。分布式采集场景下,每个任务可能落在不同的机器或不同的进程里,session 无法跨进程共享。这时候要么自行维护一个 Cookie 存储(比如存 Redis),要么每次请求都从存储中取出对应站点的 Cookie 再手动携带。另外,如果你的采集任务是一次性请求且不需要保持状态,新建 Session 反而多了一层资源消耗,直接 requests.get 更省事。

2. 请求头伪装:User-Agent 之外的细节决定成败

第一篇文章里我们讲过要模拟浏览器 User-Agent,但说实话,只改一个 UA 早就骗不过主流站点的风控了。现在的反爬体系会综合检查一堆请求头字段,任何一个不协调都可能导致异常。

2.1 一套更像真实浏览器的请求头组合

真实浏览器发出请求时,会携带一整套固定的请求头字段。我常用的基础模板长这样:

headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", }

这里要重点说两个字段。一个是Accept-Language,很多站点会拿它做初步的地域识别,如果你采集的是中文站,却带着一个纯英文的 Accept-Language,风控系统很容易判定为脚本。另一个是Sec-Fetch-*系列,这几个字段是浏览器自动添加的,用来标明请求的来源和模式,爬虫脚本最容易被识破的特征之一就是缺失这类字段。

2.2 动态生成 User-Agent 的正确方式

固定一个 UA 的问题在于,同一个 UA 在短时间内发出大量请求,特征太明显。我的做法是准备一个 UA 池,每次请求随机切换:

import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", ] def get_headers(): return { "User-Agent": random.choice(UA_POOL), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }

用第三方库fake-useragent也能实现同样的效果,但它底层会去请求一个在线接口拉数据,网络不稳定时反而拖慢速度。我更喜欢自己维护一个几十条 UA 的列表,够用且可控。

2.3 请求头顺序与 TLS 指纹的边界

前几年大家发现,光改请求头还不行——浏览器的 TLS 握手指纹(比如通过 JA3/JA4 算法计算出来的特征值)和脚本的差异太大了。requests 底层用的是 urllib3 的默认 SSL 配置,这个配置和 Chrome 的 TLS 指纹完全不同,所以很多强风控站点直接在 TLS 层就把你拒绝了。

遗憾的是,这个问题用 requests 本身解决不了。如果真遇到 TLS 指纹检测,通常得换用 curl_cffi 或 tls-client 这类能做到 TLS 指纹模拟的库,或者直接上 Playwright 这类浏览器自动化方案。我在梳理请求库选型时给过一个建议:先用 requests 把业务逻辑跑通,遇到 TLS 指纹硬拦再降级到浏览器方案,不要一上来就上重型工具。

3. 超时、重试与重定向:稳定性的核心三件套

用 requests 跑采集,最忌讳的就是"裸奔"——不设超时、不做重试、不管重定向。这样写的代码在本地跑十次可能都正常,但放在生产环境里,网络抖动一次就够你喝一壶。

3.1 connect 和 read 超时要分开设

requests 的 timeout 参数可以传单个数值,也可以传一个元组。元组的含义是(connect timeout, read timeout),前者是建立连接的最大等待时间,后者是读取响应数据的最大间隔时间。我强烈建议分开设置:

resp = requests.get( "https://example.com/page", timeout=(3, 10) # 连接超时 3 秒,读超时 10 秒 )

为什么要拆开?因为连接超时和读取超时的性质完全不同。连接超时通常意味着目标不可达,这时候快速失败比一直等划算;而读取超时只能说明服务端响应慢,可能只是某个时间点负载高了,重试一次没准就成功了。如果不加区分地统一设一个值,要么连接超时设太长导致整体变慢,要么读取超时设太短导致接口正常的请求也被误杀。

3.2 配合 urllib3 Retry 实现自动重试

requests 自身不带重试机制,但它底层依赖的 HTTPAdapter 可以通过urllib3.util.retry.Retry来配置。把两者拼起来,就能实现一个带退避策略的自动重试:

from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry_strategy = Retry( total=3, # 最多重试 3 次 connect=3, # 连接失败重试 3 次 read=3, # 读取失败重试 3 次 backoff_factor=1, # 退避因子:1, 2, 4 秒 status_forcelist=[500, 502, 503, 504], # 这些状态码触发重试 allowed_methods=["GET", "POST"], raise_on_status=True, ) session = requests.Session() adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("http://", adapter) session.mount("https://", adapter)

这里面的backoff_factor比较关键,它的退避时间是{backoff_factor} * (2 ** (retry_count - 1)),也就是第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒。这个指数级增长的策略是为防止你在目标服务器还没恢复时用高频率反复打过去,反而加重对方负担或者触发更严厉的风控。

3.3 重定向处理的两面性

requests 默认情况下会自动跟随重定向,allow_redirects=True。大多数场景下这个行为是省心的,但有两种情况你得关掉它:

  • 采集场景需要拿到真实跳转链路,比如判断一个短链最终指向哪里。
  • 某些站点会在重定向过程中设置 Cookie 或埋点参数,自动跟随可能导致请求上下文被破坏。
# 查看重定向链路 resp = requests.get("https://short.url/abc", allow_redirects=True) print(resp.history) # 展现每个跳转步骤的 Response 对象 # 禁用自动重定向,手动处理 301/302 resp = requests.get("https://short.url/abc", allow_redirects=False) if resp.status_code in (301, 302): location = resp.headers.get("Location")

还有一类重定向是 HTML 层面的 meta refresh 和 JS 跳转,这种 requests 是跟不了的,得解析响应内容后二次请求,属于另一个话题了。

4. 代理配置与 IP 轮换:规模化采集绕不开的一环

很多人在本地写爬虫,跑几十个请求完全没问题,一放到服务器上采集量稍大就疯狂报错,查来查去最后发现是 IP 被风控临时限制。IP 维度的限制是任何请求头伪装都解决不了的,唯一的出路就是换出口 IP。

4.1 requests 中配置代理的两种方式

requests 代理可以在请求级别配置,也可以在 Session 级别统一配置。请求级别适合代理池分散管理、每次请求都可能不同的场景;Session 级别适合一组请求固定走同一个代理的场景。

# 请求级别 proxies = { "http": "http://127.0.0.1:7890", "https": "http://127.0.0.1:7890", } resp = requests.get("https://example.com", proxies=proxies, timeout=(3, 10)) # Session 级别 session = requests.Session() session.proxies.update({ "http": "http://127.0.0.1:7890", "https": "http://127.0.0.1:7890", })

4.2 代理轮换的工程化思路

手里有代理池时,常见的轮换策略有两种。一种是每次请求随机挑一个代理,适合对顺序无要求、单次请求独立性强的任务;另一种是给代理设置失败次数的惩罚机制,一个代理连续失败几次就暂时移出池子,冷却一段时间后再放回来。

我推荐第二种思路,因为代理质量参差不齐是常态。我自己实现过一个轻量版本:

class ProxyManager: def __init__(self, proxy_list): self.proxy_list = proxy_list self.fail_count = {p: 0 for p in proxy_list} self.max_fail = 3 def get_proxy(self): available = [p for p in self.proxy_list if self.fail_count[p] < self.max_fail] return random.choice(available) def report_fail(self, proxy): self.fail_count[proxy] += 1 def report_success(self, proxy): self.fail_count[proxy] = 0

配合请求异常处理,每失败一次就上报一次,失败达到阈值就自动换新代理。这套逻辑虽然简单,但比简单随机轮换的稳定性高出不少。

4.3 验证代理是否真的可用

从代理池拿到的代理,不一定都能正常访问目标站点。常规做法是在正式采集前先发一个轻量请求验证连通性:

def check_proxy(proxy, test_url="https://example.com"): try: resp = requests.get(test_url, proxies={"http": proxy, "https": proxy}, timeout=(3, 5)) return resp.status_code == 200 except Exception: return False

但要注意,这个验证只能说明代理当前可连通,不代表它访问目标站点时不会被目标站点的风控拦截。所以更可靠的做法是验证时就直接用目标站点的一个轻量接口来测试,虽然会增加一点请求量,但换来的是后续请求的成功率提升。

5. 响应体处理的深度细节:编码、JSON 与流式下载

请求发出去了,响应拿到了,接下来就是处理数据。这一环节的坑主要在编码和二进制流上,处理不好数据就是乱码,或者内存直接被打爆。

5.1 正确获取响应文本的编码方式

requests 的resp.text会自动根据响应头里的Content-Type字段推断编码。麻烦在于,很多站点的响应头压根不写 charset,这时候 requests 只能去猜,猜的依据是 HTML 里的 meta 标签。一旦 meta 标签缺失或者书写不规范,requests 就会默认用 ISO-8859-1 去解码,中文全部变成乱码。

遇到这种情况,正确的处理方式是手动指定编码:

resp = requests.get("https://example.com") # 从响应头取,取不到就从 HTML 的 meta 标签里正则提取 resp.encoding = "utf-8" print(resp.text)

批量采集时我一般直接用resp.content拿字节流,然后调用自己写的编码检测函数处理。这样不受 requests 内部编码推断的影响,所有站点统一走一套逻辑,排查问题更省心。

5.2 resp.json() 的隐藏陷阱

resp.json()看起来非常便捷,但它内部用的标准 JSON 解析器对某些不规范的 JSON 格式无能为力。最常见的例子是目标接口返回的 JSON 里带了 BOM 头(\ufeff),或者整个响应是 JSONP 格式(外面包了一层callback({...})),再或者 JSON 尾部多了分号。

# 带 BOM 的 JSON,json() 会报错 raw = resp.content if raw.startswith(b"\xef\xbb\xbf"): raw = raw[3:] data = json.loads(raw) # JSONP 格式要先剥掉外层 text = resp.text start = text.find("(") + 1 end = text.rfind(")") data = json.loads(text[start:end])

5.3 大文件下载与流式读取

如果是下载图片、压缩包这类体积较大的文件,直接resp.content会把整个文件加载进内存。几十 MB 可能没什么感觉,但到了 GB 级别的文件,内存直接爆掉。

正确做法是用stream=True配合迭代读取:

resp = requests.get(large_file_url, stream=True, timeout=(5, 30)) total_size = int(resp.headers.get("Content-Length", 0)) downloaded = 0 with open("output.zip", "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) downloaded += len(chunk) resp.close()

iter_content每次返回指定大小的字节块,边下载边写入磁盘,整个过程内存占用恒定。还有一点要记得:流式请求用完要resp.close(),否则连接池里的连接一直不释放,跑多了会把连接池占满。

6. SSL 验证边界:不要让 verify=False 成为习惯

requests 在访问 HTTPS 站点时默认会验证 SSL 证书。很多教程碰到自签名证书或者证书链不完整的站点,就直接verify=False关掉验证,再配一条urllib3.disable_warnings()把警告压下去。这样做确实能快速解决问题,但埋下的隐患也是实实在在的。

6.1 关掉 SSL 验证的风险

verify=False意味着客户端不再校验服务端身份,中间人攻击的防御彻底没了。在明文 HTTP 和 HTTPS 之间反复横跳的采集任务里,如果出口网络被劫持,返回给你的可能是一个伪造的页面,而你完全察觉不到。数据准确性受影响只是小事,如果采集的是登录后的敏感信息,泄露风险就大了。

6.2 更优雅的自签名证书处理方案

遇到自签名证书的站点,不是只有关验证一条路。你可以把对方的证书下载下来,本地指定 CA 路径:

resp = requests.get("https://self-signed.example.com", verify="/path/to/self_signed_ca.pem")

如果是内部测试环境,不想每次手动配证书,也可以在环境变量里指定:

export REQUESTS_CA_BUNDLE=/path/to/ca.pem export CURL_CA_BUNDLE=/path/to/ca.pem

6.3 确认异常问题的标准排查顺序

实际项目里遇到 SSL 错误(比如SSLCertVerificationError或CERTIFICATE_VERIFY_FAILED),我的排查顺序是:

  1. 先确认系统时间是否准确,证书校验依赖时间有效性,时间偏了必报错。
  2. 检查本地 Python 环境的证书库是否过旧,有时候升级一下certifi就好。
  3. 确认目标站点的证书链是否完整,可以在浏览器里打开站点看证书详情。
  4. 只有确认是站点证书本身的问题(比如测试环境自签名),才考虑本地加 CA 或临时verify=False,而且要限定在最小范围内使用。
# 如果确实要临时关闭验证,至少用上下文管理器圈定范围 import warnings import requests from urllib3.exceptions import InsecureRequestWarning warnings.simplefilter("ignore", InsecureRequestWarning) with requests.Session() as session: # 只在需要忽略验证的请求上使用 verify=False resp = session.get("https://self-signed.example.com", verify=False)

会话结束后,后续请求依然走默认验证逻辑,不会因为一次忽略而全局放开。

7. requests 之外的进阶选型:异步与浏览器方案

requests 用顺手之后,你迟早会碰到两个瓶颈:一是单线程串行请求太慢,二是页面内容靠 JS 渲染导致拿不到数据。这两个问题都不适合在 requests 里面硬磨,正确思路是换工具。

7.1 异步请求库的核心差异

单机串行请求的速度瓶颈在于网络等待时间。一个请求从发出去到响应回来,真正用于数据处理的时间可能就几十毫秒,其余时间全在等待 I/O。异步方案的本质就是把这部分等待时间让出来,让其他请求同时进行。

requests 本身不支持异步。要在这条路上走下去,主流选择是aiohttp和httpx。httpx的 API 设计和 requests 几乎一致,迁移成本很低,它还同时支持同步和异步两种模式,对从 requests 过渡来的开发者非常友好:

import asyncio import httpx async def fetch_one(client, url): resp = await client.get(url) return resp.text async def main(): async with httpx.AsyncClient(timeout=10) as client: tasks = [fetch_one(client, f"https://example.com/page?p={i}") for i in range(20)] results = await asyncio.gather(*tasks) return results asyncio.run(main())

7.2 playwright 什么时候比 requests 更合适

requests 只能拿到服务端渲染的 HTML。现在越来越多的站点走前后端分离,列表页给的是一个空壳,数据全靠网页里的 JS 再发请求拿回来填充。这种场景下,requests 直接发请求拿到的页面是空白的,你只能靠抓包找背后的数据接口来补数据——如果接口参数加密、签名逻辑复杂,说白了就是一场硬仗。

playwright这类浏览器自动化方案可以直接启动一个无头浏览器,页面里的 JS 全部真实执行,渲染完成后再拿 HTML 或直接拦截网络请求拿接口数据。它的优点是绕过了 JS 加密这道坎,缺点是资源消耗高、并发能力弱。除非确认 requests 路线确实走不通,否则没必要一上来就上 playwright。

7.3 混合架构思路

我个人的经验是,一个健壮的采集系统往往不是单一技术栈。我的常用组合是:requests 负责 JSON 接口调用和简单页面抓取,Playwright 负责复杂页面渲染或触发式加载场景,两者之间用一套统一的任务队列衔接。这样既保住了采集效率,又不会因为个别站点技术特殊导致整个系统卡壳。

8. 两个容易被忽略的实操细节

除了上面这些大块内容,还有两个小细节值得单独说说。它们看起来不起眼,但在关键时候能帮你省下大把排查时间。

8.1 请求日志与链路追踪

采集任务跑起来之后,最怕的就是"不知道卡在哪了"。我的做法是从一开始就给每个请求打日志,至少记录时间、URL、状态码、耗时、使用的代理这五项信息:

import logging import time logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") def get_with_log(session, url, **kwargs): start = time.time() resp = session.get(url, **kwargs) elapsed = round(time.time() - start, 3) logging.info(f"{url} | {resp.status_code} | {elapsed}s") return resp

不要嫌麻烦。一旦任务跑挂了,有日志你能在几分钟内定位到具体请求、具体代理、具体状态码。没有日志的话,你只能一遍遍重启任务猜测原因,那种体验谁试谁知道。

8.2 请求频率控制:限速比你想的更关键

requests 本身不限制请求速率,你发多少它就发多少。但实际采集时,过高的请求频率只会加速 IP 封禁。我通常会在请求循环里加一个简单的间隔控制:

import time import random for url in url_list: resp = session.get(url, timeout=(3, 10)) # 基础间隔 + 随机抖动,避免形成固定节奏 time.sleep(random.uniform(0.5, 1.5))

固定间隔的问题在于,它很容易被统计规律识别出来。真实用户的访问间隔不会是均匀的,加一个随机抖动反而更接近真实行为,也能降低对目标服务器的瞬时压力。

数据采集这件事,越到后面越考验细节。请求库本身只是一个入口,真正决定整个采集链路稳不稳定的,是你对会话、超时、重试、代理、编码这些细节的把控程度。上面这些经验,都是我拿实际项目换回来的,希望你看完能少走几步弯路。

返回列表