terminal42/escargot

基于Symfony组件的Web爬虫或蜘蛛库

1.6.2 2024-06-12 13:53 UTC

README

一个库,提供基于HTTP爬取任何内容所需的一切,并基于Symfony组件以您喜欢的任何方式处理响应。

为什么还需要另一个爬虫呢?

有那么多不同的实现,有那么多编程语言,对吧?好吧,我在PHP中找到的并没有真正达到我的个人质量标准,而且我也想要一个基于Symfony HttpClient组件的,不仅限于爬取网站(HTML),还可以用作您可能想要爬取的任何内容的基石。因此,另一个库。

那个名字“Escargot”怎么样?

当我创建这个库时,我不想把它命名为“爬虫”或“蜘蛛”或任何被数百次使用过的类似名称。所以我开始思考真正爬行的事物,一个立刻出现在我脑海中的东西是蜗牛。但是“蜗牛”听起来并不那么漂亮,所以我只是用了它的法语翻译,也就是“escargot”。就是这样!此外,法语是一门很美的语言,而且你可能不知道:PHP生态系统中有大量的库是由法国人发明和维护的,所以这也是对法国PHP社区(以及Symfony社区)的一种致敬。

顺便说一句:感谢Symfony HttpClient,Escargot实际上一点也不慢;-)

安装

composer require terminal42/escargot

使用

Escargot中的所有内容都分配给了作业ID。这种设计的原因是爬取大量URI可能需要非常长的时间,而且你可能在某个时候想要停止,然后再从上次停止的地方继续,这种可能性相当高。为此,每个Escargot实例还需要一个队列,以及一个基本URI集合,以确定从哪里开始爬取。

实例化Escargot

当你还没有作业ID时,必须使用如下所示的工厂方法

<?php

use Nyholm\Psr7\Uri;
use Terminal42\Escargot\BaseUriCollection;
use Terminal42\Escargot\Escargot;
use Terminal42\Escargot\Queue\InMemoryQueue;

$baseUris = new BaseUriCollection();
$baseUris->add(new Uri('https://www.terminal42.ch'));
$queue = new InMemoryQueue();
        
$escargot = Escargot::create($baseUris, $queue);

如果你已经有了作业ID,因为你之前已经启动了爬取,我们就不再需要基本URI集合了,而是需要作业ID(再次$client是完全可选的)

<?php

use Symfony\Component\HttpClient\CurlHttpClient;
use Terminal42\Escargot\Escargot;
use Terminal42\Escargot\Queue\InMemoryQueue;

$queue = new InMemoryQueue();
        
$escargot = Escargot::createFromJobId($jobId, $queue);

不同的队列实现

如前所述,队列是Escargot的一个重要部分,因为它跟踪所有已请求的URI,但它还负责根据给定的作业ID从上次停止的地方继续。你可以创建自己的队列,并将信息存储在任何你想要的地方,通过实现QueueInterface。这个库为你提供了以下实现供你使用

  • InMemoryQueue - 一个内存队列。主要用于测试或CLI使用。一旦进程结束,数据将会丢失。

  • DoctrineQueue - 一个Doctrine DBAL队列。将数据存储在你的Doctrine/PDO兼容数据库中,因此是持久的。

  • LazyQueue - 一个队列,它接受两个QueueInterface实现作为参数。它将尽可能长时间地在主队列上工作,并在必要时回退到第二个队列。可以通过使用commit()方法将结果从第一个队列传输到第二个队列。主要用例是为了防止例如数据库被击穿,使用$queue = new LazyQueue(new InMemoryQueue(), new DoctrineQueue())。这样,你就可以获得持久性(通过调用$queue->commit($jobId)来完成)以及效率。

开始爬取

在拥有我们的 Escargot 实例之后,我们可以开始抓取,这是通过调用 crawl() 方法来实现的

<?php

$escargot->crawl();

订阅者

你可能想知道如何访问你的抓取过程的结果。在 Escargot 中,crawl() 方法不返回任何内容,而是将一切传递给订阅者,这样你就可以决定如何处理沿途收集到的结果。Escargot 执行的每个请求的流程如下,对应于订阅者的相应方法:

  1. 决定是否应该发送请求(如果没有订阅者请求该请求,则不执行)

    SubscriberInterface:shouldRequest()

  2. 如果发送了请求,等待第一个响应块并决定是否加载整个响应体

    SubscriberInterface:needsContent()

  3. 如果请求了体,数据将在最后到达的响应最后一个块时传递给订阅者

    SubscriberInterface:onLastChunk()

通过实现 SubscriberInterface 并使用 Escargot::addSubscriber() 注册它来完成添加订阅者

<?php

$escargot->addSubscriber(new MySubscriber());

