Skip to content

Python网络请求核心编程

Summary

掌握了 HTTP 协议原理后,本章进入 Python 网络请求代码实战。内容从底层 urllib 过渡到工业界主流的 requests 与支持 HTTP/2 的新一代客户端 httpx,全面覆盖 GET/POST 请求构造、查询参数编排、Headers 浏览器伪装、二进制流式多媒体下载、Session 跨请求会话维持、超时控制、SSL 证书校验、HTTP/SOCKS5 代理接入以及基于 tenacity 的指数退避自动重试。学完本章后,学生能够编写出高鲁棒性的网络请求客户端代码。

Concepts Covered

This chapter covers the following 22 concepts from the learning graph:

Concept Concept Impact Score
Python虚拟环境与包管理 1563
urllib.request底层请求模块 897
urllib.parse参数构造与转换 362
Requests库安装与核心架构 534
构造GET请求与Params查询参数 348
Response响应对象属性解析 155
响应文本编码自动识别与修正 77
定制请求头Headers伪装浏览器 186
构造POST表单请求data载荷 30
构造POST接口JSON请求载荷 28
Multipart多部分文件上传请求 1
二进制流模式下载图片与音视频 14
Requests中Cookie获取与字典注入 57
requests.Session跨请求会话保持 56
请求重定向跟随与禁止allow_redirects 1
连接超时与读取超时Timeout控制 76
SSL证书校验与警告屏蔽verify 1
HTTP与SOCKS5代理Proxies配置 7
Requests异常继承体系与捕获 13
Tenacity库与指数退避自动重试 5
HTTPX新一代客户端与HTTP/2请求 5
cURL命令转Python爬虫代码技巧 1

Prerequisites

This chapter builds on concepts from:


1. 工程环境隔离与标准库 urllib 底层探秘

在第 2 章中,我们彻底拆解了 HTTP 请求与响应报文的底层协议规范。从本章开始,我们将正式把这些协议规范转化为高质量的 Python 工程代码。在动手安装任何第三方爬虫库之前,专业工程师的第一步永远是做好项目环境隔离,并理解 Python 标准库是如何封装底层 Socket 通信的。

1.1 Python虚拟环境与包管理

在人工智能与数据采集工程中,不同项目往往依赖不同版本的第三方库(例如老项目依赖 urllib3<2.0,而新项目依赖支持 HTTP/2 的 httpx 与 playwright)。如果把所有包都一股脑安装在操作系统的全局 Python 环境中,极易引发依赖版本冲突(Dependency Hell)。因此,掌握 Python虚拟环境与包管理(Virtual Environments & Package Management) 是编写工业级爬虫的第一道工程规范。

每一个独立的爬虫项目都应当在项目根目录下创建专属的虚拟环境(.venv),并通过 requirements.txt 或现代极速包管理器 uv(基于 Rust 编写,速度比传统 pip 快 10~100 倍)锁定依赖版本:

# 方案一:使用 Python 内置 venv 模块 + 国内镜像源加速安装
python -m venv .venv
# Windows PowerShell 激活虚拟环境(Linux/macOS 使用 source .venv/bin/activate)
.\.venv\Scripts\Activate.ps1
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests[socks] httpx[http2] tenacity chardet

# 方案二(推荐):使用现代极速包管理器 uv 初始化并管理依赖
uv venv
uv pip install requests[socks] httpx[http2] tenacity chardet
uv pip freeze > requirements.txt

1.2 urllib.request 与 urllib.parse 标准库底层机制

在安装第三方库之前,Python 标准库已经内置了完整的 HTTP 客户端包 urllib。虽然在实际项目开发中我们更多使用语法更优雅的 requests 或 httpx,但理解 urllib.request底层请求模块 与 urllib.parse参数构造与转换 具有不可替代的价值:一方面,它是零外部依赖环境下(如受限服务器或内置脚本)的兜底利器;另一方面,requests 底层依赖的 urllib3 正是建立在相同的请求抽象之上。

urllib 包将网络请求拆分为四个核心子模块:

  1. urllib.request:负责打开和读取 URL。注意,如果你直接调用 urllib.request.urlopen("https://..."),它默认发送的 User-Agent 是 Python-urllib/3.12,几乎会被所有网站立刻拦截!要定制请求头或请求方法,必须先实例化一个 urllib.request.Request(url, data=..., headers=..., method=...) 对象,再将其传给 urlopen()。
  2. urllib.parse:负责 URL 的拆解(urlsplit)、相对路径拼接(urljoin,将在第 4 章详解)、查询字典转字符串(urlencode)以及中文百分号编码(quote/unquote)。特别注意:在 urllib.request.Request 中提交 POST 请求体时,data 参数只接受 bytes 字节流,必须先用 urlencode(dict).encode("utf-8") 或 json.dumps(dict).encode("utf-8") 转换为字节!
  3. urllib.error:定义了 URLError(网络不可达、DNS 失败)及其子类 HTTPError(服务器返回 403、404、500 等非 2xx 状态码时直接抛出异常)。
  4. urllib.robotparser:我们在第 1 章已经实战过的 robots.txt 合规解析器。

下面的代码示例展示了如何使用纯标准库 urllib.request 与 urllib.parse 构造一个带自定义浏览器 User-Agent、超时控制与异常捕获的完整请求流程:

import json
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urljoin
from urllib.request import Request, urlopen


def fetch_with_urllib(base_url: str, endpoint: str, params: dict) -> dict:
    """使用标准库 urllib 构造带查询参数与自定义 Headers 的 GET 请求"""
    # 1. 使用 urljoin 拼接路径,并用 urlencode 将字典转为百分号编码查询串
    full_path = urljoin(base_url, endpoint)
    query_string = urlencode(params, encoding="utf-8")
    target_url = f"{full_path}?{query_string}"

    # 2. 必须实例化 Request 对象才能注入自定义 Headers 伪装浏览器
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
        ),
        "Accept": "application/json",
    }
    req = Request(url=target_url, headers=headers, method="GET")

    try:
        # 3. 使用 with 上下文自动关闭底层 Socket 连接,显式设置 timeout=10 秒
        with urlopen(req, timeout=10) as response:
            status_code = response.getcode()
            raw_bytes = response.read()
            return {
                "status": status_code,
                "url": response.geturl(),
                "body_preview": raw_bytes.decode("utf-8")[:200],
            }
    except HTTPError as http_err:
        # 捕获 4xx / 5xx HTTP 状态码错误
        return {"status": http_err.code, "error": f"HTTP错误: {http_err.reason}"}
    except URLError as url_err:
        # 捕获 DNS 解析失败、连接拒绝或超时错误
        return {"status": -1, "error": f"网络层错误: {url_err.reason}"}


if __name__ == "__main__":
    print("标准库 urllib 请求函数已就绪,目标查询串示例:")
    print(urlencode({"course": "网络爬虫技术", "level": "专科"}, encoding="utf-8"))

