Парсим HTML на Clojure

​Время от времени пробую применить 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 стиле?

Парсим HTML на Clojure: Один комментарий

Ответить на niquola (@niquola) Отменить ответ