goldapple-mcp
by SZhukovWork
README.md
# goldapple-mcp
**English** · [Русский](README.ru.md)
An [MCP](https://modelcontextprotocol.io) server that gives LLM agents live,
honestly-labelled data from [Gold Apple](https://goldapple.ru) (Золотое
яблоко), the Russian beauty retailer: search with real pages, sorting and price
windows; category listings; product cards with every size/shade and both of the
site's prices; line ratings and reviews; delivery options to your city.
> Buyer side, anonymous: searches the public storefront, no Gold Apple account
> needed. Nothing here can order, pay or touch a cart.
## Why
Gold Apple's storefront shows a price that most visitors will not get without
signing in, and its API changes the meaning of its price fields between
endpoints. A naive scraper returns wrong numbers without noticing. This server
is built around not doing that:
| Pitfall of the site | What this server does |
|---|---|
| The JSON API rejects plain HTTP clients (GroupIB/F.A.C.C.T. anti-bot); copied cookies work only briefly, and one unsigned request gets the whole session rejected (HTTP 403) | Keeps one headless Chromium on goldapple.ru and sends every call as an `XMLHttpRequest` from inside the page, which the site's own anti-bot SDK signs. Never `fetch`, never self-made signatures. A 403 triggers one page reload and one new request, then a clear error |
| The big price on the product page is the **signed-in** price ("со скидкой при авторизации"); search tiles show it as "от N ₽" with no caption at all | Two labelled prices everywhere: `price_guest_rub` and `price_signed_in_rub` with its kind (`discount` promo or `loyalty` club price) |
| `actual`/`old` change meaning: in tiles `actual` is the signed-in price; on the card with `bestLoyalty` `actual` is the guest price and `old` is the **lower** loyalty price | Prices are built only from the unambiguous `regular`/`discount`/`loyalty`; `page_shows` reproduces the page (big / crossed-out / caption), checked against rendered pages |
| "от N ₽" on a tile is the price of the listed size or shade, not the cheapest one | `listed_size`/`listed_shade`, `price_is_for_listed_variant`; `get_product` lists every variant with its prices |
| Price sorting and the price filter work on the guest price | The window is documented as a guest-price window; `below_price_window` marks signed-in prices under it |
| Search reports at most 2000 results and serves 84 pages | `total_found_capped` with a note to slice by price windows |
| A category's price filter has its own id; a wrong id is **silently ignored** | The id is taken from the site's own filter list; if most guest prices fall outside the window the answer carries a `warning` |
| Some queries ("помада") are redirected to a category page; an empty search shows unrelated recommendations | Redirects are followed and labelled `redirected_to_category`; recommendations are never returned as results |
| Rating and star split cover the whole product line (all sizes/shades); a product without reviews gets rating `0` | `scope: "line"`, the variant's own review count next to it; no reviews → `value: null` with a note |
| Per-store stock answers 403 even to the site's own "Наличие в магазинах" panel | Not requested (a 403 can block the session); `store_stock` says so. `in_stock` is the online-order flag |
| The site starts in Moscow; the SEO title says "цена в Москве" whatever the city | The city is sent explicitly with every request and checked where the site echoes it; page HTML/SEO is never used |
| Delivery depends on an address zone | City-centre zones are used only for the city this IP is in; otherwise the answer says no zone was set |
| Promo codes ("ЧЕК"), bonus points, "бьютисы", "Долями" instalments | Reported as conditions/perks, never subtracted from a price |
| 400 and 403 come with the same nginx error page | Classified by status only: 400 is never retried |
Every answer carries `fetched_at` (UTC) and the `city` it was computed for, with
where the city came from and how it was confirmed.
## Tools
| Tool | What it returns |
|---|---|
| `search_products(query, page, sort, price_min, price_max, city, limit)` | 24 items per page; sort `popular` / `price_asc` / `price_desc` / `rating` / `newest`; `total_found`, `total_found_capped`, `has_more`, `price_range_guest_rub`. Per item: id, URL, brand, name, type, listed size/shade and variant counts, guest and signed-in price with kind and percent, `below_price_window`, line rating and review count, `in_stock`, labels, promo code |
| `browse_category(category, page, sort, price_min, price_max, city, limit)` | A category page (URL, path or id) with the same fields; up to 24 items per page, not capped at 2000 |
| `get_product(product, city, include_delivery, include_description)` | Every variant with both prices, `page_shows`, stock, bonuses, "Долями" payment; line rating with star split and the variant's review count; delivery options, dates and costs; characteristics; brand country, country of origin, EAEU documents, manufacturer (INN/OGRN when present); category; optional description, usage and ingredients |
| `get_reviews(product, page, sort, scope, limit)` | 20 per page (the site serves the first 400); `scope` `variant` or `line`; sort `helpful` / `newest` / `best` / `worst`. Date, stars, size/shade bought, pros, cons, comment, photo count, likes; line rating on page 1. Authors are never returned |
| `compare_products(products, city)` | Up to 10 items side by side: variant, both prices, stock, line rating, cheapest delivery |
| `list_cities()` | The 22 cities of the site's city picker (name and FIAS ids) |
`product` is an item id (the number at the start of a product URL) or the URL;
the id of a size or shade selects that variant.
## Requirements
Measured on 2026-09-25 (Linux, Playwright 1.63, Chromium 153 headless shell):
| | |
|---|---|
| Python | ≥ 3.10, with [uv](https://docs.astral.sh/uv/) (or pip) |
| Browser | Playwright's Chromium **headless shell**, downloaded automatically on first run: 116 MiB download, 266 MB on disk in `~/.cache/ms-playwright` (shared with other Playwright tools). It runs for as long as the server is used and is closed after 10 minutes without calls |
| Memory | 0.75–0.84 GB PSS for the whole process tree (Python + Playwright driver + Chromium) while the browser is open; ≈60 MB after it is closed. With `GOLDAPPLE_LEAN=0`: 1.16–1.35 GB |
| Start | The first call of a session opens the browser and passes the anti-bot check: 2.7–4.2 s (11 starts measured; a saved profile barely helps). The very first run also downloads Chromium (≈30 s here) |
| Speed | The site answers in 0.07–1.3 s (once 3.3 s); the server spaces calls 2 s apart. `search_products` ≈2.4 s (≈4.6 s with a new price window), `get_product` ≈5–6 s, `get_reviews` ≈2.3 s, `compare_products` ≈6 s per item |
| Display | Not needed (headless). Docker is not needed |
| System libraries | Present on desktop Linux, macOS, Windows. On a minimal Debian/Ubuntu server, once as root: `uvx --from playwright playwright install --with-deps --only-shell chromium` |
| Network | A Russian IP without VPN: the anti-bot rejects many foreign, VPN and datacenter addresses. Or set `GOLDAPPLE_PROXY` |
| Tested on | Linux (CachyOS; Playwright uses its Ubuntu build there). macOS and Windows are supported by Playwright but untested |
## Install
Claude Code:
```bash
claude mcp add goldapple -- uvx --from git+https://github.com/SZhukovWork/goldapple-mcp goldapple-mcp
```
Any MCP client (`claude_desktop_config.json`, `.mcp.json`, …):
```json
{
"mcpServers": {
"goldapple": {
"command": "uvx",
"args": ["--from", "git+https://github.com/SZhukovWork/goldapple-mcp", "goldapple-mcp"]
}
}
}
```
From a checkout: `uv venv && uv pip install -e . && .venv/bin/goldapple-mcp`.
## Configuration (environment variables)
| Variable | Default | Meaning |
|---|---|---|
| `GOLDAPPLE_CITY` | `Екатеринбург` | Default city: a name from `list_cities` (e.g. `Екатеринбург`, case-insensitive, `е` = `ё`) or its FIAS id. `search_products`, `browse_category`, `get_product` and `compare_products` also take `city` |
| `GOLDAPPLE_MIN_INTERVAL` | `2.0` | Seconds between requests to the site (minimum 0.5). Keep it: the anti-bot scores bursts |
| `GOLDAPPLE_PROXY` | — | Proxy for the browser, e.g. `http://user:pass@host:3128` |
| `GOLDAPPLE_CACHE_DIR` | `~/.cache/goldapple-mcp` | Browser profile (anti-bot cookies — treat it as a secret; directory 0700, files 0600), city list, IP location |
| `GOLDAPPLE_LEAN` | `1` | `0` loads images, fonts and third-party scripts too (≈0.5 GB more memory; not needed for the check) |
| `GOLDAPPLE_HEADLESS` | `1` | `0` shows the browser window (debugging; downloads full Chromium if missing) |
| `GOLDAPPLE_IDLE_TIMEOUT` | `600` | Seconds without calls before the browser is closed |
## What the numbers mean
- **`price_guest_rub`** — the price without signing in.
- **`price_signed_in_rub`** — the price the site displays big with the caption
"…при авторизации": it applies after signing in to a Gold Apple account.
`signed_in_price_kind`: `discount` (a promo, "со скидкой –N% при
авторизации") or `loyalty` (the Лаймовый клуб price, "со скидкой при
авторизации"). Whether every account gets exactly this price (club levels)
was not verified. Absent when there is no such price.
- **`page_shows`** — the product page as a person sees it: `big_rub`,
`crossed_rub`, `caption`.
- **`bonuses`** (bonus points, "бьютисы"), **`dolyame_4_payments_rub`** (one of
four "Долями" payments of the guest price) and **`promocode`** are perks and
conditions, not discounts.
- **Search tiles**: the price is for `listed_size`/`listed_shade`
(`price_is_for_listed_variant`); other variants can be cheaper or dearer.
- **`rating_line` / `rating.value`** — the rating of the whole product line
(all sizes/shades), as on the site; `this_variant_reviews` /
`variant_reviews` — how many of the reviews are about this variant. No
reviews → `null`, never 0.
- **`in_stock`** — the site's online-order flag; stock in a particular store is
not available.
- **Delivery** — options, dates and costs to the city for this one item;
`cost_is_minimum` = the site says "от"; `free_if` is a condition on the
order total. `city.delivery_zones` says whether a delivery zone was used.
- **`city.confirmed_by`** — whether the site echoed the city (delivery answers
do) or the city was only sent (search, cards do not echo it).
## Limitations
- Unofficial: relies on the storefront's internal API, which Gold Apple can
change at any time. Parsers are isolated in `parse.py` and covered by tests
on recorded answers.
- The anti-bot can start rejecting the headless browser; the server then
reports "blocked" instead of guessing. Waiting helps after bursts; foreign,
VPN and datacenter IPs are rejected outright.
- Per-store stock ("Наличие в магазинах"), personal prices and club levels,
the final price with a promo code, price history, the seller (partner goods
cannot be told apart), barcodes and Q&A are not available.
- Only the 22 cities of the site's city picker are supported; delivery zones
(express, on-click delivery) only for the city this computer's IP is in.
- Prices were identical in Yekaterinburg and Moscow on the products checked;
that prices never depend on the city is not proven.
- Search is capped by the site at 2000 results; reviews at the first 400.
## Roadmap
- Optional account mode (your personal prices and club level) and cart —
later.
- Checkout, payment and address changes are out of scope.
## Development
```bash
uv venv && uv pip install -e '.[dev]'
.venv/bin/pytest # offline tests on recorded answers
.venv/bin/pytest -m live # end-to-end over MCP stdio against live goldapple.ru (~25 requests)
```
## Disclaimer
Not affiliated with Gold Apple. The server uses the storefront's internal,
undocumented API; the site's operators state that they block external requests
to it. Intended for personal price research at a human rate of requests —
respect Gold Apple's terms of use.
License: MIT.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues