HTTP 基础与浏览器抓包分析

爬虫的本质是替浏览器发 HTTP 请求、再把响应解析成结构化数据,所以要掌握的不是某个库的 API,而是一次请求里带了什么、一次响应又带回了什么。本章按抓包视角拆开这两端,再给出从浏览器复制请求到 Python 复现的固定路径,以及接口与页面该怎么选。

请求与响应各由什么组成

位置组成采集时最该关心的
请求请求行:方法、路径、协议版本方法选错会被直接拒绝;查询串就是筛选条件
请求请求头:一组键值对User-AgentRefererCookieAuthorization
请求请求体:POST/PUT 提交的数据表单编码还是 JSON,决定服务端怎么解析
响应状态行:状态码与原因短语判断成功、被重定向、被限流
响应响应头:一组键值对Content-TypeContent-EncodingSet-CookieRetry-After
响应响应体:真正的数据HTML、JSON、XML 或二进制文件

请求行、请求头、请求体之间各空一行,响应同理。状态码只说明协议层的结果,不代表数据可用:200 的响应体里也可能是空列表或一段风控提示。403 不要靠换 User-Agent 硬闯,先判断自己是不是在采集本就无权访问的内容;429 是明确的降速信号,不是重试信号,必须把状态码和内容放在一起看。

方法怎么选

读取用 GET(参数在 URL)与 POST(参数在请求体,查询接口与表单登录常用),探活用 HEAD(只要响应头,最省流量);PUTDELETE 会改动对方数据,采集侧一律不使用。这既是技术纪律,也是合规底线。

状态码在采集里的含义

状态码采集视角状态码采集视角
200成功,仍要确认响应体是目标数据301/302重定向,登录失效常表现为跳到登录页
304命中协商缓存,响应体为空400参数名、类型或编码写错
401Cookie 或 token 缺失、已过期403缺请求头、IP 受限,或本就没有权限
404详情页下架、ID 拼错429请求过多,立即降速并尊重 Retry-After
500服务端错误,可退避重试502/504网关错误与网关超时,不要高频重试

Content-Type 与字符编码

取值含义处理方式
text/html; charset=utf-8网页交给 HTML 解析器处理
application/json接口数据直接 response.json()
text/plain纯文本,部分接口如此按文本解码后再判断实际格式
application/octet-stream二进制流按字节写文件,不要解码成字符串

中文乱码的根因是「服务端声明的编码」与「字节的真实编码」不一致:response.encoding 优先取响应头里的 charset,响应头没写时按 latin-1 兜底,中文就成了一串奇怪符号;response.content 是原始字节,永远可信;response.text 是按 response.encoding 解码后的字符串。

处理顺序就三步:响应头声明了 charset 就照它解码;没声明时用 response.apparent_encoding 猜;确定是 GBK 这类编码时直接 response.content.decode("gbk", errors="replace"),用 errors="replace" 保证脚本不中断,同时把替换字符记录下来回头核对。

DevTools 抓包的四步

  1. 勾选 Preserve log,否则页面跳转或刷新后前面的请求会全部消失;再勾选 Disable cache,避免拿到 304 或缓存副本。
  2. 先用 Fetch/XHR 过滤找接口,没有合适的再切回 All 找文档请求。
  3. 点开一条请求:Headers 看请求行与请求头,Payload 看参数,Response 看返回内容,Cookies 看本次带了什么。
  4. Response Headers 里确认 Content-TypeSet-Cookie:前者决定解码方式,后者说明服务端是否在下发新会话;最后右键 Copy as cURL,把整个请求固化成一条命令。

Copy as cURL 的价值在于把「浏览器到底发了什么」原样固化,排除了凭记忆手写请求头带来的误差。

先验证 cURL,再翻译成 Python

