Как стать автором
Обновить

Как проанализировать рынок фотостудий с помощью Python (1/3). Парсинг данных

Время на прочтение11 мин
Количество просмотров9.1K
В интернете огромное количество открытых данных. При правильном сборе и анализе информации можно решить важные бизнес-задачи. Например, стоит ли открыть свой бизнес?

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

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

В данной статье, первой из трех, опишу планирование и написание парсинга на Python.

Во второй статье опишу алгоритм взаимодействия парсинга с базой данных и обновления данных.

В третьей статье рассмотрю процесс анализа собранных данных и ответы на вопросы клиента, желающего открыть фотостудию.



В ходе изучения сайтов фотостудий сформулировал общую схему их работы:

  • описать свои услуги на сайте;
  • предоставить сервис бронирования залов через сайт или по телефону;
  • сообщить контакты;
  • принять посетителей в забронированное время.

Постановка задачи


Основная задача данного проекта: проанализировать рынок услуг фотостудий Москвы.
Для начала нужно понять какие компании есть на рынке и как они работают.

Самый распространенный сайт-агрегатор фотостудий: Studiorent. На нем видим примерное количество компаний, их сайты, тематики, цены и прочую общую информацию.

На сайте studiorent мы увидели важные данные для анализа рыночной ситуации: календарь бронирования. Собрав и проанализировав эти данные у разных фотостудий сможем ответить на огромное количество важных вопросов:

  • какова сезонность бизнеса;
  • какова загруженность имеющихся фотостудий;
  • в какие дни недели бронируют чаще и на сколько;
  • какой доход у фотостудий;
  • какова часовая ставка аренды фотостудии;
  • сколько залов у фотостудий и сколько их было при открытии;
  • как влияет количество залов на величину дохода с одного зала;
  • какова площадь залов
  • и многие другие.

Первая задача обозначена: необходимо создать парсинг, собирающий данные календарей бронирования разных фотостудий.

Что будем парсить?


При внимательном рассмотрении сайтов разных фотостудий, видим следующие основные сервисы бронирования:

  1. Google-Календарь;
  2. приложение AppEvent;
  3. приложение Ugoloc;
  4. самописные календари бронирования (например);
  5. запись по телефону с отсутствием информации о бронировании на сайте.

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

AppEvent неудобен для парсинга. В нем отсутствует информация о бронировании в прошлом. Поэтому AppEvent необходимо парсить ежедневно, что создает определенные неудобства, такие как: необходим длительный сбор данных (более года) для получения примерной картины рынка, необходим отдельный сервер (например, облачный) с ежедневным запуском парсера.

С Google-Календарем изначально было опасение блокировки работы парсера самим Google'ом. Есть вероятность, что придется использовать специальные платные proxy-сервисы (например), а это усложняет работу парсера и делает ее дороже.

Сервис Ugoloc показался идеальным для парсинга, благодаря небольшому сроку жизни проекта (с 2015 года) и значительному числу зарегистрированных в нем фотостудий (84 на момент написания статьи).

Легче всего будет сделать парсинг сервиса Ugoloc, т.к.:

  • сможем получить доступ ко всей истории бронирования. Следовательно, можно будет парсить данные по мере необходимости;
  • не потребуется использовать proxy. Значит, сможем использовать простые библиотеки (urllib);
  • на сервисе зарегистрировано около трети всего количества фотостудий Москвы. Следовательно, получим достоверные данные о состоянии рынка.

Структура сайта


Полный список фотостудий ссылками на их страницы представлен на странице.

На странице каждой фотостудии можем найти данные по метражу студии, высоте потолков, количеству залов, специальному оборудованию и ссылки на каждый зал.

На странице зала есть данные по метражу зала, высоте потолков, минимальному срок брони и ссылку на календарь бронирования.

Ссылка на страницу студии строится по форме: ugoloc.ru/studios/show/id_студии
Ссылка на зал: ugoloc.ru/studios/hall/id_зала
Ссылка на календарь бронирования: ugoloc.ru/studios/booking/id_зала

