ReactPHP 爬虫实战:下载整个网站的图片

什么是网页抓取?

你是否曾经需要从一个没有提供 API 的站点获取信息? 我们可以通过网页抓取,然后从目标网站的 HTML 中获得我们想要的信息,进而解决这个问题。 当然,我们也可以手动提取这些信息, 但手动操作很乏味。 所以, 通过爬虫来自动化来完成这个过程会更有效率。

在这个教程中我们会从 Pexels 抓取一些猫的图片。这个网站提供高质量且免费的素材图片。他们提供了API, 但这些 API 有 200次/小时 的请求频率限制。

发起并发请求

在网页抓取中使用异步 PHP (相比使用同步方式)的最大好处是可以在更短的时间内完成更多的工作。使用异步 PHP 使得我们可以立刻请求尽可能多的网页而不是每次只能请求单个网页并等待结果返回。 因此,一旦请求结果返回我们就可以开始处理。

首先,我们从 GitHub 上拉取一个叫做 buzz-react 的异步 HTTP 客户端的代码 -- 它是一个基于 ReactPHP 的简单、致力于并发处理大量 HTTP 请求的异步 HTTP 客户端:

composer require clue/buzz-react

现在, 我们就可以请求 pexels 上的图片页面 了:

<?php

require __DIR__ . '/vendor/autoload.php';

use Clue\React\Buzz\Browser;

$loop = \React\EventLoop\Factory::create();

$client = new Browser($loop);
$client->get('https://www.pexels.com/photo/kitten-cat-rush-lucky-cat-45170/')
    ->then(function(\Psr\Http\Message\ResponseInterface $response) {
        echo $response->getBody();
    });

$loop->run();

我们创建了 Clue\React\Buzz\Browser 的实例, 把它作为 HTTP client 使用。上面的代码发起了一个异步的 GET 请求来获取网页内容(包含一张小猫们的图片)。 $client->get($url) 方法返回了一个包含 PSR-7 response 的 promise 对象。

客户端是异步工作的,这意味着我们可以很容易地请求几个页面,然后这些请求会被同步执行:

<?php

require __DIR__ . '/vendor/autoload.php';

use Clue\React\Buzz\Browser;

$loop = \React\EventLoop\Factory::create();

$client = new Browser($loop);
$client->get('https://www.pexels.com/photo/kitten-cat-rush-lucky-cat-45170/')
    ->then(function(\Psr\Http\Message\ResponseInterface $response) {
        echo $response->getBody();
    });

$client->get('https://www.pexels.com/photo/adorable-animal-baby-blur-177809/')
    ->then(function(\Psr\Http\Message\ResponseInterface $response) {
        echo $response->getBody();
    });

$loop->run();

这里的代码含义如下:

  • 发起一个请求
  • 获取响应
  • 添加响应的处理程序
  • 当响应解析完毕就处理响应

所以,这个逻辑可以提取到一个类里,这样我们可以很容易地请求多个 URL 并添加相同的响应处理程序。让我们基于Browser创建一个包装器。

用下面的代码创建一个名为Scraper的类:

<?php

use Clue\React\Buzz\Browser;
use Psr\Http\Message\ResponseInterface;

final class Scraper
{
    private $client;

    public function __construct(Browser $client)
    {
        $this->client = $client;
    }

    public function scrape(array $urls)
    {
        foreach ($urls as $url) {
            $this->client->get($url)->then(
                function (ResponseInterface $response) {
                    $this->processResponse((string) $response->getBody());
                });
        }
    }

    private function processResponse(string $html)
    {
        // ...
    }
}

我们把Browser作为依赖项注入到构造函数并提供一个公共方法scrape(array $urls)。接着对每个指定的 URL 发起一个GET请求。当响应完成时,我们调用一个私有方法processResponse(string $html)。这个方法负责遍历 HTML 代码并下载图片。下一步是审查收到的 HTML 代码,然后从里面提取图片。

发起并发请求

