Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
35fec0b
Merge pull request #1 from apparser-development/master
lexter0705 Jun 4, 2026
2f12ae5
1) Added scanners and detectors to text_readers module
lexter0705 Jun 5, 2026
94bbd48
1) Broken links have been fixed
lexter0705 Jun 5, 2026
4c7806c
1) Version updated to 1.1.0
lexter0705 Jun 5, 2026
3d9e1f0
1) Added CompoundReader class to apparser.text_readers
lexter0705 Jun 5, 2026
a3b2360
1) Added CraftDetector class to apparser.text_readers.detectors
lexter0705 Jun 6, 2026
dc1fd29
1) Added EasyOcrDetector class to apparser.text_readers.detectors module
lexter0705 Jun 8, 2026
490c38e
1) Updated README.md
lexter0705 Jun 8, 2026
f8999d6
1) Fixed circular import
lexter0705 Jun 8, 2026
656ffd4
1) Updated documentation about page
lexter0705 Jun 8, 2026
4771528
1) The App class constructor has been updated: start_command is now u…
lexter0705 Jun 9, 2026
a3a5876
1) Added tests to EasyOcrDetector, CompoundReader, BaseTextDetector, …
lexter0705 Jun 9, 2026
d18bad0
1) Fixed new class documentation
lexter0705 Jun 11, 2026
3bcd849
1) Fixed App class windows detection
lexter0705 Jun 12, 2026
18927b7
1) Fixed docstrings and documentation
lexter0705 Jun 12, 2026
8a89191
1) DesktopUi now takes monitor offset into account
lexter0705 Jun 12, 2026
3a5f110
1) Added example.gif
lexter0705 Jun 12, 2026
09a5cb1
1) Refactored README.md
lexter0705 Jun 12, 2026
a8a433e
1) Refactored README.md
lexter0705 Jun 12, 2026
e83fd95
1) Refactored README.md and about page in documentation
lexter0705 Jun 12, 2026
ef18058
1) Comments have been added to the code example in README.md
lexter0705 Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 62 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,88 @@
<img src="https://raw.githubusercontent.com/apparser-development/apparser/refs/heads/master/apparser.svg" alt="" width="40%">

