Как я html-парсер на php писал, и что из этого вышло. Вводная часть

Привет.

Сегодня я хочу рассказать, как написать html парсер, а также с какими проблемами я столкнулся, разрабатывая подобный парсер на php. А проблем было много. И в первой части я расскажу о проектировании парсера, и о возникших проблемах, ведь html парсер отличается от парсера привычных всем языков программирования.

Введение


Я старался написать текст этой статьи максимально понятно, чтобы любой, кто даже не знаком с общим устройством парсеров мог понять то, как работает html парсер.

Здесь и далее в статье я буду называть документ, содержащий html просто «Документ».

Dom дерево, находящееся в элементе, будет называться «Подмассив».

Что должен делать парсер?


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

  • Проектировать dom-дерево на основе документа
  • Если есть ошибки в документе, то он должен их решать
  • Находить элементы в dom-дереве
  • Находить children элементы
  • Находить текст

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

Впрочем, это мелочи. Основного функционала вполне хватит, чтобы поломать голову пару ночей напролет.

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

Разделяй и властвуй


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

  • Отделение обычного текста от тегов
  • Сортировка всех полученных тегов в dom дерево

Это что касается непосредственно парсинга документа. Про поиск элементов я буду говорить чуть позже далее в этой главе.

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



Я решил опустить все мелкие детали. Например, как отличить, что после открывающего "<" идет тег, а не текст? Об этом я расскажу в следующих частях. Пока что этого вполне хватит.

Также тут стоит уточнить. Логично, что в документе помимо тегов есть еще и текст. Говоря простым языком, если парсер найдет открывающий тег и если в нем будет текст, он запишет его после открывающего тега в виде отдельного тега. Такой тег будет считаться как одиночный и не будет участвовать в дальнейшей работе парсера.

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



В данном случаи уровень означает уровень рекурсии. То есть если парсер нашел открывающий тег, он вызывает самого себя, «входит на уровень ниже», и так будет продолжаться до тех пор, пока не будет найден закрывающий тег. В этом случаи рекурсия выдает результат, «Выходит на уровень выше». Но, как обстоят дела с одиночными тегами? Такие теги считаются рекурсией ни как открывающие, ни как закрывающие. Они просто переходят в dom «Как есть».

В итоге у нас получится что-то вроде этого:

	[0] => Array
	(
	[is_closing] =>
	[is_singleton] =>
	[pointer] => 215
	[tag] => div
	[0] => Array //открывается подмассив
		(
		[0] => Array
		(
			[is_closing] =>
			[is_singleton] =>
			[pointer] => 238
			[tag] => div
			[id] => Array
			(
			[0] => tjojo
			)
				[0] => Array //открывается подмассив
					(
					[0] => Array //Текст записывается в виде отдельного тега
					(
						[tag] => __TEXT
						[0] => Привет!
					)
					[1] => Array
					(
					[is_closing] => 1
					[is_singleton] =>
					[pointer] => 268
					[tag] => div
					)
				)
			)
		)
	)

Что там насчет поиска элементов?


А теперь давайте поговорим про поиск элементов. Но тут не все так однозначно, как можно подумать. Сначала стоит разобраться, по каким критериям мы ищем элементы. Тут все просто, мы ищем их по тем же критериям, как это делает Javascript: теги, классы и идентификаторы. Но тут проблема. Дело в том, что тег может быть только один, а вот классов и идентификаторов у одного элемента — множество, либо вообще не быть. Поэтому, поиск элемента по тегу будет отличаться от поиска по классу или идентификатору. Я нарисовал схему поиска по тегу, но не волнуйтесь: поиск по классу или идентификатору не особо отличаются.



Немного уточнений. Под исходным значением я имел в виду название тега, «div» например. Также, если элемент не равен исходному значению, но у него есть подмассив с подходящим элементом, в результат запишется именно подходящий элемент с его подмассивом, если таковой существует.

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

Поиск children элементов