根据每个请求的流程,SubscriberInterface 要求你实现 3 个方法

  • shouldRequest(CrawlUri $crawlUri, string $currentDecision): string;

    在执行请求之前会调用此方法。注意逻辑是相反的:如果没有注册的订阅者请求 Escargot 执行请求,则不会进行请求。这提供了更多的灵活性。如果反过来,一个订阅者可以取消请求,从而导致另一个订阅者无法获得任何结果。你可以返回以下 3 个常量之一:

    • SubscriberInterface::DECISION_POSITIVE

      返回积极决定将导致请求被执行,不管其他订阅者返回什么。它还会导致对当前订阅者调用 needsContent()

    • SubscriberInterface::DECISION_ABSTAIN

      返回放弃决定不会导致请求被执行。然而,如果任何其他订阅者返回积极决定,则仍会对当前订阅者调用 needsContent()

    • SubscriberInterface::DECISION_NEGATIVE

      返回消极决定将确保不会在当前订阅者上调用 needsContent(),不管是否有其他订阅者返回积极决定,从而导致请求被执行。

  • needsContent(CrawlUri $crawlUri, ResponseInterface $response, ChunkInterface $chunk, string $currentDecision): string;

    当请求的第一个块到达时,会调用此方法。你现在可以访问所有头信息,但响应的内容可能尚未加载。注意逻辑是相反的:如果没有注册的订阅者请求 Escargot 提供内容,它将取消请求并提前终止。你仍然可以返回以下 3 个常量之一:

    • SubscriberInterface::DECISION_POSITIVE

      返回积极决定将导致请求完成(加载整个响应内容),不管其他订阅者返回什么。它还会导致对当前订阅者调用 onLastChunk()

    • SubscriberInterface::DECISION_ABSTAIN

      返回放弃决定不会导致请求完成。然而,如果任何其他订阅者返回积极决定,则仍会对当前订阅者调用 onLastChunk()

    • SubscriberInterface::DECISION_NEGATIVE

      返回消极决定将确保不会在当前订阅者上调用 onLastChunk(),不管是否有其他订阅者返回积极决定,从而导致请求完成。

  • onLastChunk(CrawlUri $crawlUri, ResponseInterface $response, ChunkInterface $chunk): void;

    如果在 needsContent() 阶段,某个订阅者返回了积极决定,那么在 needsContent() 阶段期间放弃或积极回复的所有订阅者都将收到响应内容。

还有 2 个其他接口你可能想集成,但你不必这样做

  • ExceptionSubscriberInterface

    在此接口中需要实现两个方法

    onTransportException(CrawlUri $crawlUri, ExceptionInterface $exception, ResponseInterface $response): void;

    此类异常会在传输出现问题时抛出,例如超时等。

    onHttpException(CrawlUri $crawlUri, ExceptionInterface $exception, ResponseInterface $response, ChunkInterface $chunk): void;

    如果状态码在300-599范围内,则会抛出此类异常。

    更多信息,请参阅Symfony HttpClient文档

  • FinishedCrawlingSubscriberInterface

    在此接口中只需实现一个方法

    finishedCrawling(): void;

    一旦爬取完成(这不意味着没有挂起的队列项,你也许已经达到了最大请求数量),实现此接口的所有订阅者都将被调用。

标签

有时你可能想在任何CrawlUri实例中添加元信息,这样你就可以让其他订阅者决定如何处理这些信息,或者在另一个请求中这些信息可能相关。例如,RobotsSubscriberCrawlUri实例包含<meta name="robots" content="nofollow">或在相应的X-Robots-Tag标题中设置了时,会标记这些实例。然后,在这个URI上找到的所有链接都不会被跟随,这发生在下一个shouldRequest()调用期间。

可能存在一些情况,单靠标签是不够的。假设你有一个订阅者,它想向一个实际上需要从文件系统或HTTP再次加载的CrawlUri实例添加信息。也许没有任何其他订阅者使用这些数据?而且你该如何在队列中存储所有这些信息呢?这就是为什么你可以延迟解析标签值。可以通过调用$escargot->resolveTagValue($tag)来完成。Escargot会询问所有实现TagValueResolvingSubscriberInterface的订阅者以获取解析后的值。

所以,如果你想在你订阅者中提供延迟加载的信息,只需添加一个常规标签——比如说my-file-info,并实现一个返回真实值的TagValueResolvingSubscriberInterface,一旦有人请求那个my-file-info标签的值。

换句话说,有时候只需要调用$crawlUri->hasTag('foobar-tag')就足够了,有时候你可能想要求Escargot使用$escargot->resolveTagValue('foobar-tag')解析标签值。这完全取决于订阅者。

爬取网站(HTML爬虫)

当人们听到“爬取”或“爬虫”这个词时,他们通常会立刻想到爬取网站。诚然,这也是这个库的主要目的之一,但如果你仔细想想,你之前所学的关于Escargot的内容与爬取网站或HTML没有任何关系。Escargot可以爬取任何基于HTTP的内容,你可以编写一个订阅者,从JSON响应中提取新URI,然后继续爬取。

