Skip to content

ivorpad/mercadona-cli

Repository files navigation

mercadona

Unofficial, agent-friendly CLI for tienda.mercadona.es — search the catalog, read prices, build a cart, and check out. Single static Go binary, no runtime deps, structured --json output for programmatic/agent use.

Unofficial. Mercadona has no public API. Bring your own credentials; use at a sane request rate. This talks to the same HTTP endpoints the website does.

Install

npm — downloads the prebuilt binary for your platform on install:

npm install -g @ivorpad/mercadona      # puts `mercadona` on your PATH
npx @ivorpad/mercadona search queso    # …or run without installing

curl (macOS / Linux):

curl -fsSL https://raw.githubusercontent.com/ivorpad/mercadona-cli/main/install.sh | sh

Override with MERCADONA_VERSION=v0.1.0 (pin a tag) or MERCADONA_INSTALL_DIR=/path (install location; defaults to /usr/local/bin, else ~/.local/bin).

Manual — download a tarball for your OS/arch from the releases page, extract, and put mercadona on your PATH.

From source (Go 1.26+) — clone, then:

go build -o mercadona ./cmd/mercadona

(go install <module>@latest isn't wired up yet: the module path is github.com/ivorjpc/mercadona, which doesn't match the repo URL.)

Commands

Read core (anonymous — no login)

mercadona search queso                      # full-text product search
mercadona search --limit 5 --json mayonesa  # structured output for agents
mercadona batch -f lista.txt                 # many terms in ONE request (≈100 items / call)
printf 'queso\ncarne\nmayonesa\n' | mercadona batch -f -
mercadona product 13406                      # detail, price, nutrition (when available)
mercadona categories                         # category tree
mercadona categories --id 112 --json         # one category's products (raw JSON)

Common flags: --wh mad1 (warehouse), --lang es, --json — and they can go anywhere after the (sub)command, not just up front. Data goes to stdout, logs/errors to stderr, exit code 1 on error — friendly to scripts and agents.

Location (warehouse) — set it from your postal code

mercadona set-postal 28022   # → resolves to warehouse mad1, saves it as the default

Product ids and prices are per-warehouse, and online checkout needs the cart's warehouse to match your delivery address — so pin it to the warehouse that serves your postal code (no login needed). Precedence: --wh flag > config.toml [defaults] > built-in mad1. import-har also auto-detects and saves the warehouse from your session. (Within a city it varies: 28022 → mad1, 28013 → mad3.)

Example:

$ mercadona batch -f lista.txt
• queso            → [51110] Queso rallado mozzarella pizza-Roma Hacendado — 1.60€ (8.000€/kg)
• carne            → [34157] Carne de pimiento choricero Hacendado — 1.55€ (11.072€/kg)
• mayonesa         → [13406] Mayonesa Hacendado — 1.20€ (2.400€/L)

Authenticated: import-har (preferred) / import-curl / login, whoami, cart, checkout

The API authenticates with a Bearer token (a SimpleJWT). The first sign-in must happen in a browser (password login needs a Google reCAPTCHA Enterprise token; Google-account users have no password at all). After that, the refresh token renews the session headlessly, foreverPOST /api/auth/tokens/ {refresh_token} needs no captcha and rotates the token. Verified.

Two login methods, one outcome. However you sign in, the response carries the same durable refresh_token, so the CLI automates identically:

Method Endpoint Request body Response
Email + password POST /api/auth/tokens/ {username, password, recaptcha_token} {access_token, customer_id, refresh_token}
Google sign-in POST /api/auth/social/google/ {id_token, postal_code} {access_token, customer_uuid, refresh_token}

✅ Preferred login method — import-har. One browser login (email or Google), then headless forever. Export a HAR after signing in and let the CLI pull the refresh token out for you:

# DevTools → Network → ⤓ "Export HAR…"  (after you've logged in, by either method), then:
mercadona import-har --file tienda.mercadona.es.har
mercadona whoami     # confirms it's authenticated

