CoderCastrov logo
CoderCastrov
Парсер

Как мы парсим 300 тысяч цен в день с Google Flights

Как мы парсим 300 тысяч цен в день с Google Flights
просмотров
9 мин чтение
#Парсер

Brisk Voyage находит дешевые, спонтанные поездки на выходные для наших участников. Основная идея заключается в том, что мы постоянно проверяем множество цен на авиабилеты и отели, и когда мы находим поездку с низкой ценой, мы отправляем электронное письмо с инструкциями по бронированию.

Испытание

Для поиска цен на авиабилеты и отели мы парсим Google Flights и Google Hotels. Отели относительно просты: чтобы найти самый дешевый отель в 100 направлениях на 5 разных дат каждый, нам нужно спарсить в общей сложности 500 страниц Google Hotels.

Парсинг цен на авиабилеты представляет собой более сложную задачу. Чтобы найти самый дешевый туда-обратно авиабилет в 100 направлениях на 5 разных дат каждый, это 500 страниц. Однако, авиабилеты не имеют только аэропорта назначения - у них также есть аэропорт отправления. Эти 500 рейсов должны быть проверены из каждого аэропорта отправления. Мы должны проверить эти рейсы в Аспен из каждого аэропорта вокруг Нью-Йорка, каждого аэропорта вокруг Лос-Анджелеса и каждого аэропорта между ними. У нас нет этого дополнительного "измерения" аэропорта отправления при парсинге отелей. Это означает, что количество цен на авиабилеты значительно превышает количество цен на отели, которые имеют отношение к нашей проблеме.

Парсинг авиарейсов

Цель состоит в том, чтобы найти самый дешевый авиарейс между каждой парой мест отправления/назначения для заданного набора дат. Для этого мы получаем около 300 000 цен на авиабилеты с 25 000 страниц Google Flights каждый день. Это не астрономическое число, но достаточно большое, чтобы нам (по крайней мере, как компании с ограниченными ресурсами) приходилось обращать внимание на экономичность затрат. За последний год мы неоднократно улучшали нашу методологию парсинга, чтобы создать довольно надежное и гибкое решение. Ниже я опишу каждый инструмент, который мы используем в нашем стеке парсинга, примерно в порядке потока данных.

AWS Simple Queue Service (SQS)

Мы используем SQS для обслуживания очереди URL-адресов для обхода. URL-адреса Google Flights выглядят так: https://www.google.com/flights?hl=en#flt=BOS.JFK,LGA,EWR.2020-11-13*JFK,LGA,EWR.BOS.2020-11-16;c:USD;e:1;sd:1;t:f.

Трехбуквенные коды в URL выше - это IATA-коды аэропортов. Если вы нажмете на эту ссылку, обратите внимание, как там указано несколько аэропортов назначения. Это один из ключей к эффективному парсингу Google Flights! Поскольку Google Flights позволяет искать несколько перелетов одновременно, мы можем получить самые дешевые цены на несколько круговых перелетов на одной странице. Google иногда не показывает рейсы для всех запрошенных перелетов. Чтобы убедиться, что у нас есть все необходимые данные, мы определяем, когда место отправления/назначения не отображается в результате, а затем повторно добавляем перелет в очередь для поиска отдельно. Это гарантирует, что мы собираем цену на самый дешевый доступный рейс для каждого перелета.

Одна очередь SQS хранит все URL-адреса Google Flights, которые нужно обойти. Когда запускается парсер, он выбирает сообщение из очереди. Порядок не важен, поэтому используется стандартная очередь (а не FIFO). Вот как выглядит очередь, когда она полна сообщений:

SQS Queue

AWS Lambda (используя Chalice)

Lambda - это место, где фактически выполняется парсер. Мы используем Chalice, отличный микрофреймворк для Python, для развертывания функций в Lambda. Хотя Serverless является самым популярным фреймворком для Lambda, он написан на NodeJS. Это нас не устраивает, так как мы наиболее знакомы с Python и хотим сохранить наш стек единообразным. Мы очень довольны Chalice - он так же прост в использовании, как Flask, и позволяет весь бэкэнд Brisk Voyage быть на Python в Lambda.

Парсер состоит из двух функций Lambda:

  • Основная функция Lambda получает сообщения из очереди SQS, парсит Google Flights и сохраняет результат. Когда эта функция запускается, она запускает браузер Chrome на экземпляре Lambda и парсит страницу. Мы определяем это как чистую функцию Lambda с помощью Chalice, так как эта функция будет отдельно вызвана:
