前言
「爬虫技术」听起来像一个独立学科,拆开看其实就是三件事:发出 HTTP 请求拿到响应、从响应里抽出结构化数据、把数据存下来并决定下一次抓哪里。Python 之所以成了爬虫的主流语言,不是语言本身有什么魔法,而是标准库把这三件事的零件都备齐了:urllib.request负责发请求,urllib.parse负责拼 URL 与编码参数,html.parser负责解析 HTML,json与csv负责落盘,urllib.robotparser甚至帮你读 robots.txt。第三方库(requests、BeautifulSoup、lxml、Scrapy、aiohttp 等)只是把这些零件包装得更顺手。
最常见的误解有两个。第一个是「爬虫就是写个for循环,只要不被封 IP 就行」——把工程问题当成了对抗问题。实际上大多数抓取失败的原因不是被封,而是编码没处理对、翻页逻辑写错、把None当成字符串拼接。第二个是「数据在网页上显示出来,就说明可以抓」——法律与服务条款不这么认为。动手前先看目标站的 robots.txt 与使用条款,遵守它,给对方留出带宽,这是本文反复强调的前提。
本文讲的是通用技术骨架,示例只用 Python 标准库,让你在没装任何第三方包的环境里也能把流程跑通。所有代码以 Python 3 为基准:Python 2.7 已于 2020 年 1 月 1 日停止维护,老教程里的print语句、dict.iteritems()、xrange现在都跑不起来,本文一律用 Python 3 写法。
一、请求层:一次 HTTP GET 到底发生了什么
用urllib.request发一个请求,最短的写法是urllib.request.urlopen(url)。它的签名是urlopen(url, data=None, [timeout, ]*, context=None):url既可以是字符串,也可以是一个Request对象;data不为None时请求方法变为 POST,且必须是已经编码好的字节串;timeout是位置参数,单位秒。
只要你不是只抓一次给自己看,就应该用Request对象,因为要设置 User-Agent(用户代理)。Request的签名是Request(url, data=None, headers={}, origin_req_host=None, unverifiable=False, method=None),headers里的每一项都等价于调用一次add_header(key, val)。设置 UA 的目的不是伪装成浏览器去骗过服务器,而是让服务器能从日志里认出你、找得到你、并决定是否给你开白名单。urllib 若不设置,默认会把自己标成Python-urllib/3.x这样的字符串。
拿到响应后,几个高频属性要认清:resp.status是整数状态码,resp.headers是http.client.HTTPMessage对象(可以像字典一样.get("Content-Type")取头),resp.read()返回字节串而不是字符串。字节串要解码成str才能做文本处理,而解码用的字符集应该从响应头里的Content-Type取,而不是想当然写utf-8。
二、编码层:乱码的根源
「爬下来的中文是乱码」几乎总是同一个原因:把字节串按错误的字符集解码。正确做法是从Content-Type头里读 charset,读不到再回退到utf-8。HTTPMessage继承自email.message.Message,提供了get_content_charset(failobj=None)方法,专门干这件事。
另一个容易忽略的点是响应可能是压缩的。urllib默认不会发送Accept-Encoding头,所以服务器通常不压缩;但如果你手动加了Accept-Encoding: gzip,就必须自己用gzip.decompress()解压resp.read()的结果,否则拿到的是二进制乱码。官方文档给出的就是这个模式:先看Content-Encoding是不是gzip,是则解压。
三、解析层:标准库能做到什么程度
html.parser.HTMLParser是一个基于事件的解析器:你继承它,重写handle_starttag(tag, attrs)、handle_endtag(tag)、handle_data(data)等方法,然后调用feed(text)把文档喂进去,最后close()收尾。attrs是「(名字, 值) 二元组」组成的列表,注意是列表不是字典,同一个属性理论上可以出现多次。
它的定位要摆正:HTMLParser只做词法层面的切分,不做容错修复,官方文档明确说它不检查结束标签是否与开始标签匹配。所以拿它写「某类新闻页正文抽取」可行,拿它当通用浏览器级 DOM 解析器会失望。要更方便的选择器语法,才轮到第三方库出场——但具体 API 请以各库官方文档为准,本文不给出记不准的调用。
| 环节 | 标准库模块 | 职责 |
|---|
| 发请求 | urllib.request | 构造 Request、发出去、拿响应 |
| 拼 URL / 编码参数 | urllib.parse | urlencode、quote、urljoin |
| 读 robots.txt | urllib.robotparser | RobotFileParser判断某 URL 能不能抓 |
| 管理 Cookie | http.cookiejar+HTTPCookieProcessor | 维持会话 |
| 解析 HTML | html.parser | 事件式解析,提取数据 |
| 解压 | gzip | 处理 gzip 压缩的响应体 |
| 落盘 | json、csv | 结构化存储 |
四、合规层:先问能不能抓,再问怎么抓
urllib.robotparser.RobotFileParser(url='')是标准库里的 robots.txt 解析器。用法是:set_url(robots_url)设定地址,read()抓取并解析,然后can_fetch(useragent, url)返回布尔值。它还有crawl_delay(useragent)(返回 Crawl-delay 的秒数或None)和site_maps()等方法。
有一个容易搞错的细节:服务器上根本没有 robots.txt 文件,和服务器拒绝提供 robots.txt,是两回事。标准库内部的read()已经按 RFC 9309 区分了这些情况——4xx(401、403 除外)表示「没有规则」,结果是允许访问一切;401、403 表示「整站禁止」;5xx 表示「服务器出错」,结果是必须假定完全禁止。真正会让read()抛异常的是网络层错误,你的代码遇到它时按保守策略当作「不允许」是稳妥的。此外,无论 robots.txt 怎么写,都应该在两次请求之间留出间隔(time.sleep),并且不要用能瞬间打满对方带宽的并发去压一个小站。
实战:一个纯标准库的最小爬虫
下面这个例子抓的是quotes.toscrape.com——它是专门为练习爬虫搭建的站点,页面结构稳定且明确用于教学,比拿真实商业网站开刀合适得多。
# 适用于 Python 3.8+
import gzip
import time
import urllib.parse
import urllib.request
import urllib.robotparser
from html.parser import HTMLParser
UA = "stdlib-tech-demo/0.1 (contact: tech@example.com)"
DELAY = 1.0 # 两次请求之间的间隔(秒)
class QuoteTextExtractor(HTMLParser):
"""从列表页里抽出每条名言的正文。"""
def __init__(self):
super().__init__()
self._in_quote = False
self._depth = 0 # 卡片内 div 的嵌套深度
self._in_text = False
self._buf = []
self.quotes = []
def handle_starttag(self, tag, attrs):
classes = dict(attrs).get("class", "").split()
if tag == "div":
if not self._in_quote and "quote" in classes:
self._in_quote = True
self._depth = 1
elif self._in_quote:
self._depth += 1
elif tag == "span" and "text" in classes and self._in_quote:
self._in_text = True
self._buf = []
def handle_data(self, data):
if self._in_text:
self._buf.append(data)
def handle_endtag(self, tag):
if tag == "span" and self._in_text:
self._in_text = False
self.quotes.append("".join(self._buf).strip())
elif tag == "div" and self._in_quote:
self._depth -= 1
if self._depth == 0:
self._in_quote = False
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read()
if resp.headers.get("Content-Encoding") == "gzip":
raw = gzip.decompress(raw)
charset = resp.headers.get_content_charset() or "utf-8"
return raw.decode(charset, errors="replace")
def robots_allow(url, user_agent):
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(user_agent, url)
def main():
index = "https://quotes.toscrape.com/"
if not robots_allow(index, UA):
print("robots.txt 不允许抓取,退出")
return
html = fetch(index)
parser = QuoteTextExtractor()
parser.feed(html)
parser.close()
for i, quote in enumerate(parser.quotes[:3], 1):
print(i, quote[:40])
time.sleep(DELAY) # 下一次请求前先等一会儿
if __name__ == "__main__":
main()这段代码把前面四层都串了一遍:先问 robots 能不能抓,再发请求,按 charset 解码,用事件解析器抽数据,最后 sleep 限速。它没有翻页、没有并发、没有存储,因为那些是后续要补的工程细节;这里只要保证骨架正确。
常见坑点
1. 把resp.read()的返回值直接当字符串用
❌html = resp.read(); if "关键词" in html:—— 字节串里不能直接和str比较,要么TypeError,要么判断永远为假。 ✅html = resp.read().decode("utf-8"),或者按Content-Type里的 charset 解码。
2. 忽略Content-Encoding直接 decode
❌ 手动加了Accept-Encoding: gzip,然后对resp.read()直接 decode,得到一屏乱码。 ✅ 先判断resp.headers.get("Content-Encoding") == "gzip",是则gzip.decompress()再解码。
3.HTMLParser.attrs当字典用
❌self.attrs["class"]—— 它其实是「(名字, 值) 元组」的列表,下标是整数,TypeError。 ✅attrs = dict(attrs)之后再.get("class", "");注意一个属性名可能重复出现。
4. 忘了调用feed()之后还要close()
❌ 只parser.feed(html)就取结果 —— 文档末尾若有未闭合的结构,数据还缓冲在内部没吐出来。 ✅parser.feed(html)之后补一句parser.close()。
5.time.sleep写成了sleep
❌sleep(1)——sleep不在内置命名空间里,直接NameError。 ✅import time后写time.sleep(1),或from time import sleep再sleep(1)。
6. 抓不到 robots.txt 就默认「随便抓」
❌except Exception: pass之后继续猛抓目标站。 ✅ 取不到时按保守策略暂停,并在日志里记下来,下次重试成功再继续。
7. 用 Python 2 的写法解码
❌print html、for k, v in d.iteritems()—— Python 2.7 已于 2020 年 1 月 1 日停止维护,print语句、iteritems、xrange在 Python 3 里全部不可用。 ✅print(html)、d.items()、range(...)。
总结
| 你要做的事 | 标准库方案 | 关键点 |
|---|
| 发请求 | urllib.request.urlopen/Request | 设 UA、设 timeout |
| 编码参数 | urllib.parse.urlencode/quote | data必须是 bytes |
| 判断能否抓 | urllib.robotparser.RobotFileParser | 先set_url再read再can_fetch |
| 解析 HTML | html.parser.HTMLParser | 事件式,不修复标签 |
| 处理乱码 | get_content_charset() | 回退utf-8 |
| 限速 | time.sleep(seconds, /) | 两次请求之间留间隔 |
| 落盘 | json/csv | 文本文件记得指定encoding |
爬虫技术的地基就是「请求—解析—存储」这三块,标准库已经把零件给齐,把每一块的边界和错误处理弄清楚,比急着上手第三方框架更划算。最后一句仍然是老话:先读目标站的 robots.txt 与条款,再动手写代码。