2. Requests 核心编程:请求构造、响应解析与乱码治理

2.1 Requests库安装与核心架构

虽然标准库 urllib 功能完备,但每次请求都要手动编码 urlencode、手动 .encode("utf-8") 转字节流、手动 .read().decode() 解码,代码显得繁琐冗长。为此,Kenneth Reitz 开发了著名的第三方库 requests(口号为 "HTTP for Humans"),它至今仍是全球下载量最高、最受 Python 爬虫工程师青睐的同步 HTTP 客户端。

理解 Requests库安装与核心架构 的关键在于看清它的分层封装关系:

  • 底层引擎(urllib3):requests 并不是直接在操作系统 socket 上从零写的,它的底层核心依赖是 urllib3 库。urllib3 负责管理真正核心的 TCP 连接池(Connection Pooling)、线程安全、TLS/SSL 证书校验以及底层重试。
  • 高层人性化接口(requests API):在 urllib3 之上,requests 封装了 PreparedRequest(预处理请求对象)、Response(响应对象)与 Session(跨请求会话与连接池容器)。当你调用 requests.get(url) 时,它内部其实自动创建了一个临时的 Session() 对象,发送完请求后立即销毁。

2.2 构造GET请求、定制Headers与解析Response对象

在日常静态网页与公开接口采集中,最频繁的操作就是组合使用 构造GET请求与Params查询参数、定制请求头Headers伪装浏览器 以及 Response响应对象属性解析。

  1. 使用 params 字典优雅传递查询参数
    在 requests.get(url, params=dict, headers=dict, timeout=tuple) 中,我们不再需要手动拼接 ?key1=val1&key2=val2,只需将一个普通的 Python 字典传给 params 参数。requests 会在底层自动完成 UTF-8 百分号编码;如果字典中某个键对应的值是列表(如 {"tag": ["AI", "LLM"]}),它还会自动编码为多值参数 ?tag=AI&tag=LLM;如果某个键的值为 None,该键会被自动忽略不发送。
  2. 定制 headers 字典伪装真实浏览器
    将包含 User-Agent、Referer、Accept-Language 的 Python 字典传给 headers 参数,即可覆盖默认的 python-requests/x.x.x 标识。
  3. 深入理解 Response 对象的四大核心属性
    当 resp = requests.get(...) 返回后,切忌只知道调 resp.text,必须分清以下属性的使用场景:
  4. resp.status_code:返回整数形式的 HTTP 状态码(如 200、403、404)。配套的快捷方法 resp.raise_for_status() 非常实用——如果状态码是 2xx 则什么都不做;如果是 4xx 或 5xx,则立即抛出 requests.HTTPError 异常,方便交由异常处理或重试装饰器接管。
  5. resp.content(原始二进制字节流 bytes):服务器返回的原始未解码字节序列(已自动完成 gzip/deflate 解压)。在下载图片、音频、视频、PDF,或者使用 lxml.etree.HTML(resp.content) 解析网页时,强烈建议直接读取 resp.content!
  6. resp.text(解码后的字符串 str):requests 根据 resp.encoding 属性自动将 resp.content 解码后的 Unicode 字符串。
  7. resp.json()(直接解析 JSON 字典/列表):当响应体是 JSON 格式时,直接调用 resp.json() 方法即可将其反序列化为 Python 的 dict 或 list(若响应体不是合法 JSON,会抛出 requests.exceptions.JSONDecodeError)。

2.3 响应文本编码自动识别与修正

很多初学者在抓取国内高校官网、政府公报网或老牌行业资讯站时,明明请求返回 200 OK,但一打印 resp.text 就出现类似 ËÑË÷ÒýÇæ 或 ˹ 的乱码。这就是爬虫开发经典的 响应文本编码自动识别与修正 问题。

为什么 resp.text 会猜错编码?原因在于 RFC 2616 HTTP/1.1 历史规范中的一条古老规定:如果服务器的响应头为 Content-Type: text/html 但忘记显式写明 ; charset=utf-8(或者写成了 ; charset=gb2312),requests 会严格遵循古老规范,将 resp.encoding 默认回退设为西欧字符集 ISO-8859-1(即 latin-1)! 用单字节的西欧字符集去解码三字节的 UTF-8 中文或双字节的 GBK 中文,自然 100% 变成乱码。

