fi1a/crawler

PHP爬虫

dev-main 2023-02-22 02:43 UTC

This package is auto-updated.

Last update: 2024-09-22 05:46:50 UTC


README

Latest Version Software License PHP Version Coverage Status Total Downloads Support mail

此包提供用于绕过链接和下载文件的API(网站解析)。使用此包,您可以获取任何外部网站的信息。您还可以创建自定义处理器,以便自定义页面解析、准备和保存的逻辑。

安装

可以使用Composer将此包安装为依赖项。

composer require fi1a/crawler

解析过程步骤

解析过程分为三个步骤

  1. 加载;
  2. 处理;
  3. 写入。

在“加载”步骤中,通过实现接口Fi1a\Crawler\UriParsers\UriParserInterface的类进行页面或文件的遍历和加载。通过实现接口Fi1a\Crawler\Restrictions\RestrictionInterface的类来确定是否加载地址。

“处理”步骤紧跟在“加载”步骤之后。在此步骤中,通过实现接口Fi1a\Crawler\UriTransformers\UriTransformerInterface的类转换地址。

最后是“写入”步骤。在写入之前,通过实现接口Fi1a\Crawler\PrepareItems\PrepareItemInterface的类(如Fi1a\Crawler\PrepareItems\PrepareHtmlItem)准备内容(将旧链接替换为新链接)。写入操作通过Fi1a\Crawler\Writers\WriterInterface执行。

通过Fi1a\Crawler\Crawler类的run方法可以依次启动这三个步骤,但也可以通过download(加载步骤)、process(处理步骤)和write(写入步骤)方法依次执行步骤。

示例

以下列出了爬取网站时最常遇到的任务。

创建网站副本

使用提供的代码可以创建https://some-domain.ru的网站副本到指定的目录__DIR__ . '/local-site'

use Fi1a\Crawler\Config;
use Fi1a\Crawler\ConfigInterface;
use Fi1a\Crawler\Crawler;
use Fi1a\Crawler\ItemStorages\ItemStorage;
use Fi1a\Crawler\ItemStorages\StorageAdapters\LocalFilesystemAdapter;
use Fi1a\Crawler\Writers\FileWriter;

$config = new Config();

$config->setVerbose(ConfigInterface::VERBOSE_DEBUG)
    ->setSizeLimit('5Mb')
    ->addStartUri('https://some-domain.ru');

$crawler = new Crawler($config, new ItemStorage(new LocalFilesystemAdapter(__DIR__ . '/runtime/storage')));

$crawler->setWriter(new FileWriter(__DIR__ . '/local-site'));

$crawler->run();

首先需要创建配置对象Config,并将其与类ItemStorage的对象一起传递给类的构造函数。然后使用类的setWriter方法设置对象FileWriter,该对象实现将元素(页面、文件)保存到本地文件系统的逻辑。

使用类的run方法启动网站解析。

新闻解析

新闻链接解析器类。在页面列表中找到并返回与详细新闻和列表相关的链接。

namespace Foo\UriParsers;

use Fi1a\Console\IO\ConsoleOutputInterface;
use Fi1a\Crawler\ItemInterface;
use Fi1a\Crawler\UriCollection;
use Fi1a\Crawler\UriCollectionInterface;
use Fi1a\Crawler\UriParsers\UriParserInterface;
use Fi1a\Http\Uri;
use Fi1a\Log\LoggerInterface;
use Fi1a\SimpleQuery\SimpleQuery;
use InvalidArgumentException;

/**
 * Парсер ссылок новостей
 */
class NewsUriParser implements UriParserInterface
{
    /**
     * @inheritDoc
     */
    public function parse(
        ItemInterface $item,
        ConsoleOutputInterface $output,
        LoggerInterface $logger
    ): UriCollectionInterface {
        $collection = new UriCollection();

        if (
            !$item->isAllow()
            || $item->getItemUri()->host() !== 'news-domain.ru'
            || $item->getItemUri()->path() !== '/news/'
        ) {
            return $collection;
        }

        $sq = new SimpleQuery((string) $item->getBody());

        // выбираем ссылки ведущие на детальную новости и ссылки постраничной навигации
        $nodes = $sq('#news .header, #news .pm_s, #news .pm_n');
        /** @var \DOMElement $node */
        foreach ($nodes as $node) {
            $value = $sq($node)->attr('href');
            if (!is_string($value) || !$value) {
                continue;
            }
            try {
                $uri = new Uri($value);
            } catch (InvalidArgumentException $exception) {
                continue;
            }

            $collection[] = $uri;
        }

        return $collection;
    }
}

