Время от времени пробую применить Clojure к реальным задачам из production. Опыта использования Clojure у меня очень мало, но нравится синтаксис, нравится философия, REPL. А что не нравится — так это скудная документация, мало статей, мало туториалов, чтобы прям по шагам для начинающих.
Итак, задача: сделать POST запрос на некий сайт и найти там url ссылки с определённым текстом. POST запрос сделал легко, теперь нужно распарить html. Быстрое гугление clojure parse html даёт несколько популярных вариантов: clj-tagsoup, Enlive и Hickory.
clj-tagsoup
Начал с clj-tagsoup — легковесный парсер основанный на Java библиотеке TagSoup.
Распарил html строку с помощью функции parse-string, получил огромную структуру векторов и мапов, но как найти требуемую ссылку? Совершенно не понятно. CSS селекторов нет, xpath нет, придётся самому писать обход дерева рекурсивными функциями? Хотелось более простого решения. Что ещё удивило, если сделать (parse-string "test"), то получу структуру, которая начинается с html, а потом body, потом мой тег a — библиотека как бы дополнила фрагмент до полного html документа. Бегать по дереву вручную лень, поэтому перешел к следующему парсеру.
Enlive
Для начала изучил документацию Enlive на предмет поддержки селекторов. Они есть и сделаны в форме нативных структур Clojure, что мне понравилось. Говорят, что они даже более мощные, чем CSS селекторы. Но, чего я долго не мог найти в документации, так это примеров куда эти селекторы собственно передавать, чтобы получить набор подходящих узлов из документа? В какую функцию, каким параметром, что на выходе? В документации куча промеров в README.md и в wiki, как заполнить шаблон, вставив какой-нибудь контент по селектору. А вот примеры поиска по селектору нашлись не сразу.
Следующий не очевидный вопрос, а какую собственно функцию для парсинга использовать? Есть deftemplate, template, defsnippet, snippet, html-resource и мутное описание. Методом проб и ошибок в REPL выбрал для себя html-resource. Важный момент, если я хочу распарсить готовую строку, то просто так эту строку в html-resource передать нельзя, нужно обернуть в java.io.StringReader (нет функции типа parse-string):
(def resource
(enlive-html/html-resource
(java.io.StringReader. "html code string goes here")))
Теперь попробуем написать селектор, который ищет требуемую мне ссылку по содержимому (т.е. по тексту ссылки), для этого используем text-pred.
Первая попытка:
(enlive-html/select
resource
[(enlive-html/text-pred #{"MSKU0581488"})])
результат: ("MSKU0581488") — получили сам текстовый узел, но не всю ссылку.
Вторая попытка: нужно искать ссылки (селектор [:a]), которые внутри содержат текстовый узел, отвечающий селектору (enlive-html/text-pred #{"MSKU0581488"}) — для этого подойдёт функция has. При этом два условия (два селектора) нужно объединить логическим И: элемент должен быть ссылкой И внутри содержать определённый текстовый узел — для этого оба селектора нужно написать рядом друг с другом, обернув дополнительными квадратными скобками:
(enlive-html/select
resource
[[:a
(enlive-html/has [(enlive-html/text-pred #{"MSKU0581488"})])]])
результат уже радует:
({:tag :a,
:attrs {:href "http://..."},
:content ("MSKU0581488")})
Вынем содержимое атрибута href:
(:href
(:attrs
(first
(enlive-html/select
resource
[[:a (enlive-html/has [(enlive-html/text-pred #{"MSKU0581488"})])]]))))
— это уже полностью рабочий вариант, но надо его сделать clojure-way.
Более стильный вариант (знатоки clojure, прокомментируйте, отредактируйте, пожалуйста):
(-> resource
(enlive-html/select [[:a (enlive-html/has [(enlive-html/text-pred #{"MSKU0581488"})])]])
first
:attrs
:href)
Оформим в функцию, которая принимает html текст страницы и текст ссылки, возвращает значение атрибута href:
(defn findlink [html linktext]
(let [resource (enlive-html/html-resource (java.io.StringReader. html))
selector [[:a (enlive-html/has [(enlive-html/text-pred #{linktext})])]]]
(-> resource
(enlive-html/select selector)
first
:attrs
:href)))
Вывод по Enlive: задачу решил, решение нравится, но разобраться с документацией было не просто.
Hickory
Третья популярная библиотека для прасинга html на Clojure (судя по гуглу) — это Hickory. Селекторы в наличии и выглядит гораздо проще, чем Enlive.
Для начала распарсим html строку с помощью hickory.core/parse. Удобно, что нет никаких дополнительных приседаний с java.io.StringReader, в отличие от Enlive. Функция prase возвращает некий документ, который нужно конвертировать в Clojure структуры данных с помощью hickory.core/as-hiccup или hickory.core/as-hickory. Селекторы работают только со структурами в формате as-hickory, поэтому парсим следующим образом:
(def resource
(hickory.core/as-hickory
(hickory.core/parse "html string goes here...")))
Составим селектор для поиска ссылки по содержимому. Документация простая и полная, селектор для поиска всех ссылок на странице получился элементарным и заработал с первого раза: (hickory.select/tag :a)
Применяем этот селектор так:
(hickory.select/select (hickory.select/tag :a) resource)
и получаем вектор элементов ссылок.
Но вот проблемка, нет никаких селекторов для поиска по текстовому содержимому узлов. Значит будем использовать нативные функции Clojure, например, filter.
(filter #(= ["MSKU0581488"] (:content %)) (hickory.select/select (hickory.select/tag :a) resource))
получаем структуру
({:type :element,
:attrs {:href "http://..."},
:tag :a,
:content ["MSKU0581488"]})
Наконец оформим в виде функции
(defn findlink2 [html linktext]
(let [resource (hickory.core/as-hickory (hickory.core/parse html))
selector (hickory.select/tag :a)
predicate #(= [linktext] (:content %))]
(->
(filter predicate (hickory.select/select selector resource))
first
:attrs
:href)))
Вывод по Hickory: проще чем Enlive, потребовалось гораздо меньше времени до получения результата.
Исходные коды с тестами выложил на GitHub: https://github.com/pqr/htmlparsers-clojure
Оставляйте свои комментарии и предложения, присылайте пул-реквесты, как решить эту задачу в идиоматичном для Clojure стиле?