在工程实践中,彻底根治中文网页乱码有三种标准方案(按推荐优先级排序):

  1. 方案一(最稳健、性能最高):如果从网页源码 <meta charset="utf-8"> 中已经明确知道目标网站的编码(国内网站 99% 不是 utf-8 就是 gb18030——注意:gb18030 完全向下兼容 gbk 和 gb2312 且字库最全),直接在读取 resp.text 之前手动指定 resp.encoding = "utf-8" 或 resp.encoding = "gb18030";或者直接使用 resp.content.decode("utf-8", errors="replace")。
  2. 方案二(自动探测备用):读取 resp.apparent_encoding 属性(底层调用 charset_normalizer 或 chardet 扫描响应体字节特征来统计推断真实编码),赋值给 resp.encoding = resp.apparent_encoding。但需要注意,apparent_encoding 需要遍历分析整个字节流,在大网页上会带来显著的 CPU 开销,且在文本极短时偶有误判。
  3. 方案三(HTML Meta 标签嗅探):先用正则从 resp.content[:1024] 前 1KB 字节中匹配 <meta.*?charset=["\']?([a-zA-Z0-9\-]+),若未找到再回退到 utf-8。
import re
import requests


def decode_html_safely(raw_bytes: bytes, content_type_header: str = "") -> tuple[str, str]:
    """工业级网页字节流智能解码器:优先响应头 -> 其次HTML <meta>标签 -> 最后UTF-8/GB18030兜底"""
    # 1. 检查 HTTP 响应头 Content-Type 是否显式声明了 charset
    header_match = re.search(r"charset=([\w\-]+)", content_type_header, re.I)
    if header_match:
        enc = header_match.group(1).lower()
        # 将 gb2312 / gbk 统一升级为超集 gb18030,防止生僻字解码报错
        if enc in ("gb2312", "gbk"):
            enc = "gb18030"
        return raw_bytes.decode(enc, errors="replace"), enc

    # 2. 扫描 HTML 前 2048 字节中的 <meta charset="..."> 声明
    head_bytes = raw_bytes[:2048]
    meta_match = re.search(br'charset=["\']?([a-zA-Z0-9\-]+)', head_bytes, re.I)
    if meta_match:
        enc = meta_match.group(1).decode("ascii", errors="ignore").lower()
        if enc in ("gb2312", "gbk"):
            enc = "gb18030"
        return raw_bytes.decode(enc, errors="replace"), enc

    # 3. 尝试标准 UTF-8 解码,若抛出 UnicodeDecodeError 则自动切换为 GB18030
    try:
        return raw_bytes.decode("utf-8"), "utf-8"
    except UnicodeDecodeError:
        return raw_bytes.decode("gb18030", errors="replace"), "gb18030"


if __name__ == "__main__":
    # 模拟一个未在响应头声明 charset、且采用 GBK 编码的老旧政务网页字节流
    gbk_html_bytes = '<html><head><meta charset="gb2312"></head><body><h1>人工智能产业扶持政策</h1></body></html>'.encode(
        "gbk"
    )
    text, detected_enc = decode_html_safely(gbk_html_bytes, "text/html")
    print(f"探测到的真实编码: {detected_enc} | 解码正文: {text}")

2.4 POST 载荷提交(data vs json vs files)与流式多媒体下载

当爬虫需要模拟表单登录、调用分页搜索接口、上传图片进行 OCR 识别,或者下载几十兆的 PDF 论文与数据集压缩包时,必须精准掌握以下四项核心技术:

  1. 构造POST表单请求 data 载荷(application/x-www-form-urlencoded)
    当你在 requests.post(url, data=payload_dict) 中把一个 Python 字典传给 data= 参数时,requests 会自动将其编码为 key1=val1&key2=val2 的表单键值对字符串,并自动在请求头中加上 Content-Type: application/x-www-form-urlencoded。
  2. 构造POST接口JSON请求载荷(application/json)
    当目标接口是一个现代前后端分离的 AJAX 接口(DevTools 中显示 Request Payload 为 {...} 且 Content-Type: application/json)时,千万不要把字典传给 data=!正确的做法是直接将 Python 字典传给 json=payload_dict 参数。requests 会在底层自动调用 json.dumps() 序列化字典,并自动设置请求头 Content-Type: application/json。
  3. Multipart多部分文件上传请求(multipart/form-data)
    当爬虫需要向目标接口上传文件(例如向在线工具接口提交音频或图像文件)时,使用 files={"file_field": ("sample.png", open("sample.png", "rb"), "image/png")} 参数。此时 requests 会自动生成随机分隔符 boundary 并构造标准的 multipart/form-data 请求体(注意:使用 files= 时,绝对不要手动在 headers 里写死 Content-Type: multipart/form-data,因为手动写死会丢失 boundary=... 参数导致服务器解析失败!)。
  4. 二进制流模式下载图片与音视频(Streaming Download)
    在采集多模态图文数据集、音频语料或大型 PDF 报告时,如果直接执行 resp = requests.get(large_video_url),默认情况下 requests 会试图把整个 500MB 的视频文件一次性全部读入内存(RAM)中,在高并发下瞬间就会撑爆内存导致程序崩溃(OOM)!
    正确的工业级做法是开启 流式下载模式(stream=True):设置 stream=True 后,requests 仅下载响应头而保持底层连接挂起,随后我们通过 resp.iter_content(chunk_size=65536) 每次只从网络缓冲区读取 64KB 的小数据块并立即写入磁盘,无论下载 10MB 还是 10GB 的文件,内存占用始终保持在几十 KB!
from pathlib import Path
import requests


def download_media_stream(
    url: str, save_path: str | Path, referer: str = "", chunk_size: int = 65536
) -> int:
    """使用 stream=True 与 iter_content 分块流式下载大型多媒体文件,防止内存溢出"""
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0",
    }
    if referer:
        headers["Referer"] = referer  # 突破图片/音视频防盗链

    target_file = Path(save_path)
    target_file.parent.mkdir(parents=True, exist_ok=True)
    downloaded_bytes = 0

    # 开启 stream=True,配合 with 上下文确保连接在下载完毕或异常中断时必定关闭
    with requests.get(url, headers=headers, stream=True, timeout=(5, 30)) as resp:
        resp.raise_for_status()
        with open(target_file, mode="wb") as f:
            for chunk in resp.iter_content(chunk_size=chunk_size):
                if chunk:  # 过滤 keep-alive 空心跳包
                    f.write(chunk)
                    downloaded_bytes += len(chunk)

    return downloaded_bytes

下表对比了 requests 中不同参数在底层生成的 HTTP 报文差异,这是初学者最容易混淆的高频考点:

调用方式与参数 传入的 Python 类型 底层自动设置的 Content-Type 请求头 最终发送的 HTTP 报文位置与格式
requests.get(url, params=d) dict 或 list[tuple] 无(GET 无请求体) 拼接到 URL ? 之后:?k1=v1&k2=v2
requests.post(url, data=d) dict 或 list[tuple] application/x-www-form-urlencoded 写入 Request Body:k1=v1&k2=v2
requests.post(url, data=s) 已序列化的 str 或 bytes 不会自动设置(需手动在 headers 声明) 原样写入 Request Body
requests.post(url, json=d) dict 或 list application/json 序列化为 JSON 字符串写入 Request Body:{"k1": "v1"}
requests.post(url, files=f) dict(含文件句柄元组) multipart/form-data; boundary=... 按 --boundary 分隔多段二进制流写入 Request Body

在掌握了 params、data、json、files 与 headers 的映射规则后,请通过下方的交互式微仿真,检验你能否根据不同的目标接口需求,精准组装出正确的 Python 请求代码并预测其生成的底层 HTTP 报文。

Diagram: Python HTTP Request Parameter and Payload Builder

flowchart LR
    subgraph Input["Python 请求参数字典"]
        P["params = {'page': 1}"]
        D["data = {'user': 'admin'}"]
        J["json = {'query': 'LLM'}"]
        H["headers = {'User-Agent': 'Mozilla/5.0'}"]
    end
    P -->|"URL 查询字符串编码"| Out1["GET /search?page=1"]
    D -->|"表单编码 x-www-form-urlencoded"| Out2["Request Body: user=admin"]
    J -->|"JSON 序列化 application/json"| Out3["Request Body: {'query': 'LLM'}"]
    H -->|"覆盖默认请求头"| Out4["HTTP Request Headers"]
Python HTTP Request Parameter and Payload Builder

Type: microsim sim-id: python-http-request-builder
Library: p5.js
Status: Specified
Bloom Level: Apply
Bloom Verb: construct
Learning Objective: The learner will construct valid Python requests calls for 6 distinct web scraping scenarios by selecting the correct HTTP method, payload parameter (params, data, json, files), and stream/headers options, and verifying the resulting raw HTTP wire message.

Prerequisites: requests.get and requests.post, params vs data vs json vs files arguments, Content-Type header generation, Referer anti-hotlinking, and stream=True chunked downloading (all defined in Sections 2.1–2.4 above).

Evidence of Mastery: Across 6 scraping task challenges, the learner selects the Method (GET/POST), Parameter Key (params/data/json/files), and Special Option (None/Referer Header/stream=True) to assemble the Python code and generate the raw HTTP packet. Mastery is constructing at least 5 of the 6 requests correctly on the first attempt.