[@app](http://twitter.com/app).lambda_function()
def crawl(event, context):
    ...
  • Вторая функция Lambda запускает несколько экземпляров первой функции. Она запускает столько парсеров, сколько нам нужно параллельно. Эта функция определена для запуска в два минуты после каждого часа во время UTC с 15 до 22 часов каждый день. Она запускает 50 экземпляров основной функции crawl, для 50 параллельных парсеров:
[@app](http://twitter.com/app).schedule(“cron(2 15,16,17,18,19,20,21,22 ? * * *)”)
def start_crawlers(event):
    app.log.info(“Starting crawlers.”)    n_crawlers = 50
    client = boto3.client(“lambda”)    for n in range(n_crawlers):
        response = client.invoke(
            FunctionName=”collector-dev-crawl”,
            InvocationType=”Event”,
            Payload=’{“crawler_id”: ‘ + str(n) + “}”,
        )    app.log.info(“Started crawlers.”)

Альтернативой было бы использование очереди SQS в качестве источника событий для функции crawl, чтобы при заполнении очереди парсеры автоматически масштабировались. Изначально мы использовали этот подход. Однако есть один большой недостаток: максимальное количество сообщений, которые могут быть обработаны одним вызовом (размер пакета), составляет 10, что означает, что функция должна быть вызвана заново для каждой группы из 10 сообщений. Это не только вызывает неэффективность вычислений, но и значительно увеличивает пропускную способность, так как кэш браузера уничтожается каждый раз при перезапуске функции. Есть способы обойти это, но по нашему опыту, они приводят к большой дополнительной сложности.

Примечание о стоимости LambdaLambda стоит $0.00001667/GB/секунда, в то время как многие экземпляры EC2 стоят шестую часть этой суммы. В настоящее время мы платим около $50 в месяц за Lambda, поэтому это означало бы, что мы могли бы существенно снизить эти затраты. Однако Lambda имеет два больших преимущества: во-первых, он мгновенно масштабируется вверх и вниз без каких-либо усилий с нашей стороны, что означает, что мы никогда не платим за простаивающий сервер. Во-вторых, это то, на чем основан остальной наш стек. Меньше технологий - меньше когнитивной нагрузки. Если количество страниц, которые мы парсим, увеличится, имеет смысл пересмотреть EC2 или аналогичный вычислительный сервис. На данный момент я считаю, что дополнительные $40 в месяц ($50 на Lambda против примерно $10 на EC2) стоят простоты для нас.

Pyppeteer

Pyppeteer - это библиотека на языке Python для взаимодействия с Puppeteer, API безголового Chrome. Поскольку для загрузки цен на Google Flights требуется выполнение JavaScript, необходимо фактически отрисовать полную страницу. Каждая из 50 функций crawl запускает свою собственную копию безголового браузера Chrome, который управляется с помощью Pyppeteer.

Запуск безголового Chrome на Lambda был вызовом. Нам пришлось предварительно упаковать необходимые библиотеки в Chalice, которые не были предустановлены в Amazon Linux, операционной системе, на которой работает Lambda. Эти библиотеки добавляются в каталог vendor внутри нашего проекта Chalice, что указывает Chalice установить их на каждый экземпляр Lambda crawl:

По мере запуска 50 функций crawl в течение примерно 5 секунд, в каждой функции запускаются экземпляры Chrome. Это мощная система, которая может масштабироваться до тысяч одновременных экземпляров Chrome с помощью изменения одной строки кода.

Функция crawl считывает URL из очереди SQS, затем Pyppeteer сообщает Chrome перейти на эту страницу через ротирующийся резиденциальный прокси от PacketStream. Резиденциальный прокси необходим для предотвращения блокировки Google IP-адреса, с которого Lambda отправляет запросы.

После загрузки и отрисовки страницы можно извлечь отрендеренный HTML, а также прочитать цены на перелеты, авиакомпании и время с помощью парсинга страницы. На каждой странице есть 10-15 результатов перелетов, которые мы хотим извлечь (нас в основном интересует самый дешевый, но другие тоже могут быть полезны). В настоящее время это делается вручную путем обхода структуры страницы, но это неустойчиво. Парсер может сломаться, если Google изменит один элемент на странице. В будущем я бы хотел использовать что-то, что менее зависит от структуры страницы.

После извлечения цен мы удаляем сообщение из SQS и помещаем обратно в очередь любые исходные/конечные пункты назначения, которые не были отображены в результатах перелетов. Затем мы переходим на следующую страницу - из очереди извлекается новый URL, и процесс повторяется. После первой просканированной страницы в каждом экземпляре crawl страницы требуют гораздо меньше пропускной способности для загрузки (~100 КБ вместо 3 МБ) из-за кэширования Chrome. Это означает, что нам выгодно сохранять экземпляр включенным как можно дольше и сканировать как можно больше поездок, чтобы сохранить кэш. Поскольку выгодно сохранять функции для сохранения кэша, тайм-аут crawl составляет 15 минут, что является максимальным значением, которое в настоящее время позволяет AWS.

DynamoDB

После извлечения данных с веб-страницы, нам нужно сохранить информацию о полетах. Мы выбрали DynamoDB для этого, потому что он имеет масштабирование по требованию. Это было важно для нас, так как мы были неуверены в том, какие нагрузки нам понадобятся. Кроме того, это дешево, и 25 ГБ предоставляется бесплатно в рамках бесплатного уровня AWS.

DynamoDB требует некоторой работы для достижения правильной настройки. Обычно таблицы могут иметь только один основной индекс с одним ключом сортировки. Добавление вторичных индексов возможно, но они либо ограничены, либо требуют дополнительного предоставления ресурсов, что увеличивает затраты. Из-за этого ограничения на индексы DynamoDB работает лучше, когда использование полностью продумано заранее. Нам потребовалось несколько попыток, чтобы правильно спроектировать таблицу. Впоследствии стало понятно, что DynamoDB немного не гибок для такого типа продукта, который мы создаем. Теперь, когда Aurora Serverless предлагает PostgreSQL, нам, вероятно, следует перейти на него в какой-то момент.

В любом случае, мы сохраняем все данные о полетах в одной таблице. Индекс имеет основной ключ - код IATA аэропорта назначения и вторичный ключ диапазона, который является ULID.

ULID отлично подходит для DynamoDB, потому что он уникален, имеет встроенную временную метку и может быть лексикографически отсортирован по этой временной метке. Это позволяет использовать ключ диапазона как уникальный идентификатор и поддерживать запросы вроде "покажи мне самые дешевые рейсы в BED, которые мы парсили за последние 30 минут":

response = crawled_flights_table.query(
    KeyConditionExpression=Key("destination").eq(iata_code) & Key("id").gt(earliest_id),
    FilterExpression=Attr("cheapest_entry").eq(1),
)

Мониторинг и тестирование

Мы используем Dashbird для мониторинга краулера и всего остального, что выполняется в Lambda. Хороший мониторинг является необходимым условием для парсинговых приложений, так как изменения структуры страницы постоянно угрожают. В любое время (даже несколько раз в день, как мы недавно видели с Google Flights... вздох) структура страницы может измениться, что приведет к сбою парсера. Мы должны получать уведомления об этом. У нас есть два отдельных механизма для отслеживания этого:

Заключение

Комбинация SQS, Lambda, Chalice, Pyppeteer, DynamoDB, Dashbird и GitHub Actions хорошо работает для нас. Это решение с низкой нагрузкой, полностью написанное на Python, не требует предоставления экземпляров и позволяет снизить затраты.

Хотя мы довольны этим на нашем текущем масштабе, составляющем примерно 300 тыс. цен и 25 тыс. страниц в день, есть некоторое место для улучшений по мере роста наших потребностей в данных:

  • Во-первых, мы должны перейти на серверы EC2, которые автоматически предоставляются при необходимости. По мере увеличения количества наших парсингов, разница между стоимостью Lambda и EC2 будет увеличиваться до того момента, когда будет иметь смысл работать на EC2. Стоимость EC2 будет примерно в 5 раз меньше, чем стоимость Lambda в терминах вычислений, но потребует больше накладных расходов и не снизит стоимость пропускной способности. Когда стоимость Lambda станет проблемой, мы перейдем к этому.
  • Во-вторых, мы перейдем с DynamoDB на Serverless Aurora, что позволит более гибко использовать данные. Это будет полезно, поскольку наша библиотека цен и потребность в альтернативном использовании данных будут расти.

Если вам это интересно, вам может понравиться Brisk Voyage - наша бесплатная рассылка, которая отправляет вам дешевые выходные поездки каждые несколько дней из аэропортов рядом с вами. Если вам нравится сервис, у нас также есть Премиум-версия, которая будет отправлять вам больше поездок и имеет несколько дополнительных функций.

Конечно, есть и другие способы улучшить нашу систему парсинга. Если у вас есть какие-либо идеи, пожалуйста, дайте нам знать!