Парсинг сайтов с помощью PHP [перевод]

Оригинал — https://www.scrapingbee.com/blog/web-scraping-php/

Вероятно, вы видели один из наших предыдущих туториалов о том, как парсить сайты, например: Ruby, JavaScript или Python и думали: а как же насчёт самого часто-используемого языка для серверного web-программирования, который очень редко используют для этой задачи? Настал этот час — сегодня посмотрим, как парсить сайты на PHP!

Имейте ввиду, что не существует одного лучшего способа это сделать — каждый подход имеет свой сценарий использования в зависимости от того, что вам нужно, как вам нравится что-либо делать и чего вы хотите достичь.

Для примера мы попробуем получить список людей, у которых совпадает день рождения. Если вы хотите повторять примеры кода вместе с нами, удостоверьтесь что у вас установлена последняя версия PHP и composer.

Создаём новую директрию и выполняем:

composer init --require="php >=7.4" --no-interaction 
composer update

Всё готово, начинаем!

HTTP Requests

Когда дело доходит до web-страниц, наиболее популярный протокол, используемый сайтами — HTTP — Hypertext Transport Protocol. Он определяет, как пользователи Worl Wide Web будут общаться между собой: как ресурсы сервера отвечают на запросы клиентов.

Ваш браузер — это клиент, и если вы откроете консоль, а затем перейдёте на вкладку Network, после чего перейдёте на сайт example.com — увидите полный список запросов к серверу, и ответов, которые он нам прислал:

Мы видим много запросов и ответов, но в общем случае простейший запрос выглядит так:

GET / HTTP/1.1 
Host: www.example.com

Давайте попробуем воссоздать то, что делает браузер.

fsockopen()

Как правило, нам не требуется создавать низко-уровневые запросы и открывать соединения, мы это делаем с помощью абстракций, который нам предоставляют различные библиотеки. Но, ради примера, давайте сделаем это с помощью самого простого инструмента, предоставляемого PHP: fsockopen():

<?php
# fsockopen.php

// In HTTP, lines have to be terminated with "\r\n" because of
// backward compatibility reasons
$request = "GET / HTTP/1.1\r\n";
$request .= "Host: www.example.com\r\n";
$request .= "\r\n"; // We need to add a last new line after the last header

// We open a connection to www.example.com on the port 80
$connection = fsockopen('www.example.com', 80);

// The information stream can flow, and we can write and read from it
fwrite($connection, $request);

// As long as the server returns something to us…
while(!feof($connection)) {
// … print what the server sent us
echo fgets($connection);
}

// Finally, close the connection
fclose($connection);

Если мы запустим файл fsockopen.php с помощью php fsockopen.php, мы увидим HTML-разметку, которую нам возвращает соответствующий запрос, когда мы открываем http://example.com в браузере.
Следущий шаг — отправим HTTP-запрос посредством Assembler… ладно, не будем 🙂 А если серьёзно: fsockopen(), как правило, не используется для отправки запросов в PHP; я просто хотел показать вам как это можно сделать используя самый простой инструмент в PHP. Хотя, с ним можно полноценно взаимодействовать с HTTP (и не только HTTP), это потребует большого количества шаблонного кода, который мы не хотим писать — HTTP-запросы, это давно решённая проблема, и чаще всего для этого используют…

cURL

Начнём с cURL (client for URL). Давайте посмотрим на код:

<?php
# curl.php

// Initialize a connection with cURL (ch = cURL handle, or "channel")
$ch = curl_init();

// Set the URL
curl_setopt($ch, CURLOPT_URL, 'http://www.example.com');

// Set the HTTP method
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');

// Return the response instead of printing it out
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);

// Send the request and store the result in $response
$response = curl_exec($ch);

echo 'HTTP Status Code: ' . curl_getinfo($ch, CURLINFO_HTTP_CODE) . PHP_EOL;
echo 'Response Body: ' . $response . PHP_EOL;

// Close cURL resource to free up system resources
curl_close($ch);

