in

如何使用Python抓取网页数据

如何使用Python抓取网页数据

网页抓取是一种强大的技术,通过寻找一个或多个域的所有URL来收集网络数据。Python有几个流行的网络抓取库和框架。

在这篇文章中,我们将首先介绍不同的爬行策略和使用案例。然后,我们将使用两个库:request和Beautiful Soup,在Python中从头开始构建一个简单的网络爬虫。接下来,我们将看到为什么使用Scrapy这样的网页抓取框架更好。最后,我们将用Scrapy建立一个爬虫实例,从IMDb收集电影元数据,并看看Scrapy如何扩展到有几百万页的网站。


什么是网络爬虫?

网页抓取网页爬取是两个不同但相关的概念。网页抓取是网页爬取的一个组成部分,爬行者的逻辑是找到要由爬取者代码处理的URL。

网络爬虫开始时有一个要访问的URL列表,称为种子。对于每个URL,爬虫在HTML中找到链接,根据一些标准过滤这些链接,并将新的链接添加到一个队列中。所有的HTML或一些特定的信息被提取出来,由一个不同的管道进行处理。


网页抓取策略

在实践中,网络爬虫只访问网页的一个子集,这取决于爬虫预算,可以是每个域的最大网页数、深度或执行时间。

大多数受欢迎的网站都提供一个robots.txt文件,以表明网站的哪些区域不允许每个用户代理抓取。与robots文件相对应的是sitemap.xml文件,它列出了可以抓取的网页。

流行的网络爬虫使用案例包括:。

  • 搜索引擎(Googlebot、Bingbot、Yandex Bot…)收集了相当一部分网络的所有HTML。这些数据被编入索引,以使其可被搜索。
  • SEO分析工具在收集HTML的基础上还收集元数据,如响应时间、响应状态,以检测破损的页面,以及不同域之间的链接,以收集反向链接。
  • 价格监测工具爬行电子商务网站,找到产品页面并提取元数据,特别是价格。然后定期重新访问产品页面。
  • Common Crawl维护一个开放的网络抓取数据库。例如,2020年10月的档案包含27.1亿个网页。

接下来,我们将比较用Python构建网络爬虫的三种不同策略。首先,只使用标准库,然后是用于发出HTTP请求和解析HTML的第三方库,最后是一个网络爬虫框架。


从头开始用Python构建一个简单的爬虫

要在Python中建立一个简单的网络爬虫,我们至少需要一个库来从URL中下载HTML,以及一个HTML解析库来提取链接。Python 提供了用于发出 HTTP 请求的标准库urllib和用于解析 HTML 的html.parser。在Github上可以找到一个只用标准库构建的Python爬虫实例。

用于请求和HTML解析的标准Python库对开发者不是很友好。其他流行的库,如request,被称为人类的HTTP,和Beautiful Soup,提供了更好的开发者体验。

你可以在本地安装这两个库。

用Python构建网页爬虫1

一个基本的爬虫可以按照前面的架构图来构建。

import logging
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup

logging.basicConfig(
    format='%(asctime)s %(levelname)s:%(message)s',
    level=logging.INFO)

class Crawler:

    def __init__(self, urls=[]):
        self.visited_urls = []
        self.urls_to_visit = urls

    def download_url(self, url):
        return requests.get(url).text

    def get_linked_urls(self, url, html):
        soup = BeautifulSoup(html, 'html.parser')
        for link in soup.find_all('a'):
            path = link.get('href')
            if path and path.startswith('/'):
                path = urljoin(url, path)
            yield path

    def add_url_to_visit(self, url):
        if url not in self.visited_urls and url not in self.urls_to_visit:
            self.urls_to_visit.append(url)

    def crawl(self, url):
        html = self.download_url(url)
        for url in self.get_linked_urls(url, html):
            self.add_url_to_visit(url)

    def run(self):
        while self.urls_to_visit:
            url = self.urls_to_visit.pop(0)
            logging.info(f'Crawling: {url}')
            try:
                self.crawl(url)
            except Exception:
                logging.exception(f'Failed to crawl: {url}')
            finally:
                self.visited_urls.append(url)

if __name__ == '__main__':
    Crawler(urls=['https://www.imdb.com/']).run()

上面的代码定义了一个Crawler类,它的辅助方法是使用request库的download_url,使用Beautiful Soup库的get_linked_urls和add_url_to_visit来过滤URL。要访问的URL和已访问的URL被存储在两个独立的列表中。你可以在你的终端上运行爬虫。

python crawler.py

爬虫对每个访问过的URL都会记录一行。

