HTML::TokeParser

    Одним из наиболее часто используемых мною модулем при парсинге HTML является HTML::TokeParser. Этот модуль разбивает весь HTML документ на токены, с которым позже можно удобно работать.

    Давайте рассмотрим какой-либо пример на практике. Возьмем сайт habrahabr.ru

    Пример 1. Необходимо спарсить список ссылок на полные статьи.

    Первое. Определяем используемую кодировку. Для этого достаточно посмотреть тег meta, для хабра это – UTF-8

    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
    


    Второе. Сохраняем веб страницу в файл. Пишем небольшой скрипт

    use strict;
    use warnings;
    use HTML::TokeParser;
    use Data::Dumper;
    
    open (my $f,"<", $ARGV[0]) ;
    my $p = HTML::TokeParser->new($f);
    
    while (my $token = $p->get_token()) 
    {
    	print Dumper ($token);	
    }
    


    Передаем ему на вход наш сохраненный файл и перенаправляем данные из STDOUT в файл. Мы должны получить что-то на подобии

    $VAR1 = [
              'T',
              '
    ',
              ''
            ];
    $VAR1 = [
              'D',
              '<!DOCTYPE html>'
            ];
    $VAR1 = [
              'T',
              '
       
    ',
              ''
            ];
    $VAR1 = [
              'S',
              'html',
              {
                'xmlns' => 'http://www.w3.org/1999/xhtml',
                'xml:lang' => 'ru'
              },
              [
                'xmlns',
                'xml:lang'
              ],
              '<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ru">'
            ];
    


    и т.д. Этот файл будет использоваться для отладки.

    Третье. Используем Firebug и смотрим, что из себя представляет ссылка на полную версию статьи. Вот что мы получаем в нашем случае

    <a href="http://habrahabr.ru/post/163525/#habracut" class="button habracut">Читать дальше →</a>
    


    Догадываемся, что мы можем легко найти все ссылки благодаря class=«button habracut». Ищем в файлике, созданном на шаге 2 строку button habracut. Пишем свой парсер, я обычно оформляю его в виде отдельного класса. Парсер должен получать данные в HTML. Вот что получаем

    Test.pl
    use strict;
    use warnings;
    use habr_parse;
    use LWP::UserAgent;
    use Data::Dumper;
    
    my $ua = LWP::UserAgent->new();
    
    my $res = $ua->get("http://habrahabr.ru");
    
    if ($res->is_success())
    {
    	my $parser = habr_parse->new();
    
    #	print Dumper ($res);
    
    	my $conf = {};
    	$conf->{content} = $res->content;	
    	$conf->{cp} = 'utf8';
    	my $r = $parser->get_page_links($conf);	
    	
    	print Dumper ($r);
    }
    
    


    Habr_parse.pm

    package habr_parse;
    use strict;
    use warnings;
    use HTML::TokeParser;
    use HTML::Entities;
    use Data::Dumper;
    use Encode;
    
    sub new
    {
    	my $class = shift;
    
    	my $self = {};
    
    	bless ($self, $class);
    
    }
    
    sub get_page_links
    {
    	my $self = shift;
    	my $conf = shift;
    
    	my @data;
    	# get internal format
    	$conf->{content} = decode($conf->{cp},$conf->{content});
    #	print Dumper ($conf);
    	decode_entities($conf->{content});
    
    	my $p = HTML::TokeParser->new(\$conf->{content});
    
    	while (my $token = $p->get_token())
    	{
    		# we found our link
    		if ($token->[0] eq 'S' && $token->[1] eq 'a' && defined ($token->[2]->{class}) && $token->[2]->{class}=~/^\s*button\s+habracut$/i)
    		{
    			push @data, $token->[2]->{href};			
    		}
    	}
    #	print Dumper ($p);	
    	
    	return \@data;
    }
    
    
    
    
    return 1;
    


    Для написания строки кода ниже, очень помогает наличие файла, созданного на шаге 2 (особенно если условий много)
    	if ($token->[0] eq 'S' && $token->[1] eq 'a' && defined ($token->[2]->{class}) && $token->[2]->{class}=~/^\s*button\s+habracut$/i)
    
    


    В принципе это простой пример, потому что каждая ссылка имеет уникальный атрибут (значение class), которого нет больше нигде. Но сила HTML::TokeParser не в этом. Рассмотрим пример 2.

    Пример 2. Необходимо для каждой статьи получит список категорий. С помощью Firebug мы замечаем, что категории находятся внутри тега div с атрибутом class=’hubs’.

    Поскольку мы заходим на сайт без куков и какой-либо аутентификации то мы не можем быть подписаны ни на один хаб, поэтом для нас выводятся ссылки с title = ‘Вы не подписаны на этот хаб’

    Если посмотреть на наш дамп, созданный на шаге 2 (пример 1), вот какой фрагмент нам нужен

    $VAR1 = [
              'S',
              'a',
              {
                'href' => 'http://habrahabr.ru/hub/photo/',
                'title' => 'Вы не подписаны на этот хаб',
                'class' => 'hub '
              },
              [
                'href',
                'class',
                'title'
              ],
              '<a href="http://habrahabr.ru/hub/photo/" class="hub " title="Вы не подписаны на этот хаб" >'
            ];
    $VAR1 = [
              'T',
              'Фототехника',
              ''
            ];
    
    


    Все получается просто, если мы вначале найдем ссылку с title =‘Вы не подписаны на этот хаб’ получим следующий токен и если это текст, сохраним.

    Я покажу немного другую технику, которая базируется на том, что мы запихываем токены в стек, проверяя самый последний токен, до тех пор пока не встретим то, что нужно. Если же нам не встретился нужный токен мы используем unget_token().

    Обратим внимание на другую закономерность после нужных нам данных идет токен с закрывающим тегом a

    $VAR1 = [
              'T',
              'Гаджеты. Устройства для гиков',
              ''
            ];
    $VAR1 = [
              'E',
              'a',
              '</a>'
            ];
    
    


    Изменим habr_parse.pm

    package habr_parse;
    use strict;
    use warnings;
    use HTML::TokeParser;
    use HTML::Entities;
    use Data::Dumper;
    use Encode;
    
    sub new
    {
    	my $class = shift;
    
    	my $self = {};
    
    	bless ($self, $class);
    
    }
    
    sub get_page_links
    {
    	my $self = shift;
    	my $conf = shift;
    
    	my @data;
    	# get internal format
    #	$conf->{content} = decode($conf->{cp},$conf->{content});
    #	print Dumper ($conf);
    #	decode_entities($conf->{content});
    
    	my $p = HTML::TokeParser->new(\$conf->{content});
    
    
    	my $tmp_conf = {};
    
    	while (my $token = $p->get_token())
    	{
    
    		# we found our link
    		if ($token->[0] eq 'S' && $token->[1] eq 'a' && defined ($token->[2]->{class}) && $token->[2]->{class}=~/^\s*button\s+habracut$/i)
    		{
    			$tmp_conf->{href} = $token->[2]->{href};			
    		}
    		elsif ($token->[0] eq 'S' && $token->[1] eq 'div' && defined ($token->[2]->{class}) && $token->[2]->{class}  eq 'hubs')
    		{
    		 	my @next;
    			my $found=0;
    
    			# вначале идет информаци по категориям
    			$tmp_conf = {};
    
    			my $token = $p->get_token();
    
    			push @next, $token;
    			
    			# пока нет закрывающегося тега div (вложенных div не должно быть).
    			while ($next[$#next][1] ne 'div')
    			{
    				push @next, $p->get_token();
    
    #				print Dumper ($next[$#next][1]);
    
    				# закрывающийся тег а
    				if ($next[$#next][0] eq 'E' && $next[$#next][1] eq 'a')
    				{
    					# предыдущий тег T с нужным нам тегом
    					if ($next[$#next-1][0] eq 'T')
    					{
    						# print	$next[$#next-1][1] . "\n";
    
    						push @{$tmp_conf->{cats}}, $next[$#next-1][1];
    
    
    						$found = 1;
    					}
    				}
    			}
    
    			if (!$found)
    			{
    				# возращаемся на исходную позицию мы не нашли категории
    				$p->unget_token(@next);
    			}
    
    			push @data, $tmp_conf;
    
    		}
    
    
    	}
    #	print Dumper ($p);	
    	
    	return \@data;
    }
    
    
    return 1;
    


    Результат

    $VAR1 = [
              {
                'cats' => [
                            'Исследования и прогнозы в IT',
                            'Будущее здесь'
                          ],
                'href' => 'http://habrahabr.ru/post/162053/#habracut'
              },
              {
                'cats' => [
                            'Фототехника',
                            'Будущее здесь'
                          ],
                'href' => 'http://habrahabr.ru/post/163433/#habracut'
              },
              {
                'cats' => [
                            'Электроника для начинающих',
                            'Гаджеты. Устройства для гиков',
                            'Будущее здесь'
                          ],
                'href' => 'http://habrahabr.ru/post/163493/#habracut'
              },
              {
                'cats' => [
                            'HTML',
                            'CSS'
                          ],
                'href' => 'http://habrahabr.ru/post/163429/#habracut'
              },
              {
                'cats' => [
                            'Железо',
                            'Блог компании Intel'
                          ],
                'href' => 'http://habrahabr.ru/company/intel/blog/162293/#habracut'
              },
              {
                'cats' => [
                            'Хабрахабр — Анонсы',
                            'Фриланс',
                            'Блог компании Тематические Медиа'
                          ],
                'href' => 'http://habrahabr.ru/company/tm/blog/163483/#habracut'
              },
              {
                'cats' => [
                            'Веб-разработка',
                            'Open source'
                          ],
                'href' => 'http://habrahabr.ru/post/163425/#habracut'
              },
              {
                'cats' => [
                            'Переводы',
                            'Операционные системы',
                            'Open source'
                          ],
                'href' => 'http://habrahabr.ru/post/148911/#habracut'
              },
              {
                'cats' => [
                            'Программирование'
                          ],
                'href' => 'http://habrahabr.ru/post/163445/#habracut'
              },
              {
                'cats' => [
                            'Работа со звуком',
                            'Ненормальное программирование'
                          ],
                'href' => 'http://habrahabr.ru/post/163525/#habracut'
              }
            ];
    
    


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

    При таком подходе как в HTML::TokeParser не сохраняется информация о вложенности, поэтому как вариант можно использовать массив с токенами и unget_token().

    Комментарии 15

      +3
      Думаю это не лучший вариант парсинга.
      Все же html надо разбирать как xml, как дерево.
      Например Mojo::DOM для Perl.
        +1
        Действительно, без DOM дерева толку от него мало. Такой токенизер ничем не лучше простого regex
          0
          Хотя я не прав. В задачи токенизера и не входит составление дерева.
            –3
            И часто лучше всего парсить регэкспом.
              0
              Поясню:
              Участвовал в нескольких проектах где нужно было парсить html и выцеплять оттуда данные.
              Я подготовил код для парсинга более 200 сайтов (на каждом несколько типов страниц).
              Так же я работал в команде, решение чам парсить и как парсить принимал не я.

              В коде были методы для парсинга и через XPath и движёк основанный на Regexp, так вот регэкспы гораздо быстрее проще и надежнее написать.

              Проблемы, что один и тот же тэг может быть по разному записан, атрибуты поменены местами, используются разные кавычки итд, здесь практически нет. На самом деле на практике на одной и той же странице и в одном и том же месте тег всегда, ВСЕГДА, одинаковый.

              С Xpath же проблема что выражение типа
              "/html/body/div[6]/div[2]/div/div[7]/div/div[3]/div/div[2]/div/ol/li[2]/div/h3/a" (то что выдаёт firebug) совершенно не надёжно.

              Искать же каждый элементы по именам классов, id и т.д. сложнее и дольше чем писать регэкспы,
              так как бывает что ни стилей ни id у них нет, бывает что тэгов в иерархии много и в каждом используется несколько (3-4) классов, бывает что xpath не хватает и нужно написать какойто код который по обходить dom.

              Надёжностью это решение тоже не отличается.

              Ещё был проект гду нужно было писать интеграционные тесты для legacy rails приложения перед рефакторингом, (именно интеграционные тестирующие полностью весь стек, без mock). Там мы парсили DOM для валидации того что есть на странице. Где-то в 30% случаев гораздо проще было парсить регэкспом (хотя бы частично).
                0
                Не советую использовать XPath скопированный из Firebug.
                Поясню — если скопировать XPath к заголовку статьи на хабре, то получим /html/body/div/div[2]/div[3]/div/div[2]/h1/a, который поломается с большой вероятностью при изменении дизайна.
                Если же немножко подумать головой, то можно написать //h3[@ class='title']/a[@ class='post_title'] или вообще ограничиться //a[@ class='post_title']. И такой XPath будет понадежнее любой регулярки.

                Опять же, в своих проектах держу несколько функций-расширений XPath, например has-class(), has-classes(), url-path-regexp(), url-domain() и пр (по названию думаю понятно что они делают).
                  0
                  Про это всё я написал.
            +1
            Да, Моджо после ТокеПарсера выглядит фантастическим.
            Единственное, что на некоторых виртуальных хостингах нет нужной для Моджо версии перла и это создаёт ряд неудобств с внедрением нового в древние проекты.
              0
              Есть еще HTML::DOM
                0
                1. Не стоит пользоваться хостингами с древней версией перла.
                2. Есть форк Mojo который работает с perl 5.8.8
                0
                также в комплекте модуль ojo для подобных однострочников:

                perl -Mojo -E 'say g("mojolicio.us")->dom->at("title")->text'
                  0
                  А вот такой абсолютно валидный HTML представим в виде DOM?

                  <b><i>xxx</b></i>
                  
                    0
                    Валидный по чьему мнению? :) Валидатор W3C не считает его валидным: «end tag for element „I“ which is not open».
                    Расширение DOM php кидает warning'и на ту же тему, но распарсивает нормально, на выходе получается корректный html:
                    <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
                    <html><body><b><i>xxx</i></b></body></html>
                    

                    Что получится конкретно с Mojo::DOM в Perl не знаю, но думаю все будет аналогично.
                      0
                      Валидный с точки зрения отображения в браузере.
                      Если браузет такое отображает (а многие, если не все — отображают без проблем), то значит есть сайты на которых такое встречается.
                      И значит ваш парсер должен быть готов к этому.
                  –1
                  Еще есть Web::Query, HTML::Parser и т.д. Пример написан для HTML::TokeParser, использование которого, все же можно часто встретить, например, в англоязычной литературе по Perl, кроме того, он включен в Perl из коробки. Именно с этого модуля, автором которого является Gisle Aas, сделавший еще целую кучу полезных модулей, многие начинали на Perl.

                  А, например, подход Microsoft, который заключался в том, что используя OLE::Automation и IE можно получать доступ к DOM и делать все что угодно, плюс выполнение JavaScript и т.д., еще круче чем Mojo::DOM, но значит ли это, что все Perl Win32 программисты побегут юзать IE?

                  Использовать регулярные выражения в парсинге HTML — это конечно же красный сигнал, для тех кто минусует. Вот пример, на stackoverflow.com — один человек попросил написать регулярное выражение для парсинга куска HTML, другой это сделал, его сразу же заминусовали, мне интересно люди хоть прочитали, что изначально спрашивал человек. Еще мне интересно, если тому кто усиленно минусует регулярные выражения, скажут, друг, я тебя заплачу намного больше, если ты вместо высоуровневего ООП модного модуля, просто будешь использовать регулярные выражения, он что откажется? ;)

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

                  Самое читаемое