Хорошо, с поиском элементов разобрались, а как насчет children элементов? Тут тоже все просто: наш парсер будет брать все вложенные подмассивы найденных до этого элементов, если таковые существуют. Если таковых нет, парсер выведет пустой результат и пойдет дальше:



Поиск текста


Тут говорить особо не о чем. Парсер просто будет брать весь полученный текст из подмассива и выводить его.

Ошибки


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

  • Символ ">" не был найден
    Такая ошибка будет возникать в том случаи, если парсер дошел до конца документа и не нашел закрывающего символа ">".
  • Неизвестное значение атрибута
    Данная ошибка сигнализирует о том, что была проведена попытка передачи значения атрибуту когда закрывающий тег был найден.

    <tag some =><!--И что там написано? А никто не знает, как и парсер-->

  • Ошибка html синтаксиса
    Данная ошибка возникает в двух случаях: Либо у атрибута тега в названии есть "<", либо если знак "=" ставится дважды, хотя значение еще не было передано.

    <tag some = ='something'><!--Случайная ошибка, с кем не бывает-->
    <tag <some ='something'><!--И что это? Тег там, где должен быть атрибут? Непорядок-->

  • Слишком много открывающих тегов
    Данная ошибка часто встречается на сайтах, и говорит она о том, что открывающих тегов больше, чем закрывающих.

    <div>
    <div id = ='wefwe'>
    Привет!
    </div>
    <!--И куда делся </div>?-->
    

    Данная ошибка не является критической и будет решаться парсером.
  • Слишком много закрывающих тегов
    То же самое, что и прошлая ошибка, только наоборот.

    <div id = ='wefwe'>
    Привет!
    </div>
    </div><!--И что ты собрался закрывать?-->
    

    Данная ошибка также не является критической.
  • Children элемент не найден
    В этом случаи парсер просто будет выводить пустой массив.

Script, style и комментарии


В парсере теги script и style будут сразу же пропускаться, поскольку я не вижу смысл их записывать. С комментариями ситуация другая. Если вы захотите из записывать, то вы сможете включить отдельную функцию скрипта, и тогда он будет их записывать. Комментарии будут записываться точно так же как и текст, то есть как отдельный тег.

Заключение


Эту статью скорее нужно считать небольшим экскурсом в тему парсеров html. Я ее написал для тех, кто задумывается над написанием своего парсера, либо для тех, кому просто интересно. Поверьте, это действительно весело!