Misconceptions: (1) Passing a Python dict to data= sends a JSON body (application/json). (2) Manual Content-Type: multipart/form-data header should be hardcoded when using files=. (3) Downloading a 500MB dataset archive with plain requests.get(url).content is safe and memory-efficient.

Instructional Rationale: Apply-level construction bridges the gap between Python syntax and wire-level HTTP messages by showing learners the exact raw HTTP Request Line, Content-Type header, and Body produced by each parameter choice before they lock in their answer.

Content:

The 6 scraping task challenges (in fixed order):

# Scraping Scenario Description Correct Configuration (Method + Arg + Option) Generated Raw HTTP Wire Preview Why (Feedback Explanation)
1 向开源社区搜索接口 /api/models 发送 GET 查询,筛选条件为 tag="视觉大模型"、第 2 页 Method: GET + Arg: params={"tag": "视觉大模型", "page": 2} + Option: Standard UA Header GET /api/models?tag=%E8%A7%86%E8%A7%89%E5%A4%A7%E6%A8%A1%E5%9E%8B&page=2 HTTP/1.1 (Body 为空) GET 请求的查询条件应传给 params=,由 requests 自动完成 UTF-8 百分号编码并拼接到 URL 问号后。
2 向传统教务系统登录表单 /auth/login.do 提交表单键值对 username="stu01" 与 password="123456" Method: POST + Arg: data={"username": "stu01", "password": "123456"} + Option: Standard UA Header POST /auth/login.do HTTP/1.1 / Content-Type: application/x-www-form-urlencoded / Body: username=stu01&password=123456 传统 HTML 表单提交需要将字典传给 data=,底层自动编码为 & 连接的键值对并声明 x-www-form-urlencoded。
3 向现代前后端分离的异步接口 /v2/rag/search 提交 JSON 格式检索载荷 {"query": "Scrapy管道", "top_k": 5} Method: POST + Arg: json={"query": "Scrapy管道", "top_k": 5} + Option: Standard UA Header POST /v2/rag/search HTTP/1.1 / Content-Type: application/json / Body: {"query": "Scrapy管道", "top_k": 5} 将字典传给 json= 参数会自动调用 json.dumps 序列化并自动设置 Content-Type: application/json。
4 从开启了防盗链保护的图片 CDN https://img.example.com/101.png 抓取高清架构图,主站域名为 https://www.example.com/ Method: GET + Arg: None (URL Direct) + Option: Add Referer: https://www.example.com/ GET /101.png HTTP/1.1 / Host: img.example.com / Referer: https://www.example.com/ 针对图片 CDN 的防盗链校验,必须在 headers 中补齐主站来源地址 Referer 才能避免 403 Forbidden。
5 从开源语料站下载体积达 1.2 GB 的微调数据集压缩包 corpus_v1.tar.gz,要求内存占用不超过 1MB Method: GET + Arg: None (URL Direct) + Option: Enable stream=True + iter_content(65536) GET /corpus_v1.tar.gz HTTP/1.1 -> 响应流式挂起,按 64KB 分块写入磁盘文件 针对大体积文件必须开启 stream=True 并配合 iter_content 分块流式落盘,避免一次性读入内存导致 OOM 崩溃。
6 向在线验证码 OCR 识别接口 /api/ocr/predict 上传本地图片文件 captcha.png Method: POST + Arg: files={"image": ("captcha.png", fp, "image/png")} + Option: Auto Multipart Boundary (No manual Content-Type) POST /api/ocr/predict HTTP/1.1 / Content-Type: multipart/form-data; boundary=c8f9... / Body: --c8f9... 上传文件使用 files= 参数,且由 requests 自动生成包含 boundary 的 Content-Type 头,切勿手动硬编码覆盖。

Provenance: Directly implements the parameter rules and table in Chapter 3 Sections 2.2 and 2.4.

Rules: Each challenge lets the learner pick from 2 Methods (GET, POST), 5 Parameter modes (None, params=dict, data=dict, json=dict, files=dict), and 4 Options (Standard UA Header, Add Referer Header, Enable stream=True + iter_content, Hardcode Content-Type: multipart/form-data). Selecting any combination immediately updates the live "Generated Raw HTTP Packet Preview" so the learner can inspect what would be sent over the wire before clicking "Commit Request".

Learner Activity:

  1. The learner reads the active scraping scenario (Challenge 1 through 6).
  2. The learner toggles the Method, Parameter mode, and Option controls, watching the Python code snippet and the Raw HTTP Wire Preview update in real time.
  3. The learner clicks "Commit Request" to submit the constructed request.
  4. The simulator validates the request against the target server expectation and displays the feedback explanation.

Feedback: 6 challenges in fixed order, 2 attempts per challenge. Correct: "请求构造完美!服务器返回 200 OK:" + Why explanation. Incorrect: "服务器拒绝请求(400/403/OOM):请查看右侧生成的原始 HTTP 报文——" + explains why the chosen parameter/header failed. Score display: 已成功构造请求:n / 6.

Starting State: Challenge 1 is active with default selection Method: GET, Arg: None, Option: Standard UA Header, showing the live Python code preview and raw HTTP packet panel.

Chapter Anchors: Matches the exact 5-row parameter comparison table and download_media_stream function in Section 2.4.


3. 会话保持、超时、代理与高鲁棒性重试工程

如果说把单次 requests.get() 跑通只能算“玩具级脚本”,那么能否正确管理跨请求会话池、设置双重超时防卡死、接入代理 IP 以及实现网络抖动自动恢复,则决定了你的爬虫能否在生产环境中连续稳定运行 7×24 小时。

在采集需要登录或带有反爬状态校验的网站时,我们经常需要处理 Requests中Cookie获取与字典注入 以及 requests.Session跨请求会话保持:

  1. 单次请求的 Cookie 获取与注入
  2. 读取响应 Cookie:服务器通过 Set-Cookie 返回的凭证存储在 resp.cookies(一个 RequestsCookieJar 对象)中,调用 requests.utils.dict_from_cookiejar(resp.cookies) 即可将其转换为普通 Python 字典。
  3. 注入请求 Cookie:如果在 Chrome DevTools 中已经复制了登录后的 Cookie,既可以直接将其作为字符串放进 headers={"Cookie": "sid=123; uid=456"},也可以作为字典传给专门的 cookies={"sid": "123", "uid": "456"} 参数。
  4. 为什么生产环境必须使用 requests.Session()?
    如前所述,如果你在循环中连续调用 100 次 requests.get(url),每次调用都是完全独立的:不仅第一次请求拿到的 Set-Cookie 在第二次请求时会丢失,而且每次调用都会销毁连接池、重新进行 TCP 三次握手与 TLS 加密握手!
    而使用 with requests.Session() as session: 创建会话对象后,它具备两大核心工程威力:
  5. 自动跨请求保持 Cookie:第一次调 session.post(login_url) 登录成功后,服务器下发的 Set-Cookie 会自动保存在 session.cookies 中;后续调用 session.get(profile_url) 时,会自动携带该 Cookie,无需手动传递!
  6. 底层 TCP+TLS 连接池复用(Keep-Alive):Session 内部持有一个 urllib3.PoolManager 连接池,对同一域名连续抓取 1,000 页时,全程复用同一条已经握手完毕的 TCP+TLS 通道,抓取速度比裸调 requests.get() 提升 3~5 倍以上,同时大幅降低目标服务器的握手负载!