# 只打印状态码、内容类型与耗时,不落地响应体,适合批量探测
curl -sS -o /dev/null -w 'status=%{http_code} type=%{content_type} total=%{time_total}\n' \
  -H 'User-Agent: Mozilla/5.0 (compatible; MyCollector/1.0; +https://example.com/bot)' \
  'https://example.com/api/list?page=1'

# 把响应头完整存盘,排查 Set-Cookie 与重定向链
curl -sS -D headers.txt -o body.json 'https://example.com/api/list?page=1'

cURL 到 Python 的映射很直白:-H 对应 headers-d 对应 data--data-raw 的 JSON 对应 json=-b 对应 cookies。可以借助 curlconverter 这类工具生成初稿,但生成后要逐项核对,尤其是 Cookie 与时间戳参数。

可直接运行的完整示例

"""探测接口的状态码、响应头与编码,并把响应体正确解码后落地。"""

from __future__ import annotations

import json
import time
from pathlib import Path

import requests

URL = "https://example.com/api/list"
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; MyCollector/1.0; +https://example.com/bot)"}


def main() -> None:
    try:
        resp = requests.get(URL, params={"page": 1, "size": 20}, headers=HEADERS, timeout=(3, 10))
        resp.raise_for_status()              # 4xx/5xx 直接抛异常,交给下面的分支处理
    except requests.exceptions.Timeout:
        print("请求超时,退避后再试")
        return
    except requests.exceptions.HTTPError as exc:
        print(f"服务端返回错误状态:{exc}")
        return

    content_type = resp.headers.get("Content-Type", "")
    print("状态码:", resp.status_code)
    print("Content-Type:", content_type)
    print("声明编码:", resp.encoding, "| 猜测编码:", resp.apparent_encoding)
    print("耗时:", round(resp.elapsed.total_seconds(), 3), "秒")

    # 响应头声明了 charset 就按它解码,否则用字节内容猜,猜不准也不中断脚本
    if "charset=" in content_type:
        text = resp.text
    else:
        text = resp.content.decode(resp.apparent_encoding or "utf-8", errors="replace")

    Path("out").mkdir(exist_ok=True)
    if "json" in content_type:
        Path("out/list.json").write_text(
            json.dumps(resp.json(), ensure_ascii=False, indent=2), encoding="utf-8"
        )
    else:
        Path("out/list.html").write_text(text, encoding="utf-8")
    print("已写入 out/ 目录,响应体前 200 字:", text[:200])
    time.sleep(1)      # 即使单次采集也留出间隔,控制对目标站点的压力


if __name__ == "__main__":
    main()

常见坑与要点对照

现象常见原因处理要点
返回 200 但内容是风控页User-Agent/Referer,或已触发限流对比浏览器请求头逐项补齐,必要时主动降速
中文全是乱码响应头 charset 与实际字节不一致打印 encodingapparent_encoding,改用字节解码
浏览器能看到数据、代码里没有数据由 JS 二次请求接口渲染用抓包找接口;没有接口就考虑渲染或官方 API
拿到 304 且响应体为空命中协商缓存调试时加 Cache-Control: no-cache,或关闭缓存
被重定向到登录页、429 频发会话失效或请求过快检查 Location 后重新登录;按 Retry-After 降速

调试顺序固定为:先用 cURL 复现,再用 Python 发同样的请求;两者结果不一致就逐项对比请求头与参数,最后才怀疑对方按 IP 或 UA 做了限制。

采集同样有硬边界:遵守 robots.txt 与网站服务条款,控制请求间隔与并发上限,不采集个人隐私信息与受版权保护的付费内容,不绕过登录、付费墙、验证码等访问控制,数据使用遵守《个人信息保护法》《数据安全法》《著作权法》。

小结:请求由请求行、请求头、请求体组成,响应对应状态行、响应头与响应体;用 Network 面板找到真实接口再翻译成 Python,按 Content-Type 与字节内容正确解码,并把频率控制与合规边界当作代码的一部分。

笔记加载中…