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

в 21:43, , рубрики: enum, html, python, telebot, vedis, Занимательные задачки, парсинг, Программирование

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

Продолжаем историю о разработке Telegram-бота для поиска билетов — HappyTicketsBot, начало можно почитать в первой части.
Во второй расскажу о самом боте, поделюсь кодом, а также идеями, которым скорее всего не суждено стать реальностью. Большая часть функционала к моменту создания бота уже была написана в формате скрипта, поэтому основной задачей стояло наладить интерфейс взаимодействия с пользователем через Telegram-messenger. Получилось не так болтологически, как в 1й части, так что attention — много кода.

Спойлер: HappyTicketsBot так и не улетел крутиться на иностранный сервер, он локальный и русский, но однажды запуск (верю) состоится =)

1. Старт с нуля

Так как опыта в проектировании Telegram-ботов не было от слова совсем, то пришлось начать с базовых статей и tutorials, которых в сети очень много. Да, кстати, что такое back-end на тот момент я тоже плохо представляла)) Самым содержательным и прикладным стал вот этот набор уроков . Модуль, на котором велось взаимодействие с Telegram — pyTelegramBotAPI (github).
Дольше всего заняло освоение идеологии декораторов, про них читала в этой статье. Там в двух частях и очень понятно.

2. Сценарий взаимодействия бота с пользователем. Базовый поиск

Как уже упомянуто в предисловии и 1-й части статьи, почти весь код парсинга был готов. Оставалось изменить способ задания параметров поиска. Исходя из этого и был построен сценарий поведения бота. Команды, доступные для пользователя, ограничиваются следующим набором:

  • /Find — начать новый поиск,
  • /Reset — сбросить параметры поиска и начать новый,
  • /LastSearch — выдает результаты, используя параметры последнего запроса,
  • /addURL добавить URL спектакля в интересы для отслеживания снижения цены,
  • /checkURL — обновить цены на интересующие спектакли,
  • /showURL — вывести все URL, добавленные в список интересов

По базовому поисковому сценарию /find пользователь переходит от одного статуса к другому, последовательно вводя необходимые для фильтра данные. После ввода последнего параметра — места представления — происходит непосредственно парсинг афиши с использованием глобально объявленных словарей, где ключ-ID пользователя, значения — введенные параметры поиска.
Для того, чтобы запоминать состояние пользователя, они сохраняются в базе. Для работы с ней используются модули Vedis (конфигуратор баз данных типа ключ-значение, почитать документацию) и Enum (работа с перечислениями, подробнее 1, 2).

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

from enum import Enum

token = "422358437:AAGmOrnuGOI4sdfsdfs5656sdfsdf_c" #токен бота
db_file = "Mydatabase.vdb"

class States(Enum):
    """
    в БД Vedis хранимые значения всегда строки,
    поэтому и тут будем использовать тоже строки (str)
    """
    S_START = "0"  # Начало нового диалога
    S_ENTER_MONTH = "1"
    S_ENTER_PRICE = "2"
    S_ENTER_TYPE = "3"
    S_ENTER_PLACE = "4"
    S_ENTER_URL="5" #этот статус не входит в базовый поиск

В итоге получаем незамысловатую цепочку перехода статусов из одного в другой.

От парсера афиши театра на Python до Telegram-бота. Часть 2 - 2
Для хранения используем БД Vedis. Инициализация пользователя, приславшего сообщение, всегда осуществляется через message.chat.id.

Код файла dbwoker.py, в котором описано взаимодействие с базой

from vedis import Vedis
import Myconfig as config

# Запрашиваем из базы статус пользователя
def get_current_state(user_id):
    with Vedis(config.db_file) as db:
        try:
            return db[user_id]
        except KeyError:  #Если такого ключа/пользователя в базе не оказалось
            return config.States.S_START.value  #Значение по умолчанию-начало диалога

# Сохраняем текущий статус пользователя в базу
def set_state(user_id, value):
    with Vedis(config.db_file) as db:
        try:
            db[user_id] = value
            return True
        except:
            print('Проблемка с юзером!')
            # тут желательно как-то обработать ситуацию
            return False

Ниже пример хендлера, который активизируется по команде /find. Как можно заметить, в этом примере нет никакого ввода данных — есть только смена статуса на «S_ENTER_MONTH». Увидев сообщение о вводе номера, пользователь его вводит и отправляет сообщение. При получении сообщения со статусом S_ENTER_MONTH, инициируется следующий этап. В случае ошибок ввода статус не меняется.

    # Начало диалога
