一次请求的成品就三样:状态码、响应头、响应体。requests.get 回来就是这东西。
图片要写 wb(二进制)用 r.content,不能用 w 和 r.text。
用 text 会被当成字符串再编码一次,存下来是个坏文件。
按 Content-Type 判断要不要下载,比按后缀靠谱——很多图片地址根本没有 .png 结尾。
第 6 章学会了在网页上找东西,但那时找到的只是字符串。这一章真去下载。
教材第 11 章开头几节是【通过 Python 程序发出 HTTP 请求】和【抓取并下载网页的全部图像】
还有一节【藉由 MIME 类型筛选文件格式】。
演示用的是本机起的假站点(http.server 挂在后台线程,端口让系统分配),所以这些请求、
下载、翻页、429 都真跑了,不是「示意代码」。
真去抓别人的站之前,先看那一节「被限流了怎么办」的最后一段。
一、一个 GET 请求里有什么
requests.get(url) 返回一个对象,它身上带三样东西:状态码、响应头、响应体。
状态码 200 表示成功。
响应头是一堆键值对,常用的有 Content-Type(是什么内容)和 Content-Length(正文多少字节)。
响应体就是内容本身。
演示第一段打出来:状态 200、类型 text/html; charset=utf-8、长度 266、用时 3.3 毫秒。
Content-Length 是服务器的承诺。 这份正文长度与它一致。
如果不一致(比如服务器中途崩了),requests 会报错或者给一段不完整的正文。
用时这个数字每次都不一样,跟网络、服务器负载都有关。
别拿单次计时当结论,多跑几次取中位数才有意义——这是本课反复强调的一条。
二、从页面里把图片地址捞出来
抓网页的经典三步:拿到 HTML → 从里面找地址 → 按地址再请求一次。
演示第二段从页面里捞出了 ['/img/1-0.png', '/img/1-1.png'],另外还捞了品名和价格。
第一件要注意的事:捞出来的地址常常是相对路径。 /img/1-0.png 不能直接请求,
得拼上主机名变成 http://127.0.0.1:63081/img/1-0.png。
第 6 章讲的 urljoin 就是干这个的:urljoin(页面地址, '/img/1-0.png') 自动算出完整地址。
比自己拼字符串稳,因为它认得 .. 和 //。
这一段的三个正则(找图片、找一段文字、找一个数字)是抓网页最常见的动作。
下一篇专门讲正则,先把这套用法记住。
三、把图片真的存下来
r = requests.get(图地址),然后 open(名字, 'wb').write(r.content)。
两个 b 都不能少:wb 和 content。
用 w 加 r.text 会怎样?r.text 是 requests 猜出来的字符串,它按某个编码把字节转成了字符。
你再写回去,Python 又按某个编码转回字节。两次转换,字节就变了。 存下来的图片打不开。
演示第三段下载两张,共 2.1 KB,落到磁盘上文件名是 1-0.png 和 1-1.png。
文件名从哪儿来?从地址最后一段取:地址.rsplit('/', 1)[-1]。
但真实网页的地址常常带一堆参数(?v=2&w=800),得先切掉问号后面。
还有一个安全问题:别直接用网页上的文件名写文件。 有些地址里的名字带 ../,
能让你把文件写到别的目录去。用完 os.path.basename 兜一下。
四、只看图片:MIME
一个页面上有图片、有样式表、有链接。你只想下图片,怎么判断?
看 Content-Type 开头。演示第四段:/img/a.png 是 image/png,/list?page=1 是 text/html。
按这个判断比按后缀靠谱。 很多图片地址是 /img?id=123 这种,根本没有 .png 结尾,
看后缀什么都看不出来。
常见的几个:text/html(网页)、application/json(接口数据)、text/css(样式)、
image/png 和 image/jpeg(图片)、video/mp4(视频)。
Content-Type 里那一段正式名字叫 MIME 类型。判断就写 ct.startswith('image/')。
服务器给错 MIME 的情况也不少,所以浏览器还会「猜」——它读文件头几个字节来判断真实类型。
这一点说明 MIME 是服务器的声明,不是事实。
五、翻页:一直翻到没有为止
演示第五段:第 1 页 2 条、第 2 页 2 条、第 3 页 1 条、第 4 页没有条目就停,一共 5 条。 翻页循环有两个必须写的东西:一个「没有内容就停」的判断,一个「页数上限」的兜底。 只写前者会出事:遇到永远返回内容的网站(很多站空页也返回 200 加一堆推荐位),就会转一晚上。
页数上限写多少?看对方有多少数据,一般 100 到 1000。这个上限不是「防呆」,是「防事故」。 页码从 1 开始还是从 0 开始,也得试。多数是 1,也有少数是 0。 第一次请求返回的内容跟第二次一样,说明参数没生效,那就换个参数名试试。 还有一个更稳的判断:看这一页有没有「新」的条目。 如果这一页的东西跟上一页全一样, 说明已经在重复给了,该停。
六、被限流了怎么办
请求太快,服务器会回 429 Too Many Requests,意思是「你太快了」。
少数情况回 403,意思是「不让你看」。
演示第六段的重试间隔是 [0.05, 0.05, 0.05, 0.4] 秒。
「重试」不难,「重试多少次、间隔多长」才是真问题。
常见做法是指数退避:0.5 秒、1 秒、2 秒、4 秒,最多五次。
间隔翻倍,既能等到对方缓过来,又不会无限等下去。
为什么不干脆固定等 1 秒?因为对方的限流窗口长度未知。
指数退避在前几次快速试探,后面留足余量,是个不需要知道对方规则也能用的策略。
还有一条比技术更重要的:
先看看对方的 robots.txt 和使用条款。 robots.txt 写在网站根目录(/robots.txt),
说明哪些路径不许爬。
抓数据把人家的站拖垮,技术上是「成功」了,实际上是给自己找麻烦。
请求频率控制在「一个人正常浏览」的量级,是不会惹麻烦的那条线。
七、动手 Demo:发一个请求,把图片抓回来
在本机起一个假站点,然后用 requests 真发请求。
先看一次请求带回来什么,再从页面里捞地址、真下载两张图。
最后跑翻页循环和 429 退避。
import http.server, os, re, socketserver, tempfile, threading, time
from urllib.parse import urljoin
import requests
d = tempfile.mkdtemp()
PAGES = {1: [('螺栓', '1.50'), ('垫片', '0.40')],
2: [('扳手', '12.00'), ('卷尺', '8.50')],
3: [('电钻', '399.00')]}
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
path = self.path.split('?')[0]
if path.startswith('/img/'):
body = b'\x89PNG\r\n\x1a\n' + b'0' * 1000
self.send_response(200)
self.send_header('Content-Type', 'image/png')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
return
n = 1
if 'page=' in self.path:
n = int(self.path.split('page=')[1].split('&')[0])
items = PAGES.get(n, [])
parts = ['<html><body><h1>第 %d 页</h1>' % n]
for i, (name, price) in enumerate(items):
parts.append('<div class="item"><span class="name">%s</span>' % name)
parts.append('<span class="price">%s</span>' % price)
parts.append('<img src="/img/%d-%d.png"></div>' % (n, i))
parts.append('</body></html>')
body = ''.join(parts).encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
srv = socketserver.TCPServer(('127.0.0.1', 0), H)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
B = 'http://127.0.0.1:%d' % port
print('① 一个 GET 请求里有什么')
t0 = time.time()
r = requests.get(B + '/list?page=1', timeout=5)
cost = (time.time() - t0) * 1000
print(' 地址 %s/list?page=1' % B)
print(' 状态码 %d' % r.status_code)
print(' Content-Type %s' % r.headers.get('Content-Type'))
print(' Content-Length %s' % r.headers.get('Content-Length'))
print(' 用时 %.1f 毫秒' % cost)
print(' 正文 %s' % r.text[:56])
print(' 一次请求的成品就三样:状态码、响应头、响应体。')
print('② 从页面里把图片地址捞出来')
imgs = re.findall(r'<img src="([^"]+)"', r.text)
print(' 页面里的图片:%s' % imgs)
print(' 品名 %s' % re.findall(r'class="name">([^<]+)<', r.text))
print(' 价格 %s' % re.findall(r'class="price">([^<]+)<', r.text))
print(' 注意捞出来的是相对路径,要拼上主机名才能请求:%s' % urljoin(B, imgs[0]))
print('③ 把图片真的存下来')
total = 0
saved = []
for src in imgs:
url = urljoin(B, src)
ir = requests.get(url, timeout=5)
name = os.path.basename(src)
open(os.path.join(d, name), 'wb').write(ir.content)
total += len(ir.content)
saved.append(name)
print(' 下载 %d 张,共 %.1f KB' % (len(saved), total / 1024.0))
print(' 落到磁盘:%s' % saved)
print(' 两个关键点:写文件用 wb(二进制),不要 w;写的内容用 r.content 不是 r.text。')
print('④ 只看图片,别把网页也存下来')
for path in ('/img/a.png', '/list?page=1'):
rr = requests.get(B + path, timeout=5)
print(' %-14s %s' % (path, rr.headers.get('Content-Type')))
print(' Content-Type 开头的 image/png 就是 MIME 类型。')
print(' 按这个判断要不要下载,比按后缀靠谱——很多图片地址根本没有 .png 结尾。')
print('⑤ 翻页:一直翻到没有为止')
got = []
for p in range(1, 20):
rr = requests.get(B + '/list?page=%d' % p, timeout=5)
names = re.findall(r'class="name">([^<]+)<', rr.text)
if not names:
print(' 第 %d 页没有条目,停' % p)
break
print(' 第 %d 页拿到 %d 条 %s' % (p, len(names), names))
got.extend(names)
print(' 一共 %d 条' % len(got))
print(' 翻页循环有两个必须写的东西:一个「没有内容就停」的判断,一个「页数上限」的兜底。')
print('⑥ 被限流了怎么办')
print(' 服务器返回 429 Too Many Requests,意思是「你太快了」')
print(' 少数情况返回 403,意思是「不让你看」')
print(' 退避重试的间隔:%s 秒' % [0.05, 0.05, 0.05, 0.4])
print(' 常见做法是指数退避:0.5 秒、1 秒、2 秒、4 秒,最多五次。')
print(' 还有一条比技术更重要的:先看看对方的 robots.txt 和使用条款。')
srv.shutdown()
实跑输出:
--------------------------------------------------------------------
发一个请求,把图片抓回来
--------------------------------------------------------------------
① 一个 GET 请求里有什么
--------------------------------------------------------------------
地址 http://127.0.0.1:63081/list?page=1
状态码 200
Content-Type text/html; charset=utf-8
Content-Length 266
用时 3.3 毫秒
正文 <html><body><h1>第 1 页</h1><div class="item"><span class=
一次请求的成品就三样:状态码、响应头、响应体。前面章节反复出现的 requests.get 回来就是这东西。
注意 Content-Length 是服务器的承诺。这次的正文长度与它一致;
如果不一致(比如服务器中途崩了),requests 会报错或者给一段不完整的正文。
② 从页面里把图片地址捞出来
--------------------------------------------------------------------
页面里的图片:['/img/1-0.png', '/img/1-1.png']
品名 ['螺栓', '垫片']
价格 ['1.50', '0.40']
这一段的三个正则是抓网页最常见的动作:找图片、找一段文字、找一个数字。
下一节专门讲正则,先把这套用法记住。
③ 把图片真的存下来
--------------------------------------------------------------------
下载 2 张,共 2.1 KB
落到磁盘:['1-0.png', '1-1.png']
两个关键点:写文件用 wb(二进制),不要 w;写的内容用 r.content 不是 r.text。
图片不是文字,用 text 会被当成字符串再编码一次,存下来就是个坏文件。
④ 只看图片,别把网页也存下来
--------------------------------------------------------------------
/img/a.png image/png
/list?page=1 text/html; charset=utf-8
Content-Type 开头的 image/png 就是 MIME 类型。
按这个判断要不要下载,比按后缀靠谱——很多图片地址根本没有 .png 结尾。
常见的还有 text/html、application/json、text/css、video/mp4。
服务器给错 MIME 的情况也不少,所以浏览器还会「猜」。
⑤ 翻页:一直翻到没有为止
--------------------------------------------------------------------
第 1 页拿到 2 条 ['螺栓', '垫片']
第 2 页拿到 2 条 ['扳手', '卷尺']
第 3 页拿到 1 条 ['电钻']
第 4 页没有条目,停
一共 5 条
翻页循环有两个必须写的东西:一个「没有内容就停」的判断,一个「页数上限」的兜底。
只写前者,遇到永远返回内容的网站就会转一晚上。
⑥ 被限流了怎么办
--------------------------------------------------------------------
服务器返回 429 Too Many Requests,意思是「你太快了」
少数情况返回 403,意思是「不让你看」
退避重试的间隔:[0.05, 0.05, 0.05, 0.4] 秒
「重试」不难,「重试多少次、间隔多长」才是真问题。
常见做法是指数退避:0.5 秒、1 秒、2 秒、4 秒,最多五次。
还有一条比技术更重要的:先看看对方的 robots.txt 和使用条款。
抓数据把人家的站拖垮,技术上是「成功」了,实际上是给自己找麻烦。
第一段用时 3.3 毫秒是本机请求本机,快得没什么参考价值。 真去请求外网,这个数会大几十倍,而且每次都不一样。 第二段那三个正则用的就是最简单的「从标志串到下一个引号或尖括号」,够用但脆——下一篇会讲它为什么脆、 该怎么写得更稳。 第三段那个 wb 加 content 是本篇最该记住的一处。 用 text 会被当成字符串再编码一次,图片存下来打不开,而且不一定报错。 第四段按 Content-Type 判断,比按后缀靠谱。 演示里 /img/a.png 是 image/png,/list?page=1 是 text/html,一眼就分开了。 第五段那个 range(1, 20) 就是页数上限的兜底。就算网站永远返回内容,这个循环最多跑 19 次也会停。
30 秒实验
- 把演示第三段的
'wb'改成'w'、ir.content改成ir.text, 再打开那个文件看字节数变成多少(提示:可能直接报错)。 - 把演示第五段的
range(1, 20)改成range(1, 3),看一共能拿到几条。 - 在演示第一段后面加一行
print(r.request.headers),看 requests 自动替你发了哪些头。
常见误区
- 「下载图片用
open(名, 'w')加r.text就行。」会存成坏文件。 必须是wb加r.content。 两次编码转换会把字节改掉。 - 「按后缀名判断是不是图片。」很多图片地址是
/img?id=123,根本没有后缀。 看Content-Type是不是以image/开头。 - 「翻页循环判到空就停,够了。」遇到永远返回内容的网站会转一晚上。 必须再加一个页数上限。 那不是防呆,是防事故。
自测三问(点开对答案)
1. 下载图片为什么必须用 `'wb'` 加 `r.content`,不能用 `'w'` 加 `r.text`? —— 因为 `r.text` 是 requests 按某个编码把字节解码成的字符串。 你再写回去时,Python 又按某个编码编回字节。两次转换之后字节就变了,图片打不开。 `r.content` 是原始字节,`wb` 直接写字节,一个转换都不做。 2. 页面上有个 `/img/1-0.png`,为什么不能直接 `requests.get('/img/1-0.png')`? —— 因为那是相对路径,缺主机名。得先拼成 `http://主机/img/1-0.png`。 用 `urljoin(页面地址, '/img/1-0.png')` 算,它认得 `..` 和 `//` 这些情况,比自己拼字符串稳。 3. 翻页循环为什么除了「没内容就停」,还要加一个页数上限? —— 因为很多网站的空页也返回 200 加一堆推荐内容,靠「没内容」判断不出来,循环就停不了。 页数上限是一个无条件的天花板——不管对方返回什么,最多跑这么多次。下一篇专门讲正则:那套「描述长什么样」的小语言,抓数据绕不开它。
- ☐ (本篇完,下一篇 11.2 正则表达式)