Этапы парсинга


  1. выгрузка списка фотостудий;
  2. выгрузка списка залов;
  3. выгрузка данных бронирования за выбранную неделю;
  4. выгрузка исторических данных бронирования;
  5. выгрузка будущих данных
  6. расшифровка выгруженных json-данных

1. Выгрузка списка фотостудий


Как выгрузить список фотостудий?

Полгода назад список фотостудий на странице выгружался как json-файл. Если вы первый раз сталкиваетесь с анализом работы сайта, содержание json файлов можно увидеть следующем образом: F12 -> «Network» -> «JS» -> выбор нужного файла -> «Response».



Таким образом обнаружил ссылку на список фотостудий: https://ugoloc.ru/studios/list.json

Для запроса используем библиотеку urllib.

Запрашиваем полные данные по списку фотостудий
url = 'https://ugoloc.ru/studios/list.json'
json_data = urllib.request.urlopen(url).read().decode()


Получили строковый формат json-данных. Для его расшифровки применим библиотеку json.

Список фотостудий находятся по ключу 'features'
json.loads(json_data)['features']


Перебирая список фотостудий и сохраняя необходимые данные, получаем следующий код процедуры
def studio_list():
    
    url = 'https://ugoloc.ru/studios/list.json'
    json_data = urllib.request.urlopen(url).read().decode()
    
    id = list()
    name = list()
    metro = list()
    address = list()
    phone = list()
    email = list()

    for i in range(len(json.loads(json_data)['features'])):
        id.append(json.loads(json_data)['features'][i]['studio']['id'])
        name.append(json.loads(json_data)['features'][i]['studio']['name'])
        metro.append(json.loads(json_data)['features'][i]['studio']['metro'])
        address.append(json.loads(json_data)['features'][i]['studio']['address'])
        phone.append(json.loads(json_data)['features'][i]['studio']['phone'])
        email.append(json.loads(json_data)['features'][i]['studio']['email'])

    return pd.DataFrame.from_dict({'studio_id': id, 
                                   'name': name, 
                                   'metro': metro, 
                                   'address': address, 
                                   'phone': phone, 
                                   'email': email}).set_index('studio_id')


На выходе процедуры получили таблицу с id студии, названием, ближайшим метро, адресом, телефоном и e-mail'ом.

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

2. Выгрузка списка залов


Подробное описание фотостудии с указанием залов находится в папке «ugoloc.ru/studios/show» + id фотостудии.