2020-12-04 18:10:10,737 INFO:Crawling: https://www.imdb.com/
2020-12-04 18:10:11,599 INFO:Crawling: https://www.imdb.com/?ref_=nv_home
2020-12-04 18:10:12,868 INFO:Crawling: https://www.imdb.com/calendar/?ref_=nv_mv_cal
2020-12-04 18:10:13,526 INFO:Crawling: https://www.imdb.com/list/ls016522954/?ref_=nv_tvv_dvd
2020-12-04 18:10:19,174 INFO:Crawling: https://www.imdb.com/chart/top/?ref_=nv_mv_250
2020-12-04 18:10:20,624 INFO:Crawling: https://www.imdb.com/chart/moviemeter/?ref_=nv_mv_mpm
2020-12-04 18:10:21,556 INFO:Crawling: https://www.imdb.com/feature/genre/?ref_=nv_ch_gr

代码非常简单,但在成功抓取一个完整的网站之前,有许多性能和可用性问题需要解决。


用Scrapy进行网页抓取

爬虫很慢,不支持并行。从时间戳中可以看出,爬行每个URL大约需要一秒钟。每次爬虫发出请求时,它都在等待请求被解决,中间没有任何工作。
下载URL逻辑没有重试机制,URL队列不是一个真正的队列,在URL数量较多的情况下效率不高。
链接提取逻辑不支持通过删除URL查询字符串参数来规范URL,不处理以#开头的URL,不支持按域名过滤URL或过滤掉对静态文件的请求。
爬虫不识别自己,并忽略了robots.txt文件。
接下来,我们将看到Scrapy是如何提供所有这些功能的,并使它很容易为你的自定义抓取进行扩展。

Scrapy是最流行的网络刮擦和抓取Python框架,在Github上有40k颗星。Scrapy的一个优点是,请求是异步安排和处理的。这意味着Scrapy可以在前一个请求完成之前发送另一个请求,或者在这中间做一些其他工作。Scrapy可以处理许多并发请求,但也可以通过自定义设置来尊重网站,这一点我们将在后面看到。

Scrapy有一个多组件的架构。通常情况下,你至少要实现两个不同的类。SpiderPipeline。网页抓取可以被认为是一种ETL,你从网络上提取数据并将其加载到你自己的存储中。蜘蛛提取数据,管道将其加载到存储中。转换可以在蜘蛛和管道中发生,但我建议你设置一个自定义的Scrapy管道来独立地转换每个项目。这样一来,处理一个项目的失败对其他项目没有影响。

在所有这些之上,你可以在组件之间添加蜘蛛和下载器中间件。

如果你以前使用过Scrapy,你就知道网络爬虫被定义为一个继承自基础Spider类的类,并实现一个解析方法来处理每个响应。

from scrapy.spiders import Spider

class ImdbSpider(Spider):
    name = 'imdb'
    allowed_domains = ['www.imdb.com']
    start_urls = ['https://www.imdb.com/']

    def parse(self, response):
        pass

Scrapy还提供了几个通用的spider类。CrawlSpider、XMLFeedSpider、CSVFeedSpider和SitemapSpider。CrawlSpider类继承自基础Spider类,并提供一个额外的规则属性来定义如何抓取网站。每个规则都使用一个LinkExtractor来指定从每个页面提取哪些链接。接下来,我们将通过为IMDb(互联网电影数据库)建立一个爬虫来看看如何使用它们中的每一个。

为IMDb建立一个Scrapy爬虫的例子

在尝试抓取IMDb之前,我检查了IMDb robots.txt文件,看看哪些URL路径是允许的。robots文件只不允许所有用户代理的26个路径。Scrapy事先读取了robots.txt文件,并在ROBOTSTXT_OBEY设置为真时尊重它。所有用Scrapy命令startproject生成的项目都是这种情况。

scrapy startproject scrapy_crawler

该命令用默认的Scrapy项目文件夹结构创建一个新项目。

scrapy_crawler/

├── scrapy.cfg
└── scrapy_crawler
    ├── __init__.py
    ├── items.py
    ├── middlewares.py
    ├── pipelines.py
    ├── settings.py
    └── spiders
        ├── __init__.py

然后你可以在scrapy_crawler/spiders/imdb.py中创建一个蜘蛛,用一个规则来提取所有链接。

from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor

class ImdbCrawler(CrawlSpider):
    name = 'imdb'
    allowed_domains = ['www.imdb.com']
    start_urls = ['https://www.imdb.com/']
    rules = (Rule(LinkExtractor()),)

你可以在终端启动爬虫程序。

scrapy crawl imdb --logfile imdb.log

你会得到很多日志,包括每个请求的一个日志。探索日志时我注意到,即使我们将allowed_domains设置为只抓取https://www.imdb.com 下的网页,也有对外部域的请求,如amazon.com。

