掌握 Python 中的网页抓取:从头开始抓取
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)
结论
我们希望您分享三个要点:
将获取 HTML 和从爬行本身中提取链接分开。
为您的用例选择合适的系统:简单顺序、并行或分布式。
从头开始构建大规模可能会受到伤害。查看免费或付费的库/解决方案。
不要忘记查看本系列之前的帖子。
+ Python 中的隐形抓取:避免像忍者一样阻塞(第二部分)
请继续关注下一篇关于进一步扩展此爬行过程的信息。
您觉得内容有帮助吗?传播信息并在 Twitter、LinkedIn 或 Facebook 上分享。
英文好的可以直接看原文:https://www.zenrows.com/blog/mastering-web-scraping-in-python-crawling-from-scratch
这个网站也是个好的学习网站