前言
「会用 requests 抓一个页面」和「理解爬虫原理」是两回事。前者是调库,后者是能回答:一次请求到拿到正文之间经历了什么、下一页地址是从哪里发现的、怎么保证同一页面不被抓两遍、循环靠什么终止。这四个问题答不上来,遇到稍微复杂一点的场景就只能靠试。
一个典型误解是「原理就是库的源码」。其实原理在协议层:HTTP 是请求—响应模型,爬虫无非是把「请求、解析、发现新链接、入队、去重」这五步循环起来。第三方库只是把这五步包装得更顺手,换掉库,原理一点不变。
本文用标准库从最底层讲起:先用http.client手工发一次请求,看清响应由什么组成;再用html.parser讲链接是怎么被发现的;最后把队列、去重、限速拼成一个广度优先(BFS)模型。示例适用于 Python 3.8 及以上。
一、请求生命周期:手工发一次 HTTP
高层库藏起来的东西,用http.client就能看清。它是一次连接、一次请求、一次响应的直接映射,不会自动跟随重定向,也不会自动处理 Cookie。
# 适用于 Python 3.8+
import http.client
conn = http.client.HTTPSConnection("www.example.com", timeout=10)
conn.request("GET", "/", headers={
"User-Agent": "PrincipleBot/0.1 (contact: me@example.com)",
})
resp = conn.getresponse()
print("状态:", resp.status, resp.reason)
print("类型:", resp.getheader("Content-Type"))
print("编码:", resp.getheader("Content-Encoding"))
body = resp.read() # bytes
conn.close()
print("响应体字节数:", len(body))一次请求的产物是「状态码 + 响应头 + 响应体」三样东西。状态码决定要不要继续处理,响应头决定响应体该怎么还原,响应体才是你要解析的内容。很多人只盯第三样,前两样直接丢掉,问题就出在这里。
高层接口urllib.request.urlopen做的就是把这些细节包好:自动跟随重定向、按需选择协议、返回一个可当上下文管理器用的响应对象。但它的行为边界要记清楚——不会自动解压,4xx / 5xx 会抛HTTPError而不是返回响应。
二、响应体的还原:编码与压缩
响应体是 bytes。要变成能解析的 str,必须经过两步还原,顺序不能反:先解压,再解码。
- 压缩:若响应头里有
Content-Encoding: gzip,响应体是 gzip 流,要先用gzip模块解压。 - 解码:从响应头的
Content-Type里取 charset,用它对 bytes 解码;取不到再退回 UTF-8。
顺序错了会得到一堆看不懂的二进制或直接抛错。高层库urlopen不替你解压,这一步必须自己确认。
三、链接发现:解析器怎么找到「下一页」
爬虫能自动抓下去,靠的是从当前页面里发现新的 URL。标准库的html.parser.HTMLParser是事件驱动的:它一边扫描一边回调,你只需要在handle_starttag里对a标签取href。
# 适用于 Python 3.8+
from html.parser import HTMLParser
from urllib.parse import urljoin
class AbsoluteLinkResolver(HTMLParser):
def __init__(self, base):
super().__init__(convert_charrefs=True)
self.base = base
self.links = []
def handle_starttag(self, tag, attrs):
if tag != "a":
return
for name, value in attrs:
if name == "href" and value:
self.links.append(urljoin(self.base, value))
parser = AbsoluteLinkResolver("https://www.example.com/dir/")
parser.feed('<a href="x.html">X</a><a href="/y">Y</a>')
parser.close()
print(parser.links)这里有两个原理级的动作:
handle_starttag(tag, attrs)的attrs是(属性名, 属性值)列表,属性值可能是None(布尔属性),要判空。- 相对地址必须用
urljoin(base, href)补成绝对地址——因为入队和去重都要求地址是规范形式。
四、调度:队列、去重、限速、终止
发现链接之后,原理上就只剩「循环」了。一个正确的循环需要四个部件:
| 部件 | 作用 | 缺失后果 |
|---|
| 待抓队列 | 保存「还没抓的地址」 | 抓完一页就停 |
| 已抓集合 | 记录「抓过的地址」 | 页面互相链接时无限循环 |
| 限速 | 控制请求间隔 | 拖垮对端,触发封禁 |
| 终止条件 | 抓够数量或队列空了 | 永远不结束 |
队列的取法决定了遍历策略:从头取是广度优先(BFS),先抓完整层再深入;从尾取就是深度优先(DFS)。入门和大多数站点抓取用 BFS,因为它能更快覆盖同一层的页面。
去重集合里存的应该是规范化后的绝对地址:去掉 fragment、补全相对路径、必要时统一大小写。否则page和page#top、/a和https://host/a会被当成两个地址重复抓。
实战:一个广度优先爬虫模型
下面把四步串成一个完整模型,控制在同站范围内、带 robots 检查,最多抓若干个页面。
# 适用于 Python 3.8+
import time
import urllib.error
import urllib.request
import urllib.robotparser
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit, urldefrag
USER_AGENT = "PrincipleBot/0.1 (contact: me@example.com)"
HEADERS = {"User-Agent": USER_AGENT}
class AbsoluteLinkResolver(HTMLParser):
def __init__(self, base):
super().__init__(convert_charrefs=True)
self.base = base
self.links = []
def handle_starttag(self, tag, attrs):
if tag != "a":
return
for name, value in attrs:
if name == "href" and value:
self.links.append(urljoin(self.base, value))
def normalize(url, base):
absolute = urljoin(base, url)
without_frag, _ = urldefrag(absolute)
return without_frag
def robots_allows(url, user_agent):
parts = urlsplit(url)
rp = urllib.robotparser.RobotFileParser()
rp.set_url(parts.scheme + "://" + parts.netloc + "/robots.txt")
try:
rp.read()
except urllib.error.URLError:
return False
return rp.can_fetch(user_agent, url)
def fetch_text(url, timeout=10):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
charset = resp.headers.get_content_charset() or "utf-8"
return raw.decode(charset, errors="replace")
def main():
start = "https://www.example.com/"
host = urlsplit(start).hostname
queue = [start] # 待抓队列(BFS)
seen = {start} # 已入队 / 已抓(去重)
crawled = 0
limit = 5
while queue and crawled < limit:
url = queue.pop(0) # 从头取 = 广度优先
if not robots_allows(url, USER_AGENT):
print("robots 禁止:", url)
continue
try:
html = fetch_text(url)
except urllib.error.HTTPError as e:
print("HTTP", e.code, url)
continue
except urllib.error.URLError as e:
print("网络错误", e.reason, url)
continue
parser = AbsoluteLinkResolver(url)
parser.feed(html)
parser.close()
for link in parser.links:
candidate = normalize(link, url)
if urlsplit(candidate).scheme not in ("http", "https"):
continue
if urlsplit(candidate).hostname != host: # 只抓同站
continue
if candidate not in seen:
seen.add(candidate)
queue.append(candidate)
crawled += 1
print("已抓:", url, "队列剩余:", len(queue))
time.sleep(1) # 限速
print("结束,共抓取", crawled, "个页面")
if __name__ == "__main__":
main()这段代码把前面四个部件都体现了:queue是待抓队列、seen同时完成入队去重、time.sleep(1)是限速、crawled < limit配合queue为空是终止条件。
常见坑点
- 只读响应体,丢掉状态码和响应头
❌ 拿到内容直接解析,不看Content-Type和状态码。 ✅ 先看状态码决定是否处理,再用响应头里的 charset 解码。
- 解压和解码顺序反了
❌ 先.decode()再想 gzip —— 压缩流上解码必然失败。 ✅ 先按Content-Encoding解压,再按 charset 解码。
- 去重集合里存原始地址
❌ 把page、page#top、https://host/page当三条记录。 ✅ 先urljoin补全、urldefrag去 fragment,再入seen。
- 忘了判断同站
❌ 把所有href都入队,很快抓到站外去。 ✅ 比较urlsplit(url).hostname,只保留同站链接。
- 队列取法写错导致策略不符预期
❌ 写queue.pop()却以为是广度优先——那是深度优先。 ✅queue.pop(0)是 BFS;明确自己用哪种并写进注释。
- 没有终止条件
❌while queue:且页面无限互链 —— 可能跑很久。 ✅ 加上数量上限或深度上限,与队列空同时作为终止条件。
- robots 检查放在入队之后却忘了限速
❌ 只查 robots,不设间隔,一秒发几十个请求。 ✅ robots 只解决「允不允许」,频率要靠time.sleep或Retry-After。
- 用 Python 2 的思路理解异常
❌except urllib2.URLError, e:—— 这种语法在 Python 3 里非法。 ✅ 用except urllib.error.URLError as e:;Python 2 已于 2020 年停止维护。
总结
| 环节 | 原理要点 | 常见失误 |
|---|
| 请求 | 状态码 + 响应头 + 响应体 | 只看响应体 |
| 还原 | 先解压、再解码 | 顺序反了或硬编码 UTF-8 |
| 发现 | 事件回调取href,urljoin补全 | 相对地址直接入队 |
| 调度 | 队列 + 去重 + 限速 + 终止 | 缺任一件都会出事 |
爬虫原理说穿了就是一句话:把「请求—还原—发现—入队—去重」循环起来,并给它加上礼貌和终止条件。把这条线在自己脑子里跑通,换任何库都不会迷路。