Главная » Игры » [Из песочницы] Как я писал графического бота и во что это превратилось

[Из песочницы] Как я писал графического бота и во что это превратилось

В этой статье разберем опыт написания инструмента, который позволяет прилагая минимум усилий и времени автоматизировать большой спектр рутинных задач.

Предисловие

Понадобилось мне сделать бота для выполнения нескольких задач, требовательных к логике и скорости реакции. Лезть в API и ковырять бинарники программ не хотелось. Было решено пойти путём визуальной автоматизации. Нашел несколько ботов, но ни один из них так и не подошел под мои требования, оказавшись или слишком медленным, или скриптовая часть была сильно урезана или был недостаточный функционал для работы с визуальной составляющей. Так как у меня был успешный опыт использования визуального бота в прошлом (хоть и медленного и сильно урезанного в скриптовой части) – решил сделать свою реализацию.

Требуемый в начале функционал

Были необходимы следующие возможности:

  • Cимуляция мыши, передвижение курсора, нажатие кнопок.
  • Cимуляция нажатий клавиш клавиатуры.
  • Возможность искать на экране заранее заготовленный кусочек картинки, например иконку или букву, а если нашло – пусть сделает что угодно с этой информацией.
  • Скриптовый интерпретатор, чтобы можно было просто описывать алгоритм действий и не требовалось компилировать раз за разом.

Существующие аналоги

Рассмотрим наиболее функциональные:
AutoIt — использует Basic для скриптинга. Есть целый ряд аналогов, но каждый из них имеет как свои плюсы, так и минусы.

Кулибины на форумах сделали возможность искать картинки на экране, к сожалению эта функция не отвечала моим требованиям ввиду излишних упрощений и куче ограничений.
Работает лишь под Windows, требует установки.

Требует компилирования для использования на других машинах, нет возможности быстро поправить скрипт, если не установлен AutoIt.

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

Имеет несколько портов под Linux/Unix системы, требует установки

Из-за упрощений – урезан функционал, например тех же http запросов. Clickermann — использует свой язык для написания скриптов, который еще надо изучать.

Отсутствует поиск картинок на экране, хотя и есть примитивный поиск пикселей.

Так же нашлось много макросов, большинство из которых работало по принципу «повторяй за мной» запоминая и повторяя действия пользователя, что в свою очередь не позволяет создать что либо с разветвленным алгоритмом действий с кучей if и while.

А у оставшихся банально не было функции поиска какой либо иконки на экране.

Аналоги на Хабре

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

Выбор технологий для собственного велосипеда

К тому же был знаком с классом Robot, который позволяет в удобной форме симулировать управление мышью и клавиатурой. С самого начала было решено использовать Java SE для написания самого ядра, это в свою очередь экономило время, так как Java использую наиболее часто.

Для Java есть реализация Jython, исполняется на JVM и не требует установки. При добавлении скриптового интерпретатора – выбран был язык Python как довольно простой и популярный. К тому же позволят работать с классами и объектами 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 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, файл с фрагментом BGR. После активного поиска ответов в поисковиках – стало ясно, что причиной всему ARGB/RGBA/RGB формат, в котором хранятся данные BufferedImage.

image

Меньшее приводим к формату большего, что заняло довольно много времени, но в конечном итоге заработало, паттерны начали успешно находиться на скриншоте гораздо быстрее, почти в два раза быстрее!
Пришлось приводить все к одному формату, а именно формату скриншота 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, популярен, лёгок в освоении, хорошо задокументирован, имеет множество готовых библиотек, а главное – скрипт легко редактировать в любом текстовом редакторе!

Запускается на JVM и не требует ничего дополнительного для начала работы, позволяет использовать буквально все классы, библиотеки Java, а так же .jar паки! Для Java есть встраиваемая реализация Python, называется Jython. В свою очередь это лишь укрепило уверенность в правильности выбора.

Подключаем 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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Be a security ninja: начни свой путь к вершинам ИБ

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

Аномалия Франго, Развязка

Заключительная часть моего романа «Аномалия Франгō». Предыдущие части можно прочитать тут: Начало, Завязка, Кульминация. ASCII анимация на КДПВ, которую я сделал пару недель назад, раскрывает аспекты жизни некоторых когорт пиратов. ГЛАВА 6. Сумка безопасности биорайдера Пилот Связного-412 Угольник едва мог ...