ReactPHP 爬蟲實戰:下載整個網站的圖片

什麼是網頁抓取?

你是否曾經需要從一個沒有提供 API 的站點獲取信息? 我們可以通過網頁抓取,然後從目標網站的 HTML 中獲得我們想要的信息,進而解決這個問題。 當然,我們也可以手動提取這些信息, 但手動操作很乏味。 所以, 通過爬蟲來自動化來完成這個過程會更有效率。

在這個教程中我們會從 Pexels 抓取一些貓的圖片。這個網站提供高質量且免費的素材圖片。他們提供了API, 但這些 API 有 200次/小時 的請求頻率限制。

[](https://user-gold-cdn.xitu.io...

file

發起併發請求

在網頁抓取中使用異步 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 的圖片頁,右擊圖片並選擇審查元素,你會看到一些東西,就像這樣:

[](https://user-gold-cdn.xitu.io...

file

我們可以看到img標籤有個image-section__image類名。我們要使用這個信息從收到的 HTML 中提取這個標籤。圖片的 URL 存儲在src屬性裏:

[](https://user-gold-cdn.xitu.io...

file

爲提取 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/pho...

這些 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 並下載相應圖片。所有這些工作會快速地異步完成。

[](https://user-gold-cdn.xitu.io...

file

結尾

在 上一個教程裏,我們使用 ReactPHP 加速網站抓取過程並同時查詢頁面。但是,如果我們也需要同時保存文件呢?在異步的應用程序中,我們不能使用諸如file_put_contents()的原生 PHP 函數,因爲它們會阻塞程序流程,所以在磁盤上存儲圖片不會有任何加速。想要在 ReactPHP 裏以異步 - 非阻塞的方式處理文件時,我們需要使用 reactphp/filesystem 包。

所以,在上面 50 行的代碼裏,我們就能加速網站抓取並運行起來。這只是一個你也可以做的簡潔例子。現在你有了怎樣構建爬蟲的基礎知識,請嘗試做一個自己的吧!

我還有一些用 ReactPHP 抓取網站的文章:如果你想 使用代理 或者 限制併發請求的數量,可以閱讀一下。

        • *

你也可以從 GitHub 找到這篇文章的例子。

轉自 PHP / Laravel 開發者社區 https://laravel-china.org/top...
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章