Этот код выглядит более понятным, чем предыдущий пример, верно? Нам уже не нужно открывать соединение с портом сервера, вручную отделять заголовки от ответа и закрывать соединение. А например для того, чтобы разрешить редирект, достаточно будет добавить опцию curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); и множество других опций(http://php.net/curl_setopt), доступных для использования.

Ура, переходим к полноценному парсингу!

Строки, регулярные выражения и .. Википедия.

Давайте посмотрим на информацию, доступную в википедии. У каждого дня в году есть своя страница, где размещены исторические события, включая дни рождения. Для примера, на странице «10 декабря»(https://en.wikipedia.org/wiki/December_10) (это мой день рождения 🙂 ), мы можем открыть консоль разработчика и увидеть структуру секции «Births»:

  • <h2> содержит <span id="Births" ...>Births</span>
  • Сразу за заголовком следует список: <ul>
  • Каждый элемент списка (<li>...</li>) содержит год, тире, имя, запятая, и краткая информация, чем известен данный человек

С этой информацией мы и будем работать, поехали:

<?php
# wikipedia.php

$html = file_get_contents('https://en.wikipedia.org/wiki/December_10');

echo $html;

Подождите, что? Да, сюрприз! file_get_contents(), вероятно, наиболее простой способ отправить GET-запрос и получить содержимое страницы (PHP позволяет делать много вещей, который вам лучше не делать).

Нас интересует часть, начинающаяся с id="Births" и заканчивающаяся после закрытого </ul>:

<?php
# wikipedia.php

$html = file_get_contents('https://en.wikipedia.org/wiki/December_10');

$start = stripos($html, 'id="Births"');

$end = stripos($html, '</ul>', $offset = $start);

$length = $end - $start;

$htmlSection = substr($html, $start, $length);

echo $htmlSection;

Давайте воспользуемся регулярным выражением, чтобы выгрузить все элементы списка в массив, и обрабатывать элементы по очереди:

preg_match_all('@<li>(.+)</li>@', $htmlSection, $matches);
$listItems = $matches[1];

foreach ($listItems as $item) {
    echo "{$item}\n\n";
}

Из вывода видно, что первое число — год рождения. За ним следует кусок HTML-прочерк. И после этого — видим <a> с именем внутри. Давайте спарсим их:

<?php
# wikipedia.php

$html = file_get_contents('https://en.wikipedia.org/wiki/December_10');

$start = stripos($html, 'id="Births"');

$end = stripos($html, '</ul>', $offset = $start);

$length = $end - $start;

$htmlSection = substr($html, $start, $length);

preg_match_all('@<li>(.+)</li>@', $htmlSection, $matches);
$listItems = $matches[1];

echo "Who was born on December 10th\n";
echo "=============================\n\n";

foreach ($listItems as $item) {
    preg_match('@(\d+)@', $item, $yearMatch);
    $year = (int) $yearMatch[0];

    preg_match('@;\s<a\b[^>]*>(.*?)</a>@i', $item, $nameMatch);
    $name = $nameMatch[1];

    echo "{$name} was born in {$year}\n";
}

Не знаю как вам, но мне такой подход не понравился. Мы спарсили, что хотели, но вместо того, чтобы элегантно перемещаться по DOM-дереву, мы вырвали из него фрагменты. И более того, этот скрипт выбросит ошибку, если год не заключён в ссылку. Можем лучше? Можем!

Guzzle, XML, XPath и IMDb:

Guzzle — это популярный HTTP-клиент для PHP, с помощью которого легко отправлять HTTP-запросы. Пакет предоставляет интуитивно-понятный API, обработку ошибок и даже позволяет добавлять middleware-обработчики к запросам. Это делает Guzzle прекрасным инструментом. Вы можете установить Guzzle с помощью команды: composer require guzzlehttp/guzzle.

Давайте пойдём дальше и посмотрм на структуру разметки этого сайта: https://www.imdb.com/search/name/?birth_monthday=12-10 (Адреса у википедии были явно лучше).

Сразу видим, что здесь нам понадобится более удобный инструмент, чем строковые PHP-функции и регулярки. Вместо списка с элементами мы видим вложенные блоки. Невозможно привязаться к конкретному id. Но хуже всего — год рождения либо закрыт в отрывке биограции, либо вообще не виден!

Мы попытаемся спарсить год позже, а пока давайте получим имена с помощью XPath — языка запросов для работы с DOM-узлами.

Ниже мы сначала получаем страницу с помощью Guzzle, преобразуем её в объект DOMDocument и инициализируем с его помощью XPath:

<?php
# imdb.php

require 'vendor/autoload.php';

$httpClient = new \GuzzleHttp\Client();

$response = $httpClient->get('https://www.imdb.com/search/name/?birth_monthday=12-10');

$htmlString = (string) $response->getBody();

// HTML is often wonky, this suppresses a lot of warnings
libxml_use_internal_errors(true);

$doc = new DOMDocument();
$doc->loadHTML($htmlString);

$xpath = new DOMXPath($doc);

Давайте внимательней посмотрим на HTML-код выше:

  • Список содержится в элементе <div class="lister-list">
  • Каждый дочерний элемент этого контейнера — это div с классом list-item-content
  • Наконец, имя можно найти в <a> внутри <h3>, который в свою очередь располагается внутри <div> с классом lister-item-content

Если подумать, мы можем пропустить дочерние div, потому что каждый элемент списка содержит только один <h3>:

$links = $xpath->evaluate('//div[@class="lister-list"][1]//h3/a');

foreach ($links as $link) {
    echo $link->textContent.PHP_EOL;
}
  • //div[@class=»lister-list»][1] возвращает первый ([1]) div с классом lister-list
  • Внутри этого div из всех элементов <h3> мы возвращаем ссылки a
  • Перебираем все ссылки и печатает имена, содержищиеся внутри ссылок

Goutte и IMDB

Guzzle — это один из многих HTTP-клиентов. Давайте посмотри на другие, но не менее интересные!

Goutte — ещё один прекрасный HTTP-клиент, написанный создателем Symfony, и сочетающий внутри себя несколько Symfony-компонентов, что позволяет парсить страницы легко и удобно:

  • BrowserKit component эмулириует поведение браузера, которым мы можем программировать
  • DomCrawler component — DOMDocument и XPath на стероидах
  • CssSelector component — транслирует CSS запросы в XPath-запросы
  • Symfony HTTP Client — новый компонент (релиз был в 2019 году) — разработанный и поддерживаемый командой Symfony, и быстро набирающий популярность

Давайте установим Goutte с помощью команды composer require fabpot/goutte и преобразуем наш предыдущий код:

<?php
# goutte_xpath.php

require 'vendor/autoload.php';

$client = new \Goutte\Client();

$crawler = $client->request('GET', 'https://www.imdb.com/search/name/?birth_monthday=12-10');

$links = $crawler->evaluate('//div[@class="lister-list"][1]//h3/a');

foreach ($links as $link) {
    echo $link->textContent.PHP_EOL;
}

Теперь давайте заменим XPath-запросы на привычный и понятный CSS:

<?php
# goutte_css.php

require 'vendor/autoload.php';

$client = new \Goutte\Client();

$crawler = $client->request('GET', 'https://www.imdb.com/search/name/?birth_monthday=12-10');

$crawler->filter('.lister-list h3 a')->each(function ($node) {
    echo $node->text().PHP_EOL;
});

Наш сценарий становится всё более читаемым, благодаря Symfony-компонентам, позволяющим разрабатывать на высоком уровне абстракции. Тем не менее, я потратил некоторое время на отладку, потому что сценарий временами не возвращал результатов. Чтобы этого избежать, установим следующую библиотеку:

composer require masterminds/html5

Как оказалось, происходит это из-за того, что Goutte отбарсывает части, которые не может распознать. Библиотека, которую мы установили выше, помогает с некоторыми моментами в html5, и после её установки всё работает должным образом.

Теперь давайте спарсим даты рождения. Вот где действительно поможет Goutte — с его помощью мы можем нажимать на ссылки! Если мы нажмём на ссылку, то увидим строку «Born: …», и после этого в DOM-дереве появится элемент <time datetime="YYYY-MM-DD"> с информацией о дате рождения.

<?php
# imdb_final.php

require 'vendor/autoload.php';

$client = new \Goutte\Client();

$client
    ->request('GET', 'https://www.imdb.com/search/name/?birth_monthday=12-10')
    ->filter('.lister-list h3 a')
    ->each(function ($node) use ($client) {
        $name = $node->text();

        $birthday = $client
            ->click($node->link())
            ->filter('#name-born-info > time')->first()
            ->attr('datetime');

        $year = (new DateTimeImmutable($birthday))->format('Y');

        echo "{$name} was born in {$year}\n";
});

Headless Browsers

Важно понимать, что то, что мы видим в консоли браузера — это конечный результат интерпретации HTML-кода в DOM-дерево. Чем больше на сайта JS, тем сильнее может модифицироваться типичный HTML-разметка. Например, если на сайте много AJAX-запросов, то мы не сможем увидеть полную структуру DOM-дерева с помощью тех инструментов, что использовали выше. Тут в игру вструпают так называемые Headless Browsers — движок браузера, без графического интерфейса, которым можно управлять программно.
Symfony Panther — это библиотека, совмещающая под коробкой Goutte и Headless Browsers, и может использовать один из установленных у вас браузеров для работы, так что ничего дополнительно нам устанавливать не потребуется.

Поскольку мы уже получили что хотели — спарсили дни рождения, давайте ради эксперимента получим снимок страницы, которую мы так старательно парсили. Устаналиваем symfony panther: composer require symfony/panther и выполняем данный код:

<?php
# screenshot.php

require 'vendor/autoload.php';

$client = \Symfony\Component\Panther\Client::createFirefoxClient();
// or
// $client = \Symfony\Component\Panther\Client::createChromeClient();

$client
    ->get('https://www.imdb.com/search/name/?birth_monthday=12-10')
    ->takeScreenshot($saveAs = 'screenshot.jpg');

В заключении

Мы попробовали несколько интересных инструментов для парсинга сайтов. Тем не менее, есть несколько важных вещей, о которых я не сказал:

  • Когда мы использовали Goutte для быстрой загрузки 50 страниц, IMDb мог интерпретировать наше соединение как необычное и заблокировать наш IP-адрес
  • Многие сайты ограничивают скорость соединения для предотвращения атаки типа «Denial-of-Service»

Для решения этих проблем могут помочь сервисы, вроде ScrapingBee, для делегирования тысяч запросов в секунду без опасения быть забаненным.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *