前言
很多人学爬虫是从背 API 开始的:记住 urlopen 怎么写、记住选择器怎么写。结果一旦换个网站就卡住,因为脑子里没有「整个东西是怎么运转的」这张图。本文反过来,先讲原理再落到代码:爬虫本质上就是一个带礼貌约束的图遍历程序,理解了这一点,任何框架都只是把这张图实现得更高效而已。
需要先纠正一个常见说法:有人把爬虫描述成「模拟浏览器点击」或者「破解网页的加密」。这两个说法都不准确。爬虫做的是发 HTTP 请求、拿 HTTP 响应、从文本里抽数据,它不点击、不渲染、也不解密(除非页面内容真的经过加密,那属于另一回事,本文不涉及)。至于「破解」------绕过登录墙、付费墙、验证码这类做法不在本文范围,本文只讲合规地把公开数据取下来。
还有一个前提要说清楚:本文所有代码基于 Python 3。Python 2.7 已于 2020 年 1 月 1 日停止维护,网上大量老文章里的 print 语句、dict.iteritems()、xrange 在 Python 3 里已经不存在,遇到要改成 print(...)、dict.items()、range。整篇文章的例子只用标准库,不需要装任何第三方包。
一、原理起点:HTTP 是「一问一答」
HTTP(HyperText Transfer Protocol,超文本传输协议)是无状态协议。客户端发一个请求,服务端回一个响应,这次交互就结束了;服务端不记得你上一次是谁(除非靠 Cookie 之类的机制显式记住)。所以「爬虫」在协议层面没有任何特殊之处------它和浏览器发的是同一类请求,区别只在请求头、频率和后续处理。
一次交互里,最需要理解的三样东西是:
- 请求方法:GET 表示「取资源」,POST 表示「提交数据」。绝大多数采集用 GET。
- 请求头 :
User-Agent表明客户端身份,Accept-Language、Referer、Cookie影响服务端返回什么。 - 响应 :状态码(200 成功、301/302 跳转、403 拒绝、404 不存在、429 请求过多)加上响应体。响应体是字节串,要按正确的字符集解码成文本。
理解了这一点,就知道「反爬」的绝大多数手段其实是在请求头、频率、Cookie 这三个维度上做文章,而不是什么黑魔法。
二、原理核心:爬虫是一次图遍历
把每个 URL 看成一个节点,把页面里的链接看成有向边,那么「顺着链接把所有页面抓一遍」就是一次图的遍历。任何爬虫的调度循环都可以写成这几步:
- 把起点 URL 放进待抓队列;
- 从队列取出一个 URL;
- 检查它是否已经抓过(去重 )、是否允许抓(robots 与频率);
- 发请求、拿响应、解析出新的链接;
- 把「同域且没抓过」的链接放回队列;
- 重复,直到队列空或达到页数上限。
用广度优先(FIFO 队列)还是深度优先(栈),决定了抓取顺序。广度优先更常见,因为它能保证「离入口近的页面先抓到」,也更方便控制总量。去重集合是这个循环里最容易被忽略又最关键的部件:没有它,一个环形链接就能让你无限抓下去。
三、页面怎么变成数据
HTTP 返回的响应体默认是「一大坨文本」。要从中拿数据,先要把它结构化。浏览器内部会把这个文本解析成一棵 DOM(Document Object Model,文档对象模型)树,然后你才能在开发者工具里点选元素。Python 端常见有两类做法:
- 事件式解析 :
html.parser.HTMLParser这类。你继承它,重写handle_starttag/handle_data/handle_endtag,它边扫边回调。省内存、无依赖,但写法啰嗦,且官方文档明确说它不检查标签是否配对。 - 树式解析:先把文本建成 DOM 再查询。标准库没有顺手的 DOM 加选择器方案,实际工作中多用第三方库;具体 API 请以各库官方文档为准。
还有一类数据根本不在 HTML 里,而是页面通过接口异步加载的 JSON。不要为了拿到它去逆向破解签名、绕过风控------正确做法是看这个站点有没有开放的数据接口或 API,有就按它的规则申请与调用。
| 原理要素 | 它解决的问题 | 对应实现 |
|---|
|---------|-----------|--------------------------|
| 请求 / 响应 | 怎么把网页拿到本地 | urllib.request.urlopen |
|-----------|--------------|--------------------------------------|
| URL 解析与拼接 | 相对链接变绝对、参数编码 | urllib.parse.urljoin / urlencode |
|------------|----------|-----------------------------|
| 队列 + 已访问集合 | 抓哪些、不重复抓 | collections.deque + set |
|----|----------|-----------------------|
| 解析 | 文本变结构化数据 | html.parser 或第三方解析库 |
|----|------------------|--------------|
| 限速 | 不给对方添麻烦,也降低被限流概率 | time.sleep |
|-----------|-------------|----------------------|
| robots 检查 | 抓取前先问「允不允许」 | urllib.robotparser |
|------|--------------|------------------------------------------|
| 会话保持 | 跨请求带着 Cookie | http.cookiejar + HTTPCookieProcessor |
四、为什么并发对爬虫「有用但要克制」
爬虫绝大部分时间花在等网络 上,属于典型的 I/O 密集任务。CPython 有全局解释器锁(GIL,Global Interpreter Lock),同一进程内同一时刻只有一个线程在执行 Python 字节码;但线程在等待网络 I/O 时会释放 GIL,所以多线程对 I/O 密集的爬虫确实能提高吞吐。反过来,CPU 密集的任务用多线程没有意义,需要用多进程或 C 扩展。
不过「能并发」不等于「该高并发」。对一个小站点开几十上百个并发,等于在替它做压力测试,既不礼貌也可能直接把自己送进黑名单。合理的做法是先设一个很小的并发上限,再配合每域名间隔。Python 3.13 起有实验性的自由线程(no-GIL)构建,但它不是默认构建、需要单独安装,所以「Python 已经没有 GIL 了」这句话目前并不成立。
实战:一个会翻页、去重、限速的最小爬虫
下面用标准库实现前面讲的遍历循环。目标站 quotes.toscrape.com 是专门给练习用的站点。
python
# 适用于 Python 3.8+
import time
import urllib.parse
import urllib.request
import urllib.robotparser
from collections import deque
from html.parser import HTMLParser
UA = "principle-walker/0.1 (contact: theory@example.net)"
DELAY = 1.0 # 每两次请求之间的间隔(秒)
class AnchorScanner(HTMLParser):
"""只收集页面里的 a 标签 href。"""
def __init__(self):
super().__init__()
self.links = []
def handle_starttag(self, tag, attrs):
if tag == "a":
href = dict(attrs).get("href")
if href:
self.links.append(href)
class SiteCrawler:
def __init__(self, start_url, max_pages=5):
self.start_url = start_url
self.max_pages = max_pages
self.host = urllib.parse.urlsplit(start_url).netloc
self.seen = set()
self.robots = self._load_robots()
def _load_robots(self):
parts = urllib.parse.urlsplit(self.start_url)
robots_url = urllib.parse.urlunsplit(
(parts.scheme, parts.netloc, "/robots.txt", "", "")
)
parser = urllib.robotparser.RobotFileParser()
parser.set_url(robots_url)
try:
parser.read()
except Exception:
return None # 拿不到 robots 时按「不抓」处理
return parser
def allowed(self, url):
if self.robots is None:
return False
return self.robots.can_fetch(UA, url)
def fetch(self, url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=15) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
return resp.read().decode(charset, errors="replace")
def run(self):
queue = deque([self.start_url])
while queue and len(self.seen) < self.max_pages:
url = queue.popleft()
if url in self.seen or not self.allowed(url):
continue
self.seen.add(url)
try:
html = self.fetch(url)
except Exception as exc:
print("抓取失败:", url, exc)
continue
print("已抓:", url)
parser = AnchorScanner()
parser.feed(html)
parser.close()
for href in parser.links:
absolute = urllib.parse.urljoin(url, href)
parts = urllib.parse.urlsplit(absolute)
if parts.scheme in ("http", "https") and parts.netloc == self.host:
clean = urllib.parse.urldefrag(absolute)[0]
if clean not in self.seen:
queue.append(clean)
time.sleep(DELAY) # 礼貌限速
if __name__ == "__main__":
SiteCrawler("https://quotes.toscrape.com/", max_pages=5).run()
这里每一行都对应第二节里的一个原理步骤:deque 是待抓队列,set 是去重,allowed 是 robots 检查,urljoin + urldefrag 是把相对链接规范成绝对链接并去掉锚点,time.sleep 是限速。把它读懂,再去看任何框架的文档都不会迷路。
常见坑点
1. 没有去重集合,环形链接导致无限抓
❌ 每解析到一个链接就直接丢进队列,页面 A 链到 B、B 又链回 A,程序永远跑不完。 ✅ 维护一个 seen 集合,入队前先判断是否访问过。
2. 相对链接当绝对链接用
❌ urlopen("/page/2/") ------ 没有 host,直接抛异常。 ✅ 用 urllib.parse.urljoin(base, href) 拼成绝对地址。
3. 拼查询参数时手工字符串连接
❌ url + "?q=" + keyword ------ 关键词里若有 &、空格、中文,URL 会坏掉。 ✅ 用 urllib.parse.urlencode({"q": keyword}),它负责百分号编码。
4. 把状态码异常当成「没数据」
❌ 只用裸 except: 吞掉一切,403 和 404 一视同仁地跳过。 ✅ 分别处理:404 说明页面不存在,403 说明被拒绝(应停止而非重试),429 说明频率过高(应退避)。
5. 用 time.sleep 之外还想靠多线程「加速一切」
❌ 「开 200 个线程就能抓得飞快」------对 CPU 密集无效,对小站还会被打进黑名单。 ✅ 认清爬虫是 I/O 密集;用少量并发 + 每域名间隔,遵守对方的频率约定。
6. 拿 resp.read() 直接当字符串比对
❌ if b"关键词" in resp.read(): 与 if "关键词" in resp.read(): 混用 ------ 前者是字节比较,后者在 Python 3 里会报 TypeError。 ✅ 先 decode 成 str 再做文本判断,解码字符集来自响应头。
7. 抄了 Python 2 的示例
❌ print html、for k, v in d.iteritems()、range = xrange(...)。 ✅ print(html)、d.items()、range(...)。Python 2.7 已于 2020 年 1 月 1 日停止维护。
总结
| 原理 | 一句话 | 落到代码 |
|---|
|----------|-----------------|-----------------------|
| 无状态请求-响应 | 一问一答,靠头信息决定返回什么 | Request + urlopen |
|-----|----------|-------------|
| 图遍历 | 从入口顺链接扩散 | 队列 + seen |
|----|-------|--------------------|
| 解析 | 文本变结构 | HTMLParser 或第三方库 |
|----|----------|--------------|
| 限速 | 宁可慢,不添麻烦 | time.sleep |
|--------|--------|-----------------------------|
| robots | 先问允不允许 | RobotFileParser.can_fetch |
|--------|-----------|-----------|
| I/O 密集 | 等待时释放 GIL | 少量线程/异步即可 |
爬虫的原理没有神秘之处:它就是把「HTTP 请求---链接扩散---文本解析」这三件事拼成一个受礼貌约束的循环。把这张图刻进脑子里,剩下的都是工程细节和合规判断。动手之前,先读一眼目标站的 robots.txt 和使用条款。