Python 环境加载中…

动态页面与并发

3 道练习题·预计 55 分钟·做对一题解锁下一段

完整通读本章 →

教学 01 / 08· 已读

前言

各位童鞋,这是爬虫课的最后一章了。前面学的 requests 加 BeautifulSoup,已经能对付大多数网站了。可总有一些情况,它们对付不了。这一章,就来看其中的两种。

第一种:页面上的数据,要靠 JavaScript 才能显示出来,网页背后又找不到好用的接口。这时候,requests 只能拿到一个空架子。怎么办呢?那就请一个真正的浏览器来打开网页,等 JavaScript 跑完了,再取内容。这要用到 Playwright 。

第二种:要抓的页面很多,一页一页地等,实在太慢了。那就一次发出好几个请求,同时等它们回来,这叫并发。基础课第 22 章讲过 asyncio,这一章就配上 httpx ,把它用起来。

这两样都是「大杀器」,可用起来是有代价的:浏览器又慢又占资源;并发一不小心,就成了给网站施压。所以这一章花在「什么时候该用、怎么用得有分寸」上的篇幅,不会比讲用法的少。

学完这一章,这门爬虫课就结束了。最后还会说说,想继续深入的话,再往后可以学些什么。

学这一章之前,需要先学完 爬虫课第 11 章 。基础课第 22 章的 async / await 学过更好,没学过也没关系,这一章会先把用到的部分讲够。

目录

这一章会学到:

  • 一、requests 拿不到的页面——数据在 JavaScript 里
  • 二、让真正的浏览器来打开——Playwright
  • 三、一次抓好几页——asyncio 加 httpx
  • 四、再往后学什么——Scrapy 和更大的爬虫
  • 五、常见坑
  • 六、自己查一次文档——Semaphore 是怎么限制并发的
  • 七、小结与回看

学完这一章,你能在需要的时候用浏览器自动化抓动态页面,用有节制的并发加快抓取,也知道下一步该往哪儿学。

教学 02 / 08· 已读

一、requests 拿不到的页面

先看第一种情况。quotes.toscrape.com 有一个专门的练习页 /js/ 。在浏览器里看,它和首页一样,有 10 条名言;可它的内容,是 JavaScript 生成的。用 requests 取一下,看看会怎样:

Python· 在本地运行
import requests
from bs4 import BeautifulSoup

html = requests.get('https://quotes.toscrape.com/js/').text
soup = BeautifulSoup(html, 'html.parser')
print(len(soup.select('div.quote')))
print('var data' in html)

在自己电脑上运行,输出是:

文本
0
True

第一行是 0,一条名言都没找到。可第二行是 True ,说明网页里确实有 var data 这几个字。这是怎么回事呢?在浏览器里「查看网页源代码」,搜一下就能看到:名言的数据,写在一段 JavaScript 里。网页打开以后,再由 JavaScript 把它们一条条画到页面上。而 requests 拿到的,只是网站最初发回来的那份 HTML,里面还没有画好的名言。

遇到这种页面,该怎么办呢?按这个顺序想办法:

  1. 先找接口:打开「网络」面板,看看数据是不是从某个接口来的。有接口就用第 7 章的办法,最省事。
  2. 再看源代码:数据要是像这样直接写在 <script> 里,有时可以把那一段文字切出来,当 JSON 解析。不过 JavaScript 不一定是标准的 JSON,切的时候很容易出错,网站一改就失效。
  3. 最后才用浏览器:前两条都走不通,就让一个真正的浏览器去打开网页,等 JavaScript 跑完,再取内容。

第三条用浏览器,是这一章新学的本事。具体怎么做,下一节就来讲。

教学 03 / 08· 已读

二、让真正的浏览器来打开

上一节说到,实在没办法的时候,就让一个真正的浏览器去打开网页。可浏览器是给人用的,程序怎么去操作它呢?这就要靠 Playwright 了。它能用代码控制浏览器:打开网页、等元素出现、点按钮、填表单、取内容,就像有一个看不见的人,在替你操作浏览器。

用之前,先安装。它除了 Python 包,还要下载浏览器本身:

Bash
python -m pip install playwright
python -m playwright install chromium

第二条命令会下载一个 Chromium 浏览器,有一百多 MB,要等一会儿。

装好以后,就来打开上一节那个 /js/ 页面。等名言出现了,再把网页交给 BeautifulSoup:

Python· 在本地运行
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto('https://quotes.toscrape.com/js/')
    page.wait_for_selector('div.quote')
    html = page.content()
    browser.close()

soup = BeautifulSoup(html, 'html.parser')
quotes = soup.select('div.quote')
print(len(quotes))
print(quotes[0].select_one('.author').get_text())

在自己电脑上运行,输出是:

文本
10
Albert Einstein