在网页抓取中使用异步 PHP (相比使用同步方式)的最大好处是可以在更短的时间内完成更多的工作。使用异步 PHP 使得我们可以立刻请求尽可能多的网页而不是每次只能请求单个网页并等待结果返回。 因此,一旦请求结果返回我们就可以开始处理。

首先,我们从 GitHub 上拉取一个叫做 buzz-react 的异步 HTTP 客户端的代码 -- 它是一个基于 ReactPHP 的简单、致力于并发处理大量 HTTP 请求的异步 HTTP 客户端:

composer require clue/buzz-react

现在, 我们就可以请求 pexels 上的图片页面 了:

<?php

require __DIR__ . '/vendor/autoload.php';

use Clue\React\Buzz\Browser;

$loop = \React\EventLoop\Factory::create();

$client = new Browser($loop);
$client->get('https://www.pexels.com/photo/kitten-cat-rush-lucky-cat-45170/')
    ->then(function(\Psr\Http\Message\ResponseInterface $response) {
        echo $response->getBody();
    });

$loop->run();

我们创建了 Clue\React\Buzz\Browser 的实例, 把它作为 HTTP client 使用。上面的代码发起了一个异步的 GET 请求来获取网页内容(包含一张小猫们的图片)。 $client->get($url) 方法返回了一个包含 PSR-7 response 的 promise 对象。

客户端是异步工作的,这意味着我们可以很容易地请求几个页面,然后这些请求会被同步执行:

<?php

require __DIR__ . '/vendor/autoload.php';

use Clue\React\Buzz\Browser;

$loop = \React\EventLoop\Factory::create();

$client = new Browser($loop);
$client->get('https://www.pexels.com/photo/kitten-cat-rush-lucky-cat-45170/')
    ->then(function(\Psr\Http\Message\ResponseInterface $response) {
        echo $response->getBody();
    });

$client->get('https://www.pexels.com/photo/adorable-animal-baby-blur-177809/')
    ->then(function(\Psr\Http\Message\ResponseInterface $response) {
        echo $response->getBody();
    });

$loop->run();

这里的代码含义如下:

  • 发起一个请求
  • 获取响应
  • 添加响应的处理程序
  • 当响应解析完毕就处理响应

所以,这个逻辑可以提取到一个类里,这样我们可以很容易地请求多个 URL 并添加相同的响应处理程序。让我们基于Browser创建一个包装器。

用下面的代码创建一个名为Scraper的类:

<?php

use Clue\React\Buzz\Browser;
use Psr\Http\Message\ResponseInterface;

final class Scraper
{
    private $client;

    public function __construct(Browser $client)
    {
        $this->client = $client;
    }

    public function scrape(array $urls)
    {
        foreach ($urls as $url) {
            $this->client->get($url)->then(
                function (ResponseInterface $response) {
                    $this->processResponse((string) $response->getBody());
                });
        }
    }

    private function processResponse(string $html)
    {
        // ...
    }
}

我们把Browser作为依赖项注入到构造函数并提供一个公共方法scrape(array $urls)。接着对每个指定的 URL 发起一个GET请求。当响应完成时,我们调用一个私有方法processResponse(string $html)。这个方法负责遍历 HTML 代码并下载图片。下一步是审查收到的 HTML 代码,然后从里面提取图片。

爬取网站

此刻我们只是获取到了响应页面的 HTML 代码。现在需要提取图片 URL。为此,我们需要审查收到的 HTML 代码结构。前往 Pexels 的图片页,右击图片并选择审查元素,你会看到一些东西,就像这样:

我们可以看到img标签有个image-section__image类名。我们要使用这个信息从收到的 HTML 中提取这个标签。图片的 URL 存储在src属性里:

为提取 HTML 标签,我们需要使用 Symfony 的 DomCrawler 组件。拉取需要的包:

composer require symfony/dom-crawler
composer require symfony/css-selector

DomCrawler 的适配组件 CSS-selector 允许我们使用类 - jQuery 的选择器遍历 DOM。当安装好一切之后,打开我们的Scraper类,在processResponse(string $html) 方法里书写一些代码。首先,我们需要创建一个Symfony\Component\DomCrawler\Crawler 类的实例,它的构造函数接受一个用于遍历的 HTML 代码字符串:

