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

什麼是網頁抓取?

你是否曾經須要從一個沒有提供 API 的站點獲取信息? 咱們能夠經過網頁抓取,而後從目標網站的 HTML 中得到咱們想要的信息,進而解決這個問題。 固然,咱們也能夠手動提取這些信息, 但手動操做很乏味。 因此, 經過爬蟲來自動化來完成這個過程會更有效率。php

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

file

發起併發請求

在網頁抓取中使用異步 PHP (相比使用同步方式)的最大好處是能夠在更短的時間內完成更多的工做。使用異步 PHP 使得咱們能夠馬上請求儘量多的網頁而不是每次只能請求單個網頁並等待結果返回。 所以,一旦請求結果返回咱們就能夠開始處理。html

首先,咱們從 GitHub 上拉取一個叫作 buzz-react  的異步 HTTP 客戶端的代碼 -- 它是一個基於 ReactPHP 的簡單、致力於併發處理大量 HTTP 請求的異步 HTTP 客戶端:react

composer require clue/buzz-react
複製代碼

如今, 咱們就能夠請求 pexels 上的圖片頁面 了:laravel

<?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 對象。git

客戶端是異步工做的,這意味着咱們能夠很容易地請求幾個頁面,而後這些請求會被同步執行:github

<?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();
複製代碼

這裏的代碼含義以下:web

  • 發起一個請求
  • 獲取響應
  • 添加響應的處理程序
  • 當響應解析完畢就處理響應

因此,這個邏輯能夠提取到一個類裏,這樣咱們能夠很容易地請求多個 URL 並添加相同的響應處理程序。讓咱們基於Browser建立一個包裝器。正則表達式

用下面的代碼建立一個名爲Scraper的類:api

<?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…

相關文章
相關標籤/搜索