链接转换器类。将新闻链接从源格式转换为新格式(https://news-domain.ru/news/news-code-1.html → /news/news-code-1/)。

namespace Foo\UriTransformers;

use Fi1a\Console\IO\ConsoleOutputInterface;
use Fi1a\Crawler\ItemInterface;
use Fi1a\Crawler\UriTransformers\UriTransformerInterface;
use Fi1a\Http\UriInterface;
use Fi1a\Log\LoggerInterface;

/**
 * Преобразует uri новостей из внешних адресов в новые
 */
class NewsUriTransformer implements UriTransformerInterface
{
    /**
     * @inheritDoc
     */
    public function transform(
        ItemInterface $item,
        ConsoleOutputInterface $output,
        LoggerInterface $logger
    ): UriInterface {
        if (!$item->isAllow()) {
            return $item->getItemUri();
        }

        $isNewsPage = preg_match(
            '#https://news-domain.ru/news/(.+)\.html#mui',
            $item->getItemUri()->uri(),
                $matches
        ) > 0;

        if (!$isNewsPage) {
            $output->writeln('    <color=blue>- Не является ссылкой на новость</>');

            return $item->getItemUri();
        }

        // Преобразуем ссылки на новости в новый формат
        $object = $item->getItemUri()
            ->withHost('')
            ->withPort(null)
            ->withPath('/news/' . $matches[1] . '/');

        return $object;
    }
}

准备类删除与新闻内容无关的“面包屑”和块。

namespace Foo\PrepareItems;

use Fi1a\Console\IO\ConsoleOutputInterface;
use Fi1a\Crawler\ItemCollectionInterface;
use Fi1a\Crawler\ItemInterface;
use Fi1a\Crawler\PrepareItems\PrepareHtmlItem;
use Fi1a\Log\LoggerInterface;
use Fi1a\SimpleQuery\SimpleQuery;

/**
 * Подготавливает HTML новости
 */
class NewsPrepareItem extends PrepareHtmlItem
{
    /**
     * @inheritDoc
     */
    public function prepare(
        ItemInterface $item,
        ItemCollectionInterface $items,
        ConsoleOutputInterface $output,
        LoggerInterface $logger
    ) {
        $isNewsPage = preg_match(
            '#https://news-domain.ru/news/(.+)\.html#mui',
            $item->getItemUri()->uri()
        ) > 0;

        if (!$isNewsPage) {
            return false;
        }

        $sq = new SimpleQuery((string) $item->getBody(), 'UTF-8');

        $news = $sq('#news');
        // Удаляем лишние элементы, остается только новость с заголовком и контентом
        $news('.share, .breadcrumbs, p:last-child')->remove();

        // Заменяем ссылки на новые ссылки новостей
        $this->replace('a', 'href', $news, $item, $items);

        return $news->html();
    }
}

添加/更新1C-Bitrix网站上的新闻的类,将它们写入IB。

namespace Foo\Writers;

use ErrorException;
use Fi1a\Console\IO\ConsoleOutputInterface;
use Fi1a\Crawler\ItemInterface;
use Fi1a\Crawler\Writers\WriterInterface;
use Fi1a\Log\LoggerInterface;
use Fi1a\SimpleQuery\SimpleQuery;
use Bitrix\Main\Loader;
use Bitrix\Iblock\IblockTable;

/**
 * Записывает результат в ИБ 1С-Битрикса
 */
class NewsWriter implements WriterInterface
{
    /**
     * @var int
     */
    protected $newsIblockId;

    public function __construct()
    {
        Loader::includeModule('iblock');

        $iblock = IblockTable::query()
            ->setSelect(['ID',])
            ->where('CODE', '=', 'furniture_news_s1')
            ->exec()
            ->fetch();

        if (!$iblock) {
            throw new ErrorException('Инфоблок новостей не найден');
        }

        $this->newsIblockId = (int) $iblock['ID'];
    }

    /**
     * @inheritDoc
     */
    public function write(ItemInterface $item, ConsoleOutputInterface $output, LoggerInterface $logger): bool
    {
        $isNewsPage = preg_match(
                '#https://news-domain.ru/news/(.+)\.html#mui',
                $item->getItemUri()->uri(),
                $matches
            ) > 0;

        if (!$isNewsPage) {
            $output->writeln('    <color=blue>- Не является страницей новости</>');

            return false;
        }

        $sq = new SimpleQuery((string) $item->getPrepareBody(), 'UTF-8');

        $name = $sq('h1')->html();
        $sq('h1')->remove();
        $code = $matches[1];
        $detailText = $sq('body')->html();
        $previewText = \TruncateText(strip_tags($detailText), 50);

        $fields = [
            'IBLOCK_ID' => $this->newsIblockId,
            'NAME' => $name,
            'CODE' => $code,
            'DETAIL_TEXT' => $detailText,
            'DETAIL_TEXT_TYPE' => 'html',
            'PREVIEW_TEXT' => $previewText,
            'ACTIVE' => 'Y',
        ];

        $news = \CIBlockElement::GetList([], [
            '=IBLOCK_ID' => $this->newsIblockId,
            '=CODE' => $code,
        ], false, false, ['ID'])->Fetch();

        $instance = new \CIBlockElement();

        if ($news) {
            $result = $instance->Update($news['ID'], $fields);
            if ($result === false) {
                $output->writeln('    <error>Не удалось обновить новость: {{}}</>', [$instance->LAST_ERROR]);
            }

            return $result;
        }

        $newsId = (int) $instance->Add($fields);

        if (!$newsId) {
            $output->writeln('    <error>Не удалось создать новость: {{}}</>', [$instance->LAST_ERROR]);

            return false;
        }

        return true;
    }
}

创建配置对象Config,设置值

  • 输出详细程度;
  • 元素在存储中的生命周期(0 - 无限制);
  • 可下载文件的限制(所有类型文件为5Mb);
  • 添加入口点,从该点开始遍历(https://news-domain.ru/news/ - 新闻列表)

设置定义行为的类

  • 方法setUriParser设置用于遍历的uri解析器(取决于内容类型);
  • 方法setUriTransformer设置地址转换器类,将外部地址转换为内部地址;
  • 方法setPrepareItem设置准备内容的类(删除与新闻无关的额外标签)。
  • 方法 setWriter 设置遍历结果的写入类(将新闻写入1C-Битрикс数据库)。

使用类 Fi1a\Crawler\Crawler 的方法 loadFromStorage 从存储中加载上次启动时处理过的元素,并为新闻列表页面标记重复处理,以查找新添加的新闻。

使用类 Fi1a\Crawler\Crawler 的方法 run 启动新闻的解析。

use Fi1a\Crawler\Config;
use Fi1a\Crawler\ConfigInterface;
use Fi1a\Crawler\Crawler;
use Fi1a\Crawler\ItemInterface;
use Fi1a\Crawler\ItemStorages\ItemStorage;
use Fi1a\Crawler\ItemStorages\StorageAdapters\LocalFilesystemAdapter;
use Fi1a\Http\Mime;
use Foo\PrepareItems\NewsPrepareItem;
use Foo\UriParsers\NewsUriParser;
use Foo\UriTransformers\NewsUriTransformer;
use Foo\Writers\NewsWriter;

$config = new Config();

$config->setVerbose(ConfigInterface::VERBOSE_DEBUG)
    ->setLifetime(0)
    ->setSizeLimit('5Mb')
    ->addStartUri('https://news-domain.ru/news/');

$crawler = new Crawler($config, new ItemStorage(new LocalFilesystemAdapter(__DIR__ . '/runtime/storage')));

$crawler->setUriParser(new NewsUriParser(), Mime::HTML)
    ->setUriTransformer(new NewsUriTransformer())
    ->setPrepareItem(new NewsPrepareItem())
    ->setWriter(new NewsWriter(), Mime::HTML);

$crawler->loadFromStorage();

// При повторном запуске страницы списка помечаем на повторную обработку для добавления новых новостей
foreach ($crawler->getItems() as $item) {
    assert($item instanceof ItemInterface);
    if (
        !$item->isAllow()
        || $item->getItemUri()->host() !== 'news-domain.ru'
        || $item->getItemUri()->path() !== '/news/'
    ) {
        continue;
    }

    $item->setDownloadStatus(null);
    $item->setProcessStatus(null);
    $item->setWriteStatus(null);
}

$crawler->run();

包中的主要类

以下是包中的主要类。使用一些类可以配置解析器的行为,而使用其他类可以扩展其功能。

  • Fi1a\Crawler\Crawler - 包的主要类;
  • Fi1a\Crawler\Config - 解析器配置;
  • Fi1a\Crawler\UriCollection - 地址集合;
  • Fi1a\Crawler\Item - 遍历元素;
  • Fi1a\Crawler\ItemCollection - 遍历元素集合;
  • Fi1a\Crawler\ItemStorages\ItemStorage - 实现解析元素存储;
    • Fi1a\Crawler\ItemStorages\StorageAdapters\LocalFilesystemAdapter - 用于本地文件系统存储的适配器;
    • Fi1a\Crawler\ItemStorages\StorageAdapters\FilesystemAdapter - 用于文件系统存储的适配器;
  • 代理
    • Fi1a\Crawler\Proxy\Proxy - 用于请求的代理;
    • Fi1a\Crawler\Proxy\ProxyCollection - 代理集合;
    • Fi1a\Crawler\Proxy\ProxyStorage - 实现代理存储;
      • Fi1a\Crawler\Proxy\StorageAdapters\LocalFilesystemAdapter - 用于本地文件系统存储的适配器;
      • Fi1a\Crawler\Proxy\StorageAdapters\FilesystemAdapter - 用于文件系统存储的适配器;
    • 为请求选择合适的代理
      • Fi1a\Crawler\Proxy\Selections\FilterByAttempts - 根据连接错误数量过滤代理;
      • Fi1a\Crawler\Proxy\Selections\Limit - 对选择的代理数量进行限制;
      • Fi1a\Crawler\Proxy\Selections\OnlyActive - 根据活动状态过滤代理;
      • Fi1a\Crawler\Proxy\Selections\SortedByTime - 按时间使用情况进行排序;
  • 扩展操作类的类
    • 限制uri遍历
      • Fi1a\Crawler\Restrictions\NotAllowRestriction - 对所有uri的遍历进行禁止;
      • Fi1a\Crawler\Restrictions\UriRestriction - 根据域名和路径进行限制;
    • 加载步骤
      • Fi1a\Crawler\UriParsers\HtmlUriParser - 解析html并返回用于遍历的uri;
    • 转换uri步骤
      • Fi1a\Crawler\UriTransformers\SiteUriTransformer - 将外部地址转换为本地地址;
    • 写入步骤
      • Fi1a\Crawler\PrepareItem\PrepareHtmlItem - 准备HTML元素(替换页面上的链接);
      • Fi1a\Crawler\Writers\FileWriter - 将遍历结果写入文件;

配置对象

  • startUri - 遍历的起点;
    • addStartUri(string $startUri) - 添加起点;
    • getStartUri(): array - 返回添加的起点;
  • httpClientConfig - http客户端配置对象 (更多关于配置对象的信息);
    • setHttpClientConfig(Fi1a\HttpClient\ConfigInterface $config) - 设置http客户端配置对象;
    • getHttpClientConfig(): Fi1a\HttpClient\ConfigInterface - 返回http客户端配置对象;
  • httpClientHandler ("Fi1a\HttpClient\Handlers\StreamHandler") - 请求处理器(可能的值:"Fi1a\HttpClient\Handlers\StreamHandler", "Fi1a\HttpClient\Handlers\CurlHandler")
    • setHttpClientHandler(string $handler) - 设置请求处理器;
    • getHttpClientHandler(): string - 返回请求处理器;
  • verbose (ConfigInterface::VERBOSE_NORMAL) - 输出详细程度(可能的值:ConfigInterface::VERBOSE_NONE, ConfigInterface::VERBOSE_NORMAL, ConfigInterface::VERBOSE_HIGHT, ConfigInterface::VERBOSE_HIGHTEST, ConfigInterface::VERBOSE_DEBUG);
    • setVerbose(int $verbose) - 设置输出详细程度;
    • getVerbose(): int - 返回输出详细程度;
  • logChannel ("crawler") - 日志记录通道;
    • setLogChannel(string $logChannel) - 设置日志记录通道;
    • getLogChannel(): string - 返回日志记录通道;
  • saveAfterQuantity (10) - 参数,指定在保存元素到存储中之前新元素的数量;
    • setSaveAfterQuantity(int $quantity) - 设置参数,用于确定在存储中保存元素的新元素数量;
    • getSaveAfterQuantity(): int - 返回参数,用于确定在存储中保存元素的新元素数量;
  • lifeTime (24 * 60 * 60) - 元素在存储中的存活时间;
    • setLifetime(int $lifeTime) - 设置元素在存储中的存活时间;
    • getLifetime(): int - 返回元素在存储中的存活时间;
  • delay ([0, 0]) - 请求之间的暂停;
    • setDelay($delay) - 设置请求之间的暂停(可能值:int|array);
    • getDelay(): array - 返回请求之间的暂停;
  • sizeLimits - 对按内容类型加载的文件的限制;
    • setSizeLimit($sizeLimit, ?string $mime = null) - 设置按内容类型加载的文件的限制;
    • getSizeLimits(): array - 返回按内容类型加载的文件的限制;
  • retry (3) - 在HTTP错误时请求地址的尝试次数;
    • setRetry(int $retry) - 设置在HTTP错误时请求地址的尝试次数;
    • getRetry(): int - 返回在HTTP错误时请求地址的尝试次数。

示例

  • 将输出详细程度设置为最高 ConfigInterface::VERBOSE_DEBUG;
  • 所有上传文件限制为5MB,jpeg类型文件限制为1MB;
  • 请求之间的暂停时间随机从3到10秒;
  • 添加入口点 https://some-domain.ru
use Fi1a\Crawler\Config;
use Fi1a\Crawler\ConfigInterface;

$config = new Config();

$config->setVerbose(ConfigInterface::VERBOSE_DEBUG)
    ->setSizeLimit('5Mb')
    ->setSizeLimit('1Mb', 'image/jpeg')
    ->setDelay([3, 10])
    ->addStartUri('https://some-domain.ru');

限制绕过

为了限制解析器使用,使用实现接口 Fi1a\Crawler\Restrictions\RestrictionInterface 的类,通过 Fi1a\Crawler\Crawler 类的 addRestriction 方法添加。

该包中有两个类用于实现限制

  • Fi1a\Crawler\Restrictions\NotAllowRestriction - 禁止绕过;
  • Fi1a\Crawler\Restrictions\UriRestriction - 根据域名和路径进行限制;

示例限制绕过 some-domain.ru 域的 news 文件夹

use Fi1a\Crawler\Restrictions\UriRestriction;

$crawler->addRestriction(new UriRestriction('https://some-domain.ru/news/'));

如果在开始加载步骤时没有设置限制,它们 Fi1a\Crawler\Restrictions\UriRestriction 将自动添加到根据 addStartUri 方法在配置对象中指定的入口点。

绕过元素

在解析时,类 Fi1a\Crawler\Item 作为最终地址的终点使用,其中包含解析所需的所有信息。

类方法

可以通过 Fi1a\Crawler\Crawler 类的 getItems 方法获取绕过元素集合;

绕过元素集合获取器

在执行解析或使用 Fi1a\Crawler\Crawler 类的 loadFromStorage 方法从存储中加载元素后,将获得元素 Fi1a\Crawler\ItemCollectionInterface 集合,可以通过 Fi1a\Crawler\Crawler 类的 getItems 方法获取。

该集合有一些辅助方法,允许根据某种属性过滤集合中的元素

  • getDownloaded - 返回成功下载的元素;
  • getProcessed - 返回成功处理的元素;
  • getWrited - 返回成功写入的元素;
  • getImages - 返回所有图像元素;
  • getFiles - 返回所有文件元素;
  • getPages - 返回所有页面元素;
  • getCss - 返回所有CSS文件元素;
  • getJs - 返回所有JavaScript文件元素。

示例将输出所有已下载的图像的链接

$crawler->loadFromStorage();
$collection = $crawler->getItems();

foreach ($collection->getDownloaded()->getImages() as $item) {
    echo $item->getItemUri()->uri() . PHP_EOL;
}

请求时使用代理

在解析网站时,经常需要使用代理。该包有用于请求时处理代理的辅助类。

在工作时记录或读取代理信息使用类 Fi1a\Crawler\Proxy\ProxyStorage。该类实现了代理存储。如何存储由构造函数的第一个参数(适配器 Fi1a\Crawler\Proxy\StorageAdapters\LocalFilesystemAdapter 将代理存储在json文件中)决定。

提供两种类型的代理:http和socks5代理。以下代码将代理添加到存储中

use Fi1a\Crawler\Proxy\Proxy;
use Fi1a\Crawler\Proxy\ProxyStorage;
use Fi1a\Crawler\Proxy\StorageAdapters\LocalFilesystemAdapter;

$proxyStorage = new ProxyStorage(new LocalFilesystemAdapter(__DIR__ . '/runtime'));

$httpProxy = Proxy::factory([
    'type' => 'http',
    'host' => '127.0.0.1',
    'port' => 50100,
    'userName' => 'username',
    'password' => 'password',
]);
$proxyStorage->save($httpProxy);

$httpProxy = Proxy::factory([
    'type' => 'socks5',
    'host' => '127.0.0.1',
    'port' => 50101,
    'userName' => 'username',
    'password' => 'password',
]);
$proxyStorage->save($httpProxy);

在下次启动解析器时,将自动从存储中加载代理数据并用于请求。

使用类 Fi1a\Crawler\Proxy\Selections\ProxySelectionInterface 选择合适的代理。

  • Fi1a\Crawler\Proxy\Selections\FilterByAttempts - 根据连接错误数量过滤代理;
  • Fi1a\Crawler\Proxy\Selections\Limit - 对选择的代理数量进行限制;
  • Fi1a\Crawler\Proxy\Selections\OnlyActive - 根据活动状态过滤代理;
  • Fi1a\Crawler\Proxy\Selections\SortedByTime - 按时间使用情况进行排序;

以下代码将只选择活动代理(OnlyActive),根据错误次数(FilterByAttempts)进行过滤,按使用时间排序(SortedByTime),并返回一个用于请求的单个代理(Limit)。

$crawler->setProxySelection(new Limit(new SortedByTime(new FilterByAttempts(new OnlyActive(), 3)), 1));

使用存储中的保存代理解析网站的示例

use Fi1a\Crawler\Config;
use Fi1a\Crawler\ConfigInterface;
use Fi1a\Crawler\Crawler;
use Fi1a\Crawler\ItemStorages\ItemStorage;
use Fi1a\Crawler\ItemStorages\StorageAdapters\LocalFilesystemAdapter;
use Fi1a\Crawler\Proxy\ProxyStorage;
use Fi1a\Crawler\Proxy\Selections\FilterByAttempts;
use Fi1a\Crawler\Proxy\Selections\Limit;
use Fi1a\Crawler\Proxy\Selections\OnlyActive;
use Fi1a\Crawler\Proxy\Selections\SortedByTime;
use Fi1a\Crawler\Proxy\StorageAdapters\LocalFilesystemAdapter as ProxyStorageLocalFilesystemAdapter;
use Fi1a\Crawler\Writers\FileWriter;

$config = new Config();

$config->setVerbose(ConfigInterface::VERBOSE_DEBUG)
    ->setSizeLimit('5Mb')
    ->addStartUri('https://some-domain.ru');

$crawler = new Crawler(
    $config,
    new ItemStorage(new LocalFilesystemAdapter(__DIR__ . '/runtime/storage')),
    new ProxyStorage(new ProxyStorageLocalFilesystemAdapter(__DIR__ . '/runtime'))
);

$crawler->setProxySelection(new Limit(new SortedByTime(new FilterByAttempts(new OnlyActive(), 3)), 1))
    ->setWriter(new FileWriter(__DIR__ . '/local-site'));

$crawler->run();