Avito BI contest

11 мин на чтение

В данной статье я хотел бы рассказать о своём решении первой задачи из Avito BI contest, в которой необходимо было парсить данные с сайта Avito. Формальный текст задачи:

Задача 1. Ответом в первой задаче будут два числа. Вам предстоит распарсить объявления и А) узнать сколько на сайте Avito в городе Москве объявлений в категории “Монеты” (Хобби и отдых -> Коллекционирование -> Монеты) без указания срока выпуска монеты, а также Б) какое соотношение монет выпущенных до 2000 года, к монетам выпущенным после. Допущения, которыми можно руководствоваться при решении задачи:

  • Если в одном объявлении продают сразу несколько монет, то нужно брать год выпуска первого предложения.
  • Если указан не точный год, а интервал выпуска монеты, то нужно брать нижнюю границу интервала При парсинге объявлений рекомендуется использовать Python. Ответ меняется с течением времени, поэтому отправлять нужно последнее (заменит слово) решение. Число отправок решения – 2 на время всего чемпионата.

Для решения задачи я использовал язык программирования Python, как и предлагалось в задаче, а также фреймворк для парсинга сайтов Scrapy. Разобраться в данном фреймворке можно как по книге Dimitrios Kouzis-Loukas, “Learning Scrapy”, так и прочитав его документацию Scrapy 1.3 documentation.

Задачу можно разделить на две части. Первая - это создание паука для сбор необработанных данных из всех объявлений, а вторая - это получение даты из каждого объявления.

Создание паука для получения всех объявлений

Для начала открывает нужный нам раздел на Avito для парсинга https://www.avito.ru/moskva/kollektsionirovanie/monety. В нём на данный момент находится примерно 35 000 объявлений о продаже монет в Москве. На каждой странице по 53 объявления о продаже, таким образом примерно 660 страниц по 53 объявления. В процессе парсинга я обнаружил, что Avito отдаёт только 100 страниц. То есть можно открыть 100-ую страницу со списком объявлений о продаже монет https://www.avito.ru/moskva/kollektsionirovanie/monety?p=100, а при попытке перейти на 101-ую https://www.avito.ru/moskva/kollektsionirovanie/monety?p=101 происходит редирект на первую страницу. А нам ведь требуется собрать ссылки на каждое объявление, чтобы уже после анализировать их содержание.

Разделение объявлений по станциям метро

Обойти данное ограничение в 100 страниц я решил разделив скрапинг по станциям метро. Вместо одного поиска по всем объявлениям в Москве я решил сделать N-поисков по количеству станций метро в Москве (на данный момент их 185), предположив что на одной станции метро не будет большее 53*100 объявлений. Строка поиска, уточняющая станцию метро, будет иметь вид https://www.avito.ru/moskva/kollektsionirovanie/monety?metro=N, где N - число, идентификатор станции метро. С помощью инструмента браузера Inspect Element (Ctrl+Shift+I) можно посмотреть соответствия названий станций метро и их идентификаторов на Avito. Выглядит это следующим образом:

<select id="directions-select" class="directions" data-filter="1"> <option data-prev-alias="metro" value="">Станция метро</option>
  <option value="1">Авиамоторная</option>
  <option value="2">Автозаводская</option>
  <option value="3">Академическая</option>
  <option value="4">Александровский сад</option>
  <option value="151">Алексеевская</option>
  <option value="2135">Алма-Атинская</option>
  <option value="5">Алтуфьево</option>
  <option value="148">Аннино</option>

  ...
  ...
  ...

  <option value="140">Шаболовская</option>
  <option value="216">Шипиловская</option>
  <option value="141">Шоссе Энтузиастов</option>
  <option value="142">Щелковская</option>
  <option value="143">Щукинская</option>
  <option value="144">Электрозаводская</option>
  <option value="145">Юго-Западная</option>
  <option value="146">Южная</option>
  <option value="147">Ясенево</option>
  </select>

Извлечь данные номера достаточно просто с использованием регулярных выражений. Например, скопировав вышеприведённые список станций метро в текстовый редактор Atom и заменив все совпадения ^\s+?<option value="(\d+?)">[^<]+?</option> на $1, . Если думать в терминах Scrapy, то начальные ссылки (start_urls) для каждой станции метро можно задать с помощью генератора списков:

