Оригинал — 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, для делегирования тысяч запросов в секунду без опасения быть забаненным.