精通 Python 网络爬虫:从零开始编写爬虫代码
你有没有尝试过抓取成千上万个网页?进一步扩大规模?处理和从系统故障中恢复?
在了解了如何从网站提取内容以及如何避免被屏蔽之后,我们将探讨抓取过程。要大规模获取数据,手动获取几个 URL 是行不通的。我们需要使用自动化系统来发现新页面并访问它们。
免责声明:实际使用时,请寻找合适的软件。以下提供更多相关信息。本指南旨在介绍爬虫过程的工作原理和基本操作,但其中还有大量细节需要讲解。
先决条件
要使代码运行,您需要安装 Python 3。有些系统已经预装了 Python 3。之后,运行以下命令安装所有必要的库pip install:
pip install requests beautifulsoup4 pandas
如何获取页面上的所有链接
requests.get从本系列的第一篇文章中,我们知道使用 `get_data()`和 ` get_link()` 函数很容易从网页上获取数据。我们将首先在一个为测试网页抓取而准备的虚拟商店BeautifulSoup中查找链接。
获取内容的基本步骤相同。然后,我们获取分页器上的所有链接,并将这些链接添加到一个元素中set。我们选择使用 `<link>` 标签来避免重复。如您所见,我们对链接的选择器进行了硬编码,这意味着它并非通用解决方案。目前,我们将专注于当前页面。
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)
这是怎么回事?当第二个线程写入新链接时,它将它们添加到了一个只有三个元素的集合中。
这只是一个非常简化的版本;更多信息请查看链接。
如何避免这些冲突?同步或加锁。文档中写道: “队列使用锁来暂时阻塞竞争线程。” 这意味着线程一会获取集合的锁,顺利地进行读写操作,然后自动释放锁。与此同时,线程二必须等待锁释放后才能进行读写操作。
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.32秒
- 只有一个工作进程的队列(
num_workers = 1):29.41秒 - 包含两个工作进程的队列(
num_workers = 2):20.05秒 - 包含五个工作进程的队列(
num_workers = 5):11.97秒 - 包含十个工作进程的队列(
num_workers = 10):12.02秒
顺序请求和使用单个工作线程几乎没有区别。线程会带来一些开销,但在这里几乎察觉不到。需要更严苛的负载测试才能体现出来。一旦我们开始增加工作线程,这些开销就会被抵消。我们可以增加更多线程,但这不会影响结果,因为它们大部分时间都处于空闲状态。
分布式处理
我们不会讨论接下来的扩展步骤:将爬虫进程分布到多个服务器上。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_query_cleaner 函数移除一些查询字符串参数。
Robots.txt
我们没有进行检查,因为我们使用的是专门用于抓取的测试网站。但请务必检查 robots.txt 文件,并在抓取实际目标网站时遵守其中的规定。此外,请勿造成超出网站处理能力的流量。再次强调,请文明礼貌地进行抓取 ;)
最终代码
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 和提取链接的过程与爬虫本身分开。
- 根据您的使用场景选择合适的系统:简单顺序系统、并行系统或分布式系统。
- 从零开始大规模构建可能会很困难。不妨考虑使用免费或付费的库/解决方案。
我们即将完成关于网络爬虫的系列文章。敬请期待下一篇,我们将探讨如何进一步扩展爬虫流程。
别忘了看看本系列的其他文章。
您觉得这些内容有用吗?请分享给更多人!👈
文章来源:https://dev.to/anderrv/mastering-web-scraping-in-python-crawling-from-scratch-1dgd