HTTP 基础与浏览器抓包分析
爬虫的本质是替浏览器发 HTTP 请求、再把响应解析成结构化数据,所以要掌握的不是某个库的 API,而是一次请求里带了什么、一次响应又带回了什么。本章按抓包视角拆开这两端,再给出从浏览器复制请求到 Python 复现的固定路径,以及接口与页面该怎么选。
请求与响应各由什么组成
| 位置 | 组成 | 采集时最该关心的 |
|---|---|---|
| 请求 | 请求行:方法、路径、协议版本 | 方法选错会被直接拒绝;查询串就是筛选条件 |
| 请求 | 请求头:一组键值对 | User-Agent、Referer、Cookie、Authorization |
| 请求 | 请求体:POST/PUT 提交的数据 | 表单编码还是 JSON,决定服务端怎么解析 |
| 响应 | 状态行:状态码与原因短语 | 判断成功、被重定向、被限流 |
| 响应 | 响应头:一组键值对 | Content-Type、Content-Encoding、Set-Cookie、Retry-After |
| 响应 | 响应体:真正的数据 | HTML、JSON、XML 或二进制文件 |
请求行、请求头、请求体之间各空一行,响应同理。状态码只说明协议层的结果,不代表数据可用:200 的响应体里也可能是空列表或一段风控提示。403 不要靠换 User-Agent 硬闯,先判断自己是不是在采集本就无权访问的内容;429 是明确的降速信号,不是重试信号,必须把状态码和内容放在一起看。
方法怎么选
读取用 GET(参数在 URL)与 POST(参数在请求体,查询接口与表单登录常用),探活用 HEAD(只要响应头,最省流量);PUT 与 DELETE 会改动对方数据,采集侧一律不使用。这既是技术纪律,也是合规底线。
状态码在采集里的含义
| 状态码 | 采集视角 | 状态码 | 采集视角 |
|---|---|---|---|
| 200 | 成功,仍要确认响应体是目标数据 | 301/302 | 重定向,登录失效常表现为跳到登录页 |
| 304 | 命中协商缓存,响应体为空 | 400 | 参数名、类型或编码写错 |
| 401 | Cookie 或 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 抓包的四步
- 勾选
Preserve log,否则页面跳转或刷新后前面的请求会全部消失;再勾选Disable cache,避免拿到 304 或缓存副本。 - 先用
Fetch/XHR过滤找接口,没有合适的再切回All找文档请求。 - 点开一条请求:
Headers看请求行与请求头,Payload看参数,Response看返回内容,Cookies看本次带了什么。 - 在
Response Headers里确认Content-Type与Set-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 与实际字节不一致 | 打印 encoding 与 apparent_encoding,改用字节解码 |
| 浏览器能看到数据、代码里没有 | 数据由 JS 二次请求接口渲染 | 用抓包找接口;没有接口就考虑渲染或官方 API |
| 拿到 304 且响应体为空 | 命中协商缓存 | 调试时加 Cache-Control: no-cache,或关闭缓存 |
| 被重定向到登录页、429 频发 | 会话失效或请求过快 | 检查 Location 后重新登录;按 Retry-After 降速 |
调试顺序固定为:先用 cURL 复现,再用 Python 发同样的请求;两者结果不一致就逐项对比请求头与参数,最后才怀疑对方按 IP 或 UA 做了限制。
采集同样有硬边界:遵守 robots.txt 与网站服务条款,控制请求间隔与并发上限,不采集个人隐私信息与受版权保护的付费内容,不绕过登录、付费墙、验证码等访问控制,数据使用遵守《个人信息保护法》《数据安全法》《著作权法》。
小结:请求由请求行、请求头、请求体组成,响应对应状态行、响应头与响应体;用 Network 面板找到真实接口再翻译成 Python,按 Content-Type 与字节内容正确解码,并把频率控制与合规边界当作代码的一部分。