Desktop application for parsing and comparing drug prices from tabletka.by pharmacies. Exports results to a formatted Excel workbook.
- Parse prices from many pharmacies at once, concurrently
- Compare every price against a reference pharmacy, with colour-coded differences
- Multi-profile support — save different pharmacy sets and switch between them
- Export to
.xlsx, or to.xlsmwith VBA sort/filter buttons — both on any platform - A GUI built with CustomTkinter, and a headless CLI running the same pipeline
- Python 3.14+
uv
The package installs and runs on Linux, macOS and Windows, macro buttons included:
the .xlsm is assembled in Python, so no Excel install is involved.
git clone https://github.com/Roman-Andr/pharmparser.git
cd pharmparser
uv syncThe
.xlsmis built in Python — no Excel needed. Excel still has to trust the macros when you open the file: allow content when prompted, or unblock the file in its file properties. Pass--use-excelto have Excel itself write the workbook over COM instead (Windows only, and needs the Trust Center's "Trust access to the VBA project object model").
cp config.json.example config.jsonThe first pharmacy in a profile is the reference: every other one is compared against
it. Values are validated on load, and a bad one names itself — a malformed colour, a
non-positive width, a pharmacy URL without a numeric id, a title Excel would reject as a
sheet name, or two pharmacies in one profile sharing a display name.
- Open tabletka.by in your browser
- Open DevTools → Network
- Load any pharmacy's price page
- Copy the
Cookierequest header and the_csrfvalue from the request payload - Paste them into
config.json
The cookie must include lim-result=5000 — the app rewrites that value to page through
results. Cookies expire; when scraping starts failing, this is the first thing to refresh.
Credentials can come from the environment (or a .env file) instead, and take precedence
over config.json:
| Variable | Overrides |
|---|---|
PHARMPARSER_COOKIE |
request.headers.Cookie |
PHARMPARSER_CSRF |
request.data._csrf |
PHARMPARSER_FILE_NAME |
settings.fileName |
config.json, .env and the cache files are all git-ignored by name. The ignore rule is
deliberately narrow rather than a blanket *.json, which used to swallow test fixtures
too — so check what you are committing if you add JSON of your own.
uv run pharmparser # GUI (same as: uv run python -m pharmparser)
uv run pharmparser-cli # headlessIn the GUI: pick or create a profile, fill in pharmacy names and URLs, press Parse. The workbook opens when it is ready. Tick Cache to reuse the last scrape for that profile instead of hitting the network — the cache is per profile and entirely opt-in.
The CLI runs the same pipeline:
uv run pharmparser-cli --profile "Основной" --output report.xlsx
uv run pharmparser-cli --macros # also add the VBA buttons (any platform)
uv run pharmparser-cli --cache # reuse this profile's cached prices
uv run pharmparser-cli --helpEvery run also appends to a rotating pharmparser.log beside the config file, which is
the thing to look at — or ask a user for — when something goes wrong.
| Sheet | Contents |
|---|---|
Данные |
One row per item: the reference price, then each competitor's price and the difference in roubles |
Проценты |
The same layout, with differences as a percentage of the reference price |
settings.title |
Assortment sizes, items cheapest everywhere, unique items, and a per-competitor breakdown |
An item is labelled name, pack, manufacturer — the manufacturer is on the end so the
column still reads and sorts by drug name. It is part of the label because the same drug
and pack from two makers sells at two prices, and without it one of them was silently
dropped (about 1 % of rows).
A blank difference cell means the comparison is undefined — one of the two pharmacies does
not stock the item. That is distinct from a difference of 0, which means the prices match.
With --macros the two price sheets also carry buttons: Apply Filters / Remove Filters,
and an up/down pair over each difference column.
The rule that holds the layout together: the domain layer imports nothing from
openpyxl, customtkinter, win32com, or the network.
pharmparser/
├── src/pharmparser/
│ ├── __main__.py # GUI entry point
│ ├── cli.py # headless entry point
│ ├── controller.py # state + use cases, driven by both front ends
│ ├── domain/ # pure model + analysis (no I/O, no frameworks)
│ │ ├── models.py # Pharmacy, PriceTable
│ │ └── analysis.py # comparisons, market summary
│ ├── config/ # pydantic schema, loader, paths, env overrides
│ ├── scraping/ # async client, pure HTML parser, fan-out service
│ ├── export/
│ │ ├── grids.py # pure sheet builders (content + layout)
│ │ ├── xlsx_writer.py # the only module that knows openpyxl
│ │ ├── protocols.py # the Exporter contract
│ │ └── vba/ # macro buttons: VBA compiler, .xlsm packer, COM injector
│ ├── cache.py # per-profile scrape cache
│ ├── logging_.py # console + rotating file logging
│ ├── platform_.py # OS capability probes
│ └── ui/ # CustomTkinter windows and widgets
├── packaging/ # PyInstaller entry points
├── tests/ # unit, integration, fixtures
├── docs/REFACTOR_PLAN.md # findings, target architecture, phase outcomes
├── pharmparser.spec # PyInstaller build definition
└── config.json.exampleFour ideas carry the design:
- Grids, not worksheets. A sheet's content and layout are built as data by pure
functions, so the report is testable without Excel.
xlsx_writeris the only module that touches openpyxl. - VBA is an optional post-step. openpyxl always writes a valid
.xlsx; the macro buttons are injected afterwards by driving Excel over COM.pythoncomandwin32comare imported lazily, so the package stays importable — and testable — everywhere. - A controller between the front ends and the use cases. Both the GUI and the CLI
drive the same
Controller, so they cannot drift apart, and it runs headless. - Validated config. Explicit models with actionable errors, atomic saves, and the
on-disk format preserved exactly, so existing
config.jsonfiles keep working.
See docs/REFACTOR_PLAN.md for the findings behind all of this
and what each phase changed.
Tagged versions are published to GitHub Releases
with a Windows and a Linux binary of both the GUI and the CLI, plus a SHA256SUMS
file and a build provenance attestation.
Version numbers are not chosen by hand. Every push to main updates a standing
Release PR carrying the next version and the changelog earned since the last
release, worked out from the commit prefixes (fix: bumps the patch, feat: the
minor — see CONTRIBUTING).
Releasing is merging that PR. That tags the version, writes CHANGELOG.md, builds
the binaries and attaches them.
So: nothing to remember, and nothing published until you merge. Merges to main
that are not the Release PR publish nothing — CI uploads binaries as workflow
artifacts for testing instead. Releases are what the auto-updater follows, so they
stay deliberate.
workflow_dispatch on the Release workflow rebuilds and re-attaches assets for an
existing tag, if a build ever needs redoing.
The packaged app checks for a newer release shortly after it starts and offers it;
it never replaces itself without being asked. The download is verified against the
published SHA256SUMS before anything is run, and any release URL outside GitHub is
refused. Running from a source checkout skips the check entirely.
The headless binary can be asked directly, and installs nothing:
pharmparser-cli --check-updateThe binaries are not code-signed, so Windows SmartScreen warns on first run. The checksum protects a download from corruption or tampering in transit; the trust root is still GitHub account security, not a certificate. Signing needs a paid certificate.
"the session looks expired" — the cookies in config.json have a limited life,
and this is the failure you will hit most often. Refresh the Cookie header and the
_csrf value from DevTools (Network tab, any request the prices page makes) and run
again. The app fails fast on this rather than retrying, so it takes about a second
to find out.
"check the pharmacy URL" — the endpoint answered 404 for that pharmacy id. The
URL in the profile must end with the numeric id, as in
https://tabletka.by/pharmacies/3563.
The report opened without the sort and filter buttons — the workbook was written
as .xlsx rather than .xlsm. Pass --macros on the CLI, and check the log for a
line about the VBA project.
Excel warns about macros on first open — expected. The workbook carries a VBA project, and Excel blocks macros in files downloaded from the internet until you allow the content, or unblock the file in its properties.
"Parse workers unavailable" — parsing fell back to a single process, so the run
is slower but correct. It happens when the app is embedded in a script with no
if __name__ == "__main__": guard, because worker processes re-import the entry
module.
See CONTRIBUTING.md. The short version:
uv sync --all-groups # runtime + dev + build dependencies
uv run ruff check . # lint
uv run mypy # type check
uv run pytest # tests
uv run pre-commit installThe GUI smoke tests need a display and skip without one. On a headless machine:
xvfb-run -a uv run pytestuv run pyinstaller pharmparser.specProduces dist/pharmparser (GUI, no console) and dist/pharmparser-cli (headless), both
single-file. PyInstaller 6.15 is the floor: earlier releases cap out at Python 3.13 and
their bootloader aborts with Failed to allocate PyConfig structure on a 3.14 build.
| Layer | Library |
|---|---|
| GUI | CustomTkinter |
| HTTP | aiohttp |
| Parsing | beautifulsoup4, lxml |
| Config | pydantic, pydantic-settings |
| Export | openpyxl, ms-ovba (VBA project), pywin32 (optional Windows COM) |
| Packaging | pyinstaller |
{ "profiles": { "Profile 1": { "Аптека 1": "https://tabletka.by/pharmacies/****" // pharmacy page URL } }, "settings": { "green": "19CF1F", // fill for a competitor price above the reference "red": "E81737", // fill for a competitor price below the reference "title": "Анализ", // names the summary sheet and the workbook "fileName": "data.xlsx", "colWidth": 50, // width of the item-name column "cellWidth": 15, // width of a price column "diffWidth": 10 // width of a "Разница" column }, "request": { "url": "https://tabletka.by/ajax-request/reload-pharmacy-price", "headers": { "Cookie": "PHPSESSID=...; _csrf=...; regionId=...; lim-result=5000" }, "data": { "sort": "name", "sort_type": "asc", "str": "", "_csrf": "..." } } }