第 1 讲 · 你的第一个爬虫,以及那条不能跨的线

本讲目标:把「爬虫」这个词从名词变成动词——真的抓下一个页面,
并看清一次 HTTP 请求背后到底发生了什么。最后撞一次 403,亲手体会
「服务器凭什么判断你是爬虫」。

配套代码:spiders/s01_first_request.py
运行:python spiders/s01_first_request.py


一、先解决一个前置问题:靶子在哪

学爬虫最痛苦的不是写代码,是没有稳定的靶子。

你兴冲冲写了个爬虫去抓某网站,返回 403。这时候你面临一个灵魂拷问:

  • 是我代码写错了?
  • 还是这网站不让我爬?

你分不清。而且更糟的是——明天网站改版了,你昨天调通的代码又挂了,你却以为是网站封了你。

所以这门课的第一件事,是自己造一个网站。这个靶场(demosite/)具备三个真实网站的特征:

特征为什么必须有
有分页、有详情页、有 JSON 接口覆盖真实站点的三种数据来源
有登录、有 CSRF、有 302 跳转真实站点的「坑」一个不少
有 UA 校验、有 403、有限流让你能练反爬应对,而不是纸上谈兵
数据固定(48 本书,种子固定)结果可复现,你跑出来的数字和我一样

启动方式有两种:

# 方式一:命令行起服务,用浏览器点着玩(推荐先这么干 10 分钟)
python run_site.py

# 方式二:代码里当上下文管理器用(课程所有案例都是这么写的)
with LocalSite() as site:
    print(site.base_url)   # http://127.0.0.1:52341 之类

LocalSite 的写法很值得抄:

class LocalSite:
    def __init__(self, port: int = 0):
        # port=0 表示「让操作系统随便给我一个空闲端口」
        self._server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
        self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
        self._thread.start()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self._server.shutdown()
        return False

三个细节:

  1. port=0:不写死端口。写死 8000 的话,你同事机器上有别的东西占了 8000,你的代码就报 Address already in use。
  2. daemon=True:主线程结束时这个服务线程跟着死,不会挂在那里不让程序退出。
  3. 只用 127.0.0.1:绑到 0.0.0.0 意味着局域网里所有人都能访问你这个练习靶场。

把靶场换成真实网站:整门课的代码里,目标地址全部来自一个变量。
想抓别的站,把 base_url 换掉、把选择器换掉就行,其余逻辑(重试、限流、入库、报表)一行不用改。
这就是「工程」和「脚本」的区别。


二、第一个请求:比想象的简单,也比想象的深

import requests

resp = requests.get(base_url + "/", timeout=5)

print("状态码     :", resp.status_code)
print("最终 URL   :", resp.url)
print("内容类型   :", resp.headers.get("Content-Type"))
print("字节长度   :", len(resp.content), "(注意是 content 不是 text)")
print("网页编码   :", resp.encoding)

跑出来是这样:

状态码     : 200
最终 URL   : http://127.0.0.1:52341/
内容类型   : text/html; charset=utf-8
字节长度   : 14213 (注意是 content 不是 text)
网页编码   : utf-8

这五行打印是每次拿到响应后都该看一眼的东西。养成习惯,能省掉后面一半的 debug 时间。逐行解释:

1. status_code —— 唯一必须每次都检查的东西

200  成功
301  永久跳转
302  临时跳转 ← 你「没爬到数据」的罪魁祸首之一,第 15 讲细说
403  拒绝访问(服务器认得你,但不想理你)
404  页面不存在
429  请求太频繁
500  服务器自己炸了

2. resp.url —— 请求发出去以后,最终落在了哪

如果发生了重定向,resp.url 才是真正返回内容的那个地址。很多人卡在「我明明请求的是 A,怎么返回的是登录页」,答案就在这里。

3. len(resp.content) 而不是 len(resp.text)

这两个的差别是中文:

  • resp.content 是原始字节(bytes)
  • resp.text 是解码后的字符串(str)

一个中文字符 UTF-8 编出来是 3 个字节。所以 len(content) != len(text)。要算「下载了多少流量」,看 content;要处理文本,用 text。

4. encoding —— 乱码的根源

requests 猜编码的规则是:先看 HTTP 响应头里的 charset,没有的话默认猜 ISO-8859-1(这是个历史遗留的糟糕默认值)。

于是国内网站经常出现这种情况:

