第 3 讲 · HTTP 速通:看懂爬虫和服务器之间的对话
本讲目标 :这一讲不教你写新爬虫 ,教你读懂响应。
状态码、编码、重定向、Cookie------看不懂这四样,后面所有报错你都只能靠猜。
配套代码 :
spiders/s03_http_basics.py运行 :
python spiders/s03_http_basics.py
一、为什么值得花一整讲只讲「看响应」
新手写爬虫的典型循环是这样:
写代码 → 没拿到数据 → 猜 → 改代码 → 还是没拿到 → 猜 → 改代码 → ...
老手的循环是这样:
写代码 → 没拿到数据 → 打印 response 的四个字段 → 看一眼就知道问题在哪 → 改对
差别不在天赋,在你有没有「读响应」的能力。
爬虫最要命的一点是:你的 bug 大多发生在别人家的服务器上 。
你不能在那里打断点,不能加 print。你唯一能拿到的一手资料,就是那个 response 对象。
所以这一讲把 response 拆开,一块块看。
二、状态码:先看第一位数字
1xx 信息 ------ 爬虫基本遇不到
2xx 成功 ------ 只有这个才是「拿到了东西」
3xx 跳转 ------ 不代表失败,但你得知道它跳去了哪里
4xx 你的问题 ------ 重试没有意义(429 除外)
5xx 服务器的问题 ------ 值得重试
背下这个分类比背具体数字有用得多。因为 HTTP 状态码有一百多个,但第一位数字只有五种含义。
爬虫真正需要记住的只有三条:
- 只有 2xx 才算真拿到了数据
- 3xx 要追它跳去哪,不能当成功
- 4xx 别重试,5xx 才重试(429 是 4xx 里的例外,它明确告诉你"待会儿再来")
用靶场把五种状态码真实地造出来:
python
cases = [
("/", {}, "正常页面"),
("/profile", {"allow_redirects": False}, "没登录 → 跳转"),
("/strict", {}, "UA 不合格 → 拒绝"),
("/book/99999", {}, "不存在的书 → 404"),
]
for path, kw, note in cases:
r = requests.get(base_url + path, timeout=5, **kw)
loc = r.headers.get("Location", "")
extra = f" Location={loc}" if loc else ""
print(f" {path:16s} → {r.status_code} {note}{extra}")
实测输出:
/ → 200 正常页面
/profile → 302 没登录 → 跳转 Location=/login
/strict → 403 UA 不合格 → 拒绝
/book/99999 → 404 不存在的书 → 404
/api/limit → 连续 4 次 → [200, 200, 200, 429] 限流接口(连打 4 次)
三个值得注意的点:
1. Location 头是 302 的"去向"
302 响应没有内容 (body 是空的),它只告诉你"去 Location 说的那个地址"。所以看到 302 而 body 为空,不是 bug,是正常的。
2. 429 是「会变的」状态码
同一个接口连打 4 次:前三次 200,第四次 429。状态码不是接口的固有属性,而是「这一次请求」的属性。
这一点决定了爬虫的很多写法:你不能假设"这个 URL 上次成功了,这次也会成功"。
3. 403 和 404 的区别
- 404 = "你要的东西不存在"(服务器愿意告诉你)
- 403 = "东西存在,但我不想给你"(服务器认得你,特意拒绝)
很多网站为了不暴露资源是否存在,会把 403 伪装成 404。所以看到 404 不一定是真不存在。
三、编码:中文乱码的真正原因
这是国内爬虫最高频的一个坑。先把规则说清楚:
python
r = requests.get(base_url + "/", timeout=5)
print("服务器声明的编码 :", r.encoding) # 来自响应头 charset
print("requests 猜的编码:", r.apparent_encoding) # 它是检测出来的
输出:
服务器声明的编码 : utf-8
requests 猜的编码: utf-8
requests 决定 r.text 用什么编码,规则是这样的:
1. 如果响应头里有 charset=xxx → 用 xxx
2. 如果没有 → 默认用 ISO-8859-1(!)
3. 除非你手动设置 r.encoding
第 2 条就是罪魁祸首。 ISO-8859-1 是西欧编码,用它解中文必然是乱码。而且它不会报错 ------你会得到一串 ä½ å¥½ 这样的鬼东西。
亲手看一次乱码
python
correct = r.content.decode("utf-8") # 正确
wrong = r.content.decode("gbk", errors="replace") # 故意用错的编码
print("按 utf-8 解码(对):", sample)
print("按 gbk 解码(错):", wrong[idx:idx+60])
按 utf-8 解码(对): 深入理解计算机系统
按 gbk 解码(错): 娣卞叆鐞嗚В璁$畻鏈虹郴缁?
看那个 娣卞叆鐞嗚В------这就是传说中的"乱码"长相。它的本质是:同一串字节,用了错误的字符集去翻译。
errors="replace" 的作用是让解不出来的字节变成 ? 而不是抛异常。写日志、写调试输出时很有用:
python
r.content.decode("utf-8", errors="replace") # 宁可看到 ? 也不要崩
正确顺序(背下来)
python
# 1. 优先信响应头的 charset(requests 已经放在 r.encoding 里了)
# 2. 响应头没写 charset 时,r.encoding 会是 ISO-8859-1,这时要手动判断:
if r.encoding is None or r.encoding.lower() in ("iso-8859-1", "ascii"):
r.encoding = r.apparent_encoding # 让 chardet 去猜
# 3. 永远别硬写 .decode("gbk"),除非你确认对方是 GBK 站
# 4. 写文件时用 utf-8-sig,Excel 打开才不乱码
第 4 条单独强调一下:
python
open(path, "w", encoding="utf-8-sig") # 有 BOM,Excel 认
open(path, "w", encoding="utf-8") # 没 BOM,Excel 打开中文乱码
utf-8-sig 会在文件开头多写 3 个字节 EF BB BF,这 3 个字节的作用就是告诉 Excel "这是 UTF-8"。Windows 上的 Excel 尤其需要它。
四、重定向:你以为爬到了,其实被带去了别处
上一讲提过一次,这一讲把它彻底讲透。
python
# 不跟跳转
r = requests.get(base_url + "/profile", timeout=5, allow_redirects=False)
print("不跟跳转 :", r.status_code, "Location =", r.headers.get("Location"))
# 跟随跳转(默认行为)
r2 = requests.get(base_url + "/profile", timeout=5)
print("跟随跳转 :", r2.status_code, "最终 URL =", r2.url)
print("拿到的是 :", "登录页" if "登录" in r2.text else "目标内容")
不跟跳转 : 302 Location = /login
跟随跳转 : 200 最终 URL = http://127.0.0.1:52341/login
拿到的是 : 登录页
这就是「看起来正常、其实啥也没爬到」的经典现场。
看第一行:302。
看第二行:200,但 最终 URL 变成了 /login。
requests 默默地帮你跳了一次,然后把登录页的 HTML 塞进了 r2.text。如果你只检查 status_code == 200,你的代码会认为"一切正常"。
正确的判断姿势
python
def fetch(client, url):
r = client.get(url)
r.raise_for_status() # 4xx/5xx 直接抛
if r.history: # 发生过跳转
print(f"⚠ {url} 被重定向到 {r.url}")
if "/login" in r.url:
raise RuntimeError("会话失效,需要重新登录")
return r.text
r.history 是个列表 ,记录了这次请求一路上的所有跳转。它不会因为 allow_redirects=True 就消失------正好相反,它就是为了记录这个而存在的。
python
>>> r.history
[<Response [302]>]
>>> r.history[0].headers["Location"]
'/login'
记住:r.history 比 r.status_code 诚实。
五、请求头 & Cookie:身份是怎么被记住的
五个最常打交道的请求头
| 请求头 | 它在表达什么 |
|---|---|
User-Agent |
我是谁(浏览器 / 脚本 / 什么版本) |
Referer |
我是从哪个页面点过来的 |
Accept |
我能接受什么格式的返回 |
Accept-Language |
我要什么语言 |
Cookie |
我之前来过,这是我的凭证 |
前四个是"自我介绍",最后一个才是"身份证"。
Referer 特别容易被忽略。 很多网站做防盗链,检查的就是它:图片、视频、下载链接,如果 Referer 不是本站的,直接 403。
Cookie 是怎么被自动记住的
python
s = requests.Session()
s.post(base_url + "/login",
data={"username": "spider", "password": "spider123"},
headers={"User-Agent": BROWSER_UA}, timeout=5)
print("登录后 cookie 罐子里的东西:")
for ck in s.cookies:
print(f" {ck.name} = {ck.value[:16]}... domain={ck.domain}")
r = s.get(base_url + "/profile", timeout=5)
print("带着 Cookie 访问 /profile →", r.status_code,
"|是否拿到用户名:", "spider" in r.text)
登录后 cookie 罐子里的东西:
sid = 8f3a2c1e9b7d4a05... domain=127.0.0.1
带着 Cookie 访问 /profile → 200 |是否拿到用户名: True
服务器在 POST /login 的响应里带了 Set-Cookie: sid=xxx,Session 自动把它存进"cookie 罐子",之后每个请求自动带上。
第 14、15 讲会深入讲登录,这里先记住两件事:
Session= 连接复用 + Cookie 自动管理,登录必备。- 不用 Session 的话,你得手动把
Set-Cookie抠出来再塞回请求头------能写,但没人愿意写。
六、本讲小结
- 状态码只看第一位数字:2xx 拿到东西、3xx 要追去向、4xx 别重试、5xx 才重试。
r.content是字节,r.text是字符串 ,中文乱码十有八九是因为encoding猜错了。utf-8-sig是写给 Excel 看的,导出中文 CSV 必须加。r.history比r.status_code诚实,被重定向时它才是真相。Session是登录的入场券,它同时解决了 Cookie 和连接复用。
七、完整代码s03_http_basics.py
python
# -*- coding: utf-8 -*-
"""第 3 讲:HTTP 速通 ------ 请求头、状态码、编码、重定向、Cookie。
这一讲不教你写爬虫,教你看懂爬虫和服务器之间到底在说什么。
看不懂这些,后面所有报错你都只能靠猜。
运行:
python spiders/s03_http_basics.py
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import requests # noqa: E402
from demosite import LocalSite # noqa: E402
BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
def part_status_codes(base_url: str) -> None:
print("=" * 64)
print("① 状态码:先看第一位数字")
print("=" * 64)
print(""" 1xx 信息 2xx 成功 3xx 跳转 4xx 你的问题 5xx 服务器的问题
爬虫只需要牢记三条:
· 2xx 才是拿到了东西
· 3xx 不代表失败,但你得知道它跳去了哪里
· 4xx 重试没有意义(除非是 429),5xx 才值得重试
""")
# 用靶场真实地制造出 200 / 302 / 403 / 404 / 429 五种状态码
cases = [
("/", {}, "正常页面"),
("/profile", {"allow_redirects": False}, "没登录 → 跳转"),
("/strict", {}, "UA 不合格 → 拒绝"),
("/book/99999", {}, "不存在的书 → 404"),
("/api/limit", {}, "限流接口(连打 4 次)"),
]
for path, kw, note in cases:
if path == "/api/limit":
codes = []
for _ in range(4):
try:
codes.append(requests.get(base_url + path, timeout=5).status_code)
except Exception as e:
codes.append(f"ERR({e.__class__.__name__})")
print(f" {path:16s} 连续 4 次 → {codes} {note}")
# 【核心修复】429 限流之后,必须休眠一段时间等窗口重置,
# 否则后续请求(如 part_encoding)会被莫名其妙拦截
print(" ⏳ 触发限流,强制休眠 3 秒让限流窗口重置...")
time.sleep(3)
continue
try:
r = requests.get(base_url + path, timeout=5, **kw)
loc = r.headers.get("Location", "")
extra = f" Location={loc}" if loc else ""
print(f" {path:16s} → {r.status_code} {note}{extra}")
except Exception as e:
print(f" {path:16s} → ERR: {e}")
print()
def part_encoding(base_url: str) -> None:
print("=" * 64)
print("② 编码:中文乱码的真正原因")
print("=" * 64)
try:
r = requests.get(base_url + "/", timeout=5)
except requests.RequestException as e:
print(f"请求失败: {e}")
return
print("服务器声明的编码 :", r.encoding)
print("requests 猜的编码:", r.apparent_encoding)
correct = r.content.decode("utf-8")
wrong = r.content.decode("gbk", errors="replace")
# 从页面里挑一段中文来对比
import re
m = re.search(r"<h3 class=\"book-title\"><a[^>]*>([^<]+)</a>", correct)
sample = m.group(1) if m else ""
print()
print("按 utf-8 解码(对):", sample)
idx = wrong.find("<h3")
print("按 gbk 解码(错):", (wrong[idx:idx + 60] if idx >= 0 else wrong[:60]))
print()
print("★ 记住这个顺序:")
print(" 1. 优先用 resp.encoding(响应头里的 charset)")
print(" 2. 响应头没写,再用 resp.apparent_encoding 猜")
print(" 3. 永远别硬写 decode('gbk'),除非你确认对方是 GBK 站")
print(" 4. 写文件时用 utf-8-sig,Excel 打开才不乱码")
print()
def part_redirect(base_url: str) -> None:
print("=" * 64)
print("③ 重定向:你以为爬到了,其实被带去了别处")
print("=" * 64)
try:
r = requests.get(base_url + "/profile", timeout=5, allow_redirects=False)
print("不跟跳转 :", r.status_code, "Location =", r.headers.get("Location"))
r2 = requests.get(base_url + "/profile", timeout=5)
print("跟随跳转 :", r2.status_code, "最终 URL =", r2.url)
print("拿到的是 :", "登录页" if "登录" in r2.text else "目标内容")
except requests.RequestException as e:
print(f"请求失败: {e}")
return
print()
print("★ 这就是「看起来正常、其实啥也没爬到」的经典现场。")
print(" 判断依据不能是 status_code==200,得看 resp.url 和内容本身。")
print()
def part_headers_and_cookie(base_url: str) -> None:
print("=" * 64)
print("④ 请求头 & Cookie:身份是怎么被记住的")
print("=" * 64)
print("常见的请求头,各自在表达什么:")
for k, v in {
"User-Agent": "我是谁(浏览器 / 脚本)",
"Referer": "我是从哪个页面点过来的",
"Accept": "我能接受什么格式的返回",
"Accept-Language": "我要什么语言",
"Cookie": "我之前来过,这是我的凭证",
}.items():
print(f" {k:16s} {v}")
print()
# 用同一个 Session 走完"登录 → 带着 Cookie 访问个人页"
s = requests.Session()
try:
s.post(base_url + "/login",
data={"username": "spider", "password": "spider123"},
headers={"User-Agent": BROWSER_UA}, timeout=5)
print("登录后 cookie 罐子里的东西:")
for ck in s.cookies:
print(f" {ck.name} = {ck.value[:16]}... domain={ck.domain}")
r = s.get(base_url + "/profile", timeout=5)
print("带着 Cookie 访问 /profile →", r.status_code,
"|是否拿到用户名:", "spider" in r.text)
except requests.RequestException as e:
print(f"Session 操作失败: {e}")
return
print()
print("★ Session 帮你做了两件事:自动存 Cookie、自动复用 TCP 连接。")
print(" 没有 Session 的话,你得手动把 Set-Cookie 抠出来再塞回请求头。")
print()
def main() -> int:
with LocalSite() as site:
part_status_codes(site.base_url)
part_encoding(site.base_url)
part_redirect(site.base_url)
part_headers_and_cookie(site.base_url)
print("本讲小结:先学会「读懂响应」,再动手写爬虫,能省掉一半 debug 时间。")
# ================== 新增:保持靶场运行 ==================
print()
print("=" * 64)
print(f"★ 靶场正在运行,请打开浏览器访问: {site.base_url}")
print("★ 推荐手动体验以下几个页面:")
print(f" - 登录页: {site.base_url}/login")
print(f" - 个人页: {site.base_url}/profile (看浏览器地址栏是怎么跳的)")
print(f" - 严格校验: {site.base_url}/strict (看 403 页面)")
print("★ 浏览完毕后,请回到这里按【回车键】关闭靶场并退出程序...")
print("=" * 64)
input()
# ========================================================
return 0
if __name__ == "__main__":
sys.exit(main())
下一讲
前面三讲我们一直在用 requests.get() 这个最朴素的写法。
但真实的爬虫代码不是这样写的------它要处理超时、重试、限流、连接复用,
还要能流式下载大文件。
下一讲 :把 requests 的六个实战能力一次补齐,
并顺手把靶场的封面图下载到本地------这是这门课的第一个真实产物。