Справочники, инструменты, документация

PHP: Парсинг сайтов регулярными выражениями

Для того, чтобы спарсить страницу сайта (то есть разобрать ее HTML код), ее для начала следует получить. А затем уже полученный код можно разобрать с помощью регулярных выражений и, либо каким-то образом его проанализировать, либо сохранить в базу данных, либо и то, и другое.

Получение страниц сайтов с помощью file_get_contents

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

echo file_get_contents('http://theory.phphtml.net');

Что вы получите в результате: у себя на экране вы увидите страницу сайта, однако, скорее всего без CSS стилей и картинок. Давайте теперь выведем не страницу сайта, а ее исходный код. Запишем его в переменную $str и выведем на экран с помощью var_dump.

$str = file_get_contents('http://theory.phphtml.net');
var_dump($str);

Учтите, что var_dump должен быть настроен корректно в конфигурации PHP. Корректно - это значит вы должны видеть теги и не должно быть ограничения на длину строки (код страницы сайта может быть очень большим и желательно видеть его весь). Итак, если все сделано хорошо, и вы видите исходный код страницы сайта - самое время приступить к его парсингу с помощью регулярных выражений.

Подводные камни

Первая неожиданность, которая ожидает вас при использовании preg_match и preg_match_all - это то, что они работают только для тегов, целиком расположенных на одной строке (то есть, в них нету нажатого энтера). Если попытаться спарсить многострочный тег - у вас ничего не получится, пока вы не включите однострочный режим с помощью модификатора s.

preg_match_all('#регулярка#s', $str, $res);
var_dump($res);

Вторая неожиданность ждет вас, когда вы попробуете поработать с кириллицей - в этом случае нужно не забыть написать модификатор u (u маленькое, не путать с большим).

preg_match_all('#регулярка#u', $str, $res);
var_dump($res);

Таким образом рекомендуем вам всегда работать с этими двумя модификаторами, вот так:

preg_match_all('#регулярка#su', $str, $res);
var_dump($res);

Попробуем разобрать теги

Пусть мы каким-то образом (например, через file_get_contents) получили HTML код сайта.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Это заголовок тайтл</title>
    </head>
    <body>
        Это основное содержимое страницы.
    </body>
</html>

Давайте займемся его разбором. Для начала давайте получим содержимое тега <title>, тега <head>, и тега <body>. Итак, получим содержимое тега <title> (в переменной $str хранится HTML код, который мы разбираем).

preg_match_all('#<title>(.+?)</title>#su', $str, $res);
var_dump($res);

Содержимое <head>:

preg_match_all('#<head>(.+?)</head>#su', $str, $res);
var_dump($res);

Содержимое <body>:

preg_match_all('#<body>(.+?)</body>#su', $str, $res);
var_dump($res);