<?php

use Clue\React\Buzz\Browser;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\DomCrawler\Crawler;

final class Scraper
{
    // ...

    private function processResponse(string $html)
    {
        $crawler = new Crawler($html);
    }
}

通过类 - jQuery 选择器查找任意元素时,请使用filter()方法。然后,attr($attribute)方法允许提取已过滤元素的某个属性:

<?php

use Clue\React\Buzz\Browser;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\DomCrawler\Crawler;

final class Scraper
{
    // ...

    private function processResponse(string $html)
    {
        $crawler = new Crawler($html);
        $imageUrl = $crawler->filter('.image-section__image')->attr('src');
        echo $imageUrl . PHP_EOL;
    }
}

让我们只打印提取出的图片 URL,检查下我们的 scraper 是否如期工作:

<?php
// index.php

require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/Scraper.php';

use Clue\React\Buzz\Browser;

$loop = \React\EventLoop\Factory::create();

$scraper = new Scraper(new Browser($loop));
$scraper->scrape([
    'https://www.pexels.com/photo/adorable-animal-blur-cat-617278/'
]);

$loop->run();

当运行这个脚本时,将会输出所需图片的完整 URL。然后我们要使用这个 URL 下载该图片。 我们再次创建一个Browser实例,然后发起一个GET请求:

<?php

use Clue\React\Buzz\Browser;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\DomCrawler\Crawler;

final class Scraper
{
    // ...

    private function processResponse(string $html)
    {
        $crawler = new Crawler($html);
        imageUrl = $crawler->filter('.image-section__image')->attr('src');
        $this->client->get($imageUrl)->then(
            function(ResponseInterface $response) {
                // 存储图片到磁盘上
        });
    }
}

到达的响应携带了请求的图片内容。现在我们需要把它保存到磁盘上。但是请花费一点时间,不要使用file_put_contents()。所有的原生 PHP 函数都在文件系统下阻塞式运行。这意味着一旦你调用了file_put_contents(),我们的应用就会停止异步行为。然后流程控制会被阻塞直到文件保存完毕。ReactPHP 有个专门的包可以解决这个问题。

异步保存文件

要以非阻塞方式异步处理文件的话,我们需要一个叫做 reactphp/filesystem 的包。拉取下来:

composer require react/filesystem

要异步使用文件系统,请创建一个Filesystem对象并把它作为依赖项提供给Scraper。此外,我们还需要提供一个目录存放下载的图片:

<?php
// index.php

require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/Scraper.php';

use Clue\React\Buzz\Browser;
use React\Filesystem\Filesystem;

$loop = \React\EventLoop\Factory::create();

$scraper = new ScraperForImages(
    new Browser($loop), Filesystem::create($loop), __DIR__ . '/images'
);

$scraper->scrape([
    'https://www.pexels.com/photo/adorable-animal-blur-cat-617278/'
]);

$loop->run();

这是更新后Scraper的构造函数:

<?php

use Clue\React\Buzz\Browser;
use Psr\Http\Message\ResponseInterface;
use React\Filesystem\FilesystemInterface;
use Symfony\Component\DomCrawler\Crawler;

final class Scraper
{
    private $client;

    private $filesystem;

    private $directory;

    public function __construct(Browser $client, FilesystemInterface $filesystem, string $directory)
    {
        $this->client = $client;
        $this->filesystem = $filesystem;
        $this->$directory = $directory;
    }

    // ...
}

好的,现在我们准备保存文件到磁盘上。首先,我们需要从 URL 提取文件名。图片的 URL 看起来就像这样:

https://images.pexels.com/photos/4602/jumping-cute-playing-animals.jpg?auto=compress&cs=tinysrgb&h=650&w=940**https://images.pexels.com/photos/617278/pexels-photo-617278.jpeg?auto=compress&cs=tinysrgb&h=650&w=940

这些 URL 的文件名是这样的:

jumping-cute-playing-animals.jpg\
pexels-photo-617278.jpeg