import-har seeds refresh_token into ~/.mercadona/config.toml (0600) and caches the current access token + cookie. From then on every 401 token_not_valid triggers an automatic refresh + retry — no browser, no captcha, unattended. (It reads only auth responses and Bearer/Cookie headers; the password in the request body is never touched.)

Prefer to do it by hand? Write the token yourself — mercadona set-refresh <token> (or edit ~/.mercadona/config.toml):

[auth]
refresh_token = "<your refresh token>"   # the durable, headless-renewable credential
[defaults]
warehouse = "mad1"        # or: `mercadona set-postal 28022` resolves + writes this for you
postal_code = "28022"

MERCADONA_TOKEN/MERCADONA_COOKIE/MERCADONA_CUSTOMER (and MERCADONA_USER/MERCADONA_PASS) env vars also work for one-off runs.

Quick one-off (no refresh): mercadona import-curl --file s.txt from a DevTools "Copy as cURL" of any …/api/customers/… request extracts the Bearer token + cookie + customer id. It has no refresh token, so it can't auto-renew — re-import when the access token expires.

mercadona login --user … --password … exists but will fail without a recaptcha_token (browser-only), and does nothing for Google accounts; prefer the HAR/refresh-token flow above.

The customer id is read automatically from the token's customer_uuid claim, so you never pass it (the literal me alias is rejected with 403). Token/cookie/ customer can also come from MERCADONA_TOKEN / MERCADONA_COOKIE / MERCADONA_CUSTOMER. Secrets are read from env/files, never taken as flags.

Test the checkout flow with your session

mercadona import-har --file tienda.mercadona.es.har   # auth (preferred; or import-curl)
mercadona whoami                           # → "ok — customer id=…"  (confirms auth)
mercadona cart get --json                  # inspect current cart (names, qty × unit_price, total)
mercadona cart add 51110 2 --max 80        # add 2× a product (capped at 80 €)
printf '51110 2\n13406 1\n' | mercadona cart set-many -f - --max 80   # many '<id> <qty>' in ONE write (0 removes)
mercadona cart clear                       # empty the cart in one write
mercadona checkout create --json           # open a checkout → id + default address
mercadona checkout addresses               # list saved delivery addresses
mercadona checkout slots --address <id>    # delivery slots (they hang off the address, not the checkout)
mercadona checkout get --checkout <id>     # show a checkout: total, address, slot
mercadona checkout set-delivery --checkout <id> --address <id> --slot <id>
mercadona checkout submit --checkout <id> --max 80 --yes   # IRREVERSIBLE — places the order

cart add adds to the existing quantity; cart set sets the absolute quantity (0 removes). For a whole basket, cart set-many -f - applies many <id> <qty> lines in a single write — and prices it first, so --max refuses before writing — while cart clear empties it. All accept --max.

The access token (a SimpleJWT) lasts ~6 weeks; when whoami starts returning 401 token_not_valid, re-import a fresh Copy as cURL (or use login).

Spending guard (agent safety)

When an agent drives the CLI, cap how much it can ever spend. Any cart/checkout over the cap is refused with a non-zero exit and an error: line — so the agent stops instead of running up a huge order. Pass it as a flag (which can go anywhere on the line):

mercadona cart add 10379 99 --max 50                       # → error: BUDGET EXCEEDED … refusing (exit 1)
mercadona checkout submit --checkout <id> --max 80 --yes   # submits only if total ≤ 80 €

Or set it once so every command is capped — MERCADONA_MAX_EUR=100 (env), or in config:

# ~/.mercadona/config.toml
[limits]
max_eur = 100        # refuse any cart/checkout whose total exceeds 100 €

Precedence is flag > env > config; 0/unset = no limit. Enforced on cart add/set/set-many, checkout create, checkout set-delivery, and — critically — checkout submit, which fails closed: with a cap set, if it can't read the order total it refuses rather than spend blind. (With no cap, submit prints a warning.)

