python爬虫基本知识
前言
「爬虫基本知识」这个词很容易被理解偏。有人把它理解成一堆 API 的集合,背下来就能写;也有人把它理解成一套「反反爬技巧」。这两者都不对。真正的基本知识是网络与文本的常识:URL 是怎么组成的、HTTP 请求和响应各自长什么样、为什么会有乱码、Cookie 是用来干什么的、robots.txt 是给谁看的。这些常识不打牢,API 背得再多也只是在试错。
还要纠正一个流传很广的说法:「爬虫就是让程序伪装成浏览器」。伪装浏览器 UA 只是绕过服务器「拒绝非浏览器访问」判断的一种做法,而这件事本身未必合规。更正当的思路是:如实标明自己的身份(比如带联系方式的 UA),遵守目标站点的 robots.txt 与使用条款,控制请求频率。本文在这一前提下讲基础。
本文只使用 Python 标准库,所有代码在 Python 3 下可直接运行。Python 2.7 已于 2020 年 1 月 1 日停止维护,老教程里的print语句、dict.iteritems()、xrange都要按 Python 3 的写法重写。
一、URL 不只是「一串网址」
URL(Uniform Resource Locator,统一资源定位符)由若干段组成,urllib.parse.urlsplit会把它们拆开:
| 组成部分 | 英文名 | 例子 | 常见错误 |
|---|
| 协议 | scheme | https | 写成http导致明文或 301 跳转 |
| 主机 | netloc | shop.example.com | 漏掉端口 |
| 路径 | path | /search | 与域名之间少了斜杠 |
| 查询串 | query | q=python&page=2 | 参数没做百分号编码 |
| 片段 | fragment | top | 误以为片段会发给服务器 |
最后一条要特别记住:片段(fragment,也就是#后面那段)不会发送给服务器。它是给浏览器自己定位用的。所以一个带#的地址,服务器看到的和你不带#时是一样的。
查询串里的中文、空格、&、=都必须经过百分号编码(percent-encoding,俗称 URL 编码)才能安全地放进 URL。手工用+或&拼字符串是最常见的错误来源;标准库的urlencode就是干这个的。
二、请求与响应的常识
HTTP 的请求方法里,采集最常用的是 GET,提交表单用 POST。POST 的请求体要放在data参数里,且必须是字节串,内容是application/x-www-form-urlencoded格式——urlencode生成的是字符串,要再.encode()一次。
响应里最需要看懂的是状态码:
| 状态码 | 含义 | 采集时该怎么处理 |
|---|
| 200 | 成功 | 正常解析 |
| 301 / 302 | 永久 / 临时跳转 | 交给库自动跟随,注意最终 URL 变了 |
| 403 | 拒绝访问 | 停止,检查自己的请求是否越界 |
| 404 | 不存在 | 放弃这条,不要重试 |
| 429 | 请求过多 | 退避后重试,并降低频率 |
| 5xx | 服务端错误 | 值得退避重试,但不是你的问题 |
urllib.request.urlopen遇到 4xx / 5xx 会抛urllib.error.HTTPError(它是URLError的子类,同时也是类文件对象)。所以「拿到 403」在代码里表现为异常,而不是一个返回值为None的响应——这点常被忽略。
请求头里常打交道的几个:User-Agent(身份)、Referer(来源页)、Cookie(会话)、Accept-Language(语言偏好)。构造Request时用headers={"键": "值"},等价于逐个调用add_header(key, val)。
三、编码:乱码只有一个根源
响应体是字节串,不是字符串。乱码的唯一根源是「用了错误的字符集去解码这段字节」。正确流程是:先看响应头Content-Type里有没有charset=...,有就用它;没有才回退到utf-8。HTTPMessage对象提供了get_content_charset(failobj=None)做这件事,返回字符集名或你给的默认值。
另一个容易踩的点是压缩。urllib 默认不发Accept-Encoding头,所以服务器通常返回未压缩内容;但只要你手动加了Accept-Encoding: gzip,就必须自己判断Content-Encoding是不是gzip,是则用gzip.decompress()解压,否则拿到的字节串是压缩过的二进制,解码必然乱码。
写文件时同理:open()不写encoding就会用平台默认编码,Windows 上可能是 GBK,跨平台读回来就乱。读写文本文件一律显式写encoding="utf-8"。
四、抓到的可能是三种东西
不是所有页面内容都藏在 HTML 里:
- HTML:静态渲染的页面,用解析器抽取。标准库有事件式的
html.parser.HTMLParser;选择器式的方案多在第三方库,具体 API 以各库官方文档为准。 - JSON:很多页面通过接口异步取数据,返回的是 JSON 文本。用
json.loads(text)解析成字典/列表即可,前提是这个接口是公开允许调用的。不要为了拿到数据去逆向破解签名或绕过风控。 - CSV / 纯文本:下载链接常常直接给一个文本文件,
urllib.request.urlretrieve(url, filename)可以把远程文件直接落盘,它返回(本地文件名, 响应头)二元组。
五、Cookie 与会话
HTTP 是无状态的,服务器靠 Cookie 记住「你是谁」。要跨请求保持同一个会话,就得有一个 Cookie 容器加一个装了 Cookie 处理器的 opener。http.cookiejar.CookieJar()是内存容器,urllib.request.HTTPCookieProcessor(jar)负责在收发请求时自动读写它,两者通过build_opener()组装成 opener。
需要说明:会话保持本身是中性的技术,但用它去「维持登录态、绕过登录墙」不是本文的范围,本文只讲它在正常访问公开页面时的作用(比如承接站点自动下发的会话标识)。
实战:拆解 URL 与维持会话
先看 URL 拆解与参数编码:
# 适用于 Python 3.8+
from urllib.parse import parse_qs, quote, urlencode, urlsplit
url = "https://shop.example.com/search?q=python 教程&page=2#top"
parts = urlsplit(url)
print(parts.scheme) # https
print(parts.netloc) # shop.example.com
print(parts.path) # /search
print(parts.query) # q=python 教程&page=2
print(parts.fragment) # top
print(parse_qs(parts.query))
# {'q': ['python 教程'], 'page': ['2']}
print(urlencode({"q": "python 教程", "page": 2}))
# q=python+%E6%95%99%E7%A8%8B&page=2
print(quote("a b/c"))
# a%20b/c —— 默认 safe='/',斜杠不被编码再看不带第三方库的会话维持:
# 适用于 Python 3.8+
import http.cookiejar
import urllib.request
UA = "cookie-session-bot/0.1 (contact: basics@example.net)"
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with opener.open(req, timeout=15) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
return resp.read().decode(charset, errors="replace")
html = get("https://quotes.toscrape.com/")
print("页面长度:", len(html))
print("本次会话保存的 cookie 数:", len(jar))
for cookie in jar:
print(cookie.name, "->", cookie.domain)第二段代码里,opener.open()的行为和urlopen()一致,区别是它会自动通过HTTPCookieProcessor处理 Set-Cookie 与后续请求的 Cookie 头。CookieJar可以迭代,每一项是一个http.cookiejar.Cookie,有name、value、domain、path等属性。
常见坑点
1. 查询参数手工拼字符串
❌url = base + "?q=" + keyword—— 关键词含空格、&、中文时 URL 结构就被破坏。 ✅urlencode({"q": keyword}),需要拼到已有查询串上时先parse_qs再合并。
2.data传了字符串而不是字节串
❌urlopen(req, data="a=1")—— POST 的data必须是字节串。 ✅urlopen(req, data=urlencode({"a": 1}).encode("utf-8"))。
3. 把 403 当成「没数据」静默跳过
❌ 用except Exception: pass把拒绝访问也吞掉,然后继续按原频率抓。 ✅HTTPError要按code分类处理,403 应当停下来反思,429 应当退避。
4. 直接对resp.read()做字符串操作
❌len(resp.read())之后又说「怎么字符数不对」 —— 字节数不等于字符数。 ✅ 先decode再数;len(b"中文".decode("utf-8"))才是 2 个字符。
5. 忘了urlopen返回的是上下文管理器
❌resp = urlopen(req)之后一直不关闭,连接资源迟迟不释放。 ✅with urlopen(req, timeout=15) as resp:。
6. 手动加了Accept-Encoding: gzip却不解压
❌ 加了压缩头就直接decode,得到乱码。 ✅ 判断Content-Encoding == "gzip"后gzip.decompress()。
7. 用 Python 2 的写法处理编码
❌text = resp.read().decode("utf-8").encode("gbk")再print text—— Python 2 的print是语句,Python 3 里必须print(...);unicode/basestring类型也已统一为str。 ✅ 全程用str表示文本、bytes表示字节,并在边界处显式编解码。
总结
| 基本知识 | 一句话 | 常见误区 |
|---|
| URL 结构 | 片段不发给服务器 | 以为#后面会影响抓取结果 |
| 百分号编码 | 中文/空格/符号必须编码 | 手工拼查询串 |
| 状态码 | 4xx/5xx 在 urllib 里是异常 | 以为会返回一个空响应 |
| 编码 | 响应体是字节,要按 charset 解码 | 一律decode("utf-8") |
| 压缩 | 自己加了Accept-Encoding就得自己解压 | 解压步骤漏掉 |
| 数据格式 | HTML / JSON / CSV 三种常见形态 | 以为数据一定在 HTML 里 |
| 会话 | CookieJar + HTTPCookieProcessor | 手工拼Cookie头 |
| 合规 | 先看 robots.txt 与条款 | 把「伪装浏览器」当基本功 |
这些基础知识点看起来零散,其实都围绕一句话:HTTP 传的是字节,服务端认的是头信息,你要做的是把字节按正确的方式还原成数据。把这句想通,乱码、403、参数丢失这些「玄学问题」大多能找到明确的原因。动手采集前,先花五分钟读目标站的 robots.txt。