这回,10 条名言都取到了。这段代码的前半段是新东西,咱们一行一行地看:

  • sync_playwright() 启动 Playwright,p.chromium.launch() 打开一个 Chromium 浏览器。默认是无头模式,也就是浏览器在后台运行,不弹出窗口。想看着它操作,就写成 launch(headless=False) 。
  • page.goto(网址) 打开网页。page.wait_for_selector('div.quote') 会一直等,等到页面上出现了名言,也就是 JavaScript 跑完了,才往下走。
  • page.content() 取出此时的 HTML,也就是开发者工具「元素」面板里看到的那一份,里面已经有名言了。
  • 后面的部分,就是熟悉的 BeautifulSoup 了。

不过,要记住它的代价:启动一个浏览器要好几秒,还要占几百 MB 内存,比 requests 慢得多,也重得多。所以能用接口、能用 requests 的,就别用浏览器。

其实 Playwright 的本事,远不止抓数据,它还能点击、输入、截图。它的 官方文档 是英文的,写得很详细,需要的时候再去学。

教学 04 / 08

三、一次抓好几页

动态页面的问题解决了,再来看第二种情况:页面太多,抓得太慢。

前面的爬虫,都是一页一页抓的:发出请求,等它回来,再发下一个。可是等待的时候,程序其实什么都没干,时间就这么白白过去了。

能不能不干等呢?这就是并发:同时发出好几个请求,一起等它们回来。基础课第 22 章讲的 asyncio,就是干这个的。不过 requests 不支持 asyncio,所以要换成一个用法和它很像的库:httpx 。先装上它:

Bash
python -m pip install httpx

并发到底能快多少?来比较一下:抓 quotes.toscrape.com 的前 5 页,一种是一页一页地抓,另一种是同时最多抓 2 页。两种都在每个请求之后停 0.5 秒:

Python· 在本地运行
import asyncio
import time

import httpx

URLS = [f'https://quotes.toscrape.com/page/{n}/' for n in range(1, 6)]


def one_by_one():
    codes = []
    with httpx.Client(timeout=10) as client:
        for url in URLS:
            codes.append(client.get(url).status_code)
            time.sleep(0.5)
    return codes


async def together():
    limit = asyncio.Semaphore(2)

    async def get(client, url):
        async with limit:
            response = await client.get(url)
            await asyncio.sleep(0.5)
            return response.status_code

    async with httpx.AsyncClient(timeout=10) as client:
        return await asyncio.gather(*(get(client, url) for url in URLS))


start = time.monotonic()
print(one_by_one(), f'{time.monotonic() - start:.1f} 秒')
start = time.monotonic()
print(asyncio.run(together()), f'{time.monotonic() - start:.1f} 秒')

在自己电脑上运行,输出类似这样(时间每次不一样):

文本
[200, 200, 200, 200, 200] 4.4 秒
[200, 200, 200, 200, 200] 3.0 秒

同样是每次停 0.5 秒,并发的版本快了不少。一页一页抓的 one_by_one() ,是熟悉的写法,就不多说了。咱们把并发的部分拆开来看:

  • httpx.AsyncClient() 是 httpx 的异步版客户端。await client.get(url) 发出请求以后,在等它回来的这段时间里,别的任务可以接着跑。
  • asyncio.gather(...) 把 5 个任务一起交出去,等它们全部完成,再按原来的顺序返回结果。
  • asyncio.Semaphore(2) 是关键。async with limit: 里面的代码,同一时间最多只有 2 个任务在跑,第 3 个任务,要等前面的出来了才能进去。

看到这里,可能有童鞋会问:「为什么不干脆 5 个一起抓呢?」因为并发数,就是你同时压在网站上的请求数。抓 5 页,倒没什么;可要是抓 5000 页时不加限制,一下子发出几千个请求,那和攻击网站就没什么两样了。所以用并发的时候,要记住这几条:

  • 并发一定要配上 Semaphore ,限制数量。对小网站,两三个就够了。
  • 每个请求之后,照样要停一停。
  • 动手之前先算一算:同时 2 个,每个停 0.5 秒,相当于一秒最多 4 个请求,网站受得了吗?

并发的写法有点新,下面先做一道练习,把它练熟。

练习 1 / 3五个请求一起等题目有问题?

fake_fetch(n) 模拟抓第 n 页:等 0.2 秒,返回 n * 10 。请在 main() 里用 asyncio.gather() 同时抓第 1~5 页,打印结果列表;再打印总用时是否少于 0.5 秒(一页一页抓要 1 秒)。

运行后的输出应该是:

文本
[10, 20, 30, 40, 50]
True

结果要在 main() 里面打印。

main.py
可编辑
做对当前题解锁下一段 ·03

推荐一个,全网最全最友好的中文 AI 编程指南: coding.stormzhang.ai

学习目录