3.2 重定向控制、双重超时(Timeout)与 SSL 证书校验

在真实网络环境中,如果不给爬虫请求加上严格的边界约束,爬虫极易陷入“假死挂起”或“掉入重定向陷阱”。

  1. 请求重定向跟随与禁止(allow_redirects)
    requests 在执行 GET/POST 时,默认参数为 allow_redirects=True——即遇到 301/302 状态码时会自动顺着响应头的 Location 跳转到新网址(最多跳转 30 次),并在 resp.history 列表中记录中间经历的所有跳转响应对象。
    实战技巧:当我们需要检测当前登录 Cookie 是否失效(很多网站 Cookie 失效后会返回 302 跳转到 /login)时,应当显式设置 allow_redirects=False,这样一旦发现 resp.status_code in (301, 302),就能立即判定登录态已过期并触发重新登录,而不必白白下载一次登录页。
  2. 连接超时与读取超时 Timeout 控制(生死红线!)
    在编写 Python 爬虫时,有一条绝对禁止违反的铁律:任何 requests 请求都必须显式传入 timeout 参数!
    因为 requests 的默认 timeout=None——这意味着如果目标服务器建立连接后既不返回数据也不主动断开连接,你的爬虫主线程就会永远卡死在那一行代码上,直到天荒地老!
    在生产工程中,最规范的做法是传入一个二元组 timeout=(connect_timeout, read_timeout),例如 timeout=(5.0, 15.0):
  3. 第一个数字 5.0 是 连接超时(Connect Timeout):指客户端与目标服务器完成 TCP 握手建立连接的最大等待秒数(通常设为 3.0~5.0 秒);
  4. 第二个数字 15.0 是 读取超时(Read Timeout):指连接建立后,等待服务器发回第一个响应字节的最大间隔秒数(通常设为 10.0~30.0 秒)。
  5. SSL证书校验与警告屏蔽(verify)
    requests 默认开启 verify=True,使用内置的 certifi 根证书库严格校验目标 HTTPS 网站的 SSL 数字证书。但在采集部分高校内网、政务老旧站点(证书过期或使用自签名证书),或者开启了 Charles/Mitmproxy 抓包代理时,会抛出 requests.exceptions.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED]。此时可在测试或受控内网环境中设置 verify=False 跳过证书校验,并配合 urllib3.disable_warnings() 屏蔽控制台刷屏的 InsecureRequestWarning 警告。

3.3 HTTP与SOCKS5代理Proxies配置

当目标网站开启了基于 IP 的频率限制(429 或 403)时,我们需要通过代理服务器(Proxy Server)中转请求,隐藏爬虫本机的真实公网 IP。掌握 HTTP与SOCKS5代理Proxies配置 非常简单:只需构造一个映射 http 和 https 协议的 proxies 字典传给请求方法或挂载到 session.proxies 上。

  • 带账密认证的 HTTP/HTTPS 代理格式:http://username:password@proxy_ip:port
  • SOCKS5 代理格式(需提前安装 pip install "requests[socks]",即安装底层 PySocks 库):socks5h://username:password@proxy_ip:port(注意:强烈建议使用 socks5h:// 而不是 socks5://,末尾的字母 h 表示将 DNS 域名解析也交由远端代理服务器执行,避免本地 DNS 污染或泄漏!)。

3.4 Requests异常体系与 Tenacity 指数退避自动重试

网络通信天生是不可靠的:代理 IP 随时可能掉线、目标服务器可能偶发 502 Bad Gateway、跨国光缆可能瞬间抖动丢包。要写出工业级高鲁棒性爬虫,必须透彻理解 Requests异常继承体系与捕获,并结合 Tenacity库与指数退避自动重试。

在 requests.exceptions 模块中,所有异常都继承自顶层基类 RequestException:

  • RequestException
  • HTTPError:调用 resp.raise_for_status() 时针对 4xx/5xx 状态码抛出的异常;
  • ConnectionError:网络层连接失败(包含 ProxyError 代理连接失败、SSLError 证书错误、DNS 解析失败);
  • Timeout:超时异常基类,细分为 ConnectTimeout(连接阶段超时)与 ReadTimeout(等待响应阶段超时);
  • TooManyRedirects:重定向次数超过上限(默认 30 次,通常意味着陷入了循环重定向陷阱)。

当遇到 Timeout、ConnectionError 或 502/503/504/429 状态码时,如果立刻(间隔 0 秒)发起重试,不仅大概率继续失败,还会加剧目标服务器负担。工业界标准的恢复算法是 带随机抖动的指数退避(Exponential Backoff with Jitter):第 \(k\) 次失败后的等待时间按指数增长(例如 \(2^1=2\text{s}, 2^2=4\text{s}, 2^3=8\text{s}\)),并叠加一个微小随机偏移量防止多个并发爬虫在同一毫秒集体发起重试冲击服务器。

在 Python 中,我们无需手写繁琐的 while 循环和 time.sleep,第三方库 tenacity 提供了声明式的 @retry 装饰器。下面这段代码展示了一个可直接复用于任何企业级爬虫项目的“高鲁棒性 Session 客户端封装”,集成了连接池复用、双重超时、自定义异常重试判定(仅对网络异常和 429/5xx 重试,遇到 404 绝不傻等重试!)以及指数退避日志记录:

import logging
import requests
from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout
from tenacity import (
    before_sleep_log,
    retry,
    retry_if_exception,
    stop_after_attempt,
    wait_exponential_jitter,
)

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("RobustCrawler")


def is_retryable_exception(exc: BaseException) -> bool:
    """精准判定哪些异常值得重试:网络超时/连接错误可重试;HTTP 状态码仅重试 429 和 5xx"""
    if isinstance(exc, (Timeout, ConnectionError)):
        return True
    if isinstance(exc, HTTPError) and exc.response is not None:
        status = exc.response.status_code
        # 404 Not Found 或 400 Bad Request 属于确定性错误,绝不浪费时间重试!
        return status == 429 or (500 <= status < 600)
    return False