让我们使用正则表达式从 URL 里提取出文件名。为了给磁盘上的未来文件获取完整路径,我们用目录把名字串联起来:

<?php

preg_match('/photos\/\d+\/([\w-\.]+)\?/', $imageUrl, $matches); // $matches[1] 包含一个文件名
$filePath = $this->directory . DIRECTORY_SEPARATOR . $matches[1];

当我们有了一个文件路径,就可以用它创建一个 文件 对象:

<?php

$file = $this->filesystem->file($filePath);

此对象表示我们要使用的文件。接着调用putContents($contents) 方法并提供一个响应体(response body)字符串:

<?php

$file = $this->filesystem->file($filePath);
$file->putContents((string)$response->getBody());

就是这样。所有异步的底层魔法隐藏在一个单独的方法内。此 hook 会创建一个写模式的流,写入数据后关闭这个流。这是Scraper::processResponse(string $html)方法的更新版本:

<?php

use Clue\React\Buzz\Browser;
use Psr\Http\Message\ResponseInterface;
use React\Filesystem\FilesystemInterface;
use Symfony\Component\DomCrawler\Crawler;

final class Scraper
{
    // ...

    private function processResponse(string $html)
    {
        $crawler = new Crawler($html);
        $imageUrl = $crawler->filter('.image-section__image')->attr('src');
        preg_match('/photos\/\d+\/([\w-\.]+)\?/', $imageUrl, $matches);
        $filePath = $matches[1];

        $this->client->get($imageUrl)->then(
            function(ResponseInterface $response) use ($filePath) {
                $this->filesystem->file($filePath)->putContents((string)$response->getBody());
        });
    }
}

我们传递了一个完整路径到响应的处理程序里。然后,我们创建了一个文件并填充了响应体。实际上,完整的Scraper只有不到 50 行的代码!

注意:在你想存储文件的位置先创建目录。putContents() 方法只创建文件,不会为指定的文件创建文件夹。

scraper 完成了。现在,打开你的主脚本,给scrape方法传递一个 URL 列表:

<?php
// index.php

<?php

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/ScraperForImages.php';

use Clue\React\Buzz\Browser;
use React\Filesystem\Filesystem;

$loop = \React\EventLoop\Factory::create();

$scraper = new ScraperForImages(
    new Browser($loop), Filesystem::create($loop), __DIR__ . '/images'
);

$scraper->scrape([
    'https://www.pexels.com/photo/adorable-animal-blur-cat-617278/',
    'https://www.pexels.com/photo/kitten-cat-rush-lucky-cat-45170/',
    'https://www.pexels.com/photo/adorable-animal-baby-blur-177809/',
    'https://www.pexels.com/photo/adorable-animals-cats-cute-236230/',
    'https://www.pexels.com/photo/relaxation-relax-cats-cat-96428/',
]);

$loop->run();

上面的代码爬取 5 个 URL 并下载相应图片。所有这些工作会快速地异步完成。

结尾

上一个教程里,我们使用 ReactPHP 加速网站抓取过程并同时查询页面。但是,如果我们也需要同时保存文件呢?在异步的应用程序中,我们不能使用诸如file_put_contents()的原生 PHP 函数,因为它们会阻塞程序流程,所以在磁盘上存储图片不会有任何加速。想要在 ReactPHP 里以异步 - 非阻塞的方式处理文件时,我们需要使用 reactphp/filesystem 包。

所以,在上面 50 行的代码里,我们就能加速网站抓取并运行起来。这只是一个你也可以做的简洁例子。现在你有了怎样构建爬虫的基础知识,请尝试做一个自己的吧!

我还有一些用 ReactPHP 抓取网站的文章:如果你想 使用代理 或者 限制并发请求的数量,可以阅读一下。


你也可以从 GitHub 找到这篇文章的例子。

转自 PHP / Laravel 开发者社区 https://laravel-china.org/topics/17492

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,042评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,996评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,674评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,340评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,404评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,749评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,902评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,662评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,110评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,577评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,258评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,848评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,726评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,952评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,271评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,452评论 2 348

推荐阅读更多精彩内容