Как все началось

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

По окончанию тестирования пользователю показывается результат написанный на картинке. И мне она показалась не совсем корректной.

Достаточно обидно
Достаточно обидно

Реализация автонабора для соревновательного режима

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

Пример соревнований
Пример соревнований

После присоединения пользователя встречает следующее окно:

Слова и поле для ввода
Слова и поле для ввода

Строка со словами меняется динамически по мере введения слов, изначально я думал что это вызовет некоторые проблемы, однако оказалось что словарь слов реализован достаточно простым способом. Код страницы выглядит следующим образом:

Начало словаря
Начало словаря

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

from bs4 import BeautifulSoup
soup = BeautifulSoup(page_source, 'html.parser')
words_list = list()
boundaries = soup.find(attrs={'id': 'row1', 'style': "top: 1px;"})
for span in boundaries.find_all('span'):
		words_list.append(span.text)

Дальше только проще. Я реализовал небольшой класс содержащий методы для запуска/получения/ввода и применил его на случайном испытании:

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time
from bs4 import BeautifulSoup
from webdriver_manager.chrome import ChromeDriverManager
import json

HTML_SOURCE = "https://10fastfingers.com/competition/611216d69046c"


class MonkeyWorker:
    def __init__(self, url, login, password):
        self.__URL = url
        #   Create actual driver
        self.driver = webdriver.Chrome(ChromeDriverManager(version="92.0.4515.107").install())
        self.driver.maximize_window()
        #   Login
        self.driver.get("https://10fastfingers.com/login")
        time.sleep(2)
        self.driver.find_element_by_xpath('//input[@name = "data[User][email]"]').send_keys(login)
        self.driver.find_element_by_xpath('//input[@name = "data[User][password]"]').send_keys(password)
        time.sleep(1)
        self.driver.find_element_by_xpath('//button[@class = "CybotCookiebotDialogBodyButton"]').click()
        self.driver.find_element_by_xpath('//button[@class = "btn btn-info" and @id = "login-form-submit"]').click()
        time.sleep(3)
        #   Open input Link
        self.driver.get(self.__URL)
        time.sleep(5)
        self.soup = BeautifulSoup(self.driver.page_source, 'html.parser')
        self.words_list = list()
        self.input_form = self.driver.find_element_by_xpath('//input[@class = "form-control" and @id = "inputfield"]')

    def get_words(self):
        boundaries = self.soup.find(attrs={'id': 'row1', 'style': "top: 1px;"})
        for span in boundaries.find_all('span'):
            self.words_list.append(span.text)

    def input_words(self):
        for word in self.words_list:
            for symbol in word:
                self.input_form.send_keys(symbol)
            self.input_form.send_keys(Keys.SPACE)

    def close(self):
        self.driver.quit()


with open('config.txt') as file:
    conf = json.load(file)

monke = MonkeyWorker(HTML_SOURCE, conf["login"], conf["password"])
monke.get_words()
monke.input_words()
char = input()
monke.close()

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

С этого момента и начинается интересная часть поста.

Встреча с анти-читом

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

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

При нажатии на кнопку Start Test сразу появляются первые проблемы. Первая из них продемонстрирована ниже:

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

driver.execute_script("$(arguments[0]).click();", self.driver.find_element_by_xpath('//button[@class = \
                                    "btn btn-large btn-info" and @id = "start-btn"]'))

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

IMAGE_URL = str(self.soup.find('div', attrs={"id": "word-img"}).find_all('img')[0]['src'])
image = requests.get(IMAGE_URL).content()

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

def img_getter():
    """
    Function to get image by opening new tab. Doesn't work because 10fastfingers have smart text genearator
    :return:
    """
    #   Getting image
    IMAGE_URL = str(self.soup.find('div', attrs={"id": "word-img"}).find_all('img')[0]['src'])
    self.driver.execute_script("window.open('');")
    self.driver.switch_to.window(self.driver.window_handles[1])
    self.driver.get(IMAGE_URL)
    #   Convert selenium screenshot to Image
    self.image = Image.open(io.BytesIO(self.driver.get_screenshot_as_png()))    # Creating Image
    #   self.driver.close()
    self.driver.switch_to.window(self.driver.window_handles[0])

    self.image.show()
    self.__image_preprocessing()

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