В общем-то ничего сложного нет, только обратите внимание на то, что как уголки тегов, так и слеш от закрывающего тега экранировать не надо (последнее верно, если ограничителем регулярки является не слеш /, а, например, решетка #, как у нас сейчас). Однако, на самом деле наши регулярки не идеальны. При некоторых условиях они просто откажутся работать. Вы должны быть готовы к этому - сайты, которые вы будете парсить - разные (часто они еще и устаревшие), и то, что хорошо работает на одном сайте, вполне может перестать работать на другом. Что же у нас не так? На самом деле тег <body> - такой же тег, как и остальные и в нем вполне могут быть атрибуты. Чаще всего это атрибут class, но могут быть и другие (например, onload для выполнения JavaScript).

Итак, перепишем регулярку с учетом атрибутов.

preg_match_all('#<body.+>(.+?)</body>#su', $str, $res);
var_dump($res);

Но и здесь мы ошиблись, при чем ошибок несколько. Первая - следует ставить не плюс +, а звездочку *, так как плюс предполагает наличия хотя бы одного символа - но ведь атрибутов в теге может и не быть - и в этом случае между названием тега body и уголком не будет никаких символов - и наша регулярка спасует (не понятно, что я тут написал - учите регулярки). Поправим эту проблему и вернемся к дальнейшему обсуждению.

preg_match_all('#<body.*>(.+?)</body>#su', $str, $res);
var_dump($res);

Вторая проблема следующая: если внутри <body> будут другие теги (а так оно и будет в реальной жизни) - то наша регулярка <body.*> зацепит лишнего. Например, рассмотрим такой код.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Это заголовок тайтл</title>
    </head>
    <body class="www">
        <p>Абзац</p>
    </body>
</html>

Регулярка <body.*> найдет не <body class="www">, как ожидалось, а <body class="www"><p>Абзац{ } - потому что мы не ограничили ей жадность. Сделаем это: место <body.*> напишем <body.*?> - в этом случае будет все хорошо. Но более хорошим вариантом будет написать вместо точки конструкцию [^>] (не закрывающий уголок), вот так - <body[^>]*?> - в этом случае мы полностью застрахуем себя от проблем такого рода, так как регулярка никогда не сможет выйти за тег.

Получение блока по id

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Это заголовок тайтл</title>
    </head>
    <body class="www">
        <div id="content">Контент</div>
        <div>Еще див</div>
    </body>
</html>

Напишем регулярку, которая получит содержимое блока с id, равным content. Итак, попытка номер один (не совсем корректная).

#<div\sid="content">(.+?)</div>#su

Что здесь не так? Проблема с пробелами - ведь между названием тега и атрибутом может быть сколько угодно пробелов, так же, как и вокруг равно в атрибутах. Все проблемы такого рода существенны - даже если ваша регулярка разбирает одну страницу сайта - это не значит, что она разберет другую подобную страницу: на ней вполне вокруг равно в атрибуте id могли поставить пробелы - и тут ваша регулярка спасует. Поэтому, регулярки парсера нужно строить так, чтобы они обходили как можно больше проблем - в этом случае ваш парсер будет работать максимально корректно на всех страницах сайта, а не только на тех, которые вы проверили.

Давайте поправим нашу регулярку:

#<div\s+?id\s*?=\s*?"content">(.+?)</div>#su

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

Кроме того, перед закрывающем уголком тега тоже могут быть пробелы (а могут и не быть) - учтем и это:

#<div\s+?id\s*?=\s*?"content"\s*?>(.+?)</div>#su

Итак, уже лучше, но еще далеко не идеал - ведь вокруг атрибута id могут быть и другие атрибуты, например так: <div class="www" id="content" onclick="">. В этом случае наша регулярка спасует. Давайте укажем, что могут быть еще и другие атрибуты:

#<div.+?id\s*?=\s*?"content".*?>(.+?)</div>#su

Обратите внимание, что после <div стоит регулярка .+?, а перед > стоит регулярка .*? - это не ошибка, так и задумано, ведь после <div обязательно должен идти пробел (то есть хотя бы один символ точно будет), а перед > может вообще не быть других атрибутов (кроме нашего id) и пробела тоже может не быть.

Регулярка стала еще более хорошей, но есть проблема: лучше не использовать точку в блоках типа .*? - мы вполне можем хватануть лишнего выйдя за наш тег (помните пример выше с body). Лучше все-таки использовать [^>] - это гарантия безопасности:

#<div[^>]+?id\s*?=\s*?"content"[^>]*?>(.+?)</div>#su

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

#<div[^>]+?id\s*?=\s*?["\']content["\'][^>]*?>(.+?)</div>#su

Обратите внимание на то, что одинарная кавычка заэкранирована - мы это делаем, так как внешние кавычки от строки PHP у нас тоже одинарные, вот тут:

preg_match_all('#кавычка внутри экранируется \' - вот так#su', $str, $res);

В общем-то регулярка достаточно хороша, но иногда идут дальше и делают так, чтобы первая кавычка от тега совпадала со второй (исключаем вариант id="content'). В этом случае делают так - первая кавычка ложится в карман, а вторая кавычка указывается карманом, чтобы совпадала с первой:

#<div[^>]+?id\s*?=\s*?(["\'])content\1[^>]*?>(.+?)</div>#su

Для нашей задачи это особо не нужно (можно быть точно уверенным, что такое id="content' - вряд ли где-то будет), но есть атрибуты, где это существенно. Например, в таком случае: <div title="Рассказ о д'Артаньяне"> - в атрибуте title вполне может затесаться одинарная кавычка и регулярка title\s*?=\s*?["\'](.+?)["\'] вытянет текст Рассказ о д - потому что поиск ведется до первой кавычки.

А вот регулярка title\s*?=\s*?(["\'])(.+?)\1 будет корректно обрабатывать <div title="Рассказ о д'Артаньяне"> и даже <div title='Рассказ о д"Артаньяне'>.

Проблема вложенных блоков

В нашей регулярке есть еще одна проблема - она не может работать с вложенными блоками. Например, если внутри дива #content есть еще один див - регулярка найдет текст до первого закрывающего </div>, а не для закрывающего дива для #content. Пример проблемного кода:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Это заголовок тайтл</title>
    </head>
    <body class="www">
        <div id="content">
            <div>Див внутри контента</div>
            Контент
        </div>
        <!-- end of content -->
        <div id="footer">Футер</div>
    </body>
</html>

Наша регулярка вытянет только <div id="content"><div>Див внутри контента</div> - остановится на первом же </div>. Что делать в этом случае?

Что делать в этом случае? Во-первых, к этому случаю всегда нужно быть готовым - даже если на исследуемых страницах сайта нет вложенных блоков - они вполне могут быть и на других страницах или появиться потом (если сайт парсится не один раз, а периодически).

Ну, а что делать - нужно просто привязываться не к </div>, а к тому, что стоит под нашим блоком (в нашем случае под контентом). В приведенном ниже коде под ним стоит <div id="footer"> - можно привязаться к нему или к <!-- end of content --> - и так, и так будет хорошо.

В HTML5 появились новые теги - header, footer, main (для контента) - с ними работать гораздо удобнее, ведь в них исключена вложенность. И очень часто вместо <div id="content"> можно увидеть просто <main> - и парсинг становится проще.

Вытягиваем заданные блоки

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Это заголовок тайтл</title>
    </head>
    <body>
        <p>Абзац 1</p>
        <p>Абзац 2</p>
        <p>Абзац 3</p>
        <p>Абзац 4</p>
        <p>Абзац 5</p>
    </body>
</html>

## Получение `href` ссылок, ссылки из блока, получение элементов по классу, кодировка документа

Иногда вам придется парсить не современные сайты, а достаточные старые. На таких сайтах кодировка чаще всего установлена в windows-1251. Поэтому, если вы попытаетесь получить русскоязычные тесты с этого сайта, вы вместо русских букв увидите вопросики - это первый признак сбившейся кодировки. В этом случае следует воспользоваться функцией `iconv`, которая перекодирует текст из устаревшего windows-1251 в современный utf-8:

```php
$str = getPageByUrl('http://theory.phphtml.net');
$str = iconv('windows-1251', 'utf-8', $str);

Как понять по HTML коду сайта, что в нем не та кодировка? Посмотрите на тег meta charset. Он может выглядеть так <meta http-equiv="content-type" content="text/html; charset=utf-8"> или так <meta http-equiv="content-type" content="text/html; charset=windows-1251">. Во втором случае кодировка не та.

Кстати, в HTML5 кодировка устанавливается так, а варианты кодировки с http-equiv="content-type" устарели. Однако, на сайтах сейчас можно встретить и тот, и другой вариант.

Кстати, на сайте может вообще не быть тега meta charset - в этом случае кодировка файла windows-1251 (в подавляющем большинстве случаев).