2020-12-06 12:25:18 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (302) to <GET https://www.amazon.com/b/?&node=5160028011&ref_=ft_iba> from <GET [https://www.imdb.com/whitelist-offsite?url=https%3A%2F%2Fwww.amazon.com%2Fb%2F%3F%26node%3D5160028011%26ref_%3Dft_iba&page-action=ft-iba&ref=ft_iba](https://www.imdb.com/whitelist-offsite?url=https%3A%2F%2Fwww.amazon.com%2Fb%2F%3F%26node%3D5160028011%26ref_%3Dft_iba&page-action=ft-iba&ref=ft_iba)>

IMDb从白名单-网站和白名单下的URL路径重定向到外部域。有一个开放的ScrapyGithub问题显示,当OffsiteMiddleware在RedirectMiddleware之前应用时,外部URL不会被过滤掉。为了解决这个问题,我们可以配置链接提取器,拒绝以两个正则表达式开头的URL。

class ImdbCrawler(CrawlSpider):
    name = 'imdb'
    allowed_domains = ['www.imdb.com']
    start_urls = ['https://www.imdb.com/']
    rules = (
        Rule(LinkExtractor(
            deny=[
                re.escape('https://www.imdb.com/offsite'),
                re.escape('https://www.imdb.com/whitelist-offsite'),
            ],
        )),
    )

规则和LinkExtractor类支持几个参数来过滤掉URLs。例如,你可以忽略特定的URL扩展名,并通过对查询字符串进行排序来减少重复的URL数量。如果你没有找到适合你使用情况的特定参数,你可以向LinkExtractor的process_links或Rule的process_values传递一个自定义函数。

例如,IMDb有两个不同的URL,内容相同。

https://www.imdb.com/name/nm1156914/

https://www.imdb.com/name/nm1156914/?mode=desktop&ref_=m_ft_dsk

为了限制抓取的URL数量,我们可以用w3lib库中的url_query_cleaner函数删除URL中的所有查询字符串,并在process_links中使用它。

from w3lib.url import url_query_cleaner

def process_links(links):
    for link in links:
        link.url = url_query_cleaner(link.url)
        yield link

class ImdbCrawler(CrawlSpider):

    name = 'imdb'
    allowed_domains = ['www.imdb.com']
    start_urls = ['https://www.imdb.com/']
    rules = (
        Rule(LinkExtractor(
            deny=[
                re.escape('https://www.imdb.com/offsite'),
                re.escape('https://www.imdb.com/whitelist-offsite'),
            ],
        ), process_links=process_links),
    )

现在我们已经限制了要处理的请求的数量,我们可以添加一个parse_item方法来从每个页面中提取数据,并将其传递给一个管道来存储它。例如,我们可以提取整个response.text以在不同的管道中处理,或者选择HTML元数据。为了选择头标签中的HTML元数据,我们可以编写自己的XPATHs,但我发现使用一个库extruct更好,它可以从HTML页面中提取所有元数据。你可以用pip install extract来安装它。

import re
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from w3lib.url import url_query_cleaner
import extruct

def process_links(links):
    for link in links:
        link.url = url_query_cleaner(link.url)
        yield link

class ImdbCrawler(CrawlSpider):
    name = 'imdb'
    allowed_domains = ['www.imdb.com']
    start_urls = ['https://www.imdb.com/']
    rules = (
        Rule(
            LinkExtractor(
                deny=[
                    re.escape('https://www.imdb.com/offsite'),
                    re.escape('https://www.imdb.com/whitelist-offsite'),
                ],
            ),
            process_links=process_links,
            callback='parse_item',
            follow=True
        ),
    )

    def parse_item(self, response):
        return {
            'url': response.url,
            'metadata': extruct.extract(
                response.text,
                response.url,
                syntaxes=['opengraph', 'json-ld']
            ),
        }

我把follow属性设置为True,这样即使我们提供了一个自定义的解析方法,Scrapy仍然会跟踪每个响应中的所有链接。我还对extruct进行了配置,使其只提取Open Graph元数据和JSON-LD,这是一种在网络中使用JSON对链接数据进行编码的流行方法,被IMDb使用。你可以运行爬虫并将JSON行格式的项目存储到一个文件中。

scrapy crawl imdb --logfile imdb.log -o imdb.jl -t jsonlines

输出文件imdb.jl为每个抓取的项目包含一行。例如,从HTML的标签中提取的一部电影的Open Graph元数据看起来是这样的。