class RobustHttpClient:
    """集成 Session 连接池、双重超时与 Tenacity 指数退避重试的工业级请求客户端"""

    def __init__(self, proxy_url: str | None = None) -> None:
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
            ),
            "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
        })
        if proxy_url:
            self.session.proxies.update({"http": proxy_url, "https": proxy_url})

    @retry(
        retry=retry_if_exception(is_retryable_exception),
        # 最多尝试 4 次;等待时间按 2^n 秒指数退避(初始2s,最大15s)并叠加随机抖动
        stop=stop_after_attempt(4),
        wait=wait_exponential_jitter(initial=2, max=15, jitter=1),
        before_sleep=before_sleep_log(logger, logging.WARNING),
        reraise=True,
    )
    def fetch(
        self,
        url: str,
        method: str = "GET",
        params: dict | None = None,
        json_data: dict | None = None,
        timeout: tuple[float, float] = (5.0, 15.0),
    ) -> requests.Response:
        resp = self.session.request(
            method=method,
            url=url,
            params=params,
            json=json_data,
            timeout=timeout,  # 强制双重超时:(连接超时5s, 读取超时15s)
        )
        resp.raise_for_status()  # 若返回 4xx / 5xx 则抛出 HTTPError 交由 tenacity 判定
        return resp

    def close(self) -> None:
        self.session.close()


if __name__ == "__main__":
    client = RobustHttpClient()
    print("✅ RobustHttpClient 初始化完成,默认 Headers 已挂载至 Session 连接池。")
    client.close()

为了直观理解为什么 Session 连接池复用与指数退避(Exponential Backoff)能显著提升采集效率并避免触发防火墙永久封禁,请操作下方的交互式微仿真进行对比实验。

Diagram: Session Connection Pool and Exponential Backoff Simulator

flowchart TD
    Start["发起网络请求 (复用 Session TCP 连接池)"] --> Resp{"是否捕获网络超时或 5xx/429?"}
    Resp -->|"否: 200 OK 正常响应"| Success["成功返回 Response 并复用底层 Keep-Alive 连接"]
    Resp -->|"是: 第 n 次请求失败"| CheckMax{"重试次数 n 是否未超上限?"}
    CheckMax -->|"未超上限: 继续重试"| Wait["指数退避等待: sleep(2^n + jitter) 秒"]
    Wait --> Start
    CheckMax -->|"已耗尽最大重试次数"| DeadLetter["记录结构化错误日志并推入死信队列 DLQ"]
Session Connection Pool and Exponential Backoff Simulator

Type: microsim sim-id: session-backoff-simulator
Library: p5.js
Status: Specified
Bloom Level: Evaluate
Bloom Verb: assess
Learning Objective: The learner will assess the total latency and success rate of scraping a 20-page endpoint under transient server overload by comparing naive requests.get (no session, immediate retry) against requests.Session with exponential backoff.

Prerequisites: TCP+TLS handshake RTT overhead, requests.Session Keep-Alive connection reuse, HTTP 429/503 transient errors vs 404 permanent errors, and exponential backoff formula \(t_k = \min(t_{\max}, t_0 \cdot 2^{k-1})\) (all defined in Sections 3.1–3.4 above).

Evidence of Mastery: The learner configures the client parameters to complete a 20-request batch against a rate-limited server (which enforces a 2.0 s cooldown window when overloaded) with a 100% success rate on valid pages and total elapsed time under 25.0 s, and correctly answers two assessment questions comparing connection overhead and retry behavior on 404 vs 503.

Misconceptions: (1) Calling requests.get() in a loop reuses the TCP/TLS connection automatically. (2) Retrying immediately (0 s wait) after a 429 or 503 recovers faster than waiting 2 s -> 4 s -> 8 s. (3) A 404 Not Found response should be retried 4 times just like a 503 Service Unavailable.

Instructional Rationale: Parameter exploration paired with a concrete latency/ban model lets learners quantify the exact RTT savings of Session connection pooling and observe why immediate retries burn through all attempts inside a server's cooldown window while exponential backoff succeeds.

Content:

Adjustable quantities and modes:

Quantity / Control Min Max Step Default Unit
Connection Mode 0 (Naive requests.get) 1 (requests.Session Keep-Alive) 1 0 mode
Base Retry Wait \(t_0\) 0.0 4.0 1.0 0.0 seconds
Max Attempts 1 4 1 3 attempts
Network RTT 0.10 0.50 0.10 0.20 seconds

Benchmark batch of 20 requests to https://api.example.edu/items?page=1..20: - Server Cooldown Window on overload (Request #7 and Request #14 trigger transient 503 overload): the server rejects any retry arriving within < 2.5 s of the initial 503 failure. - Request #19 is a deleted article returning 404 Not Found.

Comparison table of model outcomes at default RTT = 0.20 s (1 TCP RTT + 1 TLS RTT = 0.40 s handshake overhead per new connection, plus 0.20 s HTTP request/response transfer):

