[Из песочницы] Как я писал графического бота и во что это превратилось
В этой статье разберем опыт написания инструмента, который позволяет прилагая минимум усилий и времени автоматизировать большой спектр рутинных задач.
Предисловие
Понадобилось мне сделать бота для выполнения нескольких задач, требовательных к логике и скорости реакции. Лезть в API и ковырять бинарники программ не хотелось. Было решено пойти путём визуальной автоматизации. Нашел несколько ботов, но ни один из них так и не подошел под мои требования, оказавшись или слишком медленным, или скриптовая часть была сильно урезана или был недостаточный функционал для работы с визуальной составляющей. Так как у меня был успешный опыт использования визуального бота в прошлом (хоть и медленного и сильно урезанного в скриптовой части) — решил сделать свою реализацию.
Требуемый в начале функционал
Были необходимы следующие возможности:
- Cимуляция мыши, передвижение курсора, нажатие кнопок.
- Cимуляция нажатий клавиш клавиатуры.
- Возможность искать на экране заранее заготовленный кусочек картинки, например иконку или букву, а если нашло — пусть сделает что угодно с этой информацией.
- Скриптовый интерпретатор, чтобы можно было просто описывать алгоритм действий и не требовалось компилировать раз за разом.
Существующие аналоги
Есть целый ряд аналогов, но каждый из них имеет как свои плюсы, так и минусы. Рассмотрим наиболее функциональные:
AutoIt — использует Basic для скриптинга.
Кулибины на форумах сделали возможность искать картинки на экране, к сожалению эта функция не отвечала моим требованиям ввиду излишних упрощений и куче ограничений.
Работает лишь под Windows, требует установки.
Требует компилирования для использования на других машинах, нет возможности быстро поправить скрипт, если не установлен AutoIt.
AutoHotKey — использует свой синтаксис для написания скриптов, который еще надо долго изучать, называть его правильнее будет «horrible». Трудно сделать что либо с действительно разветвленной логикой без должной привычки. Поиск картинок на экране слишком урезан и не подходил под мои нужды.
Имеет несколько портов под Linux/Unix системы, требует установки
Clickermann — использует свой язык для написания скриптов, который еще надо изучать. Из-за упрощений — урезан функционал, например тех же http запросов.
Отсутствует поиск картинок на экране, хотя и есть примитивный поиск пикселей.
Так же нашлось много макросов, большинство из которых работало по принципу «повторяй за мной» запоминая и повторяя действия пользователя, что в свою очередь не позволяет создать что либо с разветвленным алгоритмом действий с кучей if и while.
А у оставшихся банально не было функции поиска какой либо иконки на экране.
Аналоги на Хабре
Читал статью, автор делал бота для получения скидки лазерной коррекции зрения. В статье описывается множество проблем, с которыми столкнулись по ходу написания. Большинство этих проблем возникло именно из-за понятных упрощений и костыль решался новым костылём, советую статейку к прочтению.
Выбор технологий для собственного велосипеда
С самого начала было решено использовать Java SE для написания самого ядра, это в свою очередь экономило время, так как Java использую наиболее часто. К тому же был знаком с классом Robot, который позволяет в удобной форме симулировать управление мышью и клавиатурой.
При добавлении скриптового интерпретатора — выбран был язык Python как довольно простой и популярный. Для Java есть реализация Jython, исполняется на JVM и не требует установки. К тому же позволят работать с классами и объектами Java напрямую из скрипта, что значительно расширяет возможности скриптинга, не ограничивая тем, что заложено в ядре бота.
Впоследствии добавил поиск картинок на экране через GPGPU при помощи OpenCL, для Java была реализация JOCL, но об этом чуть позже.
Графический интерфейс на Swing, простая и в то же время функциональная составляющая, доступная на любой JRE прямо из коробки.
Первые шаги
В Java есть класс Robot, который позволяет симулировать нажатия клавиш клавиатуры, движения мыши и кликов, проблем особых с ним не возникло. Так что я просто расширил некоторый функционал методами типа mouseClick (x, y) используя mouseMove + mousePress + mouseRelease, добавив между этими действиями Thread.sleep (ms), впоследствии добавил еще несколько методов с разными аргументами путём перегрузки. Тот же Drag&Drop в виде одного метода.
public void mouseClick(int x, int y) throws AWTException {
mouseClick(x, y, InputEvent.BUTTON1_MASK, mouseDelay);
}
public void mouseClick(int x, int y, int button_mask) throws AWTException {
mouseClick(x, y, button_mask, mouseDelay);
}
public void mouseClick(int x, int y, int button_mask, int sleepTime) throws AWTException {
bot.mouseMove(x, y);
bot.mousePress(button_mask);
sleep(sleepTime);
bot.mouseRelease(button_mask);
}
public void mouseClick(MatrixPosition mp) throws AWTException {
mouseClick(mp.x, mp.y);
}
public void mouseClick(MatrixPosition mp, int button_mask) throws AWTException {
mouseClick(mp.x, mp.y, button_mask);
}
public void mouseClick(MatrixPosition mp, int button_mask, int sleepTime) throws AWTException {
mouseClick(mp.x, mp.y, button_mask, sleepTime);
}
public MatrixPosition mousePos() {
return new MatrixPosition(MouseInfo.getPointerInfo().getLocation());
}
Таким же образом были добавлены методы keyClick ()
public void keyPress(int key_mask) {
bot.keyPress(key_mask);
}
public void keyPress(int... keys) {
for (int key : keys)
bot.keyPress(key);
}
public void keyRelease(int key_mask) {
bot.keyRelease(key_mask);
}
public void keyRelease(int... keys) {
for (int key : keys)
bot.keyRelease(key);
}
public void keyClick(int key_mask) {
bot.keyPress(key_mask);
sleep(keyboardDelay);
bot.keyRelease(key_mask);
}
public void keyClick(int... keys) {
keyPress(keys);
sleep(keyboardDelay);
keyRelease(keys);
}
Все это было необходимо для облегчения написания действий в скрипте. Поднять уровень скриптинга до более абстрактного «Тебе это нужно и ты это делаешь».
Фух, дочитали… Передохнули? А теперь поехали дальше!
Глаза ядра
Следующий шаг — самый трудный и занял больше всего времени — добавление возможности делать скриншот экрана и найти на нем заранее заготовленную иконку.
Во-первых, как делать скриншот экрана и как его хранить?
Во-вторых, как искать паттерн (иконку)?
В-третьих, откуда взять этот паттерн (иконку)?
Если с созданием скриншота не все так уж и трудно
public void grab() throws Exception {
image = robot.createScreenCapture(screenRect);
}
То с поиском вышли определенные проблемы, где взять паттерн для поиска? Создать, а как?
Для создания первого паттерна был использован старый добрый Paint, при помощи PrintScreen закинул скриншот экрана в редактор и вырезал небольшой кусочек из скриншота, сохранив в отдельном файле .bmp формата.
Хорошо, сам паттерн есть, загрузили его из кода в BufferedImage. Скриншот так же создаётся в BufferedImage, теперь предстоит сделать алгоритм поиска. В сети наткнулся на вариант пойти брутфорсом — взять первый пиксель маленькой картинки, первый пиксель большой картинки и сравнить их, если пиксели имеют одинаковый цветовой код, проверяем остальные пиксели относительно той точки. Если все пиксели совпали — это означает, что искомая картинка была найдена. Если же не совпадает — берем следующий пиксель из большой картинки и снова повторяем действие.
Звучит не очень, однако это работает.
for (int y = 0; y < screenshot.getHeight() - fragment.getHeight(); y++) {
__columnscan: for (int x = 0; x < screenshot.getWidth() - fragment.getWidth(); x++) {
if (screenshot.getRGB(x, y) != fragment.getRGB(0, 0))
continue;
for (int yy = 0; yy < fragment.getHeight(); yy++) {
for (int xx = 0; xx < fragment.getWidth(); xx++) {
if (screenshot.getRGB(x + xx, y + yy) != fragment.getRGB(xx, yy))
continue __columnscan;
}
}
System.out.println("found!”);
}
}
Запускаем, и… Работает! Нашло одно совпадение! Однако довольно медленно, что для нас совсем неприемлемо. Время тратилось на том, что каждый раз вызываются getRGB () методы, кэш процессора используется крайне неэффективно, а ведь для нас это можно сказать — чисто поиск по матрице. Матрице пикселей! Поэтому решил перевести BufferedImage объект, хранящий скриншот экрана в матрицу int[][], так же и искомый фрагмент был переведен в int[][] матрицу, поправим наши циклы для работы с матрицей. Запускаем и… Не находит.
После активного поиска ответов в поисковиках — стало ясно, что причиной всему ARGB/RGBA/RGB формат, в котором хранятся данные BufferedImage. Скриншот имел ARGB, файл с фрагментом BGR.
Пришлось приводить все к одному формату, а именно формату скриншота ARGB, так как быстрее один раз фрагменты привести к формату скриншота, чем каждый раз скриншот к формату фрагментов. Меньшее приводим к формату большего, что заняло довольно много времени, но в конечном итоге заработало, паттерны начали успешно находиться на скриншоте гораздо быстрее, почти в два раза быстрее!
// USED FOR BMP/PNG BUFFERED_IMAGE
private int[][] loadFromFile(BufferedImage image) {
final byte[] pixels = ((DataBufferByte) image.getData().getDataBuffer())
.getData();
final int width = image.getWidth();
if (rgbData == null)
rgbData = new int[image.getHeight()][width];
for (int pixel = 0, row = 0; pixel < pixels.length; row++)
for (int col = 0; col < width; col++, pixel += 3)
rgbData[row][col] = -16777216 + ((int) pixels[pixel] & 0xFF)
+ (((int) pixels[pixel + 1] & 0xFF) << 8)
+ (((int) pixels[pixel + 2] & 0xFF) << 16); // 255
// alpha, r
// g b;
return rgbData;
}
Далее дело осталось за мелкой оптимизацией вроде кэширования строки матрицы, условий if, что так же прибавило скорость получения результата поиска.
public MatrixPosition findIn(Frag b, int x_start, int y_start, int x_stop,
int y_stop) {
// precalculate all frequently used data
final int[][] small = this.rgbData;
final int[][] big = b.rgbData;
final int small_height = small.length;
final int small_width = small[0].length;
final int small_height_minus_1 = small_height - 1;
final int small_width_minus_1 = small_width - 1;
final int first_pixel = small[0][0];
final int last_pixel = small[small_height_minus_1][small_width_minus_1];
int[] row_cache_big = null;
int[] row_cache_big2 = null;
int[] row_cache_small = null;
for (int y = y_start; y < y_stop; y++) {
row_cache_big = big[y];
__columnscan: for (int x = x_start; x < x_stop; x++) {
if (row_cache_big[x] != first_pixel
|| big[y + small_height_minus_1][x
+ small_width_minus_1] != last_pixel)
// if (row_cache_big[x] != first_pixel)
continue __columnscan; // No first match
// There is a match for the first element in small
// Check if all the elements in small matches those in big
for (int yy = 0; yy < small_height; yy++) {
row_cache_big2 = big[y + yy];
row_cache_small = small[yy];
for (int xx = 0; xx < small_width; xx++) {
// If there is at least one difference, there is no
// match
if (row_cache_big2[x + xx] != row_cache_small[xx]) {
continue __columnscan;
}
}
}
// If arrived here, then the small matches a region of big
return new MatrixPosition(x, y);
}
}
return null;
}
Попробовал поиграться с типом матриц, long vs int и лучший результат был все же с int[][] матрицей при обеих конфигурациях 64/32bit JVM на i7 4790.
Мозги бота
А именно скриптовая часть, она должна быть удобна, синтаксис понятен без лишних разъяснений. В идеале подойдет любой популярный язык, который можно встроить в ядро бота и имеющий богатую документацию. Использование API ядра должно быть простым и легко запоминающимся.
Выбор пал на Python, популярен, лёгок в освоении, хорошо задокументирован, имеет множество готовых библиотек, а главное — скрипт легко редактировать в любом текстовом редакторе! К тому же я давно хотел его изучить.
Для Java есть встраиваемая реализация Python, называется Jython. Запускается на JVM и не требует ничего дополнительного для начала работы, позволяет использовать буквально все классы, библиотеки Java, а так же .jar паки! В свою очередь это лишь укрепило уверенность в правильности выбора.
Подключаем Jython в проект, создаём объект интерпретатора и запускаем наш файл скрипта.
class JythonVM {
private boolean isJythonVMLoaded = false;
private Object jythonLoad = new Object();
private PythonInterpreter pi = null;
public JythonVM() {
// TODO Auto-generated constructor stub
}
void load() {
System.out.println("CORE: Loading JythonVM...");
pi = new PythonInterpreter();
isJythonVMLoaded = true;
System.out.println("CORE: JythonVM loaded.");
synchronized (jythonLoad) {
jythonLoad.notify();
}
}
void run(String script) throws Exception {
System.out.println("CODE: Waiting for JythonVM to load");
if (!isJythonVMLoaded)
synchronized (jythonLoad) {
jythonLoad.wait();
}
System.out.println("CORE: Running " + script + "...\n\n");
pi.execfile(script);
System.out.println("CORE: Script execution finished.");
}
}
А теперь посмотрим на скрипт
# -*- coding: utf-8 -*-
print("hello")
Из скрипта подгружаем необходимые классы нашего ядра и просто создаём их объекты. Таким образом можно дёргать методы классов, а значит это позволяет использовать API ядра для выполнения необходимых нам действий!
Такими классами стали Action и MatrixPosition, впоследствии добавились классы Exception типа FragmentNotLoadedException и ScreenNotGrabbedException
Action — используется как главный класс для обращений к функционалу ядра. В нем собраны полезные методы, призванные упростить сам процесс написания скрипта, уменьшить количество лишних строк, требуемых для решения какой либо задачи. Те же mouseClick, keyClick, find фрагментов на скриншоте, grab для создания самих скриншотов и тд.
К тому же можно создавать много объектов этого класса, а соответственно использовать независимо сразу в нескольких потоках!
Дополним наш скриптик парой строк, чтобы использовать API ядра
# -*- coding: utf-8 -*-
from bot.penguee import Action
a = Action() # создаем объект для работы с API ядра
print("hello") # строчка с предыдущего скрипта, не трогаем для наглядности
a.mouseMove(1000, 500) # должно сдвинуть курсор мыши на координаты x 1000, y 500
MatrixPosition — используется как обёртка для координат на экране. В этом формате API бота возвращает координаты. Конечно же, вспоминается уже готовый класс Point, который и так имеет нужный функционал. Однако не все так просто, поля X и Y доступны только через pos.getX pos.getY методы, что доставляет множество неудобств во время скриптописания. Гораздо удобнее обращаться к полям через pos.x pos.y. Кроме того, практика показала, что позиции так же должны иметь свои названия, которые оказались необходимы для некоторых задач типа сортировки позиций между собой (обработка чисел с экрана по алфавиту).
Возможности так же расширены при помощи методов add, sub, которые позволяют создавать новую позицию, относительную координатам текущего объекта.
# -*- coding: utf-8 -*-
from bot.penguee import MatrixPosition, Action
a = Action()
print("hello")
mp = MatrixPosition(1000, 500)
print(mp.x, mp.y)
a.mouseMove(mp)
Последующие улучшения
Кэш координат паттернов
По статистике поисков стало ясно, что требуется находить в основном статичные картинки, которые не меняют своего положения на экране. Для этого был добавлен кэш с координатами. Если картинка есть в кэше, то сначала будет производиться проверка наличия картинки в закэшированных координатах, если не нашло — то поиск по остальной части экрана. Эта маленькая деталь значительно увеличила скорость исполнения скриптов.
GPGPU на вооружение
Всегда хотелось сделать быстрым процесс поиска паттерна на большом экране, оптимизация алгоритма имеет свои ограничения. До этого весь процесс поиска происходил на процессоре, разделить его на отдельные потоки не дало бы реального выигрыша в скорости, но увеличило бы проблемы с нагрузкой на порядок. Имея опыт написания kernel кода под GPGPU, подгрузил OpenCL библиотеку и накидал такой же алгоритм поиска, который использовался и на процессоре (не самый лучший вариант решения для видеокарт), с некоторыми правками для адаптации под особенности kernel program видеокарт.
Для сравнения на intel i7 4790 с разрешением экрана 1920×1080 процессорный поиск тратил 0–12 мс в худшем случае (самый дальний угол экрана), то на Intel HD 4600 0–2 мс стабильно. Однако расплачиваться приходится более долгим процессом создания самого скриншота, тк требуется загрузить матрицу скриншота экрана в память видеокарты, что занимает время. В то же время это компенсируется тем, что можно искать много разных картинок на одном и том же скриншоте, что в конечном итоге даёт выигрыш в производительности перед процессорным поиском.
Thread safety
Особенно важно сделать возможность использовать потоки и искать какие либо фрагменты независимо друг от друга, так что буферы, объекты были сделаны локальными, чтобы при написании скрипта не вылезали баги.
Кроссплатформенность
Скрипты работают одинаково на любой платформе, исключение лишь текст, который принтится в консоль, на разных операционных системах разные кодировки, так что это отдельная проблема. JVM позволяет запускать бота на любой платформе без необходимости установки. «Взял и запустил».
Итоговый результат
Количество методов ядра, которые могут быть вызваны из скрипта насчитывает более 60 штук и их число постоянно растет.
Данный инструмент удобен для тестирования ПО и графических интерфейсов. Выручает в тех случаях, когда надо что-то автоматизировать, но при этом нет желания или достаточных знаний ковырять бинарники (Хорошо подходит для эникеев).
Реальные примеры использования
По понятным причинам, не привожу названий или исходного кода.
- Торговый бот для аукциона онлайн ММО игры, бот в режиме реального времени анализирует цифры стоимости товаров и возможную прибыль от перепродажи, затем при помощи overlay слоёв пишет прямо на экране пользователя цифры возможной прибыли.
- Пассивный макрос для одиночной игры, улучшает постройки автоматически, пассивно наблюдая за наличием кнопок апгрейда и перехватывая управление на мгновение, кликая по нужным кнопкам за очень короткое время.
- При получении нового сообщения в скайп — открывает программу и окно нужного диалога.
- Офисный подсчет проделанной работы по фамилиям работников, данные визуально берутся из интерфейса устаревшей программы, которая не имеет API или лёгкого доступа в БД.
- Офисный подсчет товаров из 1C, эникей не умеет работать с API и базой напрямую, использовал этого бота.
Обзор GUI бота
Пишем простой скрипт для разбора
# -*- coding: utf-8 -*-
from bot.penguee import MatrixPosition, Action
from java.awt.event import InputEvent, KeyEvent
a = Action()
p1 = MatrixPosition(630, 230)
p2 = MatrixPosition(1230, 780)
while True:
a.grab(p1, p2)
a.searchRect(630, 230, 1230, 780) #общее окно
if a.find("verstak.gui"):
a.searchRect(760, 320, 960, 500) #Верстак
emptyCells = a.findAllPos("cell_empty")
a.searchRect(700, 520, 1220, 770) # Инвентарь
if a.findClick("coal.item"):
coalRecentPos = a.recentPos()
print(coalRecentPos.name)
for i in range(len(emptyCells)):
a.mouseClick(emptyCells[i], InputEvent.BUTTON3_MASK)
a.sleep(50)
a.mouseClick(coalRecentPos)
a.searchRect(630, 230, 1230, 780) #общее окно
result = a.findPos("verstak.arrow").relative(70, 0)
a.keyPress(KeyEvent.VK_SHIFT)
a.sleep(100)
a.mouseClick(result)
a.sleep(100)
a.keyRelease(KeyEvent.VK_SHIFT)
elif a.find("pech.gui"):
if a.find("pech.off"):
a.searchRect(700, 520, 1220, 770) # Инвентарь
if a.findClick("coal.block"):
coalBlockRecentPos = a.recentPos()
a.searchRect(630, 230, 1230, 780) #общее окно
a.mouseClick(a.findPos("pech.off"), InputEvent.BUTTON3_MASK)
a.mouseClick(coalBlockRecentPos)
result = a.findPos("verstak.arrow").relative(70, 0)
a.keyPress(KeyEvent.VK_SHIFT)
a.sleep(100)
a.mouseClick(result)
a.sleep(100)
a.keyRelease(KeyEvent.VK_SHIFT)
if a.find("pech.empty"):
a.searchRect(700, 520, 1220, 770) # Инвентарь
if a.findClick("gold.ore"):
a.searchRect(630, 230, 1230, 780) #общее окно
a.mouseClick(a.findPos("pech.empty"))
a.sleep(6000)
Ссылка на Github
Ссылка на API