[![License - BSD 3-Clause](https://img.shields.io/pypi/l/apparser.svg)](https://github.com/apparser-development/apparser/blob/master/LICENSE.md) [![unit_tests](https://github.com/apparser-development/apparser/actions/workflows/unit_tests.yml/badge.svg)](https://github.com/apparser-development/apparser/actions/workflows/unit_tests.yml)
<br>
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/apparser?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/apparser)
[![Documentation](https://img.shields.io/badge/docs-pages-green)](https://apparser-development.github.io/apparser/)
[![unit_tests](https://github.com/apparser-development/appwindows/actions/workflows/unit_tests.yml/badge.svg)](https://github.com/apparser-development/appwindows/actions/workflows/unit_tests.yml)
<br>
[![Github](https://img.shields.io/badge/github-repo-green)](https://github.com/apparser-development/apparser)
[![PyPI](https://img.shields.io/badge/PyPI-link-green)](https://pypi.org/project/apparser/)
[![GitHub](https://img.shields.io/badge/github-repo-green)](https://github.com/apparser-development/apparser)
[![Issues](https://img.shields.io/badge/github-issues-green)](https://github.com/apparser-development/apparser/issues)

# Apparser
Apparser is a Python library designed for automating desktop applications and managing UI interfaces using artificial intelligence, such as OCR or object detection models.
# Install
Apparser is a Python library for automating desktop applications and interacting with UIs using AI-powered tools such as OCR and object detection models.

# Installation
```bash
# Base Apparser package
pip install apparser

# Apparser with text recognition support
pip install "apparser[ocr]"

# Apparser with text-to-speech support
pip install "apparser[speak]"

# Apparser with object detection support
pip install "apparser[cv]"

# Apparser with all optional features
pip install "apparser[all]"
```

# Examples

1) Open terminal and write "Hello World!"
1) Open CS2 and start a game
#### Code
```python
from apparser import App
from apparser.geometry import RelativelyPoint
from apparser.instructions import Algorithm, MouseClickTo, WriteText, Sleep
from apparser.instructions import OCRAlgorithm
from apparser.instructions.ocr import WaitText, ClickOnText
from apparser.text_readers import ScreensController, RapidOcrReader

# Text labels that the OCR algorithm will look for on the screen.
play_button = "play"
deathmatch_button = "deathmatch"
group_button = "hostage group"
start_button = "go"

algorithm = Algorithm([
Sleep(1), # Wait for the application to open.
MouseClickTo(RelativelyPoint(0.5, 0.5)), # Click to window center for start writing
WriteText("Hello World") # Write text
])
# Create OCR-based algorithm.
algorithm = OCRAlgorithm([
# Wait for the main menu and open the play screen.
WaitText(play_button),
ClickOnText(play_button),
# Select the deathmatch mode.
WaitText(deathmatch_button),
ClickOnText(deathmatch_button),
# Select the hostage group and start the match.
WaitText(group_button),
ClickOnText(group_button),
ClickOnText(start_button, min_similarity=0.5),
], text_reader=ScreensController(RapidOcrReader()))

app = App("notepad", window_title="Notepad")
# Launch CS2
app = App(['cmd', '/c', 'start', 'steam://rungameid/730'], timeout=20)

# Run the prepared scenario against the application UI.
algorithm.perform(app.ui)
```
#### Video

<img src="./example.gif" alt="" width="100%"/>

# Docs
All documentation <a href="https://apparser-development.github.io/apparser/">here</a> <br>
Link to <a href="https://pypi.org/project/apparser/">PyPi</a>
Full documentation is available <a href="https://apparser-development.github.io/apparser/">here</a> <br>
Package page on <a href="https://pypi.org/project/apparser/">PyPI</a>

# Donation
If you'd like to financially support the developers for their work:

<a href="https://dalink.to/apparser">Donation link</a>

# For Developers
1) If something doesn't work - open issue.
2) If you want something fixed - open issue.
3) If you can help with the library - email.
1) If something doesn't work, open an issue.
2) If you want something fixed, open an issue.
3) If you can help with the library, email us.

apparser.development@gmail.com

Any help in development is welcome)!
Contributions are welcome!
22 changes: 12 additions & 10 deletions apparser/core/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
import subprocess
import time

Expand All @@ -11,21 +10,24 @@
class App:
"""Manage an application process and its UI wrapper."""

def __init__(self, path_to_exe: str,
def __init__(self, start_command: str | list[str],
window_title: str | None = None,
timeout: float = 1):
"""Initialize an application controller.

:param path_to_exe: Path to the executable file.
:type path_to_exe: str
:param start_command: App start command.
:type start_command: str
:param window_title: Title of the window to attach to.
:type window_title: str
:param timeout: Delay before the window lookup starts.
:type timeout: float
:raises TypeError: If any argument has an invalid type.
"""
if not isinstance(path_to_exe, str):
raise TypeError('path_to_exe must be a string')
if isinstance(start_command, str):
start_command = [start_command]

if not isinstance(start_command, list):
raise TypeError('start_command must be a string or list[str]')

if window_title is not None and not isinstance(window_title, str):
raise TypeError('window_title must be a string')
Expand All @@ -35,7 +37,7 @@ def __init__(self, path_to_exe: str,

self.__window_finder = get_finder()
self.__process: subprocess.Popen | None = None
self.__path = path_to_exe
self.__start_command = start_command
self.__timeout = timeout
self.__window_title_name: str = window_title
self.__ui: BaseUi | None = None
Expand Down Expand Up @@ -65,9 +67,9 @@ def start_app(self):
if self.__ui is not None:
return
window_processes = [i.get_process_id() for i in get_finder().get_all_windows()]
self.__process = subprocess.Popen([self.__path])
self.__process = subprocess.Popen(self.__start_command)
time.sleep(self.__timeout)
self.__find_window_by_process_id(os.getpid())
self.__find_window_by_process_id(self.__process.pid)
for i in get_finder().get_all_windows():
if self.__ui is not None:
return
Expand All @@ -76,7 +78,7 @@ def start_app(self):
if self.__ui is not None:
return
self.__find_window_by_title()
if self.__ui is not None:
if self.__ui is None:
raise WindowDoesNotValidException()

def stop_app(self):
Expand Down
3 changes: 1 addition & 2 deletions apparser/core/ui/coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

import numpy
from appwindows import Window
from appwindows.geometry import Size

from apparser.core.ui.base import BaseUi
from apparser.geometry import Point, RelativelyPoint
from apparser.geometry import Point, RelativelyPoint, Size


class CoordinatesUi(BaseUi):
Expand Down
4 changes: 2 additions & 2 deletions apparser/core/ui/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ def _(self, coordinates: Point):
@point_to_global.register(RelativelyPoint)
def _(self, coordinates: RelativelyPoint):
monitor = get_monitors()[self.__display_id]
x = round(coordinates.x * monitor.width)
y = round(coordinates.y * monitor.height)
x = getattr(monitor, "x", 0) + round(coordinates.x * monitor.width)
y = getattr(monitor, "y", 0) + round(coordinates.y * monitor.height)
local_point = Point(x, y)
return self.point_to_global(local_point)

Expand Down
3 changes: 1 addition & 2 deletions apparser/core/ui/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
import numpy

from appwindows import Window
from appwindows.geometry import Point, Size

from apparser.geometry import Point, Size, RelativelyPoint
from apparser.core.ui.base import BaseUi
from apparser.geometry.relatively_point import RelativelyPoint


class WindowUi(BaseUi):
Expand Down
3 changes: 1 addition & 2 deletions apparser/core/ui/window_by_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
from PIL import ImageGrab

from appwindows import Window
from appwindows.geometry import Point, Size

from apparser.geometry import Point, Size, RelativelyPoint
from apparser.core.ui.base import BaseUi
from apparser.geometry.relatively_point import RelativelyPoint


class WindowByDisplayUi(BaseUi):
Expand Down
8 changes: 5 additions & 3 deletions apparser/cv/readers/yolo.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ def read(self, ui: BaseUi) -> CvAllData:
x1, y1, x2, y2 = box.xyxy[0].tolist()
x = int(x1)
y = int(y1)
width = int(x2 - x1)
height = int(y2 - y1)
box_ui = CoordinatesUi(ui, Point(x, y), Size(width, height))
x2 = int(x2)
y2 = int(y2)
width = x2 - x1
height = y2 - y1
box_ui = CoordinatesUi(ui, Point(x, y), Point(x2, y2))
boxes.append(
CvBox(
class_name=cls_name,
Expand Down
2 changes: 1 addition & 1 deletion apparser/cv/utils/changes_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def _is_resized(box: CvBox, old_box: CvBox) -> bool:
:return: True if width and height both changed.
:rtype: bool
"""
return abs(box.width - old_box.width) > 0 and abs(box.height - old_box.height) > 0
return abs(box.width - old_box.width) > 0 or abs(box.height - old_box.height) > 0


class ChangesChecker:
Expand Down
9 changes: 9 additions & 0 deletions apparser/exceptions/timeout.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
class TimeoutException(Exception):
"""Represent a timeout during a waiting operation."""

def __init__(self, wait_time: float | int | None = None):
"""Initialize a timeout exception.

:param wait_time: Time waited before the timeout occurred.
:type wait_time: float | int | None
:raises TypeError: If ``wait_time`` has an invalid type.
:raises ValueError: If ``wait_time`` is negative.
"""
if wait_time is None:
super().__init__("Timeout error")
return
Expand Down
3 changes: 2 additions & 1 deletion apparser/geometry/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from appwindows.geometry import Point, Size
from appwindows.geometry import Point, Size, QuadPoints

from apparser.geometry.relatively_point import RelativelyPoint
from apparser.geometry.distance import distance

__all__ = ["Point",
"Size",
"RelativelyPoint",
"QuadPoints",
"distance"]
11 changes: 7 additions & 4 deletions apparser/instructions/default/press.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import pyautogui

from apparser.key_codes import BaseKeyCode

from apparser.instructions.base import BaseInstruction
from apparser.key_codes import BaseKeyCode


class PressKey(BaseInstruction):
Expand Down Expand Up @@ -38,15 +37,19 @@ def __init__(self, keys: list[BaseKeyCode | str]):
:type keys: list[BaseKeyCode | str]
"""
self.__keys = keys
self.__validate()

def __validate(self):
for key in self.__keys:
if not (isinstance(key, BaseKeyCode) or isinstance(key, str)):
raise TypeError('key_code must be BaseKeyCode or str')

@property
def id(self) -> int:
return 3

def perform(self, *args, **kwargs):
for key in self.__keys:
if not (isinstance(key, BaseKeyCode) or isinstance(key, str)):
raise TypeError('key_code must be BaseKeyCode or str')
pyautogui.keyDown(str(key))

for key in self.__keys:
Expand Down
5 changes: 2 additions & 3 deletions apparser/instructions/ocr/move_to_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from apparser.core import BaseUi
from apparser.exceptions import TextNotFoundException
from apparser.geometry import Point, RelativelyPoint

from apparser.text_readers import BaseTextReader, TextData

from apparser.instructions.ocr.base import OCRInstruction
Expand Down Expand Up @@ -59,8 +58,8 @@ def perform(self, ui: BaseUi, text_reader: BaseTextReader, *args, **kwargs):
needed_data, rating = self.find_text(self.__text_getter.local_answer)
if self.__min_similarity > rating:
raise TextNotFoundException(self.__min_similarity)
y_cords = list(set([i.y for i in needed_data.coordinates]))
x_cords = list(set([i.x for i in needed_data.coordinates]))
y_cords = [needed_data.coordinates.right_top.y, needed_data.coordinates.right_bottom.y]
x_cords = [needed_data.coordinates.left_top.x, needed_data.coordinates.right_top.x]
offset_point = self.__get_local_offset(ui)
x_center = round((x_cords[0] - x_cords[1]) / 2 + x_cords[1]) + offset_point.x
y_center = round((y_cords[0] - y_cords[1]) / 2 + y_cords[1]) + offset_point.y
Expand Down
10 changes: 5 additions & 5 deletions apparser/instructions/ocr/plot_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ def draw(self, bboxes: list[TextData]):
self.__paint_lines(data)

def __paint_lines(self, data: TextData):
shape = [(data.coordinates[0].x, data.coordinates[0].y), (data.coordinates[2].x, data.coordinates[2].y)]
shape = [(data.coordinates.left_top.x, data.coordinates.left_top.y), (data.coordinates.right_bottom.x, data.coordinates.right_bottom.y)]
self.__draw.rectangle(shape, outline=self.__color, width=1)

def __paint_cords(self, data: TextData):
y = data.coordinates[0].y + self.__text_move.y
y = data.coordinates.left_top.y + self.__text_move.y
if y < 0:
y = data.coordinates[2].y - self.__text_move.y
x = data.coordinates[0].x + self.__text_move.x
y = data.coordinates.right_bottom.y - self.__text_move.y
x = data.coordinates.left_top.x + self.__text_move.x
if y < 0:
x = data.coordinates[2].x - self.__text_move.x
x = data.coordinates.right_bottom.x - self.__text_move.x
self.__draw.text((x, y), data.text, fill=self.__color)


Expand Down
5 changes: 1 addition & 4 deletions apparser/instructions/ocr/print_all_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,4 @@ def id(self) -> int:
def perform(self, ui: BaseUi, text_reader: BaseTextReader, *args, **kwargs):
self.__text_getter.perform(ui, text_reader)
for i in self.__text_getter.local_answer:
points_stroke = ""
for j in i.coordinates:
points_stroke += str(j) + " "
print(f'text: "{i.text}", coordinates: {points_stroke}')
print(f'text: "{i.text}", coordinates: {str(i.coordinates)}')
Loading
Loading