不是吗?

要将我们的Escargot实例变成一个合适的网络爬虫,我们可以注册默认提供的以下两个订阅者

  • RobotsSubscriber

    此订阅者处理robots.txt内容,X-Robots-Tag标题和<meta name="robots"> HTML标签。因此,它会

    • 根据X-Robots-Tag标题设置CrawlUri标签。
    • 根据<meta name="robots"> HTML标签设置CrawlUri标签。
    • 分析Sitemap中的条目,并将找到的URI添加到队列中。
    • 根据robots.txt内容处理不允许的路径。

    此订阅者永远不会执行任何请求,因为它不关心是否请求。但如果确实请求,它会在所有找到的CrawlUri实例上添加元信息(标签)。

  • HtmlCrawlerSubscriber

    此订阅者分析HTML,然后搜索链接并将这些链接添加到队列中。它

    • 如果链接包含rel="nofollow"属性,则设置CrawlUri标签。
    • 如果链接包含属性type且值不等于text/html,则设置CrawlUri标签。
    • 为每个data-*属性(例如,为data-foobar-tag设置foobar-tag)设置CrawlUri标签。值将被忽略。

    此订阅者永远不会执行任何请求,因为它不在乎是否请求任何内容。

    注意:如果想要使链接完全被HtmlCrawlerSubscriber忽略,可以在链接上使用data-escargot-ignore

使用方式如下

<?php

use Terminal42\Escargot\Subscriber\HtmlCrawlerSubscriber;
use Terminal42\Escargot\Subscriber\RobotsSubscriber;

$escargot->addSubscriber(new RobotsSubscriber());
$escargot->addSubscriber(new HtmlCrawlerSubscriber());

这两个订阅者将帮助我们构建爬虫,但我们还需要添加一个实际上在shouldRequest()上返回肯定决定的订阅者。否则,永远不会执行任何请求。这就是你介入的地方,你可以自由决定是否要尊重先前订阅者的标签,或者不关心。一个可能的解决方案可能如下所示

<?php

use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Terminal42\Escargot\CrawlUri;
use Terminal42\Escargot\EscargotAwareInterface;
use Terminal42\Escargot\EscargotAwareTrait;
use Terminal42\Escargot\Subscriber\HtmlCrawlerSubscriber;
use Terminal42\Escargot\Subscriber\RobotsSubscriber;
use Terminal42\Escargot\Subscriber\SubscriberInterface;
use Terminal42\Escargot\Subscriber\Util;

class MyWebCrawler implements SubscriberInterface, EscargotAwareInterface
{
    use EscargotAwareTrait;

    public function shouldRequest(CrawlUri $crawlUri): string
    {
        // Check the original crawlUri to see if that one contained nofollow information
        if (null !== $crawlUri->getFoundOn() && ($originalCrawlUri = $this->getEscargot()->getCrawlUri($crawlUri->getFoundOn()))) {
            if ($originalCrawlUri->hasTag(RobotsSubscriber::TAG_NOFOLLOW)) {
                return SubscriberInterface::DECISION_NEGATIVE;
            }
        }
        
        // Skip links that were disallowed by the robots.txt
        if ($crawlUri->hasTag(RobotsSubscriber::TAG_DISALLOWED_ROBOTS_TXT)) {
            return SubscriberInterface::DECISION_NEGATIVE;
        }
    
        // Skip rel="nofollow" links
        if ($crawlUri->hasTag(HtmlCrawlerSubscriber::TAG_REL_NOFOLLOW)) {
            return SubscriberInterface::DECISION_NEGATIVE;
        }
        
        // Hint: All of the above are typical for HTML crawlers, so there's a helper for you
        // to simplify this:
        if (!Util::isAllowedToFollow($crawlUri, $this->getEscargot())) {
            return SubscriberInterface::DECISION_NEGATIVE;
        }
    
        // Skip the links that have the "type" attribute set and it's not text/html
        if ($crawlUri->hasTag(HtmlCrawlerSubscriber::TAG_NO_TEXT_HTML_TYPE)) {
            return SubscriberInterface::DECISION_NEGATIVE;
        }
    
        // Skip links that do not belong to our BaseUriCollection
        if (!$this->escargot->getBaseUris()->containsHost($crawlUri->getUri()->getHost())) {
            return SubscriberInterface::DECISION_NEGATIVE;
        }

        return SubscriberInterface::DECISION_POSITIVE;
    }

    public function needsContent(CrawlUri $crawlUri, ResponseInterface $response, ChunkInterface $chunk): string
    {
        return 200 === $response->getStatusCode() && Util::isOfContentType($response, 'text/html') ? SubscriberInterface::DECISION_POSITIVE : SubscriberInterface::DECISION_NEGATIVE;
    }

