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

Существует знаменитый ответ на Stack Overflow о том, почему этого ни в коем случае не следует делать. На самом деле, этот ответ стал настолько популярным, что в определённых кругах используется, как копипаста. Каждый раз, когда я натыкаюсь на него, то думаю что он во многом справедлив... но в то же время, не могу согласиться с ним полностью...
Но... неужели действительно нельзя?

Хотя я предполагаю, что все читатели этой статьи имеют хотя бы смутное представление о XML, в целях дальнейших обсуждений стоит всё-таки повториться. Процитирую статью об XML из Википедии:
Extensible Markup Language (XML) — это язык разметки и формат файлов для хранения, передачи и воссоздания данных. Он определяет набор правил кодирования документов в человекочитаемом и машиночитаемом формате.
Давайте выделим три момента:
Это язык разметки: в отличие от JSON и TOML2, XML определяет гораздо более конкретную структуру документа. Другие производные SGML чуть более свободно обращаются с принудительной реализацией такой структуры; запомним это на будущее.
Он машиночитаемый: предназначен для парсинга и интерпретации в дерево.
Он человекочитаемый: для чтения и понимания данных, содержащихся в документе XML, не требуется специализированных инструментов.
О чём Википедия не говорит сразу (приходится дойти до раздела 11), так это о том, что XML ужасно сложный. JSON, TOML и многие другие форматы передачи человекочитаемых данных достаточно просты, чтобы многие разработчики-самоучки могли освоить их. Да что уж говорить: RFC8259, «The JavaScript Object Notation (JSON) Data Interchange Format», состоит всего из 16 страниц, и самому описанию формата уделено примерно восемь. Для сравнения: базовая спецификация XML 1.0 (Second Edition) состоит из 59 страниц, и это ещё не включая различные расширения, на которые она разрослась с 2000 года. Неудивительно, что эта большая площадь поверхности становится потенциальной угрозой безопасности, если разработчики не знакомы со всем множеством фич.
Из-за этой нехватки глубокого знания формата новички и задумываются о парсинге XML при помощи регулярных выражений. Это проблема «ты не знаешь, чего ты не знаешь», которая приводит к совершенно другому подходу к написанию парсера.
Твой парсер ≠ мой парсер
Давайте вернёмся к части про «машиночитаемость» и «человекочитаемость». Предположим, что у нас есть стековый парсер; это позволяет легко проиллюстрировать, где находится парсер в структуре. (Напомню, что стек — это очередь/массив, в которых операции подвергаются push
, то есть добавлению значения в конец, и pop
, при котором это значение возвращается в программу.)
<a>
<b>
<c>meow</c>
<d>nya</d>
</b>
</a>
Выше показано простое XML-подобное дерево объектов.
Вот упрощённое описание того, как парсер может «обходить» дерево:
# stack=()
<a> # push a; stack=(a)
<b> # push b; stack=(a b)
<c>meow</c> # push c; stack=(a b c)
<d>nya</d> # pop; push d; stack=(a b d)
</b> # pop; stack=(a b)
</a> # pop; stack=(a)
# pop; stack=()
Хотя этот пример не демонстрирует ничего особо полезного, происходящего с деревом, на самом деле, поверх него достаточно легко добавить систему запросов селекторов в стиле DOM. Показанный ниже код реализует очень наивный парсер XML-подобных данных, который можно использовать для извлечения строк из объектов:
#!/usr/bin/env bash
# Не используйте этот код
stack=()
tokens=()
buf=
# QUERY=(a b c)
QUERY=($@)
flush() {
if [[ "$buf" ]]; then
tokens+=("$buf")
fi
buf=
}
search() {
(( ${#stack[@]} < ${#QUERY[@]} )) && return
[[ ${tokens[-1]} != "lbrack" ]] && return
for (( i=0; i<${#QUERY[@]}; i++ )); do
if [[ "${QUERY[i]}" != "${stack[-${#QUERY[@]}+i]}" ]]; then
return
fi
done
echo "query result: ${tokens[-2]}"
}
while read -rn1 chr; do
if [[ "$chr" == "<" ]]; then
flush
tokens+=("lbrack")
elif [[ "$chr" == ">" ]]; then
if [[ "${tokens[-1]}" == "lbrack" ]]; then
flush # get tag contents
stack+=("${tokens[-1]}") # помещаем в стек
elif [[ "${tokens[-1]}" == "slash" ]]; then
unset stack[${#stack[@]}-1] # извлекаем последний элемент
fi
tokens+=("rbrack")
elif [[ "$chr" == "/" && "${tokens[-1]}" == "lbrack" ]]; then
tokens+=("slash")
else
buf+="$chr"
fi
search
done
Результат будет таким:
## с точки зрения DOM, 'a b c' будет 'a > b > c'
$ ./parse.sh a b c < test.xml
query result: meow
$ ./parse.sh a b d < test.xml
query result: nya
Такое поведение «обхода» можно визуализировать ещё лучше, добавив к каждому циклу declare -p stack
:
$ ./parse.sh a b d < test
declare -a stack=()
declare -a stack=()
declare -a stack=([0]="a")
declare -a stack=([0]="a")
declare -a stack=([0]="a")
# (...)
declare -a stack=([0]="a" [1]="b")
# (...)
declare -a stack=([0]="a" [1]="b" [2]="c")
declare -a stack=([0]="a" [1]="b" [2]="c")
declare -a stack=([0]="a" [1]="b" [2]="c")
# (...)
declare -a stack=([0]="a" [1]="b")
# (...)
declare -a stack=([0]="a" [1]="b" [2]="d")
declare -a stack=([0]="a" [1]="b" [2]="d")
declare -a stack=([0]="a" [1]="b" [2]="d")
declare -a stack=([0]="a" [1]="b" [2]="d")
query result: nya
declare -a stack=([0]="a" [1]="b" [2]="d")
declare -a stack=([0]="a" [1]="b" [2]="d")
declare -a stack=([0]="a" [1]="b" [2]="d")
declare -a stack=([0]="a" [1]="b")
# (...)
declare -a stack=([0]="a")
# (...)
declare -a stack=()
Из-за того, что наш парсер однопроходной (он объединяет в один этап токенизацию и ещё несколько других этапов), мне пришлось удалить часть повторений. Также стоит учесть, что парсер предназначен только для демонстрационных целей, и он не может парсить произвольный XML. В реальном XML есть куча специальных объектов, самозавершающих тэгов и других особенностей, которые следует учитывать даже при простом извлечении текста.
Как наш мозг считывает XML
Теперь, когда вы имеете представление о том, как может работать алгоритм парсинга XML (и о том, что написание парсера — это большая боль), давайте сделаем шаг назад и поговорим о том, как парсим XML мы, существа из плоти и крови. Чтобы усложнить задачу, возьмём сырую, истинную форму XML — никакой красивый вывод не допускается.
<a><b><c>meow</c><d>nya</d></b></a>
Выше показан предыдущий пример в более сжатом виде.
Для неопытного глаза это не выглядит, как дерево.
<a >
<b >
<d >nya</d>
<c >meow</c>
</b>
</a>
О, вот теперь гораздо лучше! Такая запись семантически эквивалентна всем приведённым выше, но очень сложно представить в голове, что a > b > (c, d). Для меня этот блок в первую очередь — это строка.
Парсинг строк
Обработка XML или любого другого структурированного формата данных в виде строки похожа на ныряние в мусорные баки в поисках чего-нибудь интересного. Под этим я не подразумеваю ничего плохого: и регулярные выражения, и лазанье по помойкам приносили мне замечательные плоды. Но они же и вызывали во мне желание немедленно принять душ.
Продолжая аналогию, мы не можем спросить, почему тот или иной предмет выбросили на помойку (то есть, почему данные присутствуют и отформатированы именно так). Эта информация утеряна. Если внимательно приглядеться, мы можем делать предположения, но точно знать всё равно не будем. Хуже того, если данные меняются (как это может происходить с XML, возвращаемым API), всё дерево может иметь немного иной порядок, из-за чего ваш тщательно прописанный парсер окажется бесполезным. По этой и множеству других причин парсить XML лучше при помощи реального парсера.
Позже мы поговорим о методиках парсинга строк, но прежде нам следует поговорить о слоне в гостиной...
HTML: XML, но с особенностями
Уголок педанта
Кто-то может сказать, что и HTML, и XML — это производные от SGML, а не друг от друга, поэтому название этого раздела бессмысленно.
В противовес хотел бы сказать, что XML вселяет страх в студентов CS и хакеров, практически никто не знает SGML. HTML — это XML с особенностями.
HTML — основной язык для представления информации онлайн. Веб живёт и дышит HTML. Можно создавать веб-приложения без WebAssembly, без ECMAScript и даже без CSS. Но вам никак не обойтись1 без HTML (... или XHTML, но об этом чуть позже).
Несколько тысяч байтов назад я упоминал, что структура XML невероятно строга. HTML полностью ему противоположен — он допускает незакрытые тэги и поломанную грамматику. У парсера XML случился бы сердечный приступ, если бы его попросили спарсить HTML из веба.
Парсинг HTML почти невозможен
С правильно отформатированным HTML проблем нет. Однако браузеры спроектированы так, что они делают предположения, а не ломаются сразу, как только увидят неподходящую разметку. Это компромисс, на который пошли ради accessibility. Современные инструменты разработки упростили отладку, но в начале 90-х для неё практически не было инструментария. То, что парсеры принимали слегка поломанный ввод, без сомнений, способствовало повышению популярности HTML, когда он только появился.
К сожалению, это означает, что из HTML вырезали два слоя, имеющихся в XML. Его особенности во многом связаны с тем, как всё было реализовано в IE и Netscape тридцать лет назад. Режим соответствия стандартам в какой-то степени улучшает ситуацию, но браузеры всё равно принимают отсутствующие тэги и кавычки.
Тем не менее, практически все такие ситуации определены в стандарте, и современные браузеры реализуют всё крайне точно. Почему тогда парсинг «почти невозможен»? Живой стандарт HTML с лёгкостью переплюнул основу XML, он имеет длину... более чем 1500 страниц! ...Ладно, наверно, это не очень справедливо — на момент написания статьи с парсингом были связаны только 114 из этих страниц. Тем не менее, это всё равно в два с лишним раза больше, чем стандарт XML, и весь этот объём в основном определяет пограничные случаи! Если вы не пользуетесь реальным браузером, то есть вероятность, что ваше дерево DOM будет парситься немного по-разному на плохо форматированных страницах.
HTML4.01? Чушь! Нам нужно разработать более качественную альтернативу, подходящую всем
Ситуация: есть два брата-стандарта.
XHTML — он... странный. Этот стандарт был предложен в 1998 году и постепенно превратился к январю 2000 года в рекомендуемый W3C. К сожалению, он оказался не особо популярным (в отличие от более позднего HTML5)...
Попытка заставить мир перейти на XML, в том числе на кавычки вокруг значений атрибутов и косые черты в пустых тэгах и пространствах имён, не увенчалась успехом. Основная часть генерирующих HTML людей решила ничего не менять, и во многом из-за того, что браузеры на разметку не жаловались. Некоторые крупные сообщества всё-таки совершили этот переход и теперь наслаждаются плодами правильно сформированных систем, но не все.
~ Тим Бернерс-Ли, 2005 год
Я упомянул XHTML только потому, что с технической точки зрения у нас уже почти три десятка лет есть строгая, хорошо сформулированная альтернатива HTML, хоть о ней и знают не так много людей. Даже XHTML5 существует! И мы можем пользоваться им прямо сейчас! Он очень крутой! (Мне постоянно говорит об этом famfo, так что это должно быть правдой).
Наконец-то: парсим HTML при помощи регулярных выражений
Следующий раздел стал результатом моих многолетних попыток скрейпинга различных веб-сайтов. Я понимаю, какой негатив в некоторых кругах вызывает процесс скрейпинга, поэтому должен уверить читателя, что боты, которых я писал в прошлом, всегда выполняли запросы медленно и активно задействовали кэширование. Скрейперы генеративных ИИ, постоянно перегружающие Интернет, могут отправляться к чёрту.
Преимущества
-
Скорость разработки
На современных веб-сайтах часто есть сотни, если не тысячи вложенных элементов. Написание селектора для очень глубоко вложенного элемента может занять длительное время, особенно в случае существования дополнительных ограничений (рандомизированные имена классов? разработчик знает только div?).
На написание regex мне требуется тридцать секунд. Но на создание хорошего селектора и отладку причины его неправильной работы со следующим запросом могут уйти десятки минут и много мата.
-
Адаптируемость
Селекторы строги, они возвращают или результат, или ошибку. Это отлично, когда мы можем доверять другой части системы в том, что она отправляет хорошую, точную разметку. ОДНАКО при скрейпинге на такое рассчитывать не приходится. Например:
Часть расписания отправления самой малоактивной железнодорожной станции Западного Суссекса Допустим, нам нужно извлечь все станции, на которых вызывается этот поезд.
В ECMAScript мы бы использовали
document.querySelectorAll("#scroll0 > span")
... А затем нужно было бы объединить строки, так что нужно было бы написать что-то типаlet a=""; document.querySelectorAll("#scroll0 > span").forEach((e)=>{a+=e.innerText;}); console.log(a);
При работе с регулярным выражением я бы начал с сопоставления
scroll0".*?</div
, а затем удалял бы всё, что соответствует<[/a-zA-Z]>
. При этом остаётся много пробелов, эту проблему можно решить, выполняя сопоставлениеcurl (...) | tr -d '\r\n' | grep -Poh 'scroll0.*?</div' | sed 's@<[/a-zA-Z]*>@@g;s/ //g;'
Так мы получим следующую полезную нагрузку:
scroll0" class="scrollable"><span class="cp_header">Calling at:Ifield(1835), Crawley(1838), Three Bridges(1842), Gatwick Airport(1847), Horley(1851), Redhill(1859), Merstham(1903), Coulsdon South(1908), East Croydon(1915), London Bridge(1930), London Blackfriars(1936), City Thameslink(1938), Farringdon(1941), London St Pancras (Intl)(1945), Finsbury Park(1952), Stevenage(2013), Hitchin(2020), Arlesey(2026), Biggleswade(2031), Sandy(2035), St Neots(2042), Huntingdon(2049), <span class="cp_dest">Peterborough<span class="cp_dest">(2105)</div
Оставшуюся HTML-разметку можно удалить ещё несколькими фильтрами sed, но смысл примера не в этом. Представьте, что компания National Rail изменила свою разметку, чтобы в ней больше не использовались эти дурацкие
span
:Пример изменения страницы В таком сценарии наш селектор
#scroll0 > span
соответствует только первому элементу и возвращает словаCalling at:
без самого списка станций. С другой стороны, однострочник оболочки не поломается, потому что он использует для контекста не разметку, а только якоря. Стоит отметить, что в некоторых ситуациях это может исказить вывод, особенно при больших изменениях структуры. Но я всё равно считаю, что это преимущество регулярных выражений. -
Сложность
HTML не предназначен для потребления никакой другой программой, за исключением браузера. По своей природе это язык для представления, а не для обмена данными. Из-за этого для некоторых задач традиционные селекторы не подходят. Рассмотрим следующий пример:
Ничто не может помочь парсеру извлекать пары ключей и значений. <li> настолько обобщён, что, вероятно, встречается в нескольких разных областях страницы. Даже если бы у нас был для этого подходящий селектор, всё равно приходилось бы разбивать данные на
:
, затем удалять пробел и выполнять нечёткое сопоставление ключа. При работе с регулярными выражениями для извлечения размера экрана достаточно выполнитьgrep -Pohi 'screen size ?:.*?"' | grep -Poh '[0-9]*\.[0-9]*"'
, а половину этого вам и так пришлось бы делать.
Общие рекомендации
Задайтесь вопросом, подходят ли регулярные выражения для задачи. Если вы не занимаетесь скрейпингом и вам хватает места под новые библиотеки (вы не пишете код для встроенных систем), то правильно будет использовать какую-нибудь библиотеку для парсинга XML. В некоторых случаях скрейпинга (крайне однородные данные, например автоматически генерируемые таблицы), парсер HTML окажется гораздо полезнее, чем описанные ниже хаки.
Если вы действительно работаете с неоднородным HTML, то проверьте, нельзя ли где-то напрямую получить данные. Умный хакер не скрейпит HTML, если уже существует приложение для Android с готовыми конечными точками API, возвращающими JSON.
Не пытайтесь парсить структуру дерева. Из-за этого вы потеряете все преимущества применения регулярных выражений для скрейпинга и получите много боли.
PCRE пользоваться обязательно. У стандартных регулярных выражений нет оператора нежадного сопоставления
?
. Из-за этогоa.*b
выполнит сопоставление с первой a и с последней b, аa.*?b
выполнит сопоставление с первой a и с первой последующей b. Это ОЧЕНЬ полезно для привязки к какому-то уникальному тексту и при сопоставлении с следующим закрывающим тэгом (например, так:meow.*?
).
Если библиотека PCRE скомпилирована в grep, то она включается флагом-P
.-
Если у вас нет PCRE, то нежадное сопоставление можно имитировать так:
выполнить привязку где-нибудь (
meow.*
)заменить первое вхождение маркера конца уникальной строкой (
s|</|OwO|
)сопоставить уникальный символ (
meow.*OwO
)
Гораздо менее удобно, чем
?
, но при необходимости работает. Всегда выполняйте сопоставление с наиболее плотным разнообразием символов.
[A-Za-z0-9]*
гораздо лучше, чем.*?
По возможности выполняйте привязку к уникальным строкам текста.
List of departures
поменяется с гораздо меньшей вероятностью, чем имя класса.Если в данных есть интересный пробел, то используйте его в своих целях! Все правила парсера ломаются — если каждый элемент списка находится в отдельной строке, то не усложняйте себе жизнь, просто итеративно обходите эти строки.
Удаляйте неинтересный пробел, как только это становится возможным. Некоторые движки регулярных выражений позволяют выполнять сопоставления с
\s*
, в других придётся определять[ \t\r\n]*
или что-то похожее. Сопоставление множителей (\s{2}
,\s{3}
и так далее) и их замена одиночными пробелами в цикле может помочь в сохранении хорошего пробела там, где он нужен.Если браузер что-то запрашивает, то вы, скорее всего, можете запросить это при помощи curl. По этому пункту есть так много тонких моментов, заслуживающих отдельного поста, но для начала стоит помнить о Dev Tools -> Network -> (правой клавишей мыши на запрос) -> Copy as cURL.
Извлечение данных обязательно поломается. Хорошие боты-скрейперы учитывают это (и обрабатывают сбои). Очень хорошие боты уведомляют администратора. Превосходные боты могут даже попробовать второй путь для извлечения данных.
Пример
У меня возникли трудности с выбором подходящего конкретного примера, поэтому в конечном итоге я выбрал бессмысленный и забавный образец. Ниже показан небольшой скрейпер, извлекающий данные со страницы скачивания OpenRCT2.
Разумеется, в этом случае данные доступны множеством других способов. Тем не менее, те же принципы могут быть применимы и к проприетарным страницам.
#!/bin/bash
unset IFS
data="$(curl 'https://openrct2.io/download/release')"
# data="$(cat /tmp/dump)" # хороший скрейпер всегда создаёт прототип в локальном дампе
# выполняем сопоставление со строкой, содержащей 'v' и 'latest', чтобы получить текущую версию игры
latest_ver="$(grep -Poh 'v.*?(?=\(latest)' <<< "$data")"
# разобьём список релизов на то, что сможем использовать:
# 1. проверяем, что отсутствуют 0x01/0x02, мы используем их в качестве разделителей
# 2. разбиваем страницу по "card-header", которое встречается только между элементами списка
releases="$(tr -d $'\x01\x02' <<< "$data" | sed 's/card-header/\x01/')"
# теперь итеративно обходим то, что нашли:
while read -d$'\x01' -r release; do
# похоже, 'btn btn-link' всегда предшествует версии, давайте этим воспользуемся :)
version="$(grep -A1 'btn btn-link' <<< "$release" | tail -n1 | sed 's/ //g')"
version="${version#* }" # вырезаем все пробелы из начала строки
if [[ ! "$version" ]]; then # пусто? это должен быть первый элемент, так что используем откат
version="$latest_ver"
fi
# 'card-title' - это верхний якорь.
# далее мы можем итеративно обходить 'Download', которое встречается в конце.
# также подчищаем здесь все закрывающие тэги, потому что они всё равно находятся в конце каждой строки.
meta="$(grep card-title -C10 <<< "$release" | sed 's@</.*@@' | sed $'s/Download/\x02/')"
echo -e "OpenRCT $version\n-------"
# наконец, итеративно обходим каждый из артефактов:
while read -d$'\x02' -r entry; do
# если нам повезёт, то h5 используется только для платформы. удаляем весь предыдущий мусор.
platform="$(grep h5 <<< "$entry" | sed 's/.*>//')"
# архитектура идёт сразу после строки с h6.
# давайте выполним сопоставление с h6 и следующей строкой, а затем отбросим первую строку
arch="$(grep -A1 h6 <<< "$entry" | tail -n1 | sed 's/ *//;s/,//')"
# тип артефакта на одну строку ниже архитектуры.
# сопоставляем h6 и *две* следующие строки, а затем отбрасываем первые две.
type="$(grep -A2 h6 <<< "$entry" | tail -n1 | sed 's/ *//')"
# с url всё просто, достаточно извлечь данные из кавычек.
# пробел после " крайне важен! если бы его не было, потребовалась бы дополнительная очистка
url="$(grep '<a href' <<< "$entry" | sed -E 's@.*href="(.*)" .*@\1@')"
# представление
cat <<EOF
* $platform
- Arch: $arch
- Type: $type
- URL: $url
EOF
done <<< "$meta"
echo -e '\n\n'
done <<< "$releases"
При выполнении этого кода будет показан список артефактов, разделённых по платформам и версиям:

Подведём итог
Думаю, ответ на Stack Overflow относился к настоящему парсингу структуры с сохранением всех внутренних метаданных. С этим утверждением я согласен (да и кто бы не согласился? XML не регулярен, это факт). Однако если под парсингом понимать средство достижения цели (чем обычно и оказываются регулярные выражения...), то абсолютно возможно извлекать данные из HTML или XML при помощи регулярного выражения достаточной сложности. Именно такое примечание к этому тексту висело в моей голове все эти годы.
Примечания
-
Перед публикацией поста мне заявили, что, строго говоря, созда��ать страницы без HTML можно:
SVG
Java Applets
Flash
PDF
Последние три варианта можно с лёгкостью отмести, потому что это внешние технологии, не относящиеся ни к одной веб-спецификации. Однако SVG игнорировать гораздо сложнее. Это рекомендация W3C, поэтому можно считать её достаточно близкой. Также в ней определяется тэг <a>, так что, строго говоря. SVG можно использовать «без HTML» для создания веб-страницы. Но я всё равно отношусь к этому скептично.
В этом предложении изначально говорилось о YAML. Мой пост не посвящён YAML, но тем не менее, я получил множество указаний на то, что из него как будто можно сделать вывод о простоте YAML. Эти обсуждения совершенно никак не связаны с постом, поэтому я заменил его на TOML. Не нравится TOML? Вспомните INI. Не нравится INI? Вспомните CSV и так далее.
Комментарии (3)
RumataEstora
09.10.2025 10:23Здесь в комментариях-описаниях неверно изложена логика. Начиная со строк 4 и 5 некорректно отражены действия и состояния стека.
# stack=() <a> # push a; stack=(a) <b> # push b; stack=(a b) <c>meow</c> # push c; stack=(a b c) <d>nya</d> # pop; push d; stack=(a b d) </b> # pop; stack=(a b) </a> # pop; stack=(a) # pop; stack=()
Логичнее было бы так: каждому
<tag>
соответствует свойpush tag
, и каждому</tag>
соответствует свойpop
. Тогда состояние стека, выражаемоеstack=(...)
, точно соотвествовало бы действиям.# stack=() <a> # push a; stack=(a) <b> # push b; stack=(a b) <c>meow</c> # push c; pop; stack=(a b c) stack=(a b) <d>nya</d> # push d; pop; stack=(a b d) stack=(a b) </b> # pop; stack=(a) </a> # pop; stack=()
garm
Я сломался вот на этой строке:
RumataEstora
Кстати - да. Стек и очередь - две структуры данных (точнее - типа данных), реализующих разный способ извлечения элементов. Правильнее было бы назвать массивами или списками.