Strategy Preset Handshake Time (20 reqs) Transfer Time Overload Recovery (#7 & #14) 404 Handling (#19) Total Time & Valid Pages Fetched
Preset A: Naive requests.get + Immediate Retry (\(t_0 = 0\text{s}\), retry all errors) 20 × 0.40s = 8.00s (+ extra handshakes on retries) 4.00s Fails all 3 attempts at 0.0s wait (inside 2.5s server cooldown) -> 17 / 19 valid pages Wastes 3 retries on 404 15.60 s (Failed: 2 pages lost to 503)
Preset B: requests.Session + Immediate Retry (\(t_0 = 0\text{s}\)) 1 × 0.40s = 0.40s (19 connections reused!) 4.00s Fails all 3 attempts at 0.0s wait (inside 2.5s server cooldown) -> 17 / 19 valid pages Skips retry on 404 4.80 s (Failed: 2 pages lost to 503)
Preset C: requests.Session + Exponential Backoff (\(t_0 = 2.0\text{s}\), Max Attempts = 3, skip 404) 1 × 0.40s = 0.40s 4.00s Attempt 1 fails (503), waits 2.0s (Attempt 2 still < 2.5s), waits 4.0s (Attempt 3 at 6.0s >= 2.5s succeeds!) -> 19 / 19 valid pages Fails fast on 404 (0 retries) 17.20 s (Success: 19 / 19 valid pages fetched!)

Assessment Questions: 1. "在 RTT = 0.20 秒(单次 TCP+TLS 握手耗时 0.40 秒)的网络环境下,对同一域名连续发起 20 次请求,使用 requests.Session() 相比裸调 requests.get() 仅在握手阶段就能节省多少秒?" -> Correct Answer: 7.60 秒 (20 × 0.40 - 0.40 = 7.60 s). 2. "当第 19 页返回 404 Not Found、第 7 页返回 503 Service Unavailable 时,is_retryable_exception 函数的正确处理策略是什么?" -> Correct Answer: 对 404 立即放弃不重试,仅对 503(及 429/网络超时)执行指数退避重试.

Provenance: Derived from the TCP/TLS RTT model in Chapter 2 Section 1.3 and the RobustHttpClient tenacity configuration in Chapter 3 Section 3.4.

Rules: Each new connection costs 2 * RTT seconds for TCP+TLS handshakes plus 1 * RTT for HTTP transfer. With requests.Session (Connection Mode = 1), Request #1 pays 2 * RTT handshake + 1 * RTT transfer, and Requests #2–#20 pay 0 s handshake + 1 * RTT transfer. When Base Retry Wait \(t_0 > 0\), retry attempt \(k\) (\(k \ge 1\)) waits \(t_0 \cdot 2^{k-1}\) seconds; a transient 503 on Request #7 or #14 succeeds when cumulative wait time since the initial 503 is >= 2.5 s.

Learner Activity:

  1. The learner runs Preset A (Naive requests.get, \(t_0 = 0\text{s}\)) and observes the 20-bar timeline: every bar has a tall handshake segment, and Requests #7 and #14 fail all retries because all attempts fire within the 2.5 s server cooldown window.
  2. The learner switches Connection Mode to requests.Session Keep-Alive and sets Base Retry Wait \(t_0 = 2.0\text{s}\) and Max Attempts = 3, then re-runs the batch.
  3. The learner observes that Requests #2–#20 eliminate the handshake segment (7.60 s saved) and that Requests #7 and #14 recover on the second backoff interval (2.0s + 4.0s = 6.0s >= 2.5s).
  4. The learner answers the two assessment questions to demonstrate mastery.

Feedback: Two assessment questions plus the configuration target (19 / 19 valid pages fetched in < 25.0 s), two attempts per question. Correct: "回答正确!" + worked calculation. Incorrect: shows the exact formula (19 Reused Connections × 2 × RTT = 7.60 s) and explains why 404 is a permanent client error whereas 503 is a transient server overload.

Starting State: Connection Mode is set to 0 (Naive requests.get), \(t_0 = 0.0\text{s}\), Max Attempts = 3, RTT = 0.20 s, showing the Preset A timeline where Requests #7 and #14 fail in red, with the prompt: "观察裸调 requests.get 与 0 秒重试的失败结果,请调整参数实现 19 个有效页面 100% 采集成功且总耗时低于 25 秒。"

Chapter Anchors: Matches the RobustHttpClient class and is_retryable_exception logic in Section 3.4.


4. 新一代客户端 HTTPX 与抓包一键转代码技巧

4.1 HTTPX新一代客户端与HTTP/2请求

虽然 requests 极其经典,但它诞生于 2011 年,架构上存在两个无法回避的时代局限:一是不支持 async/await 原生异步协程,二是不支持 HTTP/2 协议。为了在保留 requests 99% 相同 API 习惯的同时全面拥抱现代 Web 标准,Encode 团队(FastAPI 底层 Starlette 的同一开源团队)打造了 HTTPX新一代客户端与HTTP/2请求(httpx)。

httpx 被称为“下一代 requests”,其三大核心优势使其成为现代 AI 爬虫工程师的标配武器:

  1. API 与 requests 几乎 100% 兼容:同样拥有 httpx.get()、httpx.post()、params=、json=、headers=、resp.status_code、resp.text、resp.json();其中等价于 requests.Session() 的上下文容器叫做 httpx.Client()。
  2. 原生支持 HTTP/2 多路复用:只需安装 pip install "httpx[http2]" 并在初始化客户端时传入 http2=True(即 with httpx.Client(http2=True) as client:),即可直接与目标服务器建立二进制分帧的 HTTP/2 连接,不仅大幅提升同域名并发抓取效率,还能直接绕过部分检测 HTTP/1.1 协议降级的反爬虫防火墙!
  3. 同步与异步双模统一:既可以用 httpx.Client() 写同步爬虫,也可以用 httpx.AsyncClient() 写单机千级并发的异步协程爬虫(将在第 8 章深入实战),而且默认强制开启 timeout=5.0 秒安全超时保護,彻底告别忘记写 timeout 导致程序永久卡死的隐患。
import httpx


def fetch_with_httpx_http2(urls: list[str]) -> list[dict]:
    """使用新一代客户端 HTTPX 开启 HTTP/2 多路复用连接池批量抓取"""
    results: list[dict] = []
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0",
        "Accept": "application/json, text/html",
    }

    # httpx.Client 等价于 requests.Session,开启 http2=True 激活 HTTP/2 协议
    with httpx.Client(http2=True, headers=headers, timeout=10.0, follow_redirects=True) as client:
        for url in urls:
            resp = client.get(url)
            results.append({
                "url": str(resp.url),
                "status": resp.status_code,
                # resp.http_version 会返回 "HTTP/2" 或 "HTTP/1.1"
                "http_version": resp.http_version,
                "length": len(resp.content),
            })
    return results


if __name__ == "__main__":
    print(f"HTTPX 客户端版本: {httpx.__version__} (已默认启用 5.0s 安全超时保护)")

4.2 cURL命令转Python爬虫代码技巧

在日常逆向分析一个复杂的网站接口时,一条请求往往包含十几个自定义 Request Headers、二十多个 Cookie 键值对以及复杂的 JSON 载荷。如果靠人工一行行把它们从 Chrome DevTools 复制粘贴成 Python 字典,不仅耗时费力,而且极易漏掉引号或拼错键名。掌握 cURL命令转Python爬虫代码技巧 可以让你在 5 秒钟内 完成从浏览器抓包到可运行 Python 脚本的转换!

完整操作流如下:

  1. 在 Chrome DevTools 的 Network 面板中右键点击目标请求 \(\rightarrow\) Copy \(\rightarrow\) 选择 Copy as cURL (bash)(注意:在 Windows 下也请优先选择 Copy as cURL (bash) 而不是 (cmd),因为 bash 格式采用标准的单引号转义,通用性最强)。你会得到如下一段命令行文本:
    curl 'https://api.example.edu/v1/search?page=1' \
      -H 'User-Agent: Mozilla/5.0' \
      -H 'Authorization: Bearer token_123' \
      -H 'Cookie: sid=abc; theme=dark' \
      --data-raw '{"keyword":"AI爬虫"}'
    
  2. 转换方式一(在线/离线可视化工具):使用开源工具网站 curlconverter.com(支持在本地通过 npx curlconverter 离线运行)或 Postman/Apifox 的导入功能,粘贴 cURL 命令即可一键生成标准 requests 或 httpx 代码,并且会自动把 -H 'Cookie: ...' 拆解为独立的 cookies 字典,把 ?page=1 拆解为 params 字典!
  3. 转换方式二(自编写轻量级 Python 解析器):为了在无外网环境下也能随时转换,我们可以利用 Python 内置的 shlex(Shell 词法分割器)和 argparse 编写一个不到 40 行的本地转换器函数,直接将剪贴板里的 curl 命令解析并输出为规范的 requests 字典结构:
import json
import shlex
from urllib.parse import parse_qsl, urlsplit, urlunsplit