Recipes — real examples

The interesting part isn't "an AI does your shopping." It's that one person can now do things that used to need a developer or an analyst: track your own inflation, rank a category by €/kg, catch genuine price drops, build an allergen-safe basket. Every output below is live CLI — and since reads need no login, most are copy-paste.

Price a shopping list written in plain words

You think in names; the cart API thinks in ids. batch bridges them in one request — the top hit per term, with its price:

$ printf 'arroz redondo hacendado\ngambón grande congelado\nmejillón mediterráneo\ntomate triturado hacendado\naceite oliva virgen extra hacendado\n' | mercadona batch -f -
• arroz redondo hacendado  → [5044] Arroz redondo Hacendado — 1.20€ (1.200€/kg)
• gambón grande congelado  → [60393] Gambón grande congelado — 6.00€ (12.000€/kg)
• mejillón mediterráneo    → [85499] Mejillón mediterráneo — 5.80€ (5.800€/kg)
• tomate triturado hacendado → [16044] Tomate triturado Hacendado — 0.55€ (1.375€/kg)
• aceite oliva virgen extra hacendado → [4740] Aceite de oliva virgen extra Hacendado — 4.95€ (4.950€/L)

Put your picks in a basket file — it takes inline # comments, so it reads like the list you started with, not a wall of ids (paella.txt):

# paella base — 3 personas
5044  1    # Arroz redondo Hacendado
60393 1    # Gambón grande congelado
85499 1    # Mejillón mediterráneo
16044 1    # Tomate triturado Hacendado
4740  0.5  # Aceite de oliva virgen extra

Then price it — total sums unit_price × qty in integer cents (exact; fractional quantities work for weight items):

$ mercadona total -f paella.txt
  [5044] Arroz redondo Hacendado — 1 × 1.20€ = 1.20€
  [60393] Gambón grande congelado — 1 × 6.00€ = 6.00€
  [85499] Mejillón mediterráneo — 1 × 5.80€ = 5.80€
  [16044] Tomate triturado Hacendado — 1 × 0.55€ = 0.55€
  [4740] Aceite de oliva virgen extra Hacendado — 0.5 × 4.95€ = 2.48€
  total: 16.03€  (5 líneas)

