fi1a / crawler
PHP爬虫
Requires
- php: ^7.3 || ^8
- ext-dom: *
- ext-json: *
- fi1a/collection: ^2.0
- fi1a/console: ^2.1
- fi1a/filesystem: ^1.0
- fi1a/http: dev-main
- fi1a/http-client: dev-main
- fi1a/log: dev-master
- fi1a/simplequery: ^2.0
Requires (Dev)
- ext-posix: *
- captainhook/captainhook: ^5.11
- fi1a/var-dumper: dev-master
- phpunit/phpunit: ^9.5
- slevomat/coding-standard: ^8.6
- squizlabs/php_codesniffer: ^3.7
- vimeo/psalm: ^4.3
This package is auto-updated.
Last update: 2024-09-22 05:46:50 UTC
README
此包提供用于绕过链接和下载文件的API(网站解析)。使用此包,您可以获取任何外部网站的信息。您还可以创建自定义处理器,以便自定义页面解析、准备和保存的逻辑。
安装
可以使用Composer将此包安装为依赖项。
composer require fi1a/crawler
解析过程步骤
解析过程分为三个步骤
- 加载;
- 处理;
- 写入。
在“加载”步骤中,通过实现接口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
- 将遍历结果写入文件;
- 限制uri遍历
配置对象
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();