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

Summer__發表於2019-01-20

什麼是網頁抓取?

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

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

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 的圖片頁,右擊圖片並選擇審查元素,你會看到一些東西,就像這樣:

file

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

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 看起來就像這樣:

images.pexels.com/photos/4602…

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

file

結尾

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

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

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


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

轉自 PHP / Laravel 開發者社群 laravel-china.org/topics/1749…

相關文章