16.03 € for the basket; a paella base for 3 ≈ 5.34 €/serving. (The same # comment basket feeds cart set-many to fill the cart in one write; add --json for {lines, total, count, complete}.)

Get the fresh item, not the frozen/canned one

A bare term often top-ranks the frozen or canned version. --fresh drops the Congelados + Conservas aisles, so the fresh product surfaces:

$ mercadona search mejillon --limit 1
  [18615] Mejillones de Chile en escabeche Hacendado pequeños — 2.65€   (Conservas, caldos y cremas)
$ mercadona search mejillon --fresh --limit 1
  [85499] Mejillón mediterráneo — 5.80€   (Marisco y pescado)

Sort a whole category by price-per-kilo

reference_price is the unit-normalised price (€/kg, €/L) on every product. Pull a whole category and rank by it to surface the genuine value buys:

$ mercadona categories --id 118 --json   # 118 = Arroz
id product price per kilo
5044 Arroz redondo Hacendado 1.20€ 1.200 €/kg
5063 Arroz largo Hacendado 1.20€ 1.200 €/kg
5020 Arroz vaporizado Hacendado 1.55€ 1.550 €/kg
5042 Arroz redondo J Sendra Hacendado 1.60€ 1.600 €/kg
5184 Arroz integral largo Hacendado 1.65€ 1.650 €/kg

Find products actually on offer (the API flags it)

Each product carries price_decreased + previous_unit_price, so you can catch genuine drops — not marketing. A scan of ~440 staples turned up dozens:

$ mercadona categories --id 112 --json | jq '.. | objects | select(.price_decreased==true)'
id product was now drop
4717 Aceite de oliva virgen extra Hacendado 14.55€ 14.40€ -1%
4706 Aceite de oliva virgen extra Gran Selección 5.95€ 5.75€ -3%
4718 Aceite de oliva virgen extra Hacendado 2.70€ 2.60€ -4%
5063 Arroz largo Hacendado 1.25€ 1.20€ -4%
26029 Garbanzo cocido Hacendado 0.85€ 0.80€ -6%
6305 Pajaritas vegetales Hacendado 1.00€ 0.90€ -10%

Read nutrition, allergens & ingredients per product (diet-safe baskets)

product prints the per-100g nutrition table for the minority of products that carry one (some prepared/processed items have it, but most products — staples and many prepared items alike — return none):

$ mercadona product 17559
[17559] Empanadilla de bacon 11% y queso 32%
  precio: 1.40€  (12.728 kg)
  formato: Pieza
  url: https://tienda.mercadona.es/product/17559/empanadilla-bacon-11-queso-32-pieza
  nutrición (Por 100 g):
    energía: 385 kcal / 1598 kJ
    Grasas: 29 g
      Saturadas: 15 g
    Hidratos de carbono: 21 g
      Azúcares: 2 g
    Proteínas: 9.2 g
    Sal: 1.1 g

For agents, --json carries the structured table at product_information.nutritional_information, next to nutrition_information (allergens + ingredients), brand, ean, origin, and details:

$ mercadona product 17559 --json | jq '{kcal: .product_information.nutritional_information[0].energy_calories.amount, allergens: .nutrition_information.allergens}'
{
  "kcal": "385.0",
  "allergens": "Contiene huevos y productos a base de huevo. Contiene leche y sus derivados..."
}

The numeric table is filled in for only a minority of products, so don't count on it for any given item — most staples (pasta, eggs, plain cheese) and many prepared items return none. By contrast nutrition_information (allergens + ingredients) is there for nearly all.

Discover regional specialties with --wh

Prices are uniform nationwide (see below), but the catalog isn't — each warehouse stocks local products. Mallorca's sobrasada shelf vs Madrid's:

$ mercadona search sobrasada --wh mad1 --json | jq .nbHits   # Madrid:   19
$ mercadona search sobrasada --wh 3842 --json | jq .nbHits   # Baleares: 28

10 sobrasada products are in Baleares but not Madrid, e.g. [20869] Sobrasada de Mallorca Can Pere Joan — 5.25€, [53114] Sobrasada cerdo negro de Mallorca — 14.84€, con miel, picante…

Verify a claim: are prices identical across regions?

Same product id, priced in five warehouses with --wh. To the cent, everywhere — islands included (Mercadona's "Siempre Precios Bajos" is literal):

$ for wh in mad1 bcn1 vlc1 svq1 3842; do mercadona product 5044 --wh $wh --json; done
id product Madrid Barcelona Valencia Sevilla Baleares
5044 Arroz redondo 1.20€ 1.20€ 1.20€ 1.20€ 1.20€ ✓ same
4740 AOVE Hacendado 4.95€ 4.95€ 4.95€ 4.95€ 4.95€ ✓ same
10379 Leche entera 5.76€ 5.76€ 5.76€ 5.76€ 5.76€ ✓ same
60393 Gambón 6.00€ 6.00€ 6.00€ 6.00€ 6.00€ ✓ same
64000 Helado bombón 2.90€ 2.90€ 2.90€ 2.90€ 2.90€ ✓ same

Compose the rest with an agent

The same primitives back richer, agent-driven flows — the Claude skill drives them, always capping spend with --max and never submitting without explicit consent:

  • Personal inflation tracker — cron the total --json recipe on your real basket → CSV → chart your own CPI.
  • Reverse budgeter — "feed 4 for a week on 50 €": batch-price candidates, optimise reference_price under --max.
  • Household cart by chat — "añade leche" in WhatsApp/Slack → cart set-many updates a shared basket through the week.
  • Pantry-photo restock — an agent maps a fridge photo to product namessearchcart set-many. (No barcode lookup — but ean is exposed, so you can build your own scan map.)
  • Smart-home / calendar triggers — Home Assistant "milk low", or "dinner party Saturday for 8" → fills the cart and books a slot.
  • DIY Subscribe-&-Save — a weekly cron rebuilds your staples with cart set-many and preps checkout; you just approve.
  • Voice-first shopping — a complete weekly shop by conversation, no app UI to fight: the clearest case of augmenting, not replacing.

Design / reliability

Three layers, by IP-sensitivity:

  1. Search → Algolia. Not behind Mercadona's Akamai at all; works from any IP. The public app-id rotates (7UZJKL1GNI7UZJKL1DJ0 …), so the CLI never relies on a hardcoded value: it ships a last-known-good fallback and, on a stale-creds signal (DNS failure / 401 / 403 / 404), re-discovers the app-id, key and index from the live SPA bundle, caches them, and retries. Survives rotation automatically.
  2. Catalog reads (/api/...) — Akamai-fronted but served to anonymous GETs at human-paced volume. Sends web-app-like headers to stay in monitor mode.
  3. Auth + cart + checkout — the only IP-sensitive part. Run from a residential IP (local, or a box on your own network — not a flagged datacenter / serverless egress), log in once and cache the token. A real browser is only needed as a fallback to mint Akamai clearance if a challenge ever appears.

Config

State lives in ~/.mercadona/ (override with MERCADONA_CONFIG_DIR):

  • config.toml — user-authored (0600): [auth] refresh_token (+ optional username/password), [defaults] warehouse/lang/postal_code (honoured by every command; set via set-postal), [limits] max_eur.
  • token.json — cached session: access + refresh token + cookie (machine-managed).
  • algolia.json — cached/auto-refreshed search credentials.

Claude skill

This repo bundles a Claude Code skill, mercadona-shop (.claude/skills/mercadona-shop/), that drives this CLI to do the grocery shop: turn a list into priced products, fill the cart, prepare delivery checkout, and place the order only on explicit user consent. Install it where your Claude reads skills (symlink or copy .claude/skills/mercadona-shop into ~/.claude/skills/); it points back at this binary, so build the CLI first.

Status

Read core (search, batch, product, categories) and the full authenticated leg (import-har/import-curl/set-refresh, whoami, cart, checkout) are implemented and verified live: reads, Algolia self-refresh, the uTLS fingerprint, headless token refresh, and a real-session cart getcheckout createset-deliverycheckout get all work, and the order total the spending guard reads is confirmed against a live checkout. checkout submit is gated behind both --yes and the --max budget cap; it has not been run end-to-end (no real order has been placed).

Releasing

Push a semver tag — GitHub Actions (.github/workflows/release.yml) cross-compiles with GoReleaser, publishes a GitHub Release (per-OS/arch archives + checksums.txt), then publishes the npm wrapper that downloads from it.

git tag v0.1.0 && git push origin v0.1.0

The workflow is hardened: actions are pinned to commit SHAs, permissions are per-job least-privilege, and npm publishes via OIDC Trusted Publishing (no long-lived token) with a SLSA provenance attestation. One-time setup: configure a Trusted Publisher for @ivorpad/mercadona on npmjs.com (Settings → Trusted Publisher → GitHub Actions: user ivorpad, repo mercadona-cli, workflow release.yml).

Dry-run the build locally (no publish; artifacts land in ./dist):

goreleaser release --snapshot --clean --skip=publish

Homebrew is prewired but disabled — to turn on brew install ivorpad/tap/mercadona, follow the commented brews: block in .goreleaser.yaml (needs a homebrew-tap repo + a HOMEBREW_TAP_GITHUB_TOKEN secret).

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors