前言「爬虫技术」听起来像一个独立学科拆开看其实就是三件事发出 HTTP 请求拿到响应、从响应里抽出结构化数据、把数据存下来并决定下一次抓哪里。Python 之所以成了爬虫的主流语言不是语言本身有什么魔法而是标准库把这三件事的零件都备齐了urllib.request负责发请求urllib.parse负责拼 URL 与编码参数html.parser负责解析 HTMLjson与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, dataNone, [timeout, ]*, contextNone)url既可以是字符串也可以是一个Request对象data不为None时请求方法变为 POST且必须是已经编码好的字节串timeout是位置参数单位秒。只要你不是只抓一次给自己看就应该用Request对象因为要设置 User-Agent用户代理。Request的签名是Request(url, dataNone, headers{}, origin_req_hostNone, unverifiableFalse, methodNone)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(failobjNone)方法专门干这件事。另一个容易忽略的点是响应可能是压缩的。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.parseurlencode、quote、urljoin读 robots.txturllib.robotparserRobotFileParser判断某 URL 能不能抓管理 Cookiehttp.cookiejarHTTPCookieProcessor维持会话解析 HTMLhtml.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 区分了这些情况——4xx401、403 除外表示「没有规则」结果是允许访问一切401、403 表示「整站禁止」5xx 表示「服务器出错」结果是必须假定完全禁止。真正会让read()抛异常的是网络层错误你的代码遇到它时按保守策略当作「不允许」是稳妥的。此外无论 robots.txt 怎么写都应该在两次请求之间留出间隔time.sleep并且不要用能瞬间打满对方带宽的并发去压一个小站。实战一个纯标准库的最小爬虫下面这个例子抓的是quotes.toscrape.com——它是专门为练习爬虫搭建的站点页面结构稳定且明确用于教学比拿真实商业网站开刀合适得多。# 适用于 Python 3.8import gzipimport timeimport urllib.parseimport urllib.requestimport urllib.robotparserfrom html.parser import HTMLParserUA stdlib-tech-demo/0.1 (contact: techexample.com)DELAY 1.0 # 两次请求之间的间隔秒class QuoteTextExtractor(HTMLParser):从列表页里抽出每条名言的正文。def __init__(self):super().__init__()self._in_quote Falseself._depth 0 # 卡片内 div 的嵌套深度self._in_text Falseself._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 Trueself._depth 1elif self._in_quote:self._depth 1elif tag span and text in classes and self._in_quote:self._in_text Trueself._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 Falseself.quotes.append(.join(self._buf).strip())elif tag div and self._in_quote:self._depth - 1if self._depth 0:self._in_quote Falsedef fetch(url):req urllib.request.Request(url, headers{User-Agent: UA})with urllib.request.urlopen(req, timeout15) as resp:raw resp.read()if resp.headers.get(Content-Encoding) gzip:raw gzip.decompress(raw)charset resp.headers.get_content_charset() or utf-8return raw.decode(charset, errorsreplace)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 不允许抓取退出)returnhtml 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/quotedata必须是 bytes判断能否抓urllib.robotparser.RobotFileParser先set_url再read再can_fetch解析 HTMLhtml.parser.HTMLParser事件式不修复标签处理乱码get_content_charset()回退utf-8限速time.sleep(seconds, /)两次请求之间留间隔落盘json/csv文本文件记得指定encoding爬虫技术的地基就是「请求—解析—存储」这三块标准库已经把零件给齐把每一块的边界和错误处理弄清楚比急着上手第三方框架更划算。最后一句仍然是老话先读目标站的 robots.txt 与条款再动手写代码。