Мне был нужен только белый прямоугольник
Мне был нужен только белый прямоугольник
def __old_image_preprocessing(self):
    image = self.image.convert('RGB')
    numpy_image = np.array(image)
    original = numpy_image.copy()
    image = image.filter(ImageFilter.MedianFilter(3))

    # Find X,Y coordinates of all white pixels
    whiteY, whiteX = np.where(np.all(numpy_image == [255, 255, 255], axis=2))
    top, bottom = whiteY[0], whiteY[-1]
    left, right = whiteX[0], whiteX[-1]

    # Crop white solid
    ROI = original[top:bottom, left:right]
    final = Image.fromarray(ROI)
    final.show()
    self.image = final

На первый взгляд все работало замечательно, однако все оказалось не так просто. Изображения были разными!

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

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

def getting_image(self):
    element = self.driver.find_element_by_xpath('//div[@id = "word-img"]')
    location = element.location_once_scrolled_into_view
    size = element.size
    area = (location["x"] * 2, location["y"] * 2, location["x"] * 2 + size["width"] * 2,
            location["y"] * 2 + size["height"] * 2)

    self.image = self.driver.get_screenshot_as_png()
    self.image = Image.open(io.BytesIO(self.image))
    self.image = self.image.crop(area)
    self.__image_preprocessing()
    
def __image_preprocessing(self):
    """
    Apply SHARPEN filter to make words detecting little better
    :return:
    """
    self.image.show()
    self.image = self.image.filter(ImageFilter.SHARPEN)
    self.image.show()
    self.image = self.image.filter(ImageFilter.SHARPEN)
    self.image.show()

И это сработало! Теперь осталось воспользоваться OCR Tesseract и дело сделано.

import pytesseract as image_engine
from pytesseract import Output

def get_words(self):

    words = image_engine.image_to_data(self.image, lang="eng", output_type=Output.DICT)
    clean_words = list(filter(lambda x: x != '', words["text"]))
    self.words_list = clean_words

При желании можно сделать более тщательную обработку изображения, исправив дисторсию слов, можно лучше настроить Tesseract, можно воспользоваться для определения слов чем-то в духе: https://github.com/courao/ocr.pytorch

Я же хотел просто увидеть результат работы. И он меня не разочаровал:

Так я реализовывал способ обойти анти-чит систему на https://10fastfingers.com/. Понятное дело что сделано это все лишь для веселья и возможности вспомнить основы работы с Selenium. Надеюсь мой пост Вам понравился. Спасибо за внимание!

Анти-чит алгоритм

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time
from bs4 import BeautifulSoup
from webdriver_manager.chrome import ChromeDriverManager
import json
import pytesseract as image_engine
from pytesseract import Output
from PIL import Image, ImageFilter
import io
import numpy as np

HTML_SOURCE = "https://10fastfingers.com/anticheat"