{
    "url": "http://www.imdb.com/title/tt2442560/",
    "metadata": {"opengraph": [{
         "namespace": {"og": "http://ogp.me/ns#"},
         "properties": [
             ["og:url", "http://www.imdb.com/title/tt2442560/"],
             ["og:image", "https://m.media-amazon.com/images/M/[email protected]_V1_UY1200_CR90,0,630,1200_AL_.jpg"],
             ["og:type", "video.tv_show"],
             ["og:title", "Peaky Blinders (TV Series 2013\u2013 ) - IMDb"],
             ["og:site_name", "IMDb"],
             ["og:description", "Created by Steven Knight.  With Cillian Murphy, Paul Anderson, Helen McCrory, Sophie Rundle. A gangster family epic set in 1900s England, centering on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby."]
        ]
   }]}
}

单个项目的JSON-LD太长了,不能包含在文章中,这里是Scrapy从script type=”application/ld+json”标签中提取的一个样本。

"json-ld": [
    {
        "@context": "http://schema.org",
        "@type": "TVSeries",
        "url": "/title/tt2442560/",
        "name": "Peaky Blinders",
        "image": "https://m.media-amazon.com/images/M/[email protected]_V1_.jpg",
        "genre": ["Crime","Drama"],
        "contentRating": "TV-MA",
        "actor": [
            {
                "@type": "Person",
                "url": "/name/nm0614165/",
                "name": "Cillian Murphy"
            },
            ...
        ]
        ...
    }
]

[文中代码均来源于Scrapingbee]

在探索日志时,我注意到爬虫的另一个常见问题。通过依次点击过滤器,爬虫生成了内容相同的URL,只是过滤器的应用顺序不同。

https://www.imdb.com/name/nm2900465/videogallery/content_type-trailer/related_titles-tt0479468

https://www.imdb.com/name/nm2900465/videogallery/related_titles-tt0479468/content_type-trailer

长的过滤和搜索URL是一个困难的问题,可以通过Scrapy的设置URLLENGTH_LIMIT限制URL的长度来部分解决。

我以IMDb为例,展示了用Python构建网络爬虫的基本原理。我没有让爬虫运行很长时间,因为我对这些数据没有具体的用例。如果你需要来自IMDb的特定数据,你可以查看IMDb Datasets项目,它提供了IMDb数据的每日输出,以及IMDbPY,一个用于检索和管理数据的Python包。


规模化的网页抓取

如果您试图抓取像IMDb这样的大网站,根据谷歌的数据,该网站有超过4500万个页面,那么通过配置以下设置,负责任地进行抓取是很重要的。你可以在BOT_NAME设置中识别你的爬虫,并提供详细的联系方式。为了限制你对网站服务器的压力,你可以增加DOWNLOAD_DELAY,限制CONCURRENT_REQUESTS_PER_DOMAIN,或者设置AUTOTHROTTLE_ENABLED,它将根据服务器的响应时间动态地调整这些设置。

请注意,Scrapy的抓取默认是针对单个域进行优化的。如果您要抓取多个域,请检查这些设置以优化广泛的抓取,包括将默认的抓取顺序从深度优先改为呼吸优先。为了限制你的抓取预算,你可以用close spider扩展的CLOSESPIDER_PAGECOUNT设置来限制请求的数量。

在默认设置下,对于像IMDb这样的网站,Scrapy每分钟抓取大约600个页面。要抓取4500万个网页,一个机器人需要50多天的时间。如果你需要抓取多个网站,最好为每个大网站或网站组启动单独的爬虫。如果你对分布式网络抓取感兴趣,你可以阅读一个开发者如何使用20个亚马逊EC2机器实例在40小时内用Python抓取了2.5亿个网页

在某些情况下,你可能会遇到需要你执行JavaScript代码来呈现所有HTML的网站。如果不这样做,你就可能无法收集到网站上的所有链接。


总    结

我们将使用第三方库下载URL和解析HTML的Python爬虫的代码与使用流行的网络爬虫框架构建的爬虫进行了比较。Scrapy是一个性能非常好的网页抓取框架,而且很容易用你的自定义代码进行扩展。但你需要知道所有可以钩住你自己代码的地方,以及每个组件的设置。

在抓取有数百万页的网站时,正确配置Scrapy变得更加重要。如果你想了解更多关于网络抓取的信息,我建议你挑选一个受欢迎的网站,并尝试抓取它。你肯定会遇到新的问题,这让这个话题变得很有吸引力!

blank

Written by 爬取 大师

阿里P12级别选手,能够突破各种反爬, 全能的爬取大师,擅长百万级的数据抓取!没有不能爬,只有你不敢想,有爬取项目可以联系我邮箱 [email protected] (带需求和预算哈, 不然多半不回复)