Avito BI contest
В данной статье я хотел бы рассказать о своём решении первой задачи из 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&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
Оставить комментарий