class MonkeyWorker:
    def __init__(self, url, login, password):
        self.__URL = url
        self.driver = webdriver.Chrome(ChromeDriverManager(version="92.0.4515.107").install())
        self.action = webdriver.ActionChains(self.driver)
        self.driver.maximize_window()
        #   Login
        self.driver.get("https://10fastfingers.com/login")
        time.sleep(2)
        self.driver.find_element_by_xpath('//input[@name = "data[User][email]"]').send_keys(login)
        self.driver.find_element_by_xpath('//input[@name = "data[User][password]"]').send_keys(password)
        time.sleep(1)
        #   Bad way is about using execute java script
        self.driver.find_element_by_xpath('//button[@class = \
                                                "CybotCookiebotDialogBodyButton"]').click()    # Apply cookies
        self.driver.find_element_by_xpath('//button[@class = "btn btn-info" and @id = "login-form-submit"]').click()
        time.sleep(1)   # Little sleep in case after login script redirect to another page

        #   Go to anti-cheat page
        self.driver.get(self.__URL)
        time.sleep(1)
        self.driver.find_element_by_xpath('//a[@href= "/anticheat/view/1/1" and @class = "btn btn-info" \
                                            and text() = "Start Test "]').click()
        time.sleep(5)

        #   Button has specific form - so in my opinion the best way to click it - use execute script
        self.driver.execute_script("$(arguments[0]).click();", self.driver.find_element_by_xpath('//button[@class = \
                                    "btn btn-large btn-info" and @id = "start-btn"]'))
        time.sleep(1)
        self.soup = BeautifulSoup(self.driver.page_source, 'html.parser')
        self.words_list = list()

        #   Save element with form where we need to put Keys
        self.input_form = self.driver.find_element_by_xpath('//textarea[@id = "word-input"]')
        self.getting_image()

    def getting_image(self):
        element = self.driver.find_element_by_xpath('//div[@id = "word-img"]')
        location = element.location_once_scrolled_into_view
        size = element.size
        area = (location["x"] * 2, location["y"] * 2, location["x"] * 2 + size["width"] * 2,
                location["y"] * 2 + size["height"] * 2)

        self.image = self.driver.get_screenshot_as_png()
        self.image = Image.open(io.BytesIO(self.image))
        self.image = self.image.crop(area)
        self.__image_preprocessing()

        def not_working():
            """
            Function to get image by opening new tab. Doesn't work because 10fastfingers have smart text genearator
            :return:
            """
            #   Getting image
            IMAGE_URL = str(self.soup.find('div', attrs={"id": "word-img"}).find_all('img')[0]['src'])
            self.driver.execute_script("window.open('');")
            self.driver.switch_to.window(self.driver.window_handles[1])
            self.driver.get(IMAGE_URL)
            #   Convert selenium screenshot to Image
            self.image = Image.open(io.BytesIO(self.driver.get_screenshot_as_png()))    # Creating Image
            #   self.driver.close()
            self.driver.switch_to.window(self.driver.window_handles[0])

            self.image.show()
            self.__image_preprocessing()


    def __image_preprocessing(self):
        """
        Apply SHARPEN filter to make words detecting little better
        :return:
        """
        self.image.show()
        self.image = self.image.filter(ImageFilter.SHARPEN)
        self.image.show()
        self.image = self.image.filter(ImageFilter.SHARPEN)
        self.image.show()

    def __old_image_preprocessing(self):
        image = self.image.convert('RGB')
        numpy_image = np.array(image)
        original = numpy_image.copy()
        image = image.filter(ImageFilter.MedianFilter(3))

        # Find X,Y coordinates of all white pixels
        whiteY, whiteX = np.where(np.all(numpy_image == [255, 255, 255], axis=2))
        top, bottom = whiteY[0], whiteY[-1]
        left, right = whiteX[0], whiteX[-1]

        # Crop white solid
        ROI = original[top:bottom, left:right]
        final = Image.fromarray(ROI)
        final.show()
        self.image = final

    def get_words(self):

        words = image_engine.image_to_data(self.image, lang="eng", output_type=Output.DICT)
        clean_words = list(filter(lambda x: x != '', words["text"]))
        self.words_list = clean_words

    def input_words(self):
        for word in self.words_list:
            for symbol in word:
                self.input_form.send_keys(symbol)
            self.input_form.send_keys(Keys.SPACE)
        self.input_form.send_keys(Keys.TAB, Keys.ENTER)

    def close(self):
        self.driver.quit()


with open('config.txt') as file:
    conf = json.load(file)


