ИГОРЬ ОРЕЩЕНКОВ, инженер-программист, iharsw@tut.by
Извлечение информации из HTML-страниц средствами PHP
В статье рассматривается реализация разбора структурированных текстов на примере анализа HTML-страниц средствами PHP
Несмотря на то что с увеличением интерактивности веб-сайтов за их информационное наполнение все больше отвечают JavaScript-сценарии, задача извлечения информации из HTML-документов не теряет актуальности. Новости, прогнозы погоды, курсы валют, котировки ценных бумаг и статистические показатели – эти, несомненно, представляющие интерес сведения по-прежнему доступны в формате HTML на сайтах соответствующих организаций.
Разбор структурированного текста может быть осуществлен с помощью регулярных выражений или программных библиотек, предназначенных для восстановления его древовидной структуры. Но регулярные выражения для сложных текстов теряют наглядность, их становится трудно воспринимать и отлаживать. Построение структурного дерева текста требует больших затрат памяти и является излишним для многих практических задач.
При извлечении информации из HTML-страниц реальных интернет-сайтов можно столкнуться с ошибками верстки, которые не критичны для отображения этих страниц в браузере, но приводят к неожиданным эффектам при построении структурного дерева документа. Веб-мастера могут вносить на сайт незначительные изменения, помещать сведения в дополнительные контейнеры или, наоборот, уменьшать степень вложенности текстов. Желательно минимизировать влияние таких преобразований структуры документов на работу программы-анализатора, извлекающей информацию с сайта.
Продемонстрируем извлечение информации из HTML-файла комбинированным способом |
В этой статье будет продемонстрировано, что построение собственного анализатора позволяет записать решение задачи анализа в императивных терминах, сохраняя при этом контроль над количеством используемых ресурсов – вычислительной мощности и оперативной памяти.
В общем виде анализатор текста должен состоять из трех модулей [1]:
- Лексический анализатор. На вход лексического анализатора подается исходный текст в виде последовательности символов – букв, цифр, скобок и знаков препинания. На выход лексического анализатора выдается последовательность лексем – слов, чисел, строк.
- Синтаксический анализатор. Принимает последовательность лексем от лексического анализатора, проверяет на соответствие формальным правилам и составляет на их основе таблицы идентификаторов и констант.
- Семантический анализатор использует результаты работы лексического и синтаксического анализаторов для окончательного решения задачи анализа текста, представляет собранную информацию в удобной для дальнейшего использования форме.
При разработке анализатора HTML-документов постараемся воспроизвести эту эталонную систему.
Определение элементов структуры текста
Начнем с лексического анализа. При его выполнении исходный текст последовательно просматривается от начала к концу с попутным выделением структурных элементов. В ходе анализа HTML-текста основной задачей является определение в нем тегов – участков текста, заключенных между угловыми скобками «<» (знак меньше) и «>» (знак больше). В свою очередь, теги состоят из имени (слова, следующего сразу за знаком «<») и атрибутов, которые тоже могут иметь значения.
Например, в программе анализатора часто встречается такой код:
- Пропустить символы-разделители «\040\t\r\n» (пробел, табуляция, возврат каретки и перевод строки) и записать новую позицию в переменную P1.
- Если символ в позиции P1 – цифра, то обнаружено число. Для его выделения нужно пропустить цифровые символы «0...9» и записать новую позицию в переменную P2.
- Если символ в позиции P1 – буква, то обнаружен идентификатор. Для его выделения нужно пропустить алфавитно-цифровые символы «0..9A...Za...z» и записать новую позицию в переменную P2.
- Если символ в позиции P1 – открывающая кавычка, то обнаружено начало строки. Для нахождения позиции конца строки нужно пропустить все символы до тех пор, пока не встретится закрывающая кавычка. Позицию конца строки нужно записать в переменную P2, а значение переменной P1 увеличить на единицу, чтобы оно указывало на начало строки, а не на открывающую кавычку.
Такой подход к анализу текста очень экономичен в плане расхода памяти. Для хранения информации об элементах текста достаточно двух числовых переменных P1 и P2, в которые записываются позиции начала элемента и символа, следующего за его концом. Значение элемента хранится неявно в анализируемом тексте и при необходимости может быть легко получено из него как подстрока [P1:P2-1]. При этом легко вычисляется длина элемента L = P2 – P1, и она может быть оценена до обработки значения элемента. Например, можно сразу сообщить об ошибке, если длина элемента превышает допустимую.
Обобщая описанную схему, в процессе лексического анализа можно обнаружить регулярное выполнение двух действий (см. рис. 1):
- поиск в тексте позиции определенного символа, который служит признаком начала синтаксической конструкции или является символом-ограничителем;
- пропуск символов из заданного набора (символов-разделителей или алфавитно-цифровых символов для записи идентификаторов) до тех пор, пока не встретится «посторонний» символ.
Рисунок 1. Шаги лексического анализа тега HTML. Зеленая стрелка – переход к следующему символу, голубая стрелка – пропуск символов из набора, оранжевая – поиск символа из набора
Поэтому в основу разрабатываемого лексического анализатора были положены две взаимно дополняющие функции: SkipOver (…, набор_символов) и SkipTo (…, набор_символов). Первая служит для пропуска любых символов, не входящих в указанный набор, и возврата позиции символа, который в этот набор входит. Вторая функция выполняет поиск позиции символа, который совпадает с одним из символов указанного набора. Исходный текст на языке PHP этих функций приведен в файле iaparser.php.
Особенностью интерпретируемых языков программирования, к каковым относится PHP, является то, что время работы участков кода сильно зависит от баланса между использованием в них встроенных функций языка и языковых конструкций. Поскольку встроенные функции являются скомпилированными блоками машинного кода, то они выполняются существенно быстрее, чем эквивалентные им конструкции, записанные операторами языка. Например, поиск символа C в строке S, записанный с помощью встроенной функции strpos ():
$P = strpos ($S, $C);
будет выполнен значительно быстрее, нежели реализованный средствами PHP:
$L = strlen ($S);
$P = 0;
while ($P < $L and $S{$P} != $C):
$P++;
endwhile;
$P = $P < $L? $P: FALSE;
Хотя на компилируемых языках оба варианта отработали бы примерно за одинаковое время. Так происходит потому, что конструкции языка PHP не выполняются процессором напрямую, а проходят через интерпретатор, который на каждой итерации цикла осуществляет вызов машинных микроподпрограмм для выполнения своих операторов. Затраты на организацию множественных вызовов могут привести к катастрофическому падению производительности.
Для повышения эффективности в реализациях часто вызываемых функций SkipOver () и SkipTo () включен «защитный код», имеющий вид:
if ($l === FALSE):
$l = strlen ($s);
endif;
Он обеспечивает определение длины обрабатываемой строки через вызов встроенной функции strlen (), только если ее значение не было передано через необязательный параметр $l. В противном случае сразу используется предоставленное значение.
Несмотря на то что исходные тексты функций SkipOver () и SkipTo () отличаются единственным символом (в первой содержится выражение с неравенством «!==», а во второй – с равенством «==»), что позволяет объединить их в одну функцию Skip () с дополнительным параметром, задающим специфику поведения, было решено это не делать для сохранения наглядности разрабатываемых с их использованием программ.
К сожалению, разработчики интерпретаторов не посчитали нужным включить в стандартную библиотеку функции для упрощения лексического анализа. И если в PHP5 появляется функция strpbrk (), которая подходит для решения первой задачи, то для пропуска разделителей по-прежнему придется написать ресурсозатратный код на PHP.
Еще один момент, на который следует обратить внимание для повышения эффективности программы, – это передача параметров в функцию. Если функция вызывается многократно для обработки большого объема текста, как например это происходит с функциями SkipOver () и SkipTo (), то передача в нее этого текста должна осуществляться по ссылке, а не по значению.
Для передачи параметра по ссылке в языке программирования PHP в заголовке объявления функции нужно предварить название параметра символом «&» (амперсанд). Если забыть это сделать, то при каждом вызове функции будет осуществляться копирование передаваемого значения (в нашем случае – всего обрабатываемого текста), что увеличит затраты и времени, и памяти.
Разбор HTML-документов
Функции, предназначенные для разбора текстов на языке HTML, собраны в файле iahtml.php. Их ядром является функция iaFindTagOneOf ($p, &$s, $tags), которая осуществляет поиск в тексте $s начиная с позиции $p одного из тегов, заданных параметром $tags.
Если нужно найти какой-то конкретный тег, например «p» или «div», то искомое значение передается как строка. Если же требуется обнаружить один из нескольких тегов, например любой из набора «h1», «h2», «h3», «h4», «h5», «h6», то список тегов нужно передать как массив: array (‘h1’, ‘h2’, ‘h3’, ‘h4’, ‘h5’, ‘h6’). В представленной реализации функции регистр тегов имеет значение, как это регламентировано спецификацией языка XML, поэтому для поиска в «произвольном» HTML в список нужно включить и теги в верхнем регистре: «H1», «H2», «H3», «H4», «H5», «H6».
В результате своей работы функция iaFindTagOneOf () возвращает либо значение FALSE, означающее, что поиск завершился неудачей, либо ассоциативный массив такой структуры:
Array (
[pos] => позиция начала тега
[len] => длина тега
[name] => имя тега (соответствует одному из элементов массива, переданного в качестве параметра $tags)
[attr] => ассоциативный массив атрибутов тега в виде:
Array (
[имя атрибута] => [значение атрибута]
. . .
)
)
Для удобства практического использования результат работы функции iaFindTagOneOf () может напрямую передаваться ей же в качестве первого аргумента. В этом случае поиск очередного тега будет продолжен с позиции, следующей за найденным тегом.
Искомые теги задаются своими именами. После реализации функции iaFindTagOneOf () оказалось, что ее можно использовать для поиска как открывающего, так и закрывающего тега. В последнем случае достаточно перед именем тега поставить символ «/» (наклонную черту). Тогда код для поиска тегов, ограничивающих HTML-элемент «div», будет выглядеть как:
$tagb = iaFindTagOneOf ($p, $html, 'div');
$tage = iaFindTagOneOf ($tagb, $html, '/div');
Остальные функции, содержащиеся в модуле, имеют вспомогательное назначение. Так, iaFindTagAny ($p, &$s) ищет в тексте $s начиная с позиции $p любой тег HTML. С ее помощью «центральная» функция iaFindTagOneOf () получает очередной тег, после чего проверяет его имя на соответствие списку, переданному в параметре $tags.
Результат работы функции $tag = iaFindTagOneOf () может быть использован в вызывающей программе напрямую, но во многих случаях синтаксические конструкции доступа к ассоциативному массиву выглядят громоздко. Поэтому в комплект включены функции обработки этого результата:
- iaFoundTagPos (&$tag) – возвращает номер позиции тега $tag в обрабатываемой строке;
- iaFoundTagAfterPos (&$tag) – возвращает номер позиции, следующей за тегом $tag;
- iaFoundTagAttr (&$tag, $attr) – позволяет получить значение атрибута $attr тега $tag (если атрибут отсутствует, то возвращается FALSE);
- iaFoundElementContent (&$s, &$tagb, &$tage) – возвращает содержимое элемента, заключенное между тегами $tagb и $tage.
Таким образом, функции из файла iahtml.php помогают произвести синтаксический анализ HTML-кода.
Демонстрация извлечения информации из HTML-страницы
В качестве примера использования описанных в статье функций рассмотрим извлечение информации из новостной ленты главной страницы сайта журнала «Системный администратор» (см. рис. 2).
Рисунок 2. Так выглядит лента новостей сайта журнала «Системный администратор» в браузере
Прежде всего найдем в HTML-коде страницы по строке «Поздравляем с наступающим Новым годом» интересующий нас участок и попробуем выявить в нем какие-нибудь характерные особенности.
К сожалению, новостные сообщения размещены в ни чем не примечательных ячейках таблицы, а класс blockTitle, которым отмечены SPAN-блоки заголовков новостей, широко используется в тексте страницы для маркировки участков сообщений, не имеющих отношения к новостной ленте. С другой стороны, сразу бросается в глаза комментарий «<!--///Main Content///-->», которым открывается новостной блок. Попробуем использовать его в качестве первого ориентира (см. рис. 3).
Рисунок 3. HTML-код начала ленты новостей на сайте журнала «Системный администратор». Подчеркнуты характерные участки, которые используются в качестве ориентиров при обработке текста
Если взглянуть на код страницы ниже отмеченного комментария, поставленная задача не выглядит безнадежной. Становится понятно, что атрибут class=blockTitle тегов элементов «span» все-таки можно использовать для фильтрации заголовков новостей, а текст самой новости определять как содержимое элементов «p». Теперь можно попытаться написать программу, в которой эти наблюдения будут формализованы.
Эта программа должна загружать из сети Интернет HTML-код сайта http://samag.ru, записывать его в кэш-файл samag.htm для последующего использования, извлекать из новостной ленты сайта информационные сообщения с их заголовками и записывать результат своей работы в файл samag.txt.
Полный текст программы можно найти в файле samag.php, загруженный на момент написания статьи код обрабатываемой интернет-страницы – в файле samag.htm, а результат работы программы – в файле samag.txt. Фалы доступна на сайте журнала http://samag.ru. Здесь же приведем только листинг функции ExtractInfo (), извлекающей информацию из веб-страницы.
Листинг. Текст функции ExtractInfo (), извлекающей новостииз ленты на сайте
1. function ExtractInfo (&$html)
2. {
3. $info = array ();
4. $tags = array (
5. 'span' => array ('SPAN', 'span'),
6. '/span' => array ('/SPAN’, '/span'),
7. 'p' => array ('P', 'p'),
8. '/p' => array ('/P’, '/p')
9. );
10. $tage = FALSE;
11. /* «Прыжок» к содержимому ленты новостей. */
12. $p = strpos ($html, '///Main Content///');
13. /* Цикл извлечения новостных блоков. */
14. $i = 0;
15. do {
16. /* Поиск заголовка новости. */
17. $tagb = iaFindTagOneOf $p, $html,$tags['span']);
18. $tage = iaFindTagOneOf ($tagb, $html, $tags['/span']);
19. /* Обработка заголовка новости. */
20. if (iaFoundTagAttr ($tagb, 'class') == 'blockTitle'):
21. $s = iaFoundElementContent ($html, $tagb, $tage);
22. if ($s !== FALSE):
23. $s = trim (strip_tags ($s));
24. $info[] = "Title $i: [$s]";
25. endif;
26. /* Поиск текста новости. */
27. $tagb = iaFindTagOneOf ($tage, $html, $tags['p']);
28. $tage = iaFindTagOneOf ($tage, $html, $tags['/p']);
29. /* Обработка текста новости. */
30. if ($tage !== FALSE):
31. $s = iaFoundElementContent ($html, $tagb, $tage);
32. if ($s !== FALSE):
33. $s = trim (strip_tags ($s));
34. $info[] = "Content $i: [$s]";
35. endif;
36. endif;
37. endif;
38. /* Определение позиции для продолжения разбора. */
39. $p = iaFoundTagAfterPos ($tage);
40. $i++;
41. } while ($p !== FALSE and $i < 10);
42. return $info;
Функция анализирует HTML-код страницы, переданный через параметр $html, и заполняет массив info[] парами строк «Title: заголовок новости» и «Content: текст новости». Начальная позиция $p определяется прямым поиском обнаруженной нами метки «///Main Content///» (строка 12). Затем следует цикл, в котором сначала отыскивается заголовок новости как содержимое элемента «span» с атрибутом class, равным значению blockTitle (строки 17-21), а потом – текст новости как содержимое очередного элемента «p» (строки 27-31). В конце цикла определяется новая позиция $p, следующая за последним обработанным тегом, и цикл повторяется для извлечения очередной новости.
О кодировании текстов
При обработке текстов нельзя игнорировать кодировку, в которой они представлены. Актуальные в настоящее время кодировки можно разделить на два класса: многобайтовые (UTF-8, UTF-16, UTF-32), представляющие полное множество символов UNICODE, и однобайтовые (ASCII, КОИ-8, CP866, Windows-1251), в которых представлены подмножества из 256 выбранных символов.
В общем случае для обработки текстов, представленных в многобайтовых кодировках, нужно использовать специальные функции языка программирования, учитывающие их особенность. Очевидно, что даже длина строки в многобайтовой кодировке будет отличаться от значения, которое вернет «обычная» функция, подсчитывающая количество байтов.
В языке программирования PHP функции для обработки текстов в многобайтовых кодировках начинаются с префикса «mb_»: mb_strlen (), mb_strpos (), mb_strstr () и т.д. Они выполняются несколько медленнее своих однобайтовых аналогов, потому что для правильной работы им нужно выполнять фоновый анализ встречающихся символов. Впрочем, в интерпретируемых языках программирования этим замедлением можно смело пренебречь.
В подпрограммах, описанных в этой статье, используются обычные однобайтовые функции. Такое решение принято по той причине, что в подавляющем большинстве случаев структура анализируемых текстов (документов HTML и XML, новостных лент RSS, информационных хранилищ JSON) определяется фразами из символов, входящих в подмножество первых 127 символов кодовой таблицы ASCII. А завоевавшая широкое распространение кодировка UTF-8 использует в своей основе префиксное кодирование [2], благодаря которому между кодами первых 127 символов кодировок ASCII и UTF-8 установлено взаимно однозначное соответствие. Многобайтовые символы UNICODE в кодировке UTF-8 содержат в своей записи только байты со значениями от 127 до 255.
Описанные в статье подпрограммы можно смело использовать для обработки текстов в однобайтовых кодировках и кодировке UTF-8. Они будут корректно определять структурные элементы текста, записанные младшими 127 символами, не затрагивая его информационное наполнение, которое в свою очередь может быть произвольным.
Мы продемонстрировали извлечение информации из HTML-файла комбинированным способом, который включает:
- прямой поиск начала интересующего участка HTML-кода с помощью стандартной функции PHP strpos ();
- последующий сбор данных путем анализа структуры HTML-кода с помощью разработанных для этой цели функций лексического и синтаксического анализа текста.
Такой подход позволяет сочетать быструю локализацию области поиска благодаря однократному вызову встроенной функции и гибкость при выполнении анализа текста специально разработанными на языке PHP для этой цели средствами. В большинстве случаев он не имеет недостатков по сравнению с построением структурного дерева HTML-документа. А кроме того не требует установки сторонних библиотек и при необходимости может быть дополнен средствами работы с регулярными выражениями.
Задача извлечения информации из HTML-страницы, рассмотренная в этой статье, гораздо проще проекта разработки компилятора для какого-нибудь языка программирования. Но следование общим принципам построения анализатора позволило сделать программу обозримой и легко адаптируемой к возможным изменениям в дизайне анализируемого сайта. Разработанные вспомогательные функции можно применять для анализа текстов на других похожих языках – XML, RSS, JSON.
Включение в стандартную библиотеку интерпретируемых языков программирования функций SkipOver (текст, набор_символов [,начальная_позиция]) и SkipTo (текст, набор_символов [,начальная_позиция]) позволило бы существенно повысить эффективность реализаций на этих языках программ для обработки текстовой информации.
- Залогова Л. А. Разработка Паскаль-компилятора. – М.: БИНОМ. Лаборатория знаний, 2012.
- UTF-8: Кодирование и декодирование. URL: https://habrahabr.ru/post/138173/.
Ключевые слова: парсинг, PHP, разработка, веб.
Facebook
Мой мир
Вконтакте
Одноклассники
Google+
|