stations_numbers = [1, 2, 3, 4, 151, 2135, 5, 148, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 2145, 215, 18, 19, 20, 1010,
                    149, 127, 1012, 2155, 22, 21, 23, 24, 25, 26, 27, 1003, 28, 152, 29, 2146, 30, 31, 32, 33, 2001, 34,
                    2143, 217, 35, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 2151, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
                    57, 58, 59, 2142, 2144, 60, 61, 62, 2002, 63, 64, 65, 1004, 66, 1001, 67, 1002, 68, 69, 70, 71,
                    2133, 72, 73, 17, 74, 75, 76, 77, 78, 79, 80, 82, 81, 36, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92,
                    93, 94, 95, 96, 97, 98, 99, 2136, 100, 101, 102, 2149, 103, 104, 2150, 105, 106, 107, 108, 1005,
                    109, 110, 111, 2147, 112, 1007, 214, 113, 114, 115, 116, 117, 118, 119, 120, 2152, 121, 122, 2148,
                    1006, 123, 124, 125, 126, 128, 1011, 1009, 1008, 129, 130, 131, 2154, 132, 133, 134, 135, 136, 137,
                    138, 139, 140, 216, 141, 142, 143, 144, 145, 146, 147]
start_urls = ['https://www.avito.ru/moskva/kollektsionirovanie/monety?metro=' + str(station_number) for station_number
              in stations_numbers]

Вначале я не обратил внимание, что список всех станций присутствует в html-странице и их оттуда можно извлечь, и начал сканировать предполагаемые номера станций метро следующим образом (но Avito достаточно быстро блокирует доступ к своему ресурсу по IP после такого агрессивного сканирования):

import requests
print([base_url + str(station_number) for station_number in range(1, 10000) if
       requests.get(base_url + str(station_number),
                    headers={'User-Agent': 'Mozilla/5.0'},
                    allow_redirects=False).status_code == 200])

Горизонтальное и вертикальное сканирование

Если с начальными ссылками (start_urls) мы определились, то теперь нам нужно задать для Scrapy правила для так называемых вертикального и горизонтального сканирования. Термины “horizontal scrapy” и “vertical scrapy” я встретил в книжке Dimitrios Kouzis-Loukas, “Learning Scrapy”. Цель вертикального сканирования - получить ссылки на все объявление в рамках одной страницу, а горизонтального - получить ссылку на следующую страницу с списком объявлений, часто это кнопка вроде “Следующая страница”. Для этого можно открыть одну из сканируемых страниц в веб-браузере, например, https://www.avito.ru/moskva/kollektsionirovanie/monety?p=2&metro=1 и с помощьюв встроенного механизма веб-браузера инспектирования элементов посмотреть html-код участка с кнопкой “Следующая страница” для горизонтального сканирования и любого объявления на странице для вертикального.

HTML-код кнопки “Следующая страница”

<a class="pagination-page js-pagination-next" href="/moskva/kollektsionirovanie/monety?p=3&amp;metro=1">
 Следующая страница →
 </a>

HTML-код одного из объявлений:

<a class="item-description-title-link" href="/moskva/kollektsionirovanie/moneta_nikolay_2_1912_912376434" title="Монета Николай 2 1912 в Москве">
 Монета Николай 2 1912
 </a>

Это мы всё делали, чтобы с помощью языка запросов XPath создать запросы для правил движения паука:

XPath-запрос для “Следующая страница”:

//a[contains(@class, "pagination-page js-pagination-next")]

XPath-запрос для объявлений:

//a[contains(@class, "item-description-title-link")]

Проверить результат работы запросов можно как в консоли веб-браузера с помощью команды вида $x('XPATH_ЗАПРОС'), например, $x('//a[contains(@class, "pagination-page js-pagination-next")]'), так и с помощью scrapy shell.

В принципе, этого достаточно, чтобы перебрать пауком все объявления и получить из них необходимые данные. Дата выпуска монеты может быть записана как в заголовке объявления, так и в его описании, поэтому их и нужно парсить в каждом объявлении. Что также реализуется с помощью запросов XPath:

Заголовок объявления:

//span[contains(@class, \'title-info-title-text\')]/text()

Описание объявления:

//div[contains(@class, \'item-description-text\')]/*/text()