monkey = MonkeyWorker(HTML_SOURCE, conf["login"], conf["password"])
monkey.get_words()
monkey.input_words()
char = input()
monkey.close()

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


  1. Dron007
    16.08.2021 03:56

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

    Достаточно просто поделить CPM на 5. Именно так считают WPM большинство англоязычных тренажёров, насколько я знаю.


    1. ivegner
      17.08.2021 12:47
      +1

      Может ли это как-то быть связано с тем, что средняя длина слова в английском языке близка к 5? А то есть подозрение, что в других языках это соотношение может быть другим. Уж не говоря о том, что не в каждом языке легко определить, что есть "слово". Например, если используется письменность без пробелов.


      1. Molozey Автор
        17.08.2021 15:23

        Достаточно интересный вопрос.
        Из соображений того, что в таких тестах обычно набираются осмысленные предложения, а не набор «очищенных» слов, выбрал такие датасеты:

        ENGLISH: github.com/dwyl/english-words/blob/master/words_alpha.txt
        RUSSIAN: github.com/danakt/russian-words

        Результаты достаточно интересные, гистограммы для разных языков:
        image

        95% доверительные интервалы для средних + разницы средних:
        image

        Но что-то я подзабыл можно ли на таких больших выборках использовать z и t критерий (Вроде как распределение нормальное для длин слов, но все же это дискретные величины, а значит не непрерывные), на всякий пожарный проверил отдельно для бутстрэпа (результат похожий), но все равно не уверен. Код примерно такой, буду рад если в случае ошибки, кто-то меня поправит.

        import pandas as pd
        import matplotlib.pylab as plt
        from statsmodels.stats.weightstats import *
        from tqdm.notebook import tqdm
        
        
        russian_pd = pd.read_csv("russian_dict.txt", header=None, sep='\n', encoding="windows-1251")
        russian_pd.columns = ["WORD"]
        russian_pd["LEN"] = russian_pd["WORD"].apply(lambda x: len(x) - 1 if str(x)[0] == '-' else len(x))
        english_pd = pd.read_csv("english_dict.txt", header=None, sep='\n')
        english_pd.columns = ["WORD"]
        english_pd["LEN"] = english_pd["WORD"].apply(lambda x: len(str(x)) - 1 if str(x)[0] == '-' else len(str(x)))
        plt.figure(figsize=(15,7))
        plt.subplot(1,2,1)
        plt.title('ENGLISH')
        plt.hist(english_pd.LEN, color='blue', edgecolor='black')
        plt.subplot(1,2,2)
        plt.title('RUSSIAN')
        plt.hist(russian_pd.LEN, color='blue', edgecolor='black')
        plt.show()
        print("ENGLISH [{0[0]:.4f}, {0[1]:.4f}]".format(zconfint(english_pd.LEN.values)))
        print("RUSSIAN [{0[0]:.4f}, {0[1]:.4f}]".format(zconfint(russian_pd.LEN.values)))
        print("MEAN 95% confidence interval [{0[0]:.4f}, {0[1]:.4f}]".format(CompareMeans(DescrStatsW(russian_pd.LEN.values), DescrStatsW(english_pd.LEN.values)).zconfint_diff()))
        def get_bootstrap_interval(sample, n_samples):
            indices = np.random.randint(0, len(sample), size=(n_samples, len(sample)))
            samples = sample[indices]
            return samples
        
        def stat_quantile(stat, alpha=.05):
            boundaries = np.percentile(stat, [100 * alpha / 2., 100 * (1 - alpha / 2.)])
            return boundaries
        
        delta_means = list((map(lambda x: x[1] - x[0], zip(list(tqdm(map(np.mean, get_bootstrap_interval(russian_pd.LEN.values, 100)), total=100)), list(tqdm(map(np.mean, get_bootstrap_interval(english_pd.LEN.values, 100)), total=100))))))
        
        print('Разница средних')
        print(stat_quantile(delta_means))
        


        По итогу результат примерно следующий:
        Средняя длина английского слова — [9.4331, 9.4519], русского — [10.9127, 10.9224]


        1. ivegner
          17.08.2021 16:12

          Спасибо, что уделили время этому вопросу!

          Насчёт применимости критериев в статистике, к сожалению, моя тройка по матстату ничего не подсказывает.

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

          Вспоминается классический пример, поразивший в своё время Льюиса Кэролла: русское слово "защищающихся" (средняя длина слова: 12 букв), которое он перевёл как "of those who defend themselves" (средняя длина слова: (2+5+3+6+10) / 5 = 26/5 = 5.2).

          К сожалению, не могу дать более твёрдой поддержки, чем результаты поиска в гугле по запросу "average word length in English language", но и они дают ощущение консенсуса относительно оценочного значения 5 для английского языка. Для русского языка я нахожу оценочные значения от 6 до 7.


      1. Dron007
        18.08.2021 02:12

        Да, это конечно же так, но по каким-то причинам, в среде компьютерных тренажёров устоялась именно константа, даже несмотря на то, что можно подсчитать среднюю длину слова в каждом отдельно взятом тексте. Видимо, для сравнимости результатов или для простоты расчётов, не знаю. В русском слова длиннее, но по-моему константа используется та же, да и метрика WPM намного менее популярна в русскоязычной среде. Это как приняли, что дюйм в CSS это 96 пикселей, и всё, живите с этим.