Reads a bank statement, works out who paid what, and enters the records into a web panel for you.
Note on language: the source code, the UI and the inline comments are in Turkish. The problem is Turkish too — Turkish name matching, Turkish number formats, Turkish government panels. This README is in English so the approach and the architecture can be read by anyone.
Anyone who has to type a hundred-odd bank transfers into a web panel by hand every month: a school, an association, a building management office, a small accounting practice.
The case this was built for: a preschool entering parents' monthly fees into a government panel. 80–130 records a month, 9–10 clicks each. About two full working days.
The hard part was never the clicking. It was understanding the statement. Parents write whatever they like in the transfer description:
Gönd: SEHER ÖZTÜRKMEN Bade Naz ÖZTÜRKMEN (Mayıs Klüp Ödemesi) 0015-...FAST işlemi
Gönd: GAMZE KARTAL Mayıs ayı beren artal 0067-Yapı ve Kredi
Tuna Efe TAŞDELEN, mayıs ayı kulüb ücreti NERMİN TAŞDELEN Ziraat Mobil Havale
The child's name is sometimes first, sometimes last, sometimes absent entirely —
only the mother's name is there. A surname is missing its first letter
(beren artal → Beren KARTAL). The word for "club" is spelled four different
ways. Four separate families share a surname and must not be confused with one
another. Mixed in are payments that are not fees at all — donations, canteen
income, rent, interest — which must never be entered.
Three stages, each meaningful on its own:
| Stage | What it does | Source |
|---|---|---|
| Read | Parses the statement: finds the header row itself, understands Turkish number format (-22.936,29), discards the bank's letterhead and footnotes |
src/ekstre.py |
| Match | Extracts a person from free text, fuzzy-matches against a roster, and rules every row certain / uncertain / none | src/eslestir.py |
| Enter | Drives the target panel — everything panel-specific comes from a recipe file | src/panel.py · src/tarifler/ |
An uncertain match is never entered on its own. It is handed back to the user.
The panels this targets are mostly legacy ASP.NET WebForms. Ids like
ctl00$ContentPlaceHolder1$txtEvrakNo shift between releases and the automation
dies silently. Aktar locates fields by the label text on screen instead — the
input next to the cell that reads "Evrak No :":
xpath=//*[self::td or self::label or self::span or self::div]
[not(*)][string-length(normalize-space(.)) < 40]
[contains(normalize-space(.), 'Evrak No')]
/following::input[1]The [not(*)] predicate was added after a real failure and is the load-bearing
part: without it, a <div> wrapping the entire page also contains the text
"Evrak No", matches, and yields the wrong input. Requiring a leaf node fixes it;
string-length < 40 guards the same problem from a second angle.
A related trap: the screen shows both "Toplam Tutar" (total) and, on the detail row, "Tutar" (amount). Searching for "Tutar" matched both. Each recipe therefore carries a per-field exclusion list.
A Turkish-specific normalisation layer on top of rapidfuzz: folding
ğ/ü/ş/ı/ö/ç, stripping month names and banking noise (gönd, fast işlemi,
havale, kulüp, aidat), then token-based scoring.
The decision uses three thresholds:
ESIK_KESIN = 88 # above this, accept automatically
ESIK_SUPHE = 72 # above this, ask the user; below, no match
FARK_ESIGI = 6 # if the top two candidates are closer than this: uncertainThe last one matters most. Even at a score of 91, if the runner-up sits at 88 the program refuses to decide — because when two families share a surname, a high score does not mean correct. Precision is worth more than coverage here: a wrongly attributed payment is far more expensive than one the user has to confirm by hand.
Real cases it handles: TUNCAL ↔ TUNÇEL (vowel error), beren artal ↔ Beren KARTAL (dropped initial), two siblings under one parent (→ uncertain, user
picks), a name that matches on a negative amount (→ uncertain, the money left
the account).
A known limitation: month names are treated as noise and stripped. When a child in the test data was named "Nisan" (April), the name was stripped and the match fell through to uncertain. Turkish given names that are also month names — Nisan, Şubat — hit this filter.
If the panel errors on record 47 of 100, losing the remaining 53 is not
acceptable. Every record runs in its own try; the error is caught, the form is
reset, the loop moves to the next row. At the end the user gets a report of which
rows failed and why.
Detecting the failure turned out to be subtler than expected. The panel's red warning text arrives in mixed case. Matching against lowercase patterns meant "Zorunlu Alanlar Boş Bırakılamaz" was never seen — and the program counted the record as saved. Silent data loss, the worst kind of bug in this class of tool.
Each record is fingerprinted from (date, document no, national id, cents) and
stored. If the same statement is processed again, those rows are marked as
already entered and skipped.
Aktar never stores, requests or transmits a credential. It opens the browser, the user signs in themselves, and the engine simply waits for the session to be ready. It then enters the user's own data, under the user's own authority, in the user's own session.
The program's own launch password is not written to disk either — only a PBKDF2-SHA256 digest (200,000 rounds) is kept. There is no licence server and no network access is required.
Rather than downloading Playwright's bundled Chromium, it attaches to the Chrome already installed on the machine, falling back to Edge. It does so by launching a normal Chrome and connecting over CDP, so the user's own profile, session and extensions apply — and the distributed package stays portable.
Files exported from banks, or round-tripped through Google Sheets, LibreOffice or older Excel, often carry fill and font definitions openpyxl doesn't recognise. The file simply won't open:
TypeError: expected <class 'openpyxl.styles.fills.Fill'>
The data is intact — only the cell's appearance is malformed.
src/excel_ac.py retries on a temporary copy in which only
the visual style blocks (fills, fonts, borders) are replaced with defaults.
The subtle part: number formats (numFmts) and the cell-style mapping (cellXfs)
are never stripped. In Excel a date is a number; what makes it a date is its
number format. Clearing that block too would have made every date read back as
45900 — the file would open, the program would run, and the output would be
quietly wrong. Block counts are preserved as well, so fillId / fontId
references inside cellXfs stay valid.
The test suite verifies the repair: row counts, dates still being real date objects, amounts and national ids are all compared one by one.
panel.py is a generic driver. Everything specific to a target — addresses,
field labels, button captions, screen flow — lives in a recipe.
src/
├── panel.py generic engine: find-by-label, waits, error isolation, the loop
├── tarifler/
│ ├── temel.py the contract a recipe fulfils
│ └── tefbis.py worked example: a Turkish MoNE revenue-entry panel
├── ekstre.py statement reader
├── eslestir.py matcher
├── excel_ac.py Excel recovery
└── arayuz.py desktop UI — reads its options from the recipe
A minimal recipe:
from panel import tr_tutar
from tarifler.temel import Tarif
class MyPanel(Tarif):
ad = "Example Panel"
giris_adresleri = {"main": "https://example.gov.tr/"}
etiketler = {"evrak_no": ("Document No", "Document No :"),
"tutar": ("Amount", "Amount :")}
haric_metinler = {"tutar": ("Total",)}
butonlar = {"kaydet": ("Save",)}
def kayit_gir(self, motor, kayit):
motor.zorunlu_alan("evrak_no").fill(str(kayit["evrak_no"]))
motor.zorunlu_alan("tutar").fill(tr_tutar(kayit["tutar"]))
motor.dugme("kaydet").click()Drop it in src/tarifler/ and add it to the TARIFLER list. The desktop UI
reads its dropdowns, mode buttons and login addresses from the recipe — adding a
panel means touching no UI code at all.
The full worked example is src/tarifler/tefbis.py
(354 lines), which drives the revenue-entry screen of TEFBİS, a Turkish Ministry
of National Education system. It is included as a reference implementation, not
as the product.
Vocabulary, since the code is Turkish: tarif = recipe, panel = panel,
ekstre = bank statement, eslestir = match, kayit = record, motor =
engine, tutar = amount, dugme = button, alan = field.
Windows, ready to run: grab the zip from
Releases, extract the whole
folder and run Aktar.exe. No Python required. Chrome or Edge must be installed.
From source:
git clone https://github.com/ardazeybek-dev/aktar.git
cd aktar
pip install -r requirements.txt
python -m playwright install chromium # only if neither Chrome nor Edge is present
python aktar.pyPython 3.10+. Runtime dependencies: openpyxl, rapidfuzz, playwright.
To build the executable yourself: pip install -r requirements-dev.txt then
python paket_yap.py (add tek for a single-file build).
Core logic — no browser, no display, so these run on every OS and Python version in CI:
python testler/testler.py # 57 tests — reading, matching, duplicate guard
python testler/dayaniklilik_testi.py # 39 tests — malformed input, varied bank layouts
python testler/bozuk_excel_testi.py # 17 tests — corrupted Excel recoveryThe engine, driven by a real browser against the mock panel in
demo/sahte_tefbis/ — which carries the same labels and the same
popup-and-return flow as the real thing. This is what proves the parts the
unit tests cannot: that a field is found from its on-screen label, that
Tutar is filled rather than Toplam Tutar, that the run comes back to the
main page after the selection popup, and that an unknown record is skipped
without stopping the loop.
python testler/panel_prova.py # 14 checks — headless, no real panel touchedThe desktop UI, opened for real. Three of the checks in the end-to-end run read
the rendered colour of a button off the screen rather than trusting
cget('bg') — which lies for a Radiobutton with indicatoron=False, and once
hid a selected button that was actually rendering white. Those three need Pillow
and a session with a screen; without either they skip and the other 42 still run,
so CI reports 42:
python testler/arayuz_akis_testi.py # 16 checks — button states, two-stage flow
python testler/uctan_uca_test.py # 45 checks — password → template → match → confirm → reportOne more runs locally but is deliberately kept out of CI, because it opens a visible Chrome and attaches to it over CDP — the thing it verifies is that the browser looks like an ordinary one, which a headless runner cannot honestly measure:
python testler/tarayici_testi.py # 12 checks — CDP attach, no automation bannerTo try the whole pipeline end to end without touching any panel:
python demo/test_verisi_uret.py # generates a fictional statement + roster
python demo/demo_calistir.py # reads, matches, writes an HTML reportSample result: 41 transactions → 20 ready to enter, 3 awaiting confirmation, 18 correctly rejected (bank fees, tax, payroll, donation, rent). No mismatches.
- Every name, national id and bank transaction in this repository is
fictional. No real personal data is present. The test data is generated by
demo/test_verisi_uret.py; the national ids satisfy the Turkish MERNIS checksum but belong to no one. - Aktar sends nothing anywhere. Neither what it reads nor what it writes leaves the machine. There is no telemetry and no licence check.
- It speeds up a user entering their own data, in their own session, under their own authority. It does not obtain access to any system the user is not already entitled to use.
- Before using it against an institutional panel, checking that organisation's terms of use is the user's responsibility.
MIT — Seyid Arda Zeybek
