前言
学爬虫最容易走偏的一步,是把「爬虫」直接等同于「解析 HTML」。于是很多人一上来就找选择器语法、背正则,遇到页面抓不到就怀疑解析写错了——而真正的问题往往出在前面:请求根本没发对、响应其实是重定向后的登录页、返回的是压缩内容没解压。
爬虫的本质是一个会自动发 HTTP 请求的客户端,加上解析、去重、调度三件事。HTTP 客户端那层没搞懂,后面全是空中楼阁。所以开篇先把概念讲清楚:一次抓取在网络上到底发生了什么、URL 由哪些部分组成、请求和响应里各有什么、抓取时要遵守哪些约定。
本文面向零基础,全部用标准库演示,不依赖任何第三方包。示例适用于 Python 3.8 及以上。另外明确一点:Python 2 已于 2020 年 1 月 1 日停止维护,本文只用 Python 3 的写法。
一、一次抓取到底发生了什么
当你写下「请求一个网页」时,底层其实依次发生这些事:
- 解析域名(DNS):把
www.example.com换成 IP 地址。 - 建立连接(TCP):和该 IP 的 443 端口(HTTPS)建立连接。
- 安全握手(TLS):HTTPS 下要协商加密。
- 发送 HTTP 请求:一行请求行(方法、路径、协议版本)+ 若干请求头。
- 接收 HTTP 响应:一行状态行(协议版本、状态码、原因短语)+ 响应头 + 响应体。
第 4、5 步之间的一切,就是「爬虫能看到的世界」。跳转、鉴权、压缩、Cookie 全都发生在这两层协议里,它们和页面长什么样无关。理解这一点,你排查问题时就会先看响应头和状态码,而不是先怀疑解析。
二、URL 由哪些部分组成
一个 URL 不是「一串地址」,而是有固定结构的。标准库的urlsplit可以把结构拆开,这比手工切字符串可靠得多。
# 适用于 Python 3.8+
from urllib.parse import urlsplit, parse_qs
url = "https://www.example.com:8443/a/b.html?x=1&y=2#frag"
parts = urlsplit(url)
print("scheme :", parts.scheme) # https
print("netloc :", parts.netloc) # www.example.com:8443
print("path :", parts.path) # /a/b.html
print("query :", parts.query) # x=1&y=2
print("fragment:", parts.fragment) # frag
print("hostname:", parts.hostname) # www.example.com
print("port :", parts.port) # 8443
print("query 字典:", parse_qs(parts.query))几点要记住:
- fragment(
#之后)不会发送给服务器。它只对浏览器有意义,所以page#a和page#b在服务器看来是同一个地址——做去重时应把它去掉。 urlsplit不拆;参数,urlparse才会。- 判断链接是不是站内的,应该比较
hostname,而不是判断字符串前缀。
三、请求和响应里有什么
请求里最需要关心的是方法、请求头,以及可选的请求体。
| 请求方法 | 语义 | 爬虫里怎么用 |
|---|
| GET | 取资源 | 抓页面、抓列表 |
| POST | 提交数据 | 表单查询、需要提交参数的接口 |
| HEAD | 只要响应头 | 探测资源是否存在、看大小 |
请求头里有几个和爬虫直接相关:User-Agent(标识客户端)、Referer(来源页)、Cookie(会话)、Accept-Encoding(声明支持的压缩方式)、Accept-Language。
响应里最重要的是状态码:
| 状态码 | 含义 | 爬虫应对 |
|---|
| 200 | 成功 | 正常解析 |
| 301 / 302 | 重定向 | 跟随到Location指向的地址 |
| 403 | 拒绝访问 | 检查 UA、频率与 robots |
| 404 | 不存在 | 记录并跳过 |
| 429 | 请求过多 | 必须退避,降低频率 |
| 5xx | 服务端错误 | 稍后重试,不要硬冲 |
urlopen默认会自动跟随重定向;但遇到 4xx / 5xx 不会返回响应对象,而是抛出HTTPError,这一点必须记住。
# 适用于 Python 3.8+
import urllib.error
import urllib.request
def get_status(url):
req = urllib.request.Request(url, method="HEAD", headers={
"User-Agent": "ConceptBot/0.1 (contact: me@example.com)",
})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status
except urllib.error.HTTPError as e:
return e.code # 4xx / 5xx 走这里
except urllib.error.URLError:
return None # 网络层失败,连响应都没有Request的method参数需要 Python 3.3 及以上。注意并非所有服务器都支持 HEAD,遇到不支持时改用 GET。
四、抓取策略与「礼貌」
概念里最后一块,也是决定你能否长期抓下去的一块:抓取策略与礼貌约定。
- robots.txt:站点根目录下的约定文件,声明哪些路径允许抓取。用
urllib.robotparser解析,不要自己写规则。 - User-Agent:写清程序身份和联系方式,让对方能区分你和技术滥用。
- 限速:请求之间留间隔;响应头里的
Retry-After要尊重。 - 去重:同一个地址抓一次就够了。先去 fragment,再把相对地址用
urljoin规范化成绝对地址。 - 遍历策略:广度优先(先抓一层再下一层)和深度优先(沿一条链抓到底)各有适用场景,入门一般用广度优先。
一个常被忽略的概念是静态渲染与动态渲染:服务端直接返回完整 HTML 的叫服务端渲染,urlopen就能拿到全部内容;由 JavaScript 在浏览器里拼出来的内容,urlopen拿到的 HTML 里根本没有数据。遇到这种情况要先判断,而不是盲目调选择器。
实战:一个 URL 规范化与判断的小工具
下面这段把相对地址规范化、去掉 fragment、判断是否同站,都是爬虫去重的基础动作。
# 适用于 Python 3.8+
from urllib.parse import urljoin, urlsplit, urldefrag
BASE = "https://www.example.com/dir/page.html"
def normalize(raw, base):
"""把相对地址补成绝对地址,并去掉 fragment。"""
absolute = urljoin(base, raw)
without_frag, _frag = urldefrag(absolute)
return without_frag
def same_site(url, base):
"""按 hostname 判断是否同站,而不是比较字符串前缀。"""
return urlsplit(url).hostname == urlsplit(base).hostname
raws = ["a.html", "/b", "../c", "https://www.example.com/d#x",
"https://other.example.net/y"]
for raw in raws:
full = normalize(raw, BASE)
print(same_site(full, BASE), full)输出里可以观察到:../c会被正确解析回上一级,#x被去掉,而other.example.net会被判定为不同站。用hostname而不是startswith判断同站,否则www.example.com.evil.net这种地址会骗过你的前缀判断。
常见坑点
- 把爬虫等同于解析 HTML
❌ 抓不到数据就反复改选择器,不看请求和响应。 ✅ 先确认状态码、响应头、响应体是不是你想要的页面,再谈解析。
- 用字符串前缀判断同站
❌url.startswith("https://www.example.com")——www.example.com.evil.net也能通过。 ✅ 用urlsplit(url).hostname比较主机名。
- 去重时不处理 fragment
❌ 把page#a和page#b当成两个地址分别抓。 ✅ 用urldefrag()去掉 fragment 后再入队。
- 手工拼相对地址
❌base.rsplit("/", 1)[0] + "/" + href—— 对../、根路径/b处理全错。 ✅ 用urljoin(base, href)。
- 把 4xx 当成功响应
❌ 只写try: resp = urlopen(...)却以为拿到的总是 200。 ✅ 记住 4xx / 5xx 会抛HTTPError,要单独捕获并读e.code。
- 忽略 robots.txt 和限速
❌ 不查 robots,循环里无间隔请求。 ✅ 先用RobotFileParser.can_fetch(),请求之间time.sleep(),并尊重Retry-After。
- 以为
urlopen会解压和渲染
❌ 指望它自动解 gzip、自动执行 JS。 ✅ 压缩要自己用gzip处理;JS 渲染的内容需要专门的方案。
- 搬 Python 2 的概念
❌ 以为urllib2.urlopen还存在、print还能当语句。 ✅ Python 2 已停止维护,统一用urllib.request与print(...)。
总结
| 概念 | 关键点 | 标准库工具 |
|---|
| HTTP 请求 / 响应 | 状态码、请求头、压缩都在这一层 | urllib.request |
| URL 结构 | fragment 不发往服务器,去重要去掉 | urlsplit/urldefrag |
| 相对地址 | 用规则解析,别手工拼 | urljoin |
| 礼貌约定 | robots、UA、限速、Retry-After | urllib.robotparser |
| 遍历策略 | 广度优先适合入门 | 队列 + 集合去重 |
概念这一层吃透之后,后面的解析、并发、存储才有意义。记住排查顺序:先看网络层,再看响应内容,最后才看解析规则——这个顺序能帮你少走很多弯路。