🐍 第11章 多线程下载、正则表达式与定时排程
📄 一句话概括

一次请求的成品就三样:状态码、响应头、响应体。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 秒实验

  1. 把演示第三段的 'wb' 改成 'w'、ir.content 改成 ir.text, 再打开那个文件看字节数变成多少(提示:可能直接报错)。
  2. 把演示第五段的 range(1, 20) 改成 range(1, 3),看一共能拿到几条。
  3. 在演示第一段后面加一行 print(r.request.headers),看 requests 自动替你发了哪些头。

常见误区

  1. 「下载图片用 open(名, 'w') 加 r.text 就行。」会存成坏文件。 必须是 wb 加 r.content。 两次编码转换会把字节改掉。
  2. 「按后缀名判断是不是图片。」很多图片地址是 /img?id=123,根本没有后缀。 看 Content-Type 是不是以 image/ 开头。
  3. 「翻页循环判到空就停,够了。」遇到永远返回内容的网站会转一晚上。 必须再加一个页数上限。 那不是防呆,是防事故。
自测三问(点开对答案) 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 加一堆推荐内容,靠「没内容」判断不出来,循环就停不了。 页数上限是一个无条件的天花板——不管对方返回什么,最多跑这么多次。

下一篇专门讲正则:那套「描述长什么样」的小语言,抓数据绕不开它。

相关笔记