@bot.message_handler(commands=["find"])
def cmd_find(message):
    state = dbworker.get_current_state(message.chat.id) 
    """
    Для начала надо проверить, завершен ли предыдущий диалог и какой статус у пользователя. 
   Его мы запрашиваем из базы. Если он не нулевой, то мы продолжаем сценарий с точки остановки и текущего статуса 
    """
    if state == config.States.S_ENTER_MONTH.value: 
        bot.send_message(message.chat.id, "Мы остановились на выборе месяца. Введи его номер")
    elif state == config.States.S_ENTER_PRICE.value:
        bot.send_message(message.chat.id, "Мы остановились на вводе порога по цене, введи его сейчас")
    elif state == config.States.S_ENTER_TYPE.value:
        bot.send_message(message.chat.id, "Кажется, кто-то обещал отправить тип представления, но так и не сделал этого :( Жду...")

    else:  # Под "остальным" понимаем состояние "0" - начало диалога
        bot.send_message(message.chat.id, "Для начала введи номер месяца для поиска")
        dbworker.set_state(message.chat.id, config.States.S_ENTER_MONTH.value) #делаем смену статуса

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

@bot.message_handler(func=lambda message: dbworker.get_current_state(message.chat.id) == config.States.S_ENTER_MONTH.value)
def user_entering_month(message):
    if not message.text.isdigit():
        bot.send_message(message.chat.id, "Напоминаю, что нужно ввести число")
        return    #проверка 1

    num[message.chat.id]=message.text #забираем число

    if int(num[message.chat.id])>12 or int(num[message.chat.id])<1:
        bot.send_message(message.chat.id, "Номера месяца в диапазоне от 1 до 12. Попробуй еще раз") #проверка 2
        return

    url_list[message.chat.id]=take_url(num[message.chat.id]) #формируем список URL-ссылок на представления

    if url_list[message.chat.id]==[]: #если афиша без спектаклей
        bot.send_message(message.chat.id, "К сожалению, афиша на этот месяц пустая. Попробуй еще раз выбрать месяц")
         return            
  
   bot.send_message(message.chat.id, "Отлично! Теперь укажи верхний порог по цене.")
   dbworker.set_state(message.chat.id, config.States.S_ENTER_PRICE.value) #смена статуса на ввод цены

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

3. Отслеживание изменения цен

Пользователь может добавить в список интересов URL, чтобы получить оповещение, когда цена снизится. Мы помним, что у нас остался незадейстованный в базовом поиске статус — S_ENTER_URL. В

@bot.message_handler(commands=["addURL"])
def cmd_add_url(message):
    bot.send_message(message.chat.id, "Введи url, который нужно отслеживать. Без https://")
    dbworker.set_state(message.chat.id, config.States.S_ENTER_URL.value) #изменяем статус
    
@bot.message_handler(func=lambda message: dbworker.get_current_state(message.chat.id) == config.States.S_ENTER_URL.value)
def user_entering_URL(message):              
    perf_url=message.text
    user_id=message.chat.id
    try:
        add_new_URL(user_id,perf_url)  
        bot.send_message(message.chat.id, 'Запись в базу прошла успешно!')
        dbworker.set_state(message.chat.id, config.States.S_START.value)  #сбрасываем статус в начало
    except:
        bot.send_message(message.chat.id, 'URL неверный! Попробуй ввести еще раз!')
        dbworker.set_state(message.chat.id, config.States.S_ENTER_URL.value)          

Для хранения списка используем .csv файл. Для взаимодействия с ним нужна пара функций — записи и чтения с проверкой изменения цены. Если она изменяется-оповещаем пользователя.

 def add_new_URL(user_id,perf_url):
    WAITING_FILE = "waiting_list.csv"
    with open(WAITING_FILE, "a", newline="") as file:
        curent_url='https://'+perf_url
        text=get_text(curent_url) #функция описана в 1й части статьи
        minPrice, name,date,typ,place=find_lowest(text)  
        user = [str(user_id), perf_url,str(minPrice)]
        writer = csv.writer(file)
        writer.writerow(user)
Код функции обновления цены чуть более длинный