def parse_curl_to_requests_spec(curl_command: str) -> dict:
    """将 Chrome DevTools 复制的 Copy as cURL (bash) 命令自动解析为 requests 参数字典"""
    # 处理多行反斜杠续行符,并用 shlex 按 POSIX shell 规则安全分词
    normalized = curl_command.replace("\\\n", " ").replace("\\\r\n", " ").strip()
    tokens = shlex.split(normalized)

    url = ""
    method = "GET"
    headers: dict[str, str] = {}
    cookies: dict[str, str] = {}
    body_raw: str | None = None

    i = 1  # 跳过开头的 'curl'
    while i < len(tokens):
        tok = tokens[i]
        if tok in ("-X", "--request") and i + 1 < len(tokens):
            method = tokens[i + 1].upper()
            i += 2
        elif tok in ("-H", "--header") and i + 1 < len(tokens):
            header_line = tokens[i + 1]
            if ":" in header_line:
                k, v = header_line.split(":", 1)
                k, v = k.strip(), v.strip()
                if k.lower() == "cookie":
                    for pair in v.split(";"):
                        if "=" in pair:
                            ck, cv = pair.strip().split("=", 1)
                            cookies[ck] = cv
                else:
                    headers[k] = v
            i += 2
        elif tok in ("-b", "--cookie") and i + 1 < len(tokens):
            for pair in tokens[i + 1].split(";"):
                if "=" in pair:
                    ck, cv = pair.strip().split("=", 1)
                    cookies[ck] = cv
            i += 2
        elif tok in ("-d", "--data", "--data-raw", "--data-binary") and i + 1 < len(tokens):
            body_raw = tokens[i + 1]
            if method == "GET":
                method = "POST"
            i += 2
        elif not tok.startswith("-") and not url:
            url = tok
            i += 1
        else:
            i += 1

    # 拆解 URL 中的查询参数
    sp = urlsplit(url)
    clean_url = urlunsplit((sp.scheme, sp.netloc, sp.path, "", ""))
    params = dict(parse_qsl(sp.query))

    return {
        "method": method,
        "url": clean_url,
        "params": params,
        "headers": headers,
        "cookies": cookies,
        "data_or_json": body_raw,
    }


if __name__ == "__main__":
    sample_curl = """
    curl 'https://api.example.edu/v1/search?page=1&lang=zh' \\
      -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)' \\
      -H 'Content-Type: application/json' \\
      -H 'Cookie: session_id=xyz999; role=student' \\
      --data-raw '{"keyword": "人工智能教材"}'
    """
    spec = parse_curl_to_requests_spec(sample_curl)
    print(json.dumps(spec, ensure_ascii=False, indent=2))

cURL 转代码后的『精简瘦身』原则

从 Chrome 复制出来的 cURL 往往包含 sec-ch-ua、sec-fetch-dest、accept-encoding、priority 等十几个冗余请求头。转为 Python 代码后,建议执行“二分精简法”:只保留 User-Agent、Referer、Cookie/Authorization 和 Content-Type 四个核心头,删掉其余次要头(特别是删掉 Accept-Encoding 以防 br 压缩乱码),让爬虫代码既清爽又不易因死板固定了某个浏览器小版本头而被关联识别。


5. 本章小结与思考练习

5.1 核心要点回顾

  • 环境隔离与三大客户端演进:每个爬虫项目均应使用 venv 或 uv 创建独立虚拟环境;Python 标准库 urllib 是零依赖底层基石,requests 是基于 urllib3 连接池的同步工业标准,而 httpx 则进一步支持了 HTTP/2 多路复用 与原生异步协程。
  • 参数位置与乱码根治:params= 自动编码 URL 查询串,data=dict 发送传统表单,json=dict 发送 JSON 载荷,大文件下载必须开启 stream=True 分块流式写入;遇到中文乱码优先检查响应头未声明 charset 导致的 ISO-8859-1 回退,使用 gb18030 或 utf-8 显式解码。
  • 高鲁棒性请求四要素:
  • 使用 requests.Session()(或 httpx.Client())自动维护跨请求 Cookie 并复用底层 TCP+TLS 连接;
  • 永远显式设置 timeout=(5.0, 15.0) 双重超时,杜绝线程永久挂死;
  • 针对反爬限流配置高匿 HTTP 或 socks5h:// 远程 DNS 解析代理;
  • 使用 tenacity 装饰器针对网络超时、429 和 5xx 状态码执行带随机抖动的指数退避自动重试(跳过 404 等确定性客户端错误)。

5.2 思考与自测练习

思考题 1:在编写一个循环抓取同一网站 5,000 篇文章的脚本时,同学 A 在循环体内每次直接调用 requests.get(url),同学 B 在循环体外创建一个 with requests.Session() as s: 并在循环体内调用 s.get(url)。请问两者的运行速度为什么会相差数倍?

因为 requests.get(url) 每次调用都会新建并销毁一个临时的 Session,导致每抓一篇文章都要重新经历一次 TCP 三次握手 + TLS 加密握手(每次耗时约 1~2 个 RTT,即 100~300 毫秒),5,000 次请求光在握手等待上就要白白浪费十几个小时!而同学 B 在循环外创建 requests.Session(),底层 urllib3 连接池会利用 HTTP/1.1 的 Connection: keep-alive 持久连接 机制,只在第 1 次请求时进行 TCP+TLS 握手,后续 4,999 次请求全部复用同一条已建立的加密通道,从而实现数倍的速度飞跃。

思考题 2:为什么在使用 tenacity 编写爬虫自动重试逻辑时,我们只对 Timeout、ConnectionError 以及状态码 429、502/503/504 进行重试,而明确排除状态码 404 Not Found 和 400 Bad Request?

因为自动重试的目的是克服瞬时性、偶发性故障(Transient Failures),例如网络瞬时丢包、代理节点抖动、目标服务器瞬时并发过高(503)或触发了短时频率窗口限制(429),等待几秒后重发往往就能成功。而 404 Not Found(目标页面已被删除或 ID 不存在)和 400 Bad Request(我们拼接的参数语法有误)属于确定性客户端错误(Permanent Client Errors)——只要不修改代码参数,哪怕重试 1 万次结果依然是 404 或 400,对它们重试只会白白浪费爬虫时间和代理流量。

思考题 3:在配置 SOCKS5 代理时,proxies = {'https': 'socks5://127.0.0.1:1080'} 与 proxies = {'https': 'socks5h://127.0.0.1:1080'} 只差了一个字母 h,它们在底层通信上有什么重要区别?

字母 h 代表 Host resolution on proxy(在代理端解析主机域名): - 使用 socks5:// 时,你的爬虫会在本地计算机向本地默认 DNS 服务器发起域名解析请求,拿到 IP 后再通过代理连接;如果目标域名在本地网络无法解析或存在 DNS 污染,请求就会直接失败。 - 使用 socks5h:// 时,爬虫会把目标网站的原始域名字符串直接发给远端 SOCKS5 代理服务器,由代理服务器在远端完成 DNS 解析并建立连接,既避免了本地 DNS 解析失败,又防止了本地 DNS 查询泄漏爬虫意图。