terminal42 / escargot
基于Symfony组件的Web爬虫或蜘蛛库
Requires
- php: ^8.1
- ext-simplexml: *
- nyholm/psr7: ^1.1
- psr/http-message: ^1.0 || ^2.0
- psr/log: ^1.1 || ^2.0 || ^3.0
- symfony/clock: ^6.2 || ^7.0
- symfony/dom-crawler: ^5.4 || ^6.0 || ^7.0
- symfony/event-dispatcher: ^5.4 || ^6.0 || ^7.0
- symfony/http-client: ^5.4 || ^6.0 || ^7.0
- webignition/robots-txt-file: ^3.0
Requires (Dev)
- doctrine/dbal: ^3.6
- fig/log-test: ^1.0
- symfony/finder: ^5.4|| ^6.0 || ^7.0
- symfony/phpunit-bridge: ^5.4 || ^6.0 || ^7.0
- terminal42/contao-build-tools: @dev
This package is auto-updated.
Last update: 2024-09-12 14:22:46 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
执行的每个请求的流程如下,对应于订阅者的相应方法:
-
决定是否应该发送请求(如果没有订阅者请求该请求,则不执行)
SubscriberInterface:shouldRequest()
-
如果发送了请求,等待第一个响应块并决定是否加载整个响应体
SubscriberInterface:needsContent()
-
如果请求了体,数据将在最后到达的响应最后一个块时传递给订阅者
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
实例中添加元信息,这样你就可以让其他订阅者决定如何处理这些信息,或者在另一个请求中这些信息可能相关。例如,RobotsSubscriber
在CrawlUri
实例包含<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 的项目
- Contao,一个开源 CMS 自 4.9 版本开始使用
Escargot
,这极大地改进了搜索索引的方式。
致谢
- 标志由出色的 @fkaminski 设计,谢谢!
路线图 / 灵感
- 在开始爬取内容之前,让
Escargot
解释 JavaScript 怎么样?这应该通过一个连接到symfony/panther
或facebook/webdriver
的HttpClientInterface
实现来实现。欢迎 PR!