当前位置:首页 > 技术杂坛 > 正文内容

掌握 Python 中的网页抓取:从头开始抓取

zhangchap3年前 (2021-09-07)技术杂坛247

Ander 是一名 Web 开发人员,他在多家初创公司工作了 10 多年,曾与各种行业和技术合作。工程师转为企业家。 您是否尝试过抓取数千个页面?进一步扩展?处理系统故障并从中恢复?

在了解了如何从网站中提取内容以及如何避免被阻止后,我们将了解抓取过程。要大规模获取数据,手动获取几个 URL 不是一种选择。我们需要使用一个自动化系统来发现新页面并访问它们。

免责声明:对于实际使用,请找到合适的软件。下面是更多信息。本指南假装是对爬行过程的工作原理和基础知识的介绍。但是有很多细节需要解决。

首先

要使代码正常工作,您需要安装 python3。有些系统已经预先安装了它。之后,通过运行 pip install 安装所有必要的库。

pip install requests,beautifulsoup4

 从本系列的第一篇文章中,我们知道使用 requests.get 和 BeautifulSoup 从网页获取数据很容易。我们将首先在准备测试抓取的测试商城中找到链接。

获取内容的基础是相同的。然后我们获取分页器上的所有链接并将这些链接添加到一个集合中。我们选择 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 执行提取部分来解决这个问题。也许最直接的方法,虽然不是可扩展的,但是使用相同的循环。但在此之前,还有一个缺失的部分:避免对同一页面进行两次抓取。

我们将跟踪另一组中已经访问过的链接,并通过在每次请求之前检查它们来避免重复。在这种情况下, 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)


注意到不同的东西了吗?抓取逻辑不附加在链接提取部分。每个助手处理一个单件。爬网函数通过调用它们并应用结果来充当协调器。

随着项目的发展,所有这些部分都可以移动到文件中或作为参数/回调传递。如果核心独立于所选页面和内容,我们可以概括用例。

我们错过了什么吗? ?

我们需要添加第一个 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())


并行请求

同步队列 线程或并行计算存在巨大风险:修改来自不同线程的相同变量或数据结构。这意味着我们的两个请求将向集合添加新链接(即 to_visit)。由于数据结构不受保护,因此两者都可以像这样读取和写入:

两者都读取其内容,即 (1, 2, 3) (简化) 线程一添加指向第 4、5 页的链接:(1、2、3、4、5)

线程二添加指向第 6、7 页的链接:(1, 2, 3, 6, 7)

这怎么发生的?当线程 2 编写新链接时,它将它们添加到只有三个元素的集合中。 这是一个非常简化的版本;检查链接以获取更多信息。

我们可以做些什么来避免这些冲突?同步或锁定。来自文档:“队列使用锁来临时阻止竞争线程。”这意味着线程一会在集合上获取锁,读写没有任何问题,然后自动释放锁。同时,线程 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。但是队列需要处理程序或工作程序来处理它们的内容。有了上面的内容,我们创建了一个队列并添加了一个项目(原始项目)。我们还修改了爬行功能,将链接放入队列中,而不是更新之前的集合。

我们将使用线程模块创建一个工作线程来处理该队列。

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


我们定义了一个新函数来处理排队的项目。为此,我们进入一个无限循环,当所有处理完成时该循环将停止。

然后获取一个项目,该项目将阻塞,直到有项目可用。我们处理该项目;目前,只需打印它以显示它是如何工作的。稍后它会调用 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

顺序请求和拥有一个工作人员之间几乎没有区别。线程会带来一些开销,但在这里几乎不明显。这将需要更严格的负载测试。一旦我们开始添加工作人员,这些开销就会得到回报。我们可以添加更多,但这不会影响结果,因为它们大部分时间都处于空闲状态。

分布式处理

我们不会介绍以下扩展步骤:在多个服务器之间分配爬网过程。 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'}, ...]


Persistency 持久性

我们没有坚持任何东西,这不会扩展。在实际情况下,我们应该存储内容甚至 HTML 本身以供以后处理。以及所有发现的带有时间戳时间的 URL。这一切听起来都像是需要一个数据库。根据需要,我们可以只存储实际内容,也可以存储整个 URL、日期、HTML 等。

Canonicals规范

链接提取部分不考虑规范链接。一个页面可以有多个 URL:查询字符串或哈希值可能会修改它。在我们的例子中,我们会抓取它两次。现在这不是问题,而是需要考虑的事情。

正确的方法是将规范 URL(如果存在)添加到访问列表中。然后我们可以从不同的源 URL 到达同一页面,但我们会检测到它是重复的。我们还可以使用 url_query_cleaner 删除一些查询字符串参数。

Robots.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. 从头开始构建大规模可能会受到伤害。查看免费或付费的库/解决方案。

不要忘记查看本系列之前的帖子。

+ Python 中的隐形抓取:避免像忍者一样阻塞(第二部分)

+ 掌握提取(第一部分)

请继续关注下一篇关于进一步扩展此爬行过程的信息。

您觉得内容有帮助吗?传播信息并在 Twitter、LinkedIn 或 Facebook 上分享。

英文好的可以直接看原文:https://www.zenrows.com/blog/mastering-web-scraping-in-python-crawling-from-scratch

这个网站也是个好的学习网站

分享给朋友:

相关文章

在IIS 7输出缓存的作用

在IIS 7中配置输出缓存 适用于:Windows 7,Windows Server 2008,Windows Server 2008 R2,Windows Vista...

destoon标题过滤手机号

修改文件:\module\sell\my.inc.php 添加下列代码: // 过滤手机号 $pattern = '/0?(13|14|15|17|18|19)[0-9]{9}/'; $p...

windows服务器php安全设置

在IIS上构建PHP网站的这一阶段,请考虑加强安全性所需的PHP配置设置,Web服务器设置和PHP应用程序设置。 完成这些任务后,请记录您的设计决策,然后再继续执行步骤1:安装IIS和PHP。 1...

requests超时,重试(Python请求的高级用法)

import requests from requests.adapters import HTTPAdapter from requests.p...

python jieba分词自定义分词器及自定义词典

import jieba jieba.initialize() # 自定义分词器的写法 n_c = jieba.Tokenizer(dictionary=...

python判断当前系统为win还是Linux

使用sys模块进行判断,以便启用在win平台下不支持的代码:import sys p = sys.platform if p != &#...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。