    public function onLastChunk(CrawlUri $crawlUri, ResponseInterface $response, ChunkInterface $chunk): void
    {
        // Do something with the data
    }
}

你现在有一个完整的网络爬虫。现在取决于你决定尊重哪些不同订阅者的标签,或者不关心,以及你实际上想对结果做什么。

在订阅者中记录日志

当然,你可以始终使用依赖注入,并注入你想要在订阅者中使用的任何日志记录服务。然而,你也可以通过使用Escargot::withLogger()将一个通用日志记录器传递给Escargot。通过让所有订阅者记录到这个中央日志记录器(或者加上你自己注入的另一个日志记录器),确保Escargot有一个中央位置供所有订阅者记录信息。在99%的情况下,我们希望知道

  • 哪个订阅者记录了消息
  • 当时处理了哪个CrawlUri实例

这就是为什么Escargot会自动将使用Escargot::withLogger()配置的日志记录器实例传递给每个实现了PSR-6 LoggerAwareInterface的订阅者。它内部进行装饰,这样相关的订阅者就已经知道了,你不必处理这个问题

<?php

use Terminal42\Escargot\CrawlUri;
use Terminal42\Escargot\Subscriber\SubscriberInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LogLevel;

class MyWebCrawler implements SubscriberInterface, LoggerAwareInterface
{
    use LoggerAwareTrait;

    public function shouldRequest(CrawlUri $crawlUri): string
    {
        if (null !== $this->logger) {
            $this->logger->log(LogLevel::DEBUG, 'My log message');
        }
    }
}

由于日志记录器是自动装饰的,它最终会进入使用Escargot::withLogger()配置的日志记录器,以及包含['source' => 'MyWebCrawler']的PSR-6 $context

为了确保当需要将CrawlUri实例也传递到PSR-6 $context数组时更容易,可以使用以下方式使用SubscriberLoggerTrait

<?php

use Terminal42\Escargot\CrawlUri;
use Terminal42\Escargot\SubscriberLoggerTrait;
use Terminal42\Escargot\Subscriber\SubscriberInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LogLevel;

class MyWebCrawler implements SubscriberInterface, LoggerAwareInterface
{
    use LoggerAwareTrait;
    use SubscriberLoggerTrait;

    public function shouldRequest(CrawlUri $crawlUri): string
    {
        // No need to check for $this->logger being null, this is handled by the trait
        $this->logWithCrawlUri($crawlUri, LogLevel::DEBUG, 'My log message');
    }
}

配置

你可以应用不同的配置到Escargot实例上

  • Escargot::withHttpClient(HttpClientInterface $client): Escargot

    如果你想要使用特定的实现或用自定义选项配置它,可以提供一个Symfony\Component\HttpClient\HttpClientInterface实例。如果你不提供任何客户端,将使用HttpClient::create(),因此将为你自动选择最佳客户端。

  • Escargot::withMaxRequests(int $maxRequests): Escargot

    返回一个具有最大总请求次数的Escargot实例,这在你只有有限的资源且只想在此运行中执行例如100个请求时可能很有用。

  • Escargot::withMaxDurationInSeconds(int $maxDurationInSeconds): Escargot

    返回一个具有最大运行总秒数的Escargot实例,这在你有有限的资源且只想在此运行中执行爬取过程例如30秒时可能很有用。

  • Escargot::withUserAgent(string $userAgent): Escargot

    返回一个具有不同 User-Agent 头的 Escargot 实例的克隆。该头在所有请求中发送,默认配置为 terminal42/escargot。注意:这仅适用于您没有使用 Escargot::withHttpClient() 配置自己的 HttpClientInterface 实例的情况。

  • Escargot::withConcurrency(int $concurrency): Escargot

    返回一个具有最大并发请求的 Escargot 实例的克隆,这些请求将在一次发送。默认情况下,此配置为 10

  • Escargot::withRequestDelay(int $requestDelay): Escargot

    返回一个具有在请求之间添加延迟(以微秒为单位)的 Escargot 实例的克隆。默认情况下,没有额外延迟。这有助于确保 Escargot 不遇到某些(D)DOS 保护或类似问题。

  • Escargot::withLogger(LoggerInterface $logger): Escargot

    返回一个具有您的 PSR-3 Psr\Log\LoggerInterface 实例的 Escargot 实例的克隆,以便更深入地了解在 Escargot 中发生的事情。

使用 Escargot 的项目

致谢

  • 标志由出色的 @fkaminski 设计,谢谢!

路线图 / 灵感

  • 在开始爬取内容之前,让 Escargot 解释 JavaScript 怎么样?这应该通过一个连接到 symfony/pantherfacebook/webdriverHttpClientInterface 实现来实现。欢迎 PR!