У меня есть небольшое увлечение — искать статьи на Хабре с отрицательным рейтингом и большим количеством комментариев. Открываю их и читаю нередко провокационный текст, дискуссии по 40 комментариев... Но была проблема — приходилось искать такие статьи вручную, так как инструменты Хабра не позволяют фильтровать статьи по отрицательному рейтингу и количеству комментариев. Публичного апи для таких нужд у Хабра тоже нет. Пришла идея — в качестве пет‑проекта написать парсер таких статей.

Парсер был написан на Elixir + Crawly + Floki. Стак был выбран не из каких‑то рациональный побуждений, а из цели получше разобраться в эликсире. Исходный код. Так как это статья, то опишу принцип работы парсера.

Релиз опубликован на гитхабе, запускается через habr_parser.bat start. Все спарсенные ссылки хранятся в виде json строк в папке temp.

@impl Crawly.Spider
def base_url(), do: "https://habr.com/"

@impl Crawly.Spider
def init() do
  [
    start_urls: gen_urls(base_url(), 1..50//5)
  ]
end

Здесь настраиваем базовый url + выбираем количество страниц для парсинга. На хабре можно просматривать только 50 страниц, поэтому выбрано такое ограничение. 1..50//5 - диапазон с 1 до 50 с шагом по 5. Это нужно для того, чтобы парсер не искал с 1 до 50 однопоточно, а запустил 10 отдельных парсеров, каждый из которых считает по 5 страниц.

defp gen_urls(base_url, range) do
  range
  |> Enum.map(fn i ->
    Crawly.Utils.build_absolute_url("/ru/articles/page#{i}/", base_url)
  end)
end

Функция для генерации url для поиска. Здесь объяснять, думаю, нечего, обычный for each цикл.

document
|> Floki.find("article")
|> Stream.map(fn x ->
  %{
    title: Floki.find(x, ".tm-title__link") |> Floki.text(),
    url:
      Floki.find(x, ".tm-title__link")
      |> Floki.attribute("href")
      |> Enum.map(fn url -> Crawly.Utils.build_absolute_url(url, response.request.url) end)
      |> List.first(),
    votes: Floki.find(x, ".tm-votes-meter__value") |> Floki.text(),
    comments:
      Floki.find(x, ".article-comments-counter-link")
      |> Floki.find("span")
      |> Floki.text(),
    time:
      Floki.find(x, ".tm-article-datetime-published")
      |> Floki.find("time")
      |> Floki.attribute("datetime")
      |> List.first()
  }
end)

Здесь происходит маппинг спарсенной html страницы, ищем по атрибуту .tm-votes-meter__value - это количество рейтинга, .tm-article-datetime-published - дата (полезно в будущем для фильтрации по ней), .article-comments-counter-link - собственно, количество комментариев. С помощью pattern matching эликсира и возможностей либы для парсинга эти атрибуты складываются в одну мапу, по которой в дальнейшем идёт фильтрация.

|> Enum.filter(fn %{votes: votes, comments: comments} ->
  Integer.parse(votes)
  |> then(fn
    {int, _} -> int < 0
    _ -> false
  end) and
    comments
    |> Integer.parse()
    |> elem(0) > 10
end)

Здесь можно настроить фильтр под свои нужды. В данном случае голоса — меньше 0, комментариев — больше 10. Можно добавить фильтрацию по дате.

urls =
  document
  |> Floki.find("#pagination-next-page")
  |> Floki.attribute("href")
  |> Enum.map(fn url ->
    Crawly.Utils.build_absolute_url(url, response.request.url)
    |> Crawly.Utils.request_from_url()
  end)

Очень важная штука для Crawly, которая позволяет ему находить ссылки для рекурсивного парсинга. Проблемы повторного парсинга уже отпарсенных страниц (так как парсер работает в несколько потоков, парсер, начинающий с 1, однажды дойдёт до 6 страницы, с которой начинает второй поток) нет, так как Crawly держит в уме уже спарсенные страницы и отбрасывает их.

Собственно, весь код. Моя первая статья на хабре, не претендую на красивый стиль написания, как и на идеально написанный код, это всё же пет‑проект, как раз написанный с целью изучения языка. Но критика приветствуется.