Как все началось
Насколько мне известно, в большинстве русскоязычных тестировщиков скорости печати используется метрика 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()
Dron007
Достаточно просто поделить CPM на 5. Именно так считают WPM большинство англоязычных тренажёров, насколько я знаю.
ivegner
Может ли это как-то быть связано с тем, что средняя длина слова в английском языке близка к 5? А то есть подозрение, что в других языках это соотношение может быть другим. Уж не говоря о том, что не в каждом языке легко определить, что есть "слово". Например, если используется письменность без пробелов.
Molozey Автор
Достаточно интересный вопрос.
Из соображений того, что в таких тестах обычно набираются осмысленные предложения, а не набор «очищенных» слов, выбрал такие датасеты:
ENGLISH: github.com/dwyl/english-words/blob/master/words_alpha.txt
RUSSIAN: github.com/danakt/russian-words
Результаты достаточно интересные, гистограммы для разных языков:
95% доверительные интервалы для средних + разницы средних:
Но что-то я подзабыл можно ли на таких больших выборках использовать z и t критерий (Вроде как распределение нормальное для длин слов, но все же это дискретные величины, а значит не непрерывные), на всякий пожарный проверил отдельно для бутстрэпа (результат похожий), но все равно не уверен. Код примерно такой, буду рад если в случае ошибки, кто-то меня поправит.
По итогу результат примерно следующий:
Средняя длина английского слова — [9.4331, 9.4519], русского — [10.9127, 10.9224]
ivegner
Спасибо, что уделили время этому вопросу!
Насчёт применимости критериев в статистике, к сожалению, моя тройка по матстату ничего не подсказывает.
Со своей стороны могу только попросить прощения, что не уточнил, что под "средней длиной" я понимал "среднюю длину слова в тексте", а не "в словаре". Текст на английском языке, выражающем многие синтактические отношения аналитически, то есть с помощью служебных слов, содержит гораздо больше коротких слов, чем текст на русском языке, выражающий те же отношения синтетически, то есть при помощи формообразования (одни причастия чего стоят, а также глагольные приставки) и словоизменения (грубо говоря, вместо предлога, выражающего дательный падеж, достаточно изменить окончание на дательный падеж).
Вспоминается классический пример, поразивший в своё время Льюиса Кэролла: русское слово "защищающихся" (средняя длина слова: 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.
Dron007
Да, это конечно же так, но по каким-то причинам, в среде компьютерных тренажёров устоялась именно константа, даже несмотря на то, что можно подсчитать среднюю длину слова в каждом отдельно взятом тексте. Видимо, для сравнимости результатов или для простоты расчётов, не знаю. В русском слова длиннее, но по-моему константа используется та же, да и метрика WPM намного менее популярна в русскоязычной среде. Это как приняли, что дюйм в CSS это 96 пикселей, и всё, живите с этим.