Spider-第03讲-HTTP速通与读懂响应

第 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 状态码有一百多个,但第一位数字只有五种含义。

爬虫真正需要记住的只有三条:

  1. 只有 2xx 才算真拿到了数据
  2. 3xx 要追它跳去哪,不能当成功
  3. 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。

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 讲会深入讲登录,这里先记住两件事:

  1. Session = 连接复用 + Cookie 自动管理,登录必备。
  2. 不用 Session 的话,你得手动把 Set-Cookie 抠出来再塞回请求头------能写,但没人愿意写。

六、本讲小结

  1. 状态码只看第一位数字:2xx 拿到东西、3xx 要追去向、4xx 别重试、5xx 才重试。
  2. r.content 是字节,r.text 是字符串 ,中文乱码十有八九是因为 encoding 猜错了。
  3. utf-8-sig 是写给 Excel 看的,导出中文 CSV 必须加。
  4. r.history 比 r.status_code 诚实,被重定向时它才是真相。
  5. 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 的六个实战能力一次补齐,

并顺手把靶场的封面图下载到本地------这是这门课的第一个真实产物。

相关推荐
默|笙10 小时前
【Linux网络】网络层协议 IP(1)理解报头、网段划分
linux·网络·tcp/ip
梦到啥说啥17 小时前
Linux网络编程:TCP网络协议
网络·网络协议·tcp/ip
Shanks.20 小时前
2026 远程控制软件实测:ToDesk / 向日葵 / RustDesk / TeamViewer 在跨城与弱网下的真实差距
网络·远程工作·软件需求
Jeremy_WW21 小时前
802.3协议解读 06:117章节 RS 和 200GMII / 400GMII I
网络·网络协议·信息与通信·光模块·802.3协议
杨凯凡21 小时前
Spring Framework 7 HTTP Interface 实战:声明式 RestClient、超时与测试
java·spring·http
坤坤子吖21 小时前
Linux网络编程:自定义应用层协议与 JSON 序列化
linux·服务器·网络·c++·笔记·json
顧棟1 天前
INFINI Gateway 服务器端口重用(SO_REUSEPORT)实现与分析
网络·gateway
沫璃染墨1 天前
从零入门计算机网络系列(十八):TCP连接管理详解——从三次握手与四次挥手到TIME_WAIT机制
运维·服务器·网络·c++·网络协议·tcp/ip·tcp
LabVIEW开发1 天前
Omron PLC:接 LabVIEW 常踩的 5 个坑
服务器·网络·labview·labview知识·labview功能·labview程序