前言
学爬虫最怕的是「知识点是散的」:今天记住urlopen的用法,明天背会一个解析器的写法,结果遇到一个新站点还是不知道从哪儿下手。这篇总结的目的就是把散落的知识点挂到一张地图上,让你随时能回答「我现在卡在这一层的哪一步」。
有一件事要先纠正:不少教程把爬虫的知识点分成「基础篇」和「反爬篇」,好像学会反爬才算完整。这个划分是错的。爬虫的知识点只分四层——请求、解析、调度、存储;合规不是第五层,而是贯穿四层的前提。至于「反爬」,多数情况下它对应的正确结论是「对方不允许,那就不抓」,而不是「想个办法绕过去」。本文不涉及任何绕过登录墙、付费墙或风控的内容。
本文所有代码以 Python 3 为基准,只用标准库。Python 2.7 已于 2020 年 1 月 1 日停止维护,print语句、dict.iteritems()、xrange、urllib2这些老写法现在都不能用,文中会在对照表里一并点明。
一、知识地图
| 层次 | 你要回答的问题 | 常用标准库 | 常见坑 |
|---|
| 请求 | 东西怎么拿下来 | urllib.request | 没设 timeout、字节没解码 |
| 解析 | 数据从哪儿抠 | html.parser | 嵌套结构提前收尾 |
| 调度 | 下一步抓哪儿、抓多少 | collections.deque+set | 环状链接无限抓 |
| 存储 | 数据怎么落盘 | json/csv | 编码没写、CSV 多空行 |
| 合规 | 允不允许抓 | urllib.robotparser | 跳过检查直接抓 |
把这张表记住,比记二十个 API 有用:遇到任何问题,先定位它在哪一层。
二、请求层要点
urllib.request.urlopen(url, data=None, [timeout, ]*, context=None):data不为None时变成 POST,且必须是字节串。urllib.request.Request(url, data=None, headers={}, origin_req_host=None, unverifiable=False, method=None):headers等价于逐个add_header(key, val)。设 UA 的目的是表明身份,不是伪装。- 响应对象:
status(整数)、headers(HTTPMessage,可.get())、read()(返回字节串)、url。它是上下文管理器,用with自动释放。 - 字符集:
resp.headers.get_content_charset(failobj=None),读不到再回退utf-8。 - 压缩:自己加了
Accept-Encoding: gzip,就要自己判断Content-Encoding并用gzip.decompress()。 - 异常:
urllib.error.URLError是网络错误基类,urllib.error.HTTPError是它的子类、代表错误状态码,同时是类文件对象,有code、reason。 - 异常分类处理:404 放弃、403 停止、429 退避、网络错误退避重试。
三、解析层要点
html.parser.HTMLParser(*, convert_charrefs=True, scripting=False):事件式解析。convert_charrefs从 3.5 起默认True,会帮你把字符引用转成对应字符(script/style内的除外)。- 三个回调:
handle_starttag(tag, attrs)、handle_data(data)、handle_endtag(tag);另外还有handle_startendtag(tag, attrs)、handle_comment(data)、handle_decl(decl)。 attrs是「(名字, 值) 二元组」的列表,不是字典。feed(data)之后一定要close()。- 官方文档明确:它不检查结束标签是否与开始标签匹配,也不会为「被外层闭合隐式关闭」的元素调用结束回调。所以嵌套结构要自己用计数器处理。
- 对结构复杂的页面,选择器式解析更省事,那属于第三方库,具体 API 以各库官方文档为准。
四、调度与并发要点
- 队列用
collections.deque(两端进出都是 O(1)),去重用set。 - 终止条件要双重:既看有没有下一页,也设页数/条数硬上限。
- 相对链接用
urllib.parse.urljoin(base, href)绝对化;去掉片段用urldefrag()。 - 爬虫是 I/O 密集任务。CPython 有全局解释器锁(GIL,Global Interpreter Lock),同进程内同一时刻只有一个线程执行字节码,但等待 I/O 时会释放 GIL,所以少量多线程有效;CPU 密集要用多进程或 C 扩展。Python 3.13 起有实验性自由线程(no-GIL)构建,但不是默认构建,不要说「Python 已经没有 GIL 了」。
- 并发要配限速:并发上限保持很小 + 全局最小请求间隔。不要为了速度去压一个小站点。
五、存储与清洗要点
- 文本文件一律显式
encoding="utf-8"。 - CSV 必须同时
newline="",否则 Windows 上每行之间多一个空行(csv文档要求)。 json.dump(..., ensure_ascii=False)让中文以汉字写入而非\uXXXX转义。- 长文本拼接用
"".join(parts),不要在循环里s = s + piece。 - 边抓边追加写,别把几万条全堆在内存里。
六、合规清单(写在代码第一行)
- 读
robots.txt:RobotFileParser的set_url→read→can_fetch,返回False就不抓。 - 读不到 robots.txt 时按「不允许」处理,稍后重试。
- UA 里写清程序名和联系方式。
- 每次请求之间
time.sleep,并设总量上限。 - 需要登录、付费或 robots 明令禁止的内容,不抓、不绕。
- 遵守著作权与用户协议,不把抓来的内容原样再发布。
七、Python 2 与版本差异对照
| 老写法(Python 2) | 现行写法(Python 3) | 说明 |
|---|
print html | print(html) | 3 里print是函数 |
urllib2.urlopen | urllib.request.urlopen | 模块在 3 里重组过 |
d.iteritems() | d.items() | 3 里返回视图,iteritems已移除 |
xrange(n) | range(n) | 3 的range本身就是惰性的 |
unicode/basestring | str | 3 里文本统一为str |
3 / 2 == 1 | 3 / 2 == 1.5,整除写// | 除法语义变了 |
版本内部的变化也要知道:字典从 3.7 起保证插入顺序;dict.keys()返回视图而非列表;asyncio.get_event_loop()在 3.12 起没有运行中的事件循环时会发DeprecationWarning,3.14 起直接抛RuntimeError——新代码应当用asyncio.run()或asyncio.get_running_loop()。
实战:把知识点串成一个小脚本
# 适用于 Python 3.8+
import csv
import json
import time
import urllib.parse
import urllib.request
import urllib.robotparser
from html.parser import HTMLParser
UA = "tutorial-sampler/0.2 (contact: notes@example.org)"
DELAY = 1.0
URL = "https://quotes.toscrape.com/"
class TextSpanParser(HTMLParser):
"""收集所有 class 含 text 的 span 的文字。"""
def __init__(self):
super().__init__()
self._in = False
self._buf = []
self.texts = []
def handle_starttag(self, tag, attrs):
if tag == "span" and "text" in dict(attrs).get("class", "").split():
self._in = True
self._buf = []
def handle_data(self, data):
if self._in:
self._buf.append(data)
def handle_endtag(self, tag):
if tag == "span" and self._in:
self._in = False
self.texts.append("".join(self._buf).strip())
def fetch_text(url, timeout=15):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
return resp.read().decode(charset, errors="replace")
def robots_allowed(url):
parts = urllib.parse.urlsplit(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 False
return parser.can_fetch(UA, url)
def save(records):
with open("quotes.json", "w", encoding="utf-8") as f:
json.dump(records, f, ensure_ascii=False, indent=2)
with open("quotes.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["index", "text"])
for i, rec in enumerate(records, 1):
writer.writerow([i, rec["text"]])
def main():
if not robots_allowed(URL):
print("robots.txt 不允许抓取,退出")
return
parser = TextSpanParser()
parser.feed(fetch_text(URL))
parser.close()
records = [{"text": t} for t in parser.texts if t]
save(records)
print("已保存", len(records), "条")
time.sleep(DELAY)
if __name__ == "__main__":
main()四十几行代码,把请求、解析、限速、合规、存储全部串了一遍。把它当作模板:换掉 URL、换掉解析器、换掉落盘格式,就是另一个采集任务。
常见坑点
1. 知识点记住了,顺序记反了
❌ 先想怎么写并发和解析,最后才想起来看 robots.txt。 ✅ 顺序永远是:合规 → 请求 → 解析 → 调度 → 存储。
2. 把「绕过反爬」当必修课
❌ 认为学会验证码识别、指纹伪装才算入门。 ✅ 遇到需要登录/付费/robots 禁止的场景,正确结论是不抓。
3.convert_charrefs的默认值记错
❌ 以为默认是False,还自己手工处理字符引用。 ✅ 从 3.5 起默认True,大部分实体字符已经帮你转好了。
4. 把attrs当字典
❌attrs["class"]直接下标。 ✅dict(attrs).get("class", "")。
5. 忘了close()却怪解析器漏数据
❌ 只feed()就取值。 ✅feed()后补close()。
6. 只设「有没有下一页」这一个终止条件
❌ 跟着 next 链接走到天荒地老。 ✅ 再加一个页数/条数硬上限。
7. CSV 没写newline=""
❌open("out.csv", "w", encoding="utf-8")。 ✅open("out.csv", "w", encoding="utf-8", newline="")。
8. 把 GIL 说反
❌ 「多线程能让 CPU 密集的爬虫变快」。 ✅ 多线程对 I/O 密集有效(等 I/O 时释放 GIL),CPU 密集要用多进程。
总结
| 你记住的这句 | 它对应哪一层 |
|---|
| 响应体是字节,要按 charset 解码 | 请求 |
attrs是列表,feed后要close | 解析 |
| 队列 + 去重 + 硬上限 | 调度 |
显式编码,CSV 加newline="" | 存储 |
先can_fetch,不允许就不抓 | 合规 |
| 多线程治 I/O,多进程治 CPU | 并发 |
爬虫的知识点不多,难的是把它们组织成一张随时能查的地图。请求、解析、调度、存储四层,加上贯穿其中的合规前提,就是全部。真正拉开差距的不是 API 记得多,而是边界处理得好不好:异常怎么分类、循环怎么终止、字段取不到怎么办、什么时候该停手。最后再强调一次没有例外的规则——动手之前,先读目标站的 robots.txt。