Данная статья является первой вводной частью. В следующих частях этого цикла уже будет участвовать непосредственно код, и будет меньше картинок с алгоритмами(что прекрасно, потому что рисовать я их не умею). Stay tuned!

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 45

    +3

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


    P.S. С дебютом :)

      0

      Спасибо за поддержку. Забрасывать не намерен, тема мне кажется интересной, поэтому хочу ее развивать.

        +3

        Вы зря пишете такие короткие статьи. Кода нет, по теме ничего нет, текста наберется может строк 140. Разбивать текст на статьи надо по теме или функциональности кода, а не по размеру. Довольно редко встречаются такие большие темы, которые надо делить на несколько частей. Судя по названию, вряд ли у вас настолько сложная тема, можно было все описать в одной статье.

          –1
          Это вводная часть, она и не должна быть большой. Если бы я тут подробнее углубился бы в детали, тогда моя статья была бы не меньше той, ссылку на которую вы оставили. А это только теоритический материал, без кода. С помощью этой статьи я хотел заинтересовать читателя темой, а не полностью охватить эту тему.
            +2

            Я говорю о том, что не надо писать вводные статьи) Пишите сразу нормальные. Если тема кому-то интересна, он и так статью прочитает. Более того, если тема кому-то интересна, ему интересны детали, а не описание того, насколько это интересно.

              0

              Справедливо, спасибо за критику.

      +1
      Насчет ошибок, пропущена ситуация когда открывается один тег, а закрывается другой.

      P.S. И есть еще теги, которые можно не закрывать (img, input), а еще можно закрывать теги коротко: <div… />
        0

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

          +1

          и не закрывающиеся tr td тоже?

            0
            Кстати, а вот это я уже не учел. Спасибо за комментарий, приму к сведению.
              0
              li вроде тоже могут не закрываться.
                0
                А ведь правда. Вот я сглупил. Спасибо за комментарий.
          0

          Разве HTML позволяет закрывать тэг div коротко? Насколько я знаю, коротко закрывать можно только тэги без контента (те самые, которые можно не закрывать) и тэги внедренных SVG и MATHML.

            0
            Хмм, разве? Тогда странно, почему так. Допустим, тег div не содержит ни текстового контента, ни узлов-потомков, почему его тогда нельзя закрыть коротко, а надо писать закрывающий тег? В XML же можно коротко закрывать.
              0
              Работает же.
              <!DOCTYPE html>
              <html>
                  <body>
                      <div style="width: 100px; height: 100px; background-color: red;" />
                  </body>
              </html>
              
                +1

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


                <style>div { border: solid 1px; padding: 10px; }</style>
                <div/>
                <div/>
                <div/>

                В вашем же примере тэг div на самом деле закрывается на строчке </body>

                  0
                  Ага, вижу. Сколько лет уже программирую, а не знал.

                  P.S. Но всё-таки странно это.\
                  P.P.S. Они накладываются друг на друга, но отображаются оба.
                    +1

                    Это не наложение. Они находятся один внутри другого. То есть браузер просто проигнорировал все лишние "/" и увидел три открывающих тэга.


                    Ничего странного, HTML — не XML.

                      0
                      Более того, XHTML — тоже не XML.

                      <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
                       "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
                      <html xmlns="http://www.w3.org/1999/xhtml">
                      <head>
                      <title>Test</title>
                      <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
                      <style type="text/css">
                      div { border: solid 1px; padding: 10px; }
                      </style>
                      </head>
                      <body>
                      <div/>
                      <div/>
                      <div/>
                      </body>
                      </html>
                      
                        +1

                        Ну почему же? В режиме XHTML как раз всё работает.


                        Просто XHTML определяется браузером не по DOCTYPE, а по Content Type (а для локальных файлов — по расширению).


                        У слову, у вас Content-Type как раз неправильный указан. Плюс я не уверен что в режим XHTML можно переключиться уже после начала парсинга.

                          +1
                          Вот кстати не понимаю, почему в HTML5 ушли от XHTML?
                            +1

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


                            А во-вторых, этот XHTML местами слишком многословный и неудобный для редактирования человеком.

                              0
                              Ясно, спасибо.
                              0

                              Не ушли, даже в актуальных спецификациях HTML 5.2 есть несколько разделов, посвящённых XML-синтаксису. Я проверял — как минимум в Firefox это всё реализовано и работает. Валидатор (Nu Html Checker) тоже успешно валидирует HTML5 в XML-синтаксисе.

                              0
                              Посыпаю голову пеплом, правда ваша.
                0
                Не доводилось ли пользоваться tidy? Мне не раз это расширение помогало с невалидным html при парсинге.
                  –2
                  Нет. Все вручную, только хардкор.
                    0

                    Правильно товарищ пишет: ровно эта проблема решена в tidy, и там просто огромная туча кода на Си для этого. Обильно политая слезами и кровью десятков тысяч страждущих, а также приправленная сотнями юнит-тестов. Свое можно писать там, где не возникает комбинаторного взрыва. А где он возникает, лучше взять готовое.

                  0
                  А какая была цель написания своего парсера?
                    0
                    Чистый интерес. Ну и еще мне надоел simple dom
                      0
                      Есть полноценный DOM. А к нему XPath который на задаче парсинга данных из страницы подходит очень хорошо и главное он уже существует стандартный. Рассматривали?
                        –1
                        Я не особо разбирался в этой теме, поэтому нет. Более того, мне это не нужно. Функционала моего парсера мне хватает, как и скорости. Я буду писать об этом дальше, но сейчас могу сказать, что мой парсер ищет текст с сайта new your times примерно за 1.5 секунды
                          0
                          Ну… это наверное вместе с загрузкой самой страницы. И это время совсем не стоит учитывать для оценки производительности парсера. Иначе что-то здесь не так.
                            –1
                            Вообще это очень размытая цифра. В конце концов, парсеру же еще нужно загрузить страницу, так что тут больше скорость загрузки зависит от интернета. Если брать текст из уже скачанной страницы, то он обрабатывает текст с все того же сайта за 250мс
                            0
                            Для промышленного парсера скорость это только один из показателей. Еще очень важна и сложность его использования в прикладной плоскости. Рекомендую добавить туда поддержку XPath.
                              0
                              Неплохая идея, только тут стоит подумать о той же производительности. Ведь если я решу добавить подобный функционал, мне придется писать еще один синтаксический анализатор только для поиска. При этом получится, что этот синтаксический анализатор будет даже больше, чем анализатор html, ведь в XPath есть уже функции, операторы и др.
                                0
                                А речь про полную поддержку и не идет. Какое-то подмножество. Я так когда-то так для JS делал, т.к. там плохо было с XPath.

                                В целом нужно понимать, что вся это история чисто образовательная и до реального промысленного использования не дойдет.
                      –1
                      Сомнительная статья. Как такое попадает в лучшее?
                      На первом же изображении: «Берем символ, если он равен '<' — считаем его тегом и обрабатываем». Считаем символ тегом? И таких ошибок — куча.
                      Информации мало.
                      Сама информация — низкого качества. Выдумываете бред.
                      С наскоку дам больше информации лучшего качества:
                      Html — имеет простую структуру, смотрим rfc1866.
                      Имеем два типа нод: TagNode и TextNode (не может иметь детей).
                      Скрипты и стили к парсингу отношения не имеют и рассматриваются как обычные теги, содержащие одну дочернюю TextNode.
                      Самый простой парсер можно написать на регулярках, и через рекурсию строить дерево.
                      Если есть желание написать что-либо более интересное — читаем «книгу Дракона», да, она о разработке компиляторов, но в ней полно информации по интересующей нас теме — синтаксическом и лексическом анализе. Определяем алфавит, лексемы. Пишем лексический анализатор (автомат), который все это обрабатывает, пропускаем исходный код и на выходе получаем список лексем. Дальше строим из лексем плоский массив из открывающих/закрывающих тегов, блоков текста. В конце проходим по этому массиву строим дерево и заодно выявляем ошибки — незакрытые теги, цепи и.т.д.
                        0
                        Касательно того, что я пишу бред. Это небольшая ошибка, я имел ввиду то, что парсер меняем состояние и начинает дальнейшую цепочку символов обрабатывать как тег. Прошу прощения, хотя с другой стороны, тем кто вообще не знаком с синтаксическими анализаторами, как мне кажется, будет более понятен мой вариант объяснения. Также я читал книгу по трансляторам, более того, некоторый материал будет использоваться в дальнейших частях цикла. Например про те же состояния и теорию конечных автоматов. Частей будет еще где-то две. Касательно нод. Вы не учли ноду комментариев, а ведь они тоже будут в документе, и их тоже нужно будет обрабатывать. Также хочу отметить, что вариант написания парсера на регулярках — плохая идея. Такой парсер будет работать примерно так же, как simple dom.
                          –2
                          Это небольшая ошибка, я имел ввиду то, что парсер меняем состояние и начинает дальнейшую цепочку символов обрабатывать как тег.

                          Это смысловая ошибка, читатель не должен догадываться о том, что вы имели ввиду.
                          Прошу прощения, хотя с другой стороны, тем кто вообще не знаком с синтаксическими анализаторами, как мне кажется, будет более понятен мой вариант объяснения.

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

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

                          Если бы вы описывали процесс, с информацией, указанной выше, то можно было бы еще поверить в две хорошие части, разделенные на: теоретическую и практическую, либо на: разбор и анализ. Что же вы хотите растянуть на три, описывая информацию вашей манерой — непонятно.
                          Касательно нод. Вы не учли ноду комментариев, а ведь они тоже будут в документе, и их тоже нужно будет обрабатывать.

                          Вы не учли то, что я писал комментарий, а не статью. Да, для них будет отдельное правило.
                          Также хочу отметить, что вариант написания парсера на регулярках — плохая идея. Такой парсер будет работать примерно так же, как simple dom.

                          Естественно — на одной регулярке, получится простая поделка и далеко не уедешь, но другого я и не утверждал. Боюсь, сейчас вы показали полное отсутствие компетентности в данной теме. В профильной литературе, и, например, в lex регулярные выражения используются повсеместно.
                            0
                            Боюсь, сейчас вы показали полное отсутствие компетентности в данной теме. В профильной литературе, и, например, в lex регулярные выражения используются повсеместно.

                            Нет, я показал отсутствие компетентности в регулярных выражениях, но никак не в синтаксическом анализе. В конце концов, если бы то, что вы сказали выше — правда, думаю моя статья вообще бы не вышла на хабре. К тому же, я не говорю, что использовать регулярные выражения вообще в синтаксических анализаторах это сразу плохо. Вовсе нет. Просто в данной задаче это не нужно, так как она достаточно простая и использовать регулярные выражения нет смысла.
                          0
                          Html — имеет простую структуру, смотрим rfc1866.

                          Со времен rfc1866 вышло аж три новые версии Html.


                          Скрипты и стили к парсингу отношения не имеют и рассматриваются как обычные теги, содержащие одну дочернюю TextNode.

                          Ну-ну:


                          <script>
                              <div>
                          </script>

                          Здесь тэг <script> содержит текстовую ноду <div>. Любой другой тэг в этой ситуации содержал бы вложенный тэг <div>. А значит, парсить скрипты нужно всё-таки несколько по-другому.


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

                          Неа, нельзя. HTML не является регулярным языком.


                          Дальше строим из лексем плоский массив из открывающих/закрывающих тегов, блоков текста.

                          Нельзя так делать, некоторые тэги (тот же <script>) меняют режимы лексера.

                            0
                            Ого, спасибо за пояснение! Я сначала думал, что у меня мало знаний, поэтому я не смогу написать парсер на регулярках. А оказывается его написать и нельзя. Касательно script вы правы. В конце концов, в script могут быть разные выражения с использованием "<" и других символов, и нельзя допустить, чтобы такой код обрабатывался как обычные теги. Все это предусмотрено.
                              –1
                              Со времен rfc1866 вышло аж три новые версии Html.

                              Не путайте комментарий с постом.
                              Здесь тэг script содержит текстовую ноду div. Любой другой тэг в этой ситуации содержал бы вложенный тэг div. А значит, парсить скрипты нужно всё-таки несколько по-другому.

                              Для парсера без разницы — что внутри у тега script. Синтаксический анализатор построил токены, лексический прошелся по ним, и после обработки все, что внутри script, превращается в текстовую ноду. Да, для данного случая анализ сложнее, но сути это не меняет. То, что код внутри не валиден — не проблема парсера html. Уж логика у вас больно странная — автор может вырезать script, но не может вместо этого создать ноду с текстом.
                              Неа, нельзя. HTML не является регулярным языком.

                              Читайте не между строк. Писалось о простом парсере, который будет работать с минимумом проблем. Хотя, если пойти дальше, то все равно:
                              https://habr.com/ru/post/171667/
                              Льзя. Вы уж простите, но nikic'у я верю больше.
                              https://www.w3.org/TR/WD-html-lex/ Ну и у w3c получилось на lex, который использует регулярные выражения. А вы и дальше считайте, что нельзя.
                                0
                                Лексический анализатор строит токены, а не синтаксический. Я вполне могу создать текстовую ноду, только смысла в этом нет. В том же simple dom все script и style вырезаются регулярками.

                          Only users with full accounts can post comments. Log in, please.