Апрель 2018-го года. Мне было 14. Мы с друзьями играли в тогда очень популярную онлайн-викторину «Клевер» от ВКонтакте. Один из нас (обычно я) всегда был за ноутбуком, чтобы пытаться быстро гуглить вопросы и глазами искать в поисковой выдаче правильный ответ. Но вдруг я понял, что каждый раз выполняю одно и то же действие, и решил попробовать написать это на частично известном мне тогда Python 3.

Шаг 0. Что здесь происходит


Для начала я освежу в вашей памяти механику «Клевера».

Игра для всех начинается в одно и то же время — в 13:00 и в 20:00 по Москве. Чтобы сыграть, нужно в это время зайти в приложение и подключиться к прямой трансляции. Игра идет 15 минут, в течение которых участникам на телефон одновременно приходят вопросы. На ответ дается 10 секунд. Затем объявляется верный ответ. Все, кто угадали, проходят дальше. Всего вопросов 12, и если ответить на все – получишь денежный приз.
image
Получается, наша задача — мгновенно ловить новые вопросы от сервера Клевера, обрабатывать их через какой-либо поисковик, а по результатам выдачи определять правильный ответ. Вывод ответа было решено производить в телеграм-бота, чтобы уведомления из него всплывали на телефоне прямо во время игры. И все это желательно за пару секунд, ведь время на ответ си��ьно ограничено. Если вы хотите увидеть, как довольно простой, но рабочий код (а посмотреть на такой будет полезно новичкам) помогал нам обыгрывать Клевер – добро пожаловать под кат.

Шаг 1. Получаем вопросы с сервера


Сначала это показалось самым сложным этапом. Я уже сделал глубокий вдох и готов был полезть в дебри вроде компьютерного зрения, перехвата трафика или декомпиляции приложения… Как вдруг меня ждал сюрприз – у Клевера открытое API! Оно нигде не задокументировано, но если во время игры, как только всем игрокам задали вопрос, сделать request на api.vk.com, то в ответ мы получим заданный вопрос и варианты ответов к нему в JSON:

image

https://api.vk.com/method/execute.getLastQuestion?v=5.5&access_token=VK_USER_TOKEN


В качестве access_token необходимо передавать API-токен любого пользователя ВКонтакте, но важно, чтобы он был изначально выписан именно для Клевера. Его app_id – 6334949.

Шаг 2. Обрабатываем вопрос через поисковик


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



Поэтому я начал искать API. Google не подошел — их решения были очень ограниченными и возвращали очень мало данных. Самым щедрым оказался Яндекс.XML — он разрешает посылать 10000 запросов в день, не более 5 в секунду, а данные возвращает очень быстро. В запросе к нему опционально количество страниц (вплоть до 100) и количество пассажей — специальных величин, которые используются для формирования сниппетов. Данные мы получаем в XML. Однако это все те же сниппеты.

Чтобы вы могли ознакомиться и поиграть с тем, что возвращает Яндекс, то вот пример ответа на запрос «Как зовут главного антагониста в серии видеоигр «The Legend of Zelda»?»: Яндекс. Диск.

Мне повезло, и оказалось, что в pypi под это уже существует отдельный модуль yandex-search. И вот, я попробовал получить вопрос с сервера, найти его в яндексе, из сниппетов сделать один большой текст и разбить его на предложения:

import requests as req
import yandex_search
import json

apiurl = "https://api.vk.com/method/execute.getLastQuestion?access_token=VK_USER_TOKEN&v=5.5"
clever_response = (json.loads(req.get(apiurl).content))["response"]
# {'text': 'Какой из этих мультфильмов первым получил премию Оскар в номинации «Лучший анимационный полнометражный фильм»?', 'answers': [{'id': 0, 'users_answered': 0, 'text': '«История игрушек»'}, {'id': 1, 'users_answered': 0, 'text': '«Корпорация монстров»'}, {'id': 2, 'users_answered': 0, 'text': '«Шрек»'}], 'stop_time': 0, 'is_first': 0, 'is_last': 1, 'number': 12, 'id': 22, 'sent_time': 1533921436}

question = str(clever_response["text"])
ans1, ans2, ans3 = str(clever_response["answers"][0]["text"]).lower(), str(clever_response["answers"][1]["text"]).lower(), str(clever_response["answers"][2]["text"]).lower()

def yandexfind(question):
    finded = yandex.search(question).items
    snips = ""
    for i in finded:
        snips += (i.get("snippet")) + "\n"
    return snips

items = yandexfind(question)
itemslist = list(items.split(". "))


Шаг 3. В поисках ответов


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

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

hint = [] #Список предложений, содержащих один из вариантов ответа
for sentence in itemslist: #Чекаем каждое предложение из сниппетов
    if (ans1 in sentence) or (ans2 in sentence) or (ans3 in sentence):
        hint.append(sentence)
    if len(hint) > 4:
        break


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

if len(hint) == 0:
    def cut(string):
        if len(string) > 2:
            return string[0:-2]
        else:
            return string
    short_ans1, short_ans2, short_ans3 = cut(ans1), cut(ans2), cut(ans3)
    for pred in itemslist: #Чекаем каждое предложение из сниппетов
        if (short_ans1 in pred) or (short_ans2 in pred) or (short_ans3 in pred)
            hint.append(pred)