На странице фотостудии находим список ссылок на залы. Список ссылок получаем с помощью библиотек BeautifulSoup и регулярных выражений re:

  1. вначале делаем get-запрос (urllib.request.urlopen) страницы студии;
  2. потом переводим полученные строковые данные в объект BeautifulSoup с разбором как html-код («html.parser');
  3. затем находим все ссылки, в которых есть указание папки „studios/hall/“.

Код запроса:
url_studio = 'https://ugoloc.ru/studios/show/' + str(584)
html = urllib.request.urlopen(url_studio).read()
soup = BeautifulSoup(html, "html.parser")

halls_html = soup.find_all('a', href=re.compile('studios/hall/'))


Получили список BeautifulSoup-объектов, содержащих ссылки на страницы залов. Дальнейшие действия:

  1. извлекаем ссылки на зал методом .get('href') или указанием индекса ['href'];
  2. проверяем, проходили ли по этой ссылке ранее (необходимо при работе цикла);
  3. выгружаем данные по названию зала, ссылки, площади, высоте потолков;
  4. проверяем, не является ли зал гримерным местом (в названии есть слог „грим“).

Код процедуры запроса списка залов
def hall_list(studio_id):
    
    st_id = list()
    hall_id = list()
    name = list()
    is_hall = list()
    square = list()
    ceiling = list()
    
    for id in studio_id:
        url_studio = 'https://ugoloc.ru/studios/show/' + str(id)
        html = urllib.request.urlopen(url_studio).read()
        soup = BeautifulSoup(html, "html.parser")

        halls_html = soup.find_all('a', href=re.compile('studios/hall/'))
        halls = dict()
        for hall in halls_html:
            if int(hall.get('href').replace('/studios/hall/','')) not in hall_id:
                st_id.append(id)
                name.append(hall['title'])
                hall_id.append(int(hall.get('href').replace('/studios/hall/','')))
                if 'грим' in str.lower(hall['title']):
                    is_hall.append(0)
                else:
                    is_hall.append(1)
                    
                url_hall = 'https://ugoloc.ru/studios/hall/' + str(hall.get('href').replace('/studios/hall/',''))
                html_hall = urllib.request.urlopen(url_hall).read()
                soup_hall = BeautifulSoup(html_hall, "html.parser")
                
                try:
                    square.append(int(soup_hall.find_all('div', class_='param-value')[0].contents[0]))
                except:
                    square.append(np.nan)
                try:
                    ceiling.append(float(soup_hall.find_all('div', class_='param-value')[1].contents[0]))
                except:
                    ceiling.append(np.nan)
    
    return pd.DataFrame.from_dict({'studio_id': st_id, 
                                   'hall_id': hall_id, 
                                   'name': name, 
                                   'is_hall': is_hall,
                                   'square': square,
                                   'ceiling': ceiling
                                  }).set_index('hall_id')


3. Выгрузка данных бронирования за выбранную неделю


Для выгрузки данных по бронированию за неделю необходимо найти ссылку на выгрузку json-данных. В данном случае найти ее можно в коде страницы поиском по слову „json“. Первое совпадение (27 строка) содержит переменную:
var ajax_url = '/studios/calendar/975.json?week='

Проверим данную ссылку: https://ugoloc.ru/studios/calendar/975.json?week=
Работает! Видим данные по часам бронирования, по дням, по стоимости.

Значение параметра week по умолчанию равен 0. Для просмотра предыдущих недель берем отрицательные значения: -1 (предыдущая неделя), -2 (2 недели назад) и т.д., — для просмотра следующих, соответственно, положительные значения.

Выгружаем json-данные по бронированию
url_booking = 'https://ugoloc.ru/studios/calendar/' + str(id) + '.json?week=' + str(week)

json_booking = json.loads(urllib.request.urlopen(url_booking).read().decode())


Данные по датам можем увидеть по индексу 'days',
по рабочим часам — индекс 'hours',
по ценам — индекс 'prices',
по минимальном сроке бронирования — индекс 'min_hours',
по бронированию — индекс 'bookings'.

Собираем данные по бронированию за выбранную неделю
def get_week_booking(id, week=0):
    url_booking = 'https://ugoloc.ru/studios/calendar/' + str(id) + '.json?week=' + str(week)

    json_booking = json.loads(urllib.request.urlopen(url_booking).read().decode())
    
    booking = {
        'hall_id': id,
        'week': week,
        'monday_date': json_booking['days']['1']['date'],
        'days': json_booking['days'],
        'hours': json_booking['hours'],
        'bookings': json_booking['bookings'],
        'prices': json_booking['prices'],
        'min_hours': json_booking['min_hours'],
        'is_opened': 1 if np.sum([len(json_booking['bookings'][str(x)]) for x in range(1, 8)]) > 0 else 0
    }
    time.sleep(.1)
    
    return booking


Отдельной строкой проверяем доступно ли хотя бы одно бронирование за неделю. Если доступно, то считаем студию открытой.

4. Выгрузка исторических данных бронирования


Для загрузки исторических данных загружаем данные бронирования за прошлую неделю, позапрошлую, 3 недели назад и т.д.

Основная проблема состоит в том, чтобы определить момент открытия зала. Если запросить данные 1000 недель назад, то API выгрузит нам корректные данные.
Формулируем критерий, подтверждающий, что зал закрыт: если зал ни разу не бронировался 2 месяца подряд (9 недель), то он не работал.

Используя критерий, напишем процедуру
def get_past_booking(id, weeks_ago = 500):
    week = -1
    null_period = 9
    flag = 0
    d = dict()
    
    while flag != 1:
        d[week] = get_week_booking(id, week)
        if (len(d) > null_period 
            and 1 not in [d[-1 * x]['is_opened'] for x in range(len(d) - 9, len(d))]
           ):
            
            flag = 1
            for x in range(0, null_period + 1):
                del d[week + x]
        
        if week < weeks_ago * -1:
            return d
        week += -1
    
    time.sleep(1)
    return d


Отдельными параметрами обозначил срок в просмотре данных 10 лет (500 недель), означающий что исторические данные более 10 лет нам не интересны (притом, что агрегатор открыт с 2015 года).

Кроме того, устанавливаем срок ожидания между запросом данных в 0,1 секунду.

5. Выгрузка будущих данных


В выгрузке данных бронирования будущих периодов важно найти неделю, когда заканчивается основная часть бронирований.

Устанавливаем аналогичный с предыдущим пунктом критерий: если в течение 2 месяцев подряд (9 недель) не обнаружено ни одной брони, то дальнейшие периоды можем не просмартивать.

Получаем код процедуры
def get_future_booking(id):
    week = 0
    null_period = 9
    flag = 0
    d = dict()
    
    while (flag != 1 and week <= 30):
        d[week] = get_week_booking(id, week)
        if (len(d) > null_period and 1 not in [d[x]['is_opened'] for x in range(len(d) - 9, len(d))]):
            flag = 1
            for x in range(0, null_period):
                del d[week - x]
            
        week += 1
    
    time.sleep(1)
    return d


6. Расшифровка выгруженных json-данных


Для расшифровки json-данных в своем часто использовал методы try, except: если не расшифровывается как привычный тип данных, например dictionary (try), то расшифровываем как другой ожидаемый тип, например list (except). Сейчас понимаю, лучше строить вычисления на проверке типа данных напрямую (функция type()) и дальнейшей их обработкой.

Мы написали процедуры по выгрузке данных по бронированию для выбранного зала. Следующей задачей необходимо перевести json-данные в табличный вид DataFrame для удобства дальнейшей обработки или записи в базу данных.

Для расшифровки перебираем каждый день недели.

Дату переводим из текстового формата в формат даты
cur_date = pd.Timestamp(datetime.datetime.strptime(d[week]['days'][str(weeks_day)]['date'], '%d.%m.%Y').isoformat())


Часы бронирования могут быть представлены в виде текста (»12:00"), числа (12), а при круглосуточной работе могут быть указаны конечным числом бронирования (24).

Для расшифровки часов работы использовал методы try, except
            try:
                try:
                    working_hour = list([int(x) for x in d[week]['prices'][str(weeks_day)].keys()])
                    working_hour_is_text = 1
                except:
                    working_hour = list(d[week]['prices'][str(weeks_day)].keys())
            except:
                working_hour = list(range(len(d[week]['prices'][str(weeks_day)])))


Если цена бронирования установлена на одном уровне вне зависимости от дня недели и времени, то цена может храниться числом или строкой. Кроме того, цена может храниться в виде списка или словаря.

Для расшифровки цены бронирования использовал методы try, except
            try:
                price = list(
                        d[week]['prices'][str(weeks_day)].values() 
                        if type(d[week]['prices'][str(weeks_day)].values()) != type(dict())
                        else d[week]['prices'][str(weeks_day)]
                        )
            except:
                price = list(
                        d[week]['prices'][str(weeks_day)]
                        if type(d[week]['prices'][str(weeks_day)]) != type(dict())
                        else d[week]['prices'][str(weeks_day)]
                        )


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

Расшифровка доступного для бронирования времени:
            try:
                booking_hours = sorted([int(x) for x in d[week]['bookings'][str(weeks_day)]])
                duration = [d[week]['bookings'][str(weeks_day)][str(h)]['duration'] for h in booking_hours]
            except:
                booking_hours = 0
                duration = 24


Забронированными являются рабочие часы, недоступные к бронированию. То есть, если зал работает с 10:00 до 22:00 и доступно к бронированию время с 10:00 до 18:00, то время с 18:00 до 22:00 считаем забронированными. Эту логику применяем для вычисление забронированного времени.

Общая процедура расшифровки json-данных:
def hall_booking(d):
    hour = list(range(24))
    
    df = pd.DataFrame(columns=['hour', 'date', 'is_working_hour', 'price', 'duration', 'week', 'min_hours'])
    
    for week in d.keys():
        
        for weeks_day in range(1, 8):

            working_hour_is_text = 0
            cur_date = pd.Timestamp(datetime.datetime.strptime(d[week]['days'][str(weeks_day)]['date'], '%d.%m.%Y').isoformat())
            try:
                try:
                    working_hour = list([int(x) for x in d[week]['prices'][str(weeks_day)].keys()])
                    working_hour_is_text = 1
                except:
                    working_hour = list(d[week]['prices'][str(weeks_day)].keys())
            except:
                working_hour = list(range(len(d[week]['prices'][str(weeks_day)])))
            try:
                price = list(
                        d[week]['prices'][str(weeks_day)].values() 
                        if type(d[week]['prices'][str(weeks_day)].values()) != type(dict())
                        else d[week]['prices'][str(weeks_day)]
                        )
            except:
                price = list(
                        d[week]['prices'][str(weeks_day)]
                        if type(d[week]['prices'][str(weeks_day)]) != type(dict())
                        else d[week]['prices'][str(weeks_day)]
                        )

            try:
                booking_hours = sorted([int(x) for x in d[week]['bookings'][str(weeks_day)]])
                duration = [d[week]['bookings'][str(weeks_day)][str(h)]['duration'] for h in booking_hours]
            except:
                booking_hours = 0
                duration = 24
            min_hours = d[week]['min_hours']

            df_temp = pd.DataFrame(hour, columns = ['hour'])
            df_temp['date'] = cur_date
            df_temp['is_working_hour'] = [1 if y else 0 for y in [x in working_hour for x in hour]]
            df_temp['price'] = 0
            if len(working_hour) == 24:
                df_temp['price'] = price
            else:
                df_temp.loc[working_hour, 'price'] = price
            df_temp['duration'] = 0

            if duration != 24 and working_hour_is_text == 0:
                df_temp.loc[[x in booking_hours for x in df_temp['hour']], 'duration'] = duration
            elif duration != 24 and working_hour_is_text != 0:
                df_temp.loc[[x in booking_hours for x in df_temp['hour']], 'duration'] = duration
            else:
                df_temp.loc[0, 'duration'] = 24
            df_temp['week'] = week
            df_temp['min_hours'] = min_hours

            df = pd.concat([df, df_temp])
    
    df = df.sort_values(by=['week', 'date', 'hour'])
    df.index = list(range(len(df)))
    df['is_booked'] = 0
    
    for i in df.index:
        if df.loc[i, 'duration'] != 0:
            if i + df.loc[i, 'duration'] < df.index[-1]:
                df.loc[i:(i + int(df.loc[i, 'duration'])) - 1, 'is_booked'] = 1
            else:
                df.loc[i:, 'is_booked'] = 1
    
    df['hall_id'] = d[np.min(list(d.keys()))]['hall_id']
    
    return df


Итог


Мы рассмотрели работу парсера, собирающего данные по бронированию залов московских фотостудий с сайта ugoloc.ru. В результате выгрузили список фотостудий, список залов, список забронированных часов и перевели в формат DataFrame. С полученными уже можно работать, но парсинг занимает продолжительное время и выгруженные данные необходимо где-то хранить.

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

Готовый проект вы можете найти на моей странице в github.
Теги:
Хабы:
+4
Комментарии0

Публикации

Истории

Работа

Data Scientist
63 вакансии
Python разработчик
142 вакансии

Ближайшие события