如何掌握Python进行网页抓取

如何掌握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_workersmax_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 允许这样做,并且一些库可以帮助您(CeleryRedis Queue)。这是一个巨大的进步,我们今天已经涵盖了足够多的内容。

作为快速预览,它背后的想法与线程相同。每个项目都将按照我们迄今为止所见的方式进行处理,但在不同的线程中,甚至在运行相同代码的机器中。通过这种方法,我们可以进一步扩展;理论上,没有限制。但在现实中,总会有一个限制或瓶颈,通常是处理分发的中心节点。

扩大规模时考虑

出于教育目的,我们展示了一个简化版本的爬行过程。要大规模应用所有这些,您应该首先考虑几件事。

构建 vs 购买 vs 开源

在编写自己的爬行库之前,请尝试其中的一些选项。许多伟大的开源库都可以实现它:Scrapypyspidernode-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)

结论

我们希望您分享三个要点:

  1. 将获取 HTML 和从爬行本身中提取链接分开。
  2. 为您的用例选择合适的系统:简单的顺序、并行或分布式。
  3. 从头开始大规模建设可能会受到伤害。查看免费或付费的库/解决方案。

类似文章