От парсера афиши театра на Python до Telegram-бота. Часть 1



    Я очень люблю оперу и балет, но не очень — отдавать большие деньги за билеты. Ежедневный просмотр сайта театра с тыканьем в каждую кнопку ужасно утомлял, а внезапно появлявшиеся билеты по 170 рублей на супер-составы бередили душу.
    Чтобы автоматизировать это дело появился скриптик, который бежит по афише и собирает информацию о самых дешевых билетах на выбранный месяц. Запросы из серии «выдай список всех опер в марте на старой и новой сцене до 1000 рублей». Подруга обронила «а ты не Telegram-бота делаешь?». Такого в плане не было, но почему бы и нет. Бот родился, хоть и крутился на домашнем ноутбуке.
    Потом Telegram заблокировали. Мысль запулить бота на рабочий сервер растаяла, да и интерес, чтобы довести функционал до ума, угас. Под катом рассказываю о судьбе сыщика дешевых билетов с самого начала и о том, что с ним сталось после года использования.

    1. Зарождение идеи и постановка задачи


    В первоначальной постановке у всей истории была одна задача — формировать отфильтрованный по цене список спектаклей, чтобы экономить время на ручном просмотре каждого спектакля афиши в отдельности. Единственный театр, чья афиша интересовала, был и остается Мариинский. Личный опыт быстро показал, что бюджетная «галерка» открывается в случайные дни на случайные спектакли, а раскупается достаточно быстро (если состав стоящий). Чтобы ничего не упустить, и нужен автоматический сборщик.
    Вид афиши с кнопочками, по которым приходилось вручную переходить
    image

    Хотелось за прогон скрипта получать ограниченный набор интересующих спектаклей. Главным критерием, как уже говорилось, была цена на билет.
    API сайта и билетной системы в открытом доступе нет, поэтому было принято решение (не мудрствуя лукаво) пропарсить HTML-страницы, по тегам выдергивая нужное. Открываем главную, жмем F12 и изучаем структуру. Выглядело адекватно, так что дело быстро дошло до 1й реализации.
    Понятно, что такой подход не масштабируется на другие сайты с афишами и посыпется, если текущую структуру решат сменить. Если у читателей есть идеи, как сделать стабильнее без API, пишите в комментарии.

    2. Первая реализация. Минимальный функционал


    К реализации подошла с опытом работы с Python только для решения задач, связанных с машинным обучением. Да и какого-то глубокого понимания html и web-архитектуры не было (и не появилось). Поэтому все делалось по принципу «куда иду знаю, а как идти — сейчас найдем»
    Для первых набросков понадобилось 4 вечерних часа и знакомство с модулями requests и Beautiful Soup 4 (не без помощи годной статьи, спасибо автору). Для допиливания наброска — еще выходной день. Не до конца уверена, что модули самые оптимальные в своем сегменте, но текущие потребности они закрыли. Вот что вышло на первом этапе.
    Какую информацию и откуда выдергивать можно понять по структуре сайта. Первым делом — собираем адреса представлений, которые есть в афише на выбранный месяц.
    Структура страницы афиши в браузере, все удобно подсвечивается
    image

    Из html-страницы нам надо считать чистые URL-адреса, чтобы потом пройтись по ним и посмотреть ценник. Примерно так происходит сборка списка линков.
    import requests
    import numpy as np
    from bs4 import BeautifulSoup
    
    def get_text(url):
    #из URL вытаскиваем html
        r = requests.get(url)
        text=r.text 
        return text
    
    def get_items(text,top_name,class_name):
    """
    из всего html-текста собираем "грязные" url-ки, т.е. с какой-то обвеской. В нашем случае выдергиваем их через top_name и class_name
    итог выглядит как-то так
    <a class="c_theatre2 c_chamber_halls" href="//tickets.mariinsky.ru/ru/performance/WWpGeDRORFUwUkRjME13/">Купить билет</a>
    """
       soup = BeautifulSoup(text, "lxml")
       film_list = soup.find('div', {'class': top_name})
       items = film_list.find_all('div', {'class': [class_name]})
       dirty_link=[]
       for item in items:
           dirty_link.append(str(item.find('a')))
       return dirty_link
    
    def get_links(dirty_list,start,end):
    #из "грязной" версии забираем чистые URL-ы
        links=[]
        for row in dirty_list:
            if row!='None': 
                i_beg=row.find(start)
                i_end=row.rfind(end)
                if i_beg!=-1 & i_end!=-1:
                    links.append(row[i_beg:i_end])
        return links
    
    #пользователь вводит, в каком месяце ищем, так как афиша по месяцам
    num=int(input('Введите номер месяца для поиска: '))
    
    #URL афиши зафиксирован. Год можно подтягивать из текущей даты, но так тоже окей=)
    url ='https://www.mariinsky.ru/ru/playbill/playbill/?year=2019&month='+str(num)
    
    #ключевые слова для поиска
    top_name='container content gr_top'
    class_name='t_button'
    start='tickets'
    end='/">Купить'
    
    #вызов функций
    text=get_text(url)
    dirty_link=get_items(text,top_name,class_name)
    
    #и получаем списочек URL-адресов, ведущих на покупку билетов
    links=get_links(dirty_link,start,end) 
    

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

    • тип представления (1-опера, 2-балет, 3-концерт, 4-лекция)
    • место проведения (1-старая сцена, 2-новая сцена, 3-концертный зал, 4-камерные залы)

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

    Далее мы проходимся по всем полученным линкам. Делаем get_text и ищем по нему нижнюю цену, а также выдергиваем сопутствующую информацию. Из-за того, что приходится заглядывать в каждый URL и преобразовывать его в text, время работы программы не мгновенное. Хорошо бы оптимизировать, но я не придумала, как.
    Приводить сам код не буду, получится длинновато, но там все правда адекватно и «интуитивно понятно» с Beautiful Soup 4.
    Если цена меньше заявленной пользователем и тип-место соответствуют заданным, то в консоли выводится сообщение о спектакле. Был еще вариант сохранения всего этого в .xls, но это не прижилось. Смотреть в консоли и сразу переходить по ссылкам удобнее, чем тыкаться в файл.
    image

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

    3. Расширение функционала


    На втором этапе решила отслеживать изменение цен, храня ссылки на интересующие спектакли в отдельном файле (точнее URL на них). В первую очередь это актуально для балетов — сильно дёшево на них бывает редко и в общую бюджетную выдачу они не попадут. Но с 5 тысяч до 2х падение значимо, особенно если спектакль с звездным составом, и его хотелось отследить.
    Чтобы это сделать надо сначала добавить URL-адреса для отслеживания, а потом периодически «перетряхивать» их и сравнивать новую цену со старой.
    def add_new_URL(user_id,perf_url):
    #user_id нужно, чтобы отличать пользователей и потом пригодилось в телеграм-боте
        WAITING_FILE = "waiting_list.csv"
        with open(WAITING_FILE, "a", newline="") as file:
            curent_url='https://'+perf_url
            text=get_text(curent_url)     
    #проходим разок и собираем инфо о спектакле-минимальную цену, название,дату,тип и место
            minP, name,date,typ,place=find_lowest(text)  
            user = [str(user_id), perf_url,str(m)]
            writer = csv.writer(file)
            writer.writerow(user)
    
    def update_prices():
    #а так можно обновлять цены на интересующие спектакли
        print('Обновляю цены')
        WAITING_FILE = "waiting_list.csv"
        with open(WAITING_FILE, "r", newline="") as file:
            reader = csv.reader(file)  
            gen=[]
            for row in reader:
                gen.append(list(row))
        L=len(gen)
        lowest={}
        with open(WAITING_FILE, "w", newline="") as fl:
            writer = csv.writer(fl)
            for i in range(L):
                lowest[gen[i][1]]=gen[i][2] #добавляем по ключу URL цену
            for k in lowest.keys():
                text=get_text('https://'+k)  
                minP, name,date,typ,place=find_lowest(text)  
                if minP==0: #где билетов нет ставим большой ценник, а при их появлении цена "упадет"
                    minP=100000                
                if int(minP)<int(lowest[k]): #если выбранная цена ниже, чем в базе
                    lowest[k]=minP
                    for i in range(L):
                        if gen[i][1]==k: #если у кого-то этот URL в подписках
                            gen[i][2]=str(minP)
                            print('Обновилась цена на '+k+' Теперь билеты от '+str(minP))
            writer.writerows(gen)    
    
    add_new_URL('12345','tickets.mariinsky.ru/ru/performance/ZVRGZnRNbmd3VERsNU1R/')    
    update_prices()

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

    Дальше рождался Telegram-бот, не так легко-быстро-задорно, но все же родился. Чтобы не собирать все в одну кучу, история о нем (а также о нереализованных идеях и попытке проделать такое с сайтом Большого театра) будет во второй части статьи.

    ИТОГ: затея удалась, пользователь(я) доволен. Потребовалась пара выходных разобраться, как взаимодействовать с html-страницами. Благо Python язык-почти-для-всего и готовые модули помогают вбить гвоздь не задумываясь о физике работы молотка.

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

    UPD: Продолжение истории — Часть 2
    • +6
    • 6,8k
    • 5
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 5

      0
      Форматирование для слабаков?
        0
        Чудесный код. Особенно навык именования переменных — не ниже 80 уровня.
          0
          Для решения конкретной задачи отличный скрипт (решает ведь!). Будет интересно посмотреть, что вы ещё хотели реализовать в рамках задачи.
            0
            Рекомендую автору прочитать Code Complete МакКонелла, благо на русском языке можно найти в свободном доступе, и больше не называть некоторые переменные одной буквой
              0
              Благодарю за конструктив, ознакомлюсь!

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое