Парсинг веб-сайтов с помощью Elixir и Crawly. Отображение в браузере.
Введение
Поскольку количество веб-сайтов, использующих JavaScript для отображения контента, растет, растет и спрос на извлечение данных из них. Сама интерактивность добавляет некоторую сложность в процесс извлечения данных, так как невозможно получить полный контент с помощью обычного запроса, сделанного из командной строки HTTP-клиента.
Один из способов решения этой проблемы - симулировать асинхронный запрос с дополнительным POST-запросом. Хотя, на мой взгляд, такой подход добавляет еще больше сложности и хрупкости в ваш код.
Другой способ решения проблемы - использовать отображение браузера вместо командных HTTP-клиентов. Позвольте мне показать вам, как настроить паука Crawly, чтобы извлечь данные с autotrader.co.uk с помощью отображения браузера. Вы будете удивлены, насколько это просто!
Начало работы
Давайте создадим новый проект Elixir:
mix new autosites — sup
Теперь, когда проект создан, давайте добавим и загрузим самую последнюю версию Crawly:
# Запустите "mix help deps", чтобы узнать о зависимостях.
defp deps do
[
{:crawly, "~> 0.8"},
{:meeseeks, "~> 0.14.0"}
]
Хорошо, на этом этапе давайте немного изучим нашу цель. Давайте откроем одну из страниц, содержащих информацию о арендованном автомобиле: https://www.autotrader.co.uk/cars/leasing/product/201911194518336
Хорошо, пока все идет хорошо. Давайте попробуем получить эту страницу с помощью Crawly:
$ iex -S mix
iex(1)> response = Crawly.fetch("https://www.autotrader.co.uk/cars/leasing/product/201911194518336")
%HTTPoison.Response{
body: "<!doctype html> …",
headers: [
{"Date", "Mon, 17 Feb 2020 13:49:42 GMT"},
{"Content-Type", "text/html;charset=utf-8"},
{"Transfer-Encoding", "chunked"},
ЗАМЕТКА: Autotrader.co.uk - это динамический веб-сайт с большим количеством автомобилей на продажу. Возможно (и, вероятно, так и будет), что к моменту, когда вы прочтете эту статью, данный автомобиль (id: 201911194518336) будет недоступен на их веб-сайте. Мы рекомендуем выбрать один из других автомобилей из раздела https://www.autotrader.co.uk/cars/leasing.
Хорошо, похоже, что автомобиль можно получить всего за 192,94 фунта в месяц... круто! Но давайте посмотрим, действительно ли мы можем получить эти данные в нашей оболочке. Давайте визуализируем наш загруженный HTML в браузере, чтобы увидеть, как он выглядит после загрузки:
iex(9)> File.write("/tmp/nissan.html", response.body)
Хорошо, теперь попробуем найти цену на данной странице:
Хорошо. Теперь видно, что в блоке цены нет цены. Как же мы будем с этим справляться? Один из возможных подходов - просмотреть исходный код страницы и найти следующий блок:
Однако, написание соответствующего регулярного выражения для извлечения этих данных из JavaScript - настоящая проблема. И, говоря серьезно, это добавит много ненужной сложности в код паука.
Давайте попробуем альтернативный подход.
Извлечение динамического контента
Новая версия Crawly 0.8.0 поставляется с поддержкой подключаемых загрузчиков, что позволяет нам переопределить способ, которым Crawly получает HTTP-ответы. В нашем случае нас интересует возможность направлять все запросы через браузер, чтобы получить отрендеренные страницы. Один из возможных вариантов - использовать легковесный браузер, который выполнит базовый JavaScript за нас. В качестве демонстрации мы возьмем рендерер Splash.
Splash - это легковесный и простой браузерный рендерер на основе Python и Qt. Хотя он может быть не подходящим для сложных целей, мы считаем его довольно полезным для более простых случаев.
Давайте запустим локальный сервис Splash:
docker run -it -p 8050:8050 scrapinghub/splash — max-timeout 300
Теперь Splash работает и может принимать запросы.
Настройка Crawly
Давайте зададим некоторые основные конфигурации. Предполагая, что мы хотим извлечь: id, заголовок, URL и цену, конфигурация может выглядеть следующим образом.
Создайте файл config/config.exs со следующим содержимым:
# Этот файл отвечает за настройку вашего приложения
# и его зависимостей с помощью модуля Mix.Config.
use Mix.Configconfig :crawly,
fetcher: {Crawly.Fetchers.Splash, [base_url: "http://localhost:8050/render.html"]},
# Определяет, как повторять запросыretry:
[
retry_codes: [400, 500],
max_retries: 5,
ignored_middlewares: [Crawly.Middlewares.UniqueRequest]
],closespider_timeout: 5,
concurrent_requests_per_domain: 20,
closespider_itemcount: 1000,
middlewares: [
Crawly.Middlewares.DomainFilter,
Crawly.Middlewares.UniqueRequest,
Crawly.Middlewares.UserAgent
],
user_agents: [
"Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41"
],
pipelines: [
{Crawly.Pipelines.Validate, fields: [:id, :title, :url, :price]},
{Crawly.Pipelines.DuplicatesFilter, item_id: :id},
{Crawly.Pipelines.JSONEncoder, []},
{Crawly.Pipelines.WriteToFile, extension: "jl", folder: "/tmp"}
]
Важная часть здесь:
fetcher: {Crawly.Fetchers.Splash, [base_url:"http://localhost:8050/render.html"]},
Так как это определяет, что Crawly будет использовать загрузчик Splash для получения страниц из целевого веб-сайта.
Получение страницы автомобиля еще раз
Теперь, как только мы все настроили, пришло время получить страницу еще раз, чтобы увидеть разницу:
iex(3)> response = Crawly.fetch("https://www.autotrader.co.uk/cars/leasing/product/201911194518336")
iex(3)> File.write("/tmp/nissan_splash.html", response.body)
Теперь давайте посмотрим, как выглядит отрендеренная страница:
Ну :(. Это немного пусто. Это произошло потому, что мы не дали Splash достаточно времени для отображения страницы. Давайте немного изменим конфигурацию и попросим Splash подождать 3 секунды после загрузки страницы (чтобы у него было достаточно времени для отображения всех элементов).
Обновленная конфигурация выглядит следующим образом:
fetcher: {Crawly.Fetchers.Splash, [base_url: "http://localhost:8050/render.html", wait: 3]}
Теперь, после повторной загрузки страницы (просто повторите предыдущие команды), мы получим следующие результаты:
Паук
Наконец, давайте обернем все в паука, чтобы мы могли извлечь информацию о всех доступных автомобилях на Autotrader. Процесс написания паука описан в нашей предыдущей статье и также в руководстве по началу работы с Crawly, поэтому мы не будем повторять это здесь. Но для полноты картины давайте посмотрим, как может выглядеть код паука (вам нужно добавить {:meeseeks, "~> 0.14.0"}
в зависимости mix.exs):
defmodule AutotraderCoUK do
[@behaviour](http://twitter.com/behaviour) Crawly.Spider require Logger import Meeseeks.CSS [@impl](http://twitter.com/impl) Crawly.Spider
def base_url(), do: "https://www.autotrader.co.uk/" [@impl](http://twitter.com/impl) Crawly.Spider
def init() do
[
start_urls: [
"https://www.autotrader.co.uk/cars/leasing/search",
"https://www.autotrader.co.uk/cars/leasing/product/201911194514187"
]
]
end[@impl](http://twitter.com/impl) Crawly.Spider
def parse_item(response) do
case String.contains?(response.request_url, "cars/leasing/search") do
false ->
parse_product(response)true ->
parse_search_results(response)
end
enddefp parse_search_results(response) do
# Разбор страницы только один раз
parsed_body = Meeseeks.parse(response.body, :html)
# Извлечение элементов href
hrefs =
parsed_body
|> Meeseeks.all(css("ul.grid-results__list a"))
|> Enum.map(fn a -> Meeseeks.attr(a, "href") end)
|> Crawly.Utils.build_absolute_urls(base_url())# Получение пагинации
pagination_hrefs =
parsed_body
|> Meeseeks.all(css(".pagination a"))
|> Enum.map(fn a ->
number = Meeseeks.own_text(a)
"/cars/leasing/search?pageNumber=" <> number
end)all_hrefs = hrefs ++ pagination_hrefsrequests =
Crawly.Utils.build_absolute_urls(all_hrefs, base_url())
|> Crawly.Utils.requests_from_urls()%Crawly.ParsedItem{requests: requests, items: []}
enddefp parse_product(response) do
# Разбор страницы только один раз
parsed_body = Meeseeks.parse(response.body, :html)title =
parsed_body
|> Meeseeks.one(css("h1.vehicle-title"))
|> Meeseeks.own_text()price =
parsed_body
|> Meeseeks.one(css(".card-monthly-price__cost span"))
|> Meeseeks.own_text()thumbnails =
parsed_body
|> Meeseeks.all(css("picture img"))
|> Enum.map(fn elem -> Meeseeks.attr(elem, "src") end)url = response.request_urlid =
response.request_url
|> URI.parse()
|> Map.get(:path)
|> String.split("/product/")
|> List.last()item = %{
id: id,
url: url,
thumbnails: thumbnails,
price: price,
title: title
}%Crawly.ParsedItem{items: [item], requests: []}
end
end
Наконец, давайте запустим паука:
iex(1)> Crawly.Engine.start_spider(AutotraderCoUK)
Вот и все. Теперь у вас будут извлечены контент!
Я считаю, что такой подход довольно интересен, поскольку он позволяет переложить сложность извлечения данных на внешний сервис. Таким образом, сам код не должен иметь дело с этим и может оставаться простым.
Спасибо за чтение!