В случае описания следует обратить внимание, что оно может быть написано не только с помощью обычно текста, а также с помощью html-разметки. Поэтому использовать функцию в конце XPath-запроса text() нужно для всех тегов внутри описания. Для обработки полученных данных можно использовать функцию MapCompose(). Только стоит быть аккуратным с удалением всех тэгов MapCompose(remove_tags), если до этого не использовать text(), а собирать данные вместе с тегами, так как в этом случае дата производства может “склеиться” с другим словом. Тут можно, например, удалить все запятые в описании MapCompose(lambda i: i.replace(',', ' ')), чтобы в случае импортирования данных в csv-формат можно их было легко открыть в Excel.

Код получившегося паука можно посмотреть в файле проекта AvitoBIContest/coins/spiders/adverts.py.

Получение даты из объявления

Паук возвращает объект Item, полученные со страницы объявления данные, который передается в Pipeline. В Pipeline удобно реализовать постобработку получаемых от паука данных. Поэтому в нём удобно реализовать класс для получения даты из текста и описания объявления.

Объединим текст заголовка и объявления в единую область поиска. И весь процесс парсинга даты из этой области поиска сведётся к написанию регулярных выражений. Всё бы хорошо, но дата в объявлениях записана зачастую не так очевидно, как хотелось бы. В лучше случае будет указана дата в виде “монета 1992 года выпуска”, но почти во всех объявлениях используются сокращения даты, то просто число написано “1957”, даже двадцатый век просто подразумевается “92”. На примерах я выделил следующие способы написания даты в объявлениях:

  • Просто год в виде трёх или четырёх значного числа: 970, 1957
  • Отрезок времени: 1957-1978 года, 1957-1978 гг, 867-88
  • Точная дата с указанием числа и месяца: 01.10.2013
  • Века: 19 в, 19-20в, XIX, XIX-XX
  • Краткие формы: 57, 92-93 года

На каждую форму записи даты следует написать регулярное выражение для парсинга. Но, что ожидаемо, извлечённые данные будут достаточно “грязными”, они будут содержать много других чисел, которые не относятся к дате изготовления монет, такие как цена, различные номера, тиражи, пробу металла, вес, размеры, дату события в честь которого они выпущены, количество лет от знаменательного события и т.п. Такие числа нужно каким-то образом отсеять. Для решения этой проблемы я написал регулярные выражения для чисел в объявлениях, которые выглядят и извлекаются как даты из них, но датами изготовления не являются. Это возможно только с теми числами, которые записаны вместе со словами, явно указывающими, что это не дата изготовления. И убрал данные совпадения из области поиска, тем самым почистив её от лишних дат. Можно было бы сначала парсить даты, а уже из них вычищать числа, являющимися всё-таки не датами, но так можно затереть дату, совпавшую с каким-либо другим числом. Не знаю, какой из подход был бы лучше.

Также я решил разделить регулярные выражения для парсинга дат на две части. Одни будут описывать числа, которая с большой вероятностью является датой, а если таких дат не найдётся, то использовать регулярки из второй части, которые будут описывать уже предполагаемые даты. Ещё я сделал предположение, что монеты на Avito продаются не младше 300 года нашей эры, так как количество таких монет навряд ли будет большим, а вот числа до 300 часто встречаются для обозначения различных других характеристик, таких как вес, размер и т.д..

Помимо самого совпадения с регулярным выражением будем хранить позицию в области поиска, где нашлось данное совпадение, чтобы после найти первое совпадение. Это первое совпадение и будет датой производства монеты. А если дат не найдено, то считается, что дата в объявлении не указана.

Исходный код Pipeline можно посмотреть здесь: AvitoBIContest/coins/pipelines.py

Остаётся только добавить отдельный код для получения ответов на поставленный вопрос. Стоит отметить, что для получения верного абсолютно значение количества монет без даты я умножаю полученный результат на коэффициент, учитывающий, что я мог обработать не все объявления, а процентное соотношение объявлений без даты к тем, которые с датой, примерно одинаковое для любой случайно выборки объявлений из всего массива. Данный коэффциент является отношением количества объявлений на Avito к количеству учтённых мной. Также я добавил функцию, очищающую заголовок и описание после всех операций, так как они нам не нужны для дальшейшего анализа.

Запустить получившегося паука для сбора объявлений и парсинга дат из них можно следующей командой:

scrapy crawl adverts -o coins.json

Результат его работы будет записан в файл coins.json. Далее запускаем функцию get_result.py для рассчёта из полученных данных требуемого ответа, предварительно записав туда количество объявлений на сайте Avito в момент парсинга, чтобы получить правильный коэффициент.

Сам проект скрапера лежит на GitHub: AvitoBIContest

Оставить комментарий