def update_prices(bot):
    WAITING_FILE = "waiting_list.csv"
    with open(WAITING_FILE, "r", newline="") as file:
        reader = csv.reader(file)  
        waitingList=[]
        for row in reader:
            waitingList.append(list(row)) 
    L=len(waitingList)
    lowest={}
    with open(WAITING_FILE, "w", newline="") as fl:
        writer = csv.writer(fl)
        for i in range(L):
            lowest[waitingList[i][1]]=waitingList[i][2] #добавляем по ключу URL цену
        for k in lowest.keys():
            text=get_text('https://'+k)  
            minPrice, name,date,typ,place=find_lowest(text)  #об этом говорилось в 1й части статьи
            if minPrice==0: #если билетов нет
                minPrice=100000                
            if int(minPrice)<int(lowest[k]): #если выбранная цена ниже, чем в базе
                lowest[k]=minPrice #обновим цену в базе
                for i in range(L):
                    if waitingList[i][1]==k: #если у кого-то этот URL в подписках
                        waitingList[i][2]=str(minPrice) #обновим цену
                        bot.send_message(int(gen[i][0]),'Обновилась цена на '+k+' Теперь билеты от '+str(minPrice))
        writer.writerows(waitingList) #и перезаписываем файл с новыми ценами. СДЕЛАТЬ БЫ ТОЛЬКО СТРОКУ... но...

В результате, по команде /checkURL пользователь может получить такой результат (сейчас понимаю, что надо бы название спектакля тоже выводить, но это вещи из серии «руки не дошли»).
От парсера афиши театра на Python до Telegram-бота. Часть 2 - 3

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

4. Пишем активность и ошибки в логи

В этом нам поможет модуль Logging. Запись информации происходит только на этапе завершения базового поиска, в хендлере, в котором статус пользователя переходит из S_ENTER_PLACE в S_START. Запись ошибок, в свою очередь, происходит при их возникновении.
Много сказать о том, как модуль работает, я не смогу, поэтому лучше обратиться к информации вовне.

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

Описание логгера

def save_logs(str):
    loggerInfo.info(str) #добавляем строчку в лог

logging.basicConfig(format = u'%(levelname)-8s [%(asctime)s] %(message)s', level = logging.ERROR, filename = u'loggerErrors.log')

global loggerInfo
loggerInfo = logging.getLogger(__name__)
loggerInfo.setLevel(logging.INFO)
handler = logging.FileHandler('loggerUsers.log')
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
loggerInfo.addHandler(handler)
log = logging.getLogger("ex")

Из-за разрыва соединения бот периодически падал, поэтому ошибка интернет соединения отлавливалась и бот перезапускался автоматом через 10 секунд. Но это не всегда спасало, поэтому держала запущенным TeamViewer, чтобы при необходимости поднять.

5. Нереализованное

У нас получился бот, заменяющий функционал скрипта, но позволяющий получать информацию в удобной форме внутри мессенджера. Основные мои потребности он закрыл.
Разборки с модулями и написание стройных хендлеров длились около месяца в режиме работы по выходным и иногда по вечерам. В конце этого периода интерес уже стал угасать и функционал застрял на начальной точке. Пробиться через принципы работы на webhook-ах с наскока не удалось, а потом и Telegram заблокировали. До этого был план запулить крутиться back-end на рабочий сервер, но… vpn ради этого там ставить не будут=)
Вот что осталось в планах, некоторые из которых может и реализуются однажды томным летним/зимним вечером:

  • нагрузочное тестирование с большим потоком пользователей. Пока непонятно, будет ли бот работать стабильно и не путать пользователей;
  • оповещение о появлении в расписании артиста нового спектакля. Любимых «белых кроликов» у меня много, за всеми не уследить (а хотелось бы);
  • оповещении о появлении в продаже билетов определенной категории. Был один знакомый, любитель первого ряда партера, который словить сложно вручную;
  • регулярная автоматическая проверка интересующих URL-ов на предмет снижения цены по таймеру. Сейчас это делается в конце базового поиска, таймер не удалось наладить быстро, так что оставила по-простому;
  • сохранение своей истории посещений спектаклей. Куда-нибудь в файлик .csv, дату-название-состав исполнителей-свой комментарий, чтобы не растерять;
  • поиск заданной категории билетов. Задавать не только цену, но и сектор (партер-бенуар и т.д);
  • перенести всё в навык для Алисы. why not?
  • сделать мобильное приложение с тем же функционалом. why not?

Был заход на Большой театр. Чтобы словить билеты на «Нуреева», но не удалось за два вечера расковырять html афиши, так что тоже отложено в перечень нереализованного.

ИТОГ

Лень оказалась двигателем прогресса и она же его остановила. До выгрузки бота на сторонний сервер дело не дошло, все-таки это требует более широких компетенций и знаний в области Web. Проект выдался интересным и позволил освоить чуть лучше Python, увидеть еще одну его грань (помимо привычного Machine learning-а), а также подарил много чудесных вечеров в театре по бросовой цене. Спасибо ему за это, поставленные задачи он закрывал на ура.

Как ни старалась, в статье всё равно получилось много кода и мало текста. Буду рада пояснить непонятное или мало описанное в комментариях =)

Автор: YuliyaCl

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js