Время от времени пробую применить 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 стиле?
Можно было бы еще saxonом или его оберткой попробовать (xpath, xquery). Вкусить прелесть interop.