resp = requests.get("http://某国内网站/")
print(resp.encoding)        # ISO-8859-1  ← 猜错了
print(resp.text[:50])       # 一堆乱码
# 手动纠正:
resp.encoding = "utf-8"     # 或者 "gbk"
print(resp.text[:50])       # 正常了

第 3 讲会专门用靶场演示这个坑。


三、一次请求到底发了什么

requests 帮我们做了很多事,多到让人忘了请求长什么样。把它展开看:

req = requests.Request("GET", base_url + "/")
prepared = req.prepare()
for k, v in prepared.headers.items():
    print(f"  {k}: {v}")

输出:

  User-Agent: python-requests/2.34.2
  Accept-Encoding: gzip, deflate, br, zstd
  Accept: */*
  Connection: keep-alive

看第一行。 User-Agent: python-requests/2.34.2。

这等于在脑门上贴了张纸条:“我是爬虫脚本,请拒绝我。”

真实浏览器发的是这样:

User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.9
Referer: https://example.com/

所以绝大多数网站拦截爬虫的第一道关,就是查这个 User-Agent。一个字的成本,拦掉 90% 的脚本小子。


四、撞一次墙:403 Forbidden

靶场有个 /strict 路由,规则很明确:UA 不能是 python-requests,且必须带本站的 Referer。

# 第一次:裸 requests,什么都不改
bad = requests.get(base_url + "/strict", timeout=5)
print(bad.status_code)      # 403
print(bad.text.strip()[:60])  # 403 Forbidden:请求头不像正常浏览器
# 第二次:装上浏览器 UA + Referer
headers = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                   "AppleWebKit/537.36 (KHTML, like Gecko) "
                   "Chrome/124.0.0.0 Safari/537.36"),
    "Referer": base_url + "/",
}
good = requests.get(base_url + "/strict", headers=headers, timeout=5)
print(good.status_code)     # 200

同一个 URL,同一份代码,改了两个请求头,从 403 变成 200。

结论:爬虫和浏览器的差别,不在「能不能发请求」(TCP 层面完全一样),而在**「请求长不长得像人」**。

这条线是双向的:

  • 你可以伪装请求头 → 网站也可以查得更深(TLS 指纹、JS 行为、鼠标轨迹、访问频率)
  • 你伪装得越像 → 越是「刻意伪装」,法律上的性质也可能跟着变

而第二种伪装,第 19 讲会讲清楚什么时候该停手。


五、技术之上:那条不能跨的线

这一节不是走过场,请认真读。

本课程所有练习都指向 127.0.0.1 的自建靶场——随便爬、随便试,怎么折腾都不会伤害任何人。

但把代码指向真实网站之前,请先回答四个问题:

问题判断标准
这是公开数据吗?公开信息 vs 个人隐私 / 付费内容,是两回事
网站允许吗?先看 /robots.txt 和服务条款
会影响别人吗?你的频率会不会让正常用户的访问变慢
数据拿去干什么?自用学习 vs 转卖 / 骚扰 / 拼凑个人信息

关于 robots.txt,有一个必须说清楚的点:

它没有技术强制力,但它是有法律意义的边界声明。

服务器没法阻止你忽略 robots.txt。但「网站明确声明了不许爬,你还是爬了」这件事,在后续的纠纷里会被当成「你明知故犯」的证据。

看看靶场的 robots.txt 长什么样:

GET {base_url}/robots.txt
User-agent: *
Disallow: /submit
Disallow: /profile
Crawl-delay: 1

Sitemap: http://127.0.0.1:PORT/sitemap.xml

翻译成人话:“公开的图书页随便看,但别碰用户的提交入口和个人页。”

这就是绝大多数正常网站的默认态度。遵守它,其实不碍事。


六、本讲小结

  1. 先造靶子再练手。没有稳定靶子的爬虫学习,等于闭着眼睛打靶。
  2. 每次拿到响应,先看状态码、最终 URL、Content-Type、编码。这四样能解决一半问题。
  3. Content-Type/text/content 的区分是中文爬虫的必修课,encoding 猜错就是乱码。
  4. User-Agent 是第一道也是最好跨的一道门,但它不是唯一的一道。
  5. 技术能力不等于授权。robots.txt 是边界声明,读它花你 10 秒。

七、完整代码

spiders/s01_first_request.py

# -*- coding: utf-8 -*-
"""第 1 讲:你的第一个爬虫,以及那条不能跨的线。

本讲做三件事:
  1. 用 requests 抓下一个页面,把"爬虫"从名词变成动词
  2. 看清一次 HTTP 请求到底发生了什么(状态码 / 编码 / 响应体)
  3. 撞一次 403 —— 亲手体会"服务器凭什么判断你是爬虫"

运行:
    python spiders/s01_first_request.py
"""
from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import requests                                            # noqa: E402
from demosite import LocalSite                             # noqa: E402


def part1_hello_spider(base_url: str) -> None:
    print("=" * 64)
    print("① 第一个请求:requests.get()")
    print("=" * 64)

    resp = requests.get(base_url + "/", timeout=5)

    # 这四行是每次拿到响应后都该看一眼的东西,养成习惯
    print("状态码     :", resp.status_code)
    print("最终 URL   :", resp.url)
    print("内容类型   :", resp.headers.get("Content-Type"))
    print("字节长度   :", len(resp.content), "(注意是 content 不是 text)")
    print("网页编码   :", resp.encoding)
    print()
    print("--- 响应体前 180 个字符 ---")
    print(resp.text[:180].replace("\n", " "))
    print()


def part2_what_is_a_request(base_url: str) -> None:
    print("=" * 64)
    print("② 一次请求到底发了什么")
    print("=" * 64)

    # requests 会自带的请求头(也就是"我是谁、我想要什么格式")
    req = requests.Request("GET", base_url + "/")
    prepared = req.prepare()
    print("默认请求头:")
    for k, v in prepared.headers.items():
        print(f"  {k}: {v}")
    print()
    print("★ 注意 User-Agent:默认是 python-requests/2.x。")
    print("  这等于在脑门上写着『我是爬虫脚本』——绝大多数网站第一眼就是看它。")
    print()


def part3_hit_the_wall(base_url: str) -> None:
    print("=" * 64)
    print("③ 撞一次墙:403 Forbidden")
    print("=" * 64)

    # /strict 要求:UA 不能是 python-requests,且必须带本站 Referer
    bad = requests.get(base_url + "/strict", timeout=5)
    print(f"裸 requests 访问 /strict → {bad.status_code}")
    print("  服务器返回:", bad.text.strip()[:60])
    print()

    # 换成浏览器 UA + Referer 再试
    headers = {
        "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                       "AppleWebKit/537.36 (KHTML, like Gecko) "
                       "Chrome/124.0.0.0 Safari/537.36"),
        "Referer": base_url + "/",
    }
    good = requests.get(base_url + "/strict", headers=headers, timeout=5)
    print(f"带上浏览器 UA + Referer 再试 → {good.status_code}")
    print()
    print("★ 结论:爬虫和浏览器的差别不在『能不能发请求』,而在『请求头像不像人』。")
    print("  这条线是双向的:你可以伪装,网站也可以查得更深(指纹、行为、频率)。")
    print()


def part4_the_line() -> None:
    print("=" * 64)
    print("④ 技术之上:那条不能跨的线")
    print("=" * 64)
    print("""本课程所有练习都指向本机 127.0.0.1 的自建靶场,随便爬、随便试。
但把代码指向真实网站之前,请先回答四个问题:

  1. 这是公开数据吗?        —— 公开信息和个人隐私、付费内容,是两回事
  2. 网站允许吗?            —— 先看目标站 /robots.txt 和服务条款
  3. 会影响别人吗?          —— 你的频率会不会让别人的正常访问变慢
  4. 数据拿去干什么?        —— 自用学习 vs 转卖、骚扰、拼凑个人信息

技术能力不等于授权。robots.txt 说 Disallow,是网站明确表达的"请别爬这一块",
它没有技术强制力,但它是有法律意义的边界声明。

我们这套靶场也写了 robots.txt,你可以先去看看它长什么样:
    GET {base_url}/robots.txt
""".format(base_url="<靶场地址>"))


def main() -> int:
    with LocalSite() as site:
        part1_hello_spider(site.base_url)
        part2_what_is_a_request(site.base_url)
        part3_hit_the_wall(site.base_url)
        part4_the_line()
        print("靶场 robots.txt 实际内容:")
        print(requests.get(site.base_url + "/robots.txt", timeout=5).text)
    return 0


if __name__ == "__main__":
    sys.exit(main())

下一讲

靶场造好了,但你可能还没搞清它到底长什么样:有哪些页面、哪些接口、
哪些是故意给你挖的坑。

下一讲:把靶场的十几个路由挨个过一遍,标注每个路由练的是哪一讲,
让它变成你后面 18 讲的"练习清单"。

Logo

更多推荐