如何掌握Python进行网页抓取
您是否曾经尝试过抓取数千个页面?进一步扩大规模?处理系统故障并从中恢复?
在了解了如何从网站中提取内容以及如何避免被阻止之后,我们将看一下抓取过程。要大规模获取数据,手动获取一些 URL 不是一种选择。我们需要使用一个自动化系统来发现新页面并访问它们。
免责声明:对于实际使用,请找到合适的软件。以下是有关的更多信息。本指南假装是对爬行过程如何工作和做基础知识的介绍。但是有很多细节需要解决。
先决条件
要使代码正常工作,您需要安装 python3。有些系统已经预装了它。之后,通过运行安装所有必需的库pip install
。
pip install requests beautifulsoup4
如何获取页面上的所有链接
requests.get
从本系列的第一篇文章中,我们知道使用和从网页获取数据很容易BeautifulSoup
。我们将从在准备测试抓取的假商店中找到链接开始。
获取内容的基础是相同的。然后我们获取分页器上的所有链接并将链接添加到set
. 我们选择 set 以避免重复。如您所见,我们对链接的选择器进行了硬编码,这意味着它不是通用的解决方案。目前,我们将专注于手头的页面。
import requests from bs4 import BeautifulSoup to_visit = set() response = requests.get('https://scrapeme.live/shop/page/1/') soup = BeautifulSoup(response.content, 'html.parser') for a in soup.select('a.page-numbers'): to_visit.add(a.get('href')) print(to_visit) # {'https://scrapeme.live/shop/page/2/', '.../3/', '.../46/', '.../48/', '.../4/', '.../47/'}
一次一个 URL,顺序
现在我们有几个链接,但无法访问所有链接。我们需要某种循环来为每个可用的 URL 执行提取部分来解决这个问题。也许最直接的方法(虽然不是可扩展的方法)是使用相同的循环。但在此之前,还有一个遗漏的部分:避免两次抓取同一页面。
我们将跟踪另一个已经访问过的链接set
,并通过在每次请求之前检查它们来避免重复。在这种情况下,to_visit
没有被使用,只是为了演示目的而维护。为了防止访问每个页面,我们还将添加一个max_visits
变量。现在,我们忽略该robots.txt
文件,但我们必须保持礼貌和友善。
visited = set() to_visit = set() max_visits = 3 def crawl(url): print('Crawl: ', url) response = requests.get(url) soup = BeautifulSoup(response.content, 'html.parser') visited.add(url) for a in soup.select('a.page-numbers'): link = a.get('href') to_visit.add(link) if link not in visited and len(visited) < max_visits: crawl(link) crawl('https://scrapeme.live/shop/page/1/') print(visited) # {'.../3/', '.../1/', '.../2/'} print(to_visit) # { ... new ones added, such as pages 5 and 6 ... }
它是一个递归函数,有两个退出条件:没有更多的链接可以访问,或者我们达到了最大访问量。在任何一种情况下,它都会退出并打印访问过的链接和待处理的链接。
需要注意的是,同一个链接可以添加多次,但只会被抓取一次。在一个大项目中,想法是设置一个计时器,并且只在几天后请求每个 URL。
关注点分离
我们说这不是关于提取或解析内容,但我们需要在它变得纠缠之前分离关注点。为此,我们将创建三个辅助函数:获取 HTML、提取链接和提取内容。正如他们的名字所暗示的那样,他们每个人都将执行网络抓取的主要任务之一。
第一个将使用与之前相同的库从 URL 获取 HTML,但try
为了安全起见将其包装在一个块中。
def get_html(url): try: return requests.get(url).content except Exception as e: print(e) return ''
第二个,提取链接,将像以前一样工作。
def extract_links(soup): return [a.get('href') for a in soup.select('a.page-numbers') if a.get('href') not in visited]
最后一个将是提取我们想要的内容的占位符。由于我们正在简化这部分,它将从同一页面获取基本信息,无需在详细信息页面中输入。
为了表明我们可以提取一些内容,我们将打印每个产品的标题(神奇宝贝名称)。
def extract_content(soup): for product in soup.select('.product'): print(product.find('h2').text) # Bulbasaur, Ivysaur, ...
将它们组装在一起。
def crawl(url): if not url or url in visited: return print('Crawl: ', url) visited.add(url) html = get_html(url) soup = BeautifulSoup(html, 'html.parser') extract_content(soup) links = extract_links(soup) to_visit.update(links)
注意到不一样的东西了吗?抓取逻辑不附加到链接提取部分。每个助手处理一个单件。该crawl
函数通过调用它们并应用结果来充当协调器。
随着项目的发展,所有这些部分都可以移动到文件或作为参数/回调传递。如果核心独立于所选页面和内容,我们可以概括用例。我们需要添加第一个 URL 并调用抓取功能。由于crawl
不再是递归的,我们将在一个单独的循环中处理它。
to_visit.add('https://scrapeme.live/shop/page/1/') while (len(to_visit) > 0 and len(visited) < max_visits): crawl(to_visit.pop())
并行请求
缺少一个重要部分:并行性。HTTP 请求处理程序大部分时间都是空闲的,等待响应返回。这意味着我们可以在不使机器超载的情况下同时发送多个。然后在它们返回时处理它们。
需要注意的是,这种方法仅在命令不是强制性的情况下才有效。但是我们已经在使用集合了,根据Python 的定义,“集合是没有重复元素的无序集合”。这意味着我们的流程从一开始就是无序的。
在深入研究并行请求之前,我们必须了解几个概念:同步和队列。
同步队列
线程或并行计算存在巨大风险:从不同的线程修改相同的变量或数据结构。这意味着我们的两个请求将向一个集合添加新链接(即to_visit
)。由于数据结构不受保护,两者都可以像这样读写它:
- 两者都读其内容,即
(1, 2, 3)
(简体) - 线程一添加到页面的链接
4, 5
:(1, 2, 3, 4, 5)
- 线程二添加到页面的链接
6, 7
:(1, 2, 3, 6, 7)
这怎么发生的?当线程二编写新链接时,它会将它们添加到只有三个元素的集合中。
这是一个非常简化的版本;检查链接以获取更多信息。
我们可以做些什么来避免这些冲突?同步或锁定。来自文档:“队列使用锁来临时阻止竞争线程。” 这意味着线程 1 将获取集合上的锁,读取和写入没有任何问题,然后自动释放锁。同时,线程 2 必须等到锁可用。只有这样才能读写。
import queue q = queue.Queue() q.put('https://scrapeme.live/shop/page/1/') def crawl(url): ... links = extract_links(soup) for link in links: if link not in visited: q.put(link)
目前,它不起作用。不用担心。现有代码的变化是最小的:我们to_visit
用一个队列代替。但是队列需要处理程序或工作人员来处理它们的内容。通过以上内容,我们创建了一个队列并添加了一个项目(原始项目)。我们还修改了crawl
将链接放入队列的功能,而不是更新之前的集合。
我们将使用线程模块创建一个工作线程来处理该队列。
from threading import Thread def queue_worker(i, q): while True: url = q.get() # Get an item from the queue, blocks until one is available print('to process:', url) q.task_done() # Notifies the queue that the item has been processed q = queue.Queue() Thread(target=queue_worker, args=(0, q), daemon=True).start() q.put('https://scrapeme.live/shop/page/1/') q.join() # Blocks until all items in the queue are processed and marked as done print('Done') # to process: https://scrapeme.live/shop/page/1/ # Done
我们定义了一个新函数来处理排队的项目。为此,我们进入了一个无限循环,该循环将在所有处理完成后停止。
然后是get
一个项目,它将阻塞直到有一个项目可用。我们处理该项目;目前,只需打印它来展示它是如何工作的。它稍后会调用crawl
。
最后,我们通过调用通知队列该项目已被处理task_done
。
一旦队列收到所有项目的通知并且为空,它将停止执行并结束无限循环。这就是join
函数的作用,“阻塞直到队列中的所有项目都被获取和处理”。
现在我们还需要两件事:处理项目和创建更多线程(它不会只与一个线程并行,对吗?)。
def queue_worker(i, q): while True: url = q.get() if (len(visited) < max_visits and url not in visited): crawl(url) q.task_done() q = queue.Queue() num_workers = 4 for i in range(num_workers): Thread(target=queue_worker, args=(i, q), daemon=True).start()
运行它时要小心,因为大数字num_workers
会max_visits
启动很多请求。如果脚本出于某种原因存在一些小错误,您可以在几秒钟内执行数百个请求。
表现
我们运行具有不同设置的基准测试仅作为参考。
- 顺序请求:29,32s
- 与一名工人排队 (
num_workers = 1
): 29,41s - 队列中有两个工人 (
num_workers = 2
): 20,05s - 队列有五个工人 (
num_workers = 5
): 11,97s - 有 10 个工人的队列 (
num_workers = 10
): 12,02s
顺序请求和拥有一个 worker 之间几乎没有区别。线程会带来一些开销,但在这里几乎不会引起注意。这将需要更严格的负载测试。一旦我们开始添加工作人员,这些开销就会得到回报。我们可以添加更多,但这不会影响结果,因为它们大部分时间都是空闲的。
分布式处理
我们不会介绍以下扩展步骤:将爬行过程分布在多个服务器中。Python 允许这样做,并且一些库可以帮助您(Celery或Redis Queue)。这是一个巨大的进步,我们今天已经涵盖了足够多的内容。
作为快速预览,它背后的想法与线程相同。每个项目都将按照我们迄今为止所见的方式进行处理,但在不同的线程中,甚至在运行相同代码的机器中。通过这种方法,我们可以进一步扩展;理论上,没有限制。但在现实中,总会有一个限制或瓶颈,通常是处理分发的中心节点。
扩大规模时考虑
出于教育目的,我们展示了一个简化版本的爬行过程。要大规模应用所有这些,您应该首先考虑几件事。
构建 vs 购买 vs 开源
在编写自己的爬行库之前,请尝试其中的一些选项。许多伟大的开源库都可以实现它:Scrapy、pyspider、node-crawler (Node.js) 或Colly (Go)。以及许多为您提供抓取和爬取解决方案的公司和服务。
避免被封锁
正如我们在之前的帖子中看到的,我们可以采取多种措施来避免阻塞。其中一些是代理和标头。这是一个简单的片段,将它们添加到我们当前的代码中。
请注意,这些免费代理可能不适合您。他们的寿命很短。
proxies = { 'http': 'http://190.64.18.177:80', 'https': 'http://49.12.2.178:3128', } headers = { 'authority': 'httpbin.org', 'cache-control': 'max-age=0', 'sec-ch-ua': '"Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"', 'sec-ch-ua-mobile': '?0', 'upgrade-insecure-requests': '1', 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 'sec-fetch-site': 'none', 'sec-fetch-mode': 'navigate', 'sec-fetch-user': '?1', 'sec-fetch-dest': 'document', 'accept-language': 'en-US,en;q=0.9', } def get_html(url): try: response = requests.get(url, headers=headers, proxies=proxies) return response.content except Exception as e: print(e) return ''
提取内容
我们不会在这里详细介绍,只是一个简单的片段,用于提取每个项目的 id、名称和价格。我们将所有内容存储在一个data
数组中,这不是一个好主意。但这足以用于演示目的。
data = [] def extract_content(soup): for product in soup.select('.product'): data.append({ 'id': product.find('a', attrs={'data-product_id': True})['data-product_id'], 'name': product.find('h2').text, 'price': product.find(class_='amount').text }) print(data) # [{'id': '759', 'name': 'Bulbasaur', 'price': '£63.00'}, {'id': '729', 'name': 'Ivysaur', 'price': '£87.00'}, ...]
持久性
我们没有坚持任何东西,这不会扩展。在实际情况下,我们应该存储内容甚至 HTML 本身以供以后处理。以及所有发现的带有时间戳时间的 URL。这一切听起来都像是需要一个数据库。根据需要,我们可以只存储实际内容或一般地存储整个 URL、日期、HTML 等。
URL
链接提取部分不考虑规范链接。一个页面可以有多个 URL:查询字符串或散列可能会修改它。在我们的例子中,我们会抓取它两次。现在这不是问题,但需要考虑。
正确的方法是将规范 URL(如果存在)添加到访问列表中。然后我们可以从不同的源 URL 到达同一页面,但我们会检测到它是重复的。我们还可以使用url_query_cleaner删除一些查询字符串参数。
机器人.txt
我们没有检查它,因为我们正在使用准备抓取的测试网站。但在抓取实际目标时请检查robots文件并以其为准。在它之上,不要造成超过他们可以处理的流量。再一次,礼貌而友善;)
最终代码
import requests from bs4 import BeautifulSoup import queue from threading import Thread starting_url = 'https://scrapeme.live/shop/page/1/' visited = set() max_visits = 100 # careful, it will crawl all the pages num_workers = 5 data = [] def get_html(url): try: response = requests.get(url) # response = requests.get(url, headers=headers, proxies=proxies) return response.content except Exception as e: print(e) return '' def extract_links(soup): return [a.get('href') for a in soup.select('a.page-numbers') if a.get('href') not in visited] def extract_content(soup): for product in soup.select('.product'): data.append({ 'id': product.find('a', attrs={'data-product_id': True})['data-product_id'], 'name': product.find('h2').text, 'price': product.find(class_='amount').text }) def crawl(url): visited.add(url) print('Crawl: ', url) html = get_html(url) soup = BeautifulSoup(html, 'html.parser') extract_content(soup) links = extract_links(soup) for link in links: if link not in visited: q.put(link) def queue_worker(i, q): while True: url = q.get() # Get an item from the queue, blocks until one is available if (len(visited) < max_visits and url not in visited): crawl(url) q.task_done() # Notifies the queue that the item has been processed q = queue.Queue() for i in range(num_workers): Thread(target=queue_worker, args=(i, q), daemon=True).start() q.put(starting_url) q.join() # Blocks until all items in the queue are processed and marked as done print('Done') print('Visited:', visited) print('Data:', data)
结论
我们希望您分享三个要点:
- 将获取 HTML 和从爬行本身中提取链接分开。
- 为您的用例选择合适的系统:简单的顺序、并行或分布式。
- 从头开始大规模建设可能会受到伤害。查看免费或付费的库/解决方案。