Но даже после такой подстраховки все равно были случаи, когда hint оставался пустым, просто потому что в результатах не всегда хоть как-то затрагивались ответы. Скажем, на вопрос «У какого из этих писателей есть повесть, названная так же, как и песня группы Би 2?» точного ответа не найти. В этом случае я прибегал к обратному подходу – наводил справки по ответам и выводил вариант на основе того, как часто в результатах упоминаются слова из вопроса.

if len(hint) == 0:
    questionlist = question.split(" ")
    blacklist = ["что", "такое", 'как', 'называется', 'в', 'каком', 'году', 'для', 'чего', 'какой', 'какого', 'кого', 'кто', 'зачем', 'является', 'самым', 'большим', 'маленьким', 'из', 'этого', 'входит', 'этих', 'кого', 'у', 'а', 'сколько']
    for w in questionlist:
        if w in blacklist:
            questionlist.remove(w)

    yandex_ans1 = yandexfind(ans1)
    yandex_ans2 = yandexfind(ans2)
    yandex_ans3 = yandexfind(ans3)
    #Чуть позже я сделал этот процесс асинхронным, но это было костыльно

    count_ans1, count_ans2, count_ans3 = 0, 0, 0

    for w in questionlist:
        count_ans1 += yandex_ans1.count(w)
        count_ans2 += yandex_ans2.count(w)
        count_ans3 += yandex_ans3.count(w)

if (count_ans1 + count_ans2 + count_ans3) > 5:
    if count_ans1 > (count_ans2 + count_ans3):
        print(ans1)
    elif count_ans2 > (count_ans1 + count_ans3):
        print(ans2)
    elif count_ans3 > (count_ans2 + count_ans1):
        print(ans3)


На этом месте скрипт обрел базовую функциональность. И вот, спустя всего полторы недели после релиза Клевера, мы сидим и уже играем с таким самописным «читом». Видели бы вы наши с другом лица, когда мы впервые выиграли игру, читая в командной строке как по волшебству появляющиеся предложения!

Шаг 4. Вывод четких ответов


Но скоро такой формат надоел. Во-первых, нужно было каждую игру сидеть с ноутбуком. Во-вторых, скрипт просили себе друзья, и я устал каждому объяснять, как вставить свой токен ВКонтакте, как настроить Яндекс.XML (он привязан к IP, то есть под каждого пользователя скрипта нужно было создавать аккаунт) и как установить питон на компьютер.

Было бы куда лучше, если бы ответы всплывали в пуш-уведомлениях на телефоне прямо во время игры! Просто посмотрел наверх экрана и ответил так, как написано в пуш-уведомлении! А организовать это для всех можно, если создать скрипту свой телеграм-канал! Чудесно!

Но просто выводить в телеграм все те же предложения – не вариант. Читать их с телефона крайне неудобно. Поэтому пришлось учить скрипт самому понимать, какой ответ правильный.

Импортируем telebot и все функции print() меняем на send_tg() и notsure(), который мы будем использовать в последнем методе, так как промахивается он немного чаще остальных:

def send_tg(ans):
    bot.send_message("@autoclever", str(ans).capitalize())
    print(str(ans))
    return
    
def notsure(ans):
    send_tg(ans.capitalize() + ". Это неточно!")
    hint.append("WE TRIED!")


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

Так что нехитро подсчитываем упоминания слов в результатах:

anscounts = {
    ans1: 0,
    ans2: 0,
    ans3: 0
}

for s in hint:
    for a in [ans1, ans2, ans3]:
        anscounts[a] += s.count(a)

right = (max(anscounts, key=anscounts.get))

send_tg(right)
#Ура!



Что получилось в итоге:
image

Дальнейшая судьба


Справедливости ради надо сказать, что машина смерти у меня не получилась. В среднем бот отвечал правильно только на 9-10 вопросов из 12ти. Оно и понятно, ведь встречались каверзные, которые не поддавались парсингу Яндексовского поиска. Меня, да и моих друзей утомило постоянно пролетать на парочке вопросов и ждать удачной игры, на которой бот наконец-то на все ответит правильно. Чуда не происходило, скрипт дорабатывать уже не сильно хотелось, и тогда мы, перестав питать надежды на легкую победу, забросили игру.

Со временем моя идея начала закрадываться в головы других молодых разработчиков. К закату 2018-го года насчитывалось как минимум 10 ботов и сайтов, выводящих свои догадки по вопросам в Клевере. Задача-то не такая сложная. Но что удивительно, никто из них так и не перешагнул планку в 9-10 вопросов за игру, а позднее все упали и вовсе до 7-8, как и мой бот. Видимо, составители вопросов просекли, как нужно составлять вопросы, чтобы труд поисковиков был нерелевантен.

К сожалению, бота уже не доработать, ведь 31го декабря Клевер провел последний эфир, а датасет вопросов у меня не сохранился. Тем не менее, это был отличный опыт для начинающего программиста. И наверняка был бы отличный вызов для продвинутого – только представьте себе дуэт word2vec и text2vec, асинхронные запросы к Яндексу, Гуглу и Википедии одновременно, продвинутый классификатор вопросов и алгоритм переформулировки вопроса в случае неудачи… Эх! Пожалуй, за такие возможности я любил эту игру больше, чем за сам геймплей.