Spider-第01讲-第一个爬虫与那条不能跨的线
第 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
三个细节:
port=0:不写死端口。写死 8000 的话,你同事机器上有别的东西占了 8000,你的代码就报Address already in use。daemon=True:主线程结束时这个服务线程跟着死,不会挂在那里不让程序退出。- 只用
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
翻译成人话:“公开的图书页随便看,但别碰用户的提交入口和个人页。”
这就是绝大多数正常网站的默认态度。遵守它,其实不碍事。
六、本讲小结
- 先造靶子再练手。没有稳定靶子的爬虫学习,等于闭着眼睛打靶。
- 每次拿到响应,先看状态码、最终 URL、Content-Type、编码。这四样能解决一半问题。
Content-Type/text/content的区分是中文爬虫的必修课,encoding猜错就是乱码。User-Agent是第一道也是最好跨的一道门,但它不是唯一的一道。- 技术能力不等于授权。
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 讲的"练习清单"。
更多推荐



所有评论(0)