No description
Find a file
2026-10-07 11:32:59 +01:00
ocado_grocy Prepare public release with MIT license and private user storage 2026-10-07 11:32:59 +01:00
tests Prepare public release with MIT license and private user storage 2026-10-07 11:32:59 +01:00
.gitignore Initial commit. 2026-09-19 11:32:53 +01:00
config.example.toml Prepare public release with MIT license and private user storage 2026-10-07 11:32:59 +01:00
LICENSE Prepare public release with MIT license and private user storage 2026-10-07 11:32:59 +01:00
pyproject.toml Prepare public release with MIT license and private user storage 2026-10-07 11:32:59 +01:00
README.md Prepare public release with MIT license and private user storage 2026-10-07 11:32:59 +01:00

Ocado → Grocy

Import new delivered Ocado orders using a visible Python Playwright browser. Completed orders are skipped by default. Login cookies persist between runs. After importing, the command adds missing Ocado pictures and runs conservative Open Food Facts enrichment for products in the processed orders. All products are included by default, including fresh, chilled and frozen items. Use --shelf-stable-only to restrict an import.

Install

Python 3.11+ is required.

python3 -m venv .venv
.venv/bin/pip install -e '.[test]'
.venv/bin/python -m playwright install chromium

Create a private user configuration, then set your Grocy URL, API key and Open Food Facts contact:

install -d -m 700 "${XDG_CONFIG_HOME:-$HOME/.config}/ocado-grocy"
install -m 600 config.example.toml "${XDG_CONFIG_HOME:-$HOME/.config}/ocado-grocy/config.toml"

Run the install command only for a new configuration: it replaces an existing file. GROCY_URL and GROCY_API_KEY override the config for the main importer. Ocado sign-in happens in the browser; no Ocado password is stored in the TOML file.

Default private locations (the corresponding XDG_CONFIG_HOME, XDG_STATE_HOME and XDG_DATA_HOME variables are supported):

Contents Location
Configuration and Grocy API key ~/.config/ocado-grocy/config.toml
Import journal ~/.local/state/ocado-grocy/imports.sqlite3
Browser profile and login cookies ~/.local/state/ocado-grocy/browser/
Receipts, images, OFF responses and reports ~/.local/share/ocado-grocy/history/

Below, history/ means the configured archive directory. The journal prevents duplicate stock imports: keep it together with the receipts when moving installations.

Existing checkouts containing ./config.toml keep the old local paths until migrated. To migrate, stop the importer and browser, move the configuration, journal, .ocado-browser/ and history/ to the locations above, and update [import].state_file to the journal's absolute path (or remove it to use the new default). Do not overwrite an existing destination. All three commands use the same journal resolver. Explicit custom configs retain a journal next to that config unless state_file overrides it; relative overrides are resolved against the config directory.

Run

# Preview new delivered orders, write no stock
.venv/bin/ocado-grocy --dry-run

# Import all products from orders not previously completed
.venv/bin/ocado-grocy

# Import one complete order, including perishables
.venv/bin/ocado-grocy --order-id 1000000000001 --all-products

# Sign in and save the browser session without importing
.venv/bin/ocado-grocy --login-only

# Limit history by date or number of orders
.venv/bin/ocado-grocy --since 2026-01-01 --limit 10 --dry-run

# Reuse downloaded receipt JSON without opening a browser
.venv/bin/ocado-grocy --cached

# Fetch current details again, including updates to already imported products
.venv/bin/ocado-grocy --order-id 1000000000001 --refresh

Images and Open Food Facts enrichment are enabled by default. Set [openfoodfacts].contact in config.toml for Open Food Facts requests. Use --no-images or --no-openfoodfacts to disable either step. --dry-run performs neither enrichment step and writes nothing to Grocy. --cached only skips Ocado browsing; enrichment can still use the network.

When an order is explicitly selected with --order-id, enrichment also runs for its already-imported lines, so repeating the command retries missing pictures or Open Food Facts work without adding stock again. It only processes live products linked to completed journal entries for those orders. Image and Open Food Facts failures are recorded separately; one does not prevent the other from running. An enrichment failure exits with an error after preserving the completed stock import. The next normal run retries only failed products before checking for new orders, even when all orders are already loaded. Existing error reports are recovered into the retry queue once. --no-images and --no-openfoodfacts defer retries for the disabled stage; --dry-run and --login-only do not retry. Retry results are saved in history/enrichment/retries/ and history/enrichment/retry-summary.json. Successful retries leave the queue, while unresolved failures remain queued. Reports are in history/enrichment/ORDER_ID/ (a stable batch identifier is used for multiple orders).

# Import and enrich the latest complete delivered order
.venv/bin/ocado-grocy --limit 1 --all-products

# Retry enrichment for a cached order; imported stock is skipped
.venv/bin/ocado-grocy --cached --order-id 1000000000002 --all-products

# Run only Open Food Facts for one imported order
.venv/bin/ocado-grocy-off --order-id 1000000000002 --apply

python -m ocado_grocy and ocado-grocy-history run the same command. --order-id may be repeated. --help lists options. There is no standalone HTML-file importer: acquisition always uses Playwright, or normalized JSON cached from a previous browser run.

Authentication and downloads

Playwright launches Chromium with headless=False. Complete sign-in and any CAPTCHA in that window. The private browser profile saves cookies and local storage for reuse. Its auth.json is an additional cookie/storage backup. The application does not store your password. Both the profile and the receipt archive are excluded from Git.

--profile, --archive, --config, and --login-timeout customize locations and login waiting time. Explicit relative profile/archive paths are relative to the working directory; the defaults are user-specific locations outside the checkout. Do not run two browser processes with the same profile.

The command checks the newest orders first and stops loading history once a batch contains a completed order. On the first run, it continues through Ocado's explicit end-of-list marker. Previously discovered incomplete orders remain eligible for retry, and completed orders are filtered out before loading receipts. Explicit --order-id selections continue searching until all requested orders are found or the list ends. --limit counts new orders after this filtering. --order-id explicitly selects an old order for retry, refreshing, or importing previously excluded items; --refresh alone does not reselect completed orders. It reads each order's authenticated /api/order/v6/orders/ID/decorated JSON response as the browser loads it. It does not parse prices or quantities from rendered HTML or use the live trolley. Accepted substitutions are included; unavailable originals and rejected replacements are excluded. Discontinued products work without a product-page link.

Delivered item quantities and paid line prices must reconcile with the API order summary before importing. Delivery, bag charges and order-level credits are not stock items. Exact expiry dates and delivery dates come from the API, so historical orders do not depend on relative UI labels such as “Expired” or “Tuesday”.

Normalized receipts are saved to history/ORDER_ID.json, excluding account/payment details. Successful downloads are reused unless --refresh is specified. history/orders.json retains all previously discovered orders and merges in each new batch, including when you use --limit or --order-id. Failed orders are listed in history/download-errors.json and retried on the next browser run.

Shelf-stable selection

All products are imported by default. With --shelf-stable-only, no maintained list of pantry products or product-name heuristics is needed. Selection uses Ocado's storageType and categoryPath:

  • Refrigerated/frozen products and fresh food/bakery categories are excluded.
  • Shelf-stable food/drink and household/personal-care categories are included.
  • Missing or ambiguous categories are left for review. CUPBOARD alone is insufficient: Ocado also uses it for bananas, onions and potatoes.

Every decision is recorded in history/report.json. Catalogue classifications are imperfect, so the filter is conservative. If needed, optional config overrides can resolve a particular product; they are not required to run the importer:

[pantry.overrides]
"123456011" = true
"987654011" = false

--all-products is the default and bypasses the optional shelf-stable filter for a complete order. Existing imported stock is not removed when a classification changes.

Grocy dates, units and product details

The order's delivery date is stored as Grocy's purchased date. Grocy's technical record creation timestamp remains the actual import time. Expiry dates are retained even when in the past; missing dates use 2999-12-31 with an “Expiry unknown” note. The backfill does not infer which items you have consumed. Remove used-up stock through Grocy and reruns will not restore it.

New products use one Pack per purchased retail unit: two 1.25 L drinks add two packs; one five-banana pack adds one pack. Descriptions retain brand, categories, storage, pack information, promotions, and source/image links where available. Ocado image URLs are retained in metadata. Use the image command below to copy them into Grocy product pictures. Notes stay short: Ocado YYYY-MM-DD

New products use the configurable Ocado imports location. Existing products retain their locations and units. Matching uses explicit mappings, imported Ocado product IDs, exact barcodes when available, then names ignoring case/repeated whitespace. It does not guess that different names represent the same product. To map an Ocado product to existing Grocy stock units:

[products."72496011"]
product_id = 123
stock_per_purchase = 3

Without an override, existing Grocy purchase-to-stock conversions are used. Paid prices are divided by stock quantity as required by the Grocy API. Tare-weight and parent-only products are rejected. Configure Grocy's currency as GBP.

Previously imported products are refreshed by default, including fresh/chilled products omitted from the shelf-stable backfill. Refreshing descriptions does not add stock or change notes. Existing enriched fields are kept when new metadata lacks a replacement. The newest available order is considered first for a product appearing in several orders. --no-refresh-existing disables this.

Duplicate protection and recovery

The journal also tracks completion per order and Grocy server in loaded_orders. Order completion tracks the stock import separately from enrichment failures. After the stock import and enrichment attempt, the order is complete; failed picture or Open Food Facts work stays in a persistent, per-product retry queue. Interrupted stock imports remain eligible for processing; individual completed stock lines still prevent duplicate bookings. Orders with no eligible stock lines are also tracked. Uncertain Open Food Facts candidates are a successful review result, not a processing failure. Dry runs never mark orders loaded.

Existing journal data is migrated conservatively: an old order counts as loaded only when its cached receipt reconciles with completed journal entries for all selected shelf-stable lines, with no uncertain writes. Downloaded receipts alone do not count as loaded. This preserves earlier shelf-stable backfills without importing their excluded perishables; use --order-id ID when you want those too. A cached check with no new orders exits without Grocy access when there are no pending enrichment retries.

Keep imports.sqlite3 and the cached receipts. Journal paths are relative to the config. The same journal supports single-order and history imports. Completed lines are skipped, including stock you subsequently consumed or deleted in Grocy. Changing/deleting the journal removes that protection.

API rows are ordered deterministically. Previously imported rows are matched by their stored fingerprints and restored to their original journal positions, including imports made by the earlier HTML importer. Actual changes in product identity, quantities, prices or dates require review rather than automatic reimport.

A pending journal entry is committed before each stock write and marked done after a successful response. A timeout might occur after Grocy accepts stock, so pending entries are not retried automatically. Check Grocy for the purchase-date note and match the product to its journal row, then reconcile that specific journal row:

SELECT * FROM imports WHERE order_id = 'ORDER';

-- Only if the stock booking succeeded:
UPDATE imports SET status = 'done'
WHERE server = 'https://grocy.example.com/api' AND order_id = 'ORDER' AND line = 1;

-- OR, only if the stock booking definitely did not occur:
DELETE FROM imports
WHERE server = 'https://grocy.example.com/api' AND order_id = 'ORDER' AND line = 1;

Use one importer at a time. Grocy cannot transact an entire receipt atomically; earlier completed writes remain after a later failure. Reports are saved even when an import fails.

Verification

.venv/bin/python -m pytest -q

Tests use sanitized real API data and a fake Grocy API. They cover delivered orders, substitutions, discontinued products, dates, summary reconciliation, category filtering, conversions, metadata refresh, stable line identity and interrupted imports. Tests do not mutate live stock.

Open Food Facts enrichment

.venv/bin/pip install -e '.[test]'
.venv/bin/ocado-grocy-off                  # preview, no Grocy writes
.venv/bin/ocado-grocy-off --apply          # apply clear matches
.venv/bin/ocado-grocy-off --offline        # repeat a preview from cached data
.venv/bin/ocado-grocy-off --refresh-cache  # download fresh search responses

Set [openfoodfacts].contact in config.toml (or pass --contact) for the identifying User-Agent required by Open Food Facts. Requests are spaced at least 6.2 seconds apart. Run one enrichment process at a time.

The script reads the import journal for this Grocy server and searches by brand and product-name words for each eligible imported product using the official Search-a-licious API. Normal imports search only products from the orders being processed; queued failed enrichments are retried separately. Each product search reads at most 100 candidates, without paginating through whole brand catalogs. Truncated results require review and cannot produce automatic matches. --limit applies before searches, and non-food products do not trigger searches. Recognized household and personal-care categories are skipped. Missing data, different pack sizes, name/variant differences and multiple matching barcodes require review. Automatic matching requires the same brand, meaningful name words and metric pack size, including multipack structure, plus a valid GTIN check digit. Previously established Open Food Facts barcode matches are retained on reruns, including manually reviewed matches. Current product details are fetched again and checked before applying a search match.

Writes add a barcode to Grocy's barcode table and an openfoodfacts section to the product description. Available nutrition, ingredient/allergen information, labels, packaging, serving size and image/source links are retained with attribution. Nutrition values keep their source units and basis; they are not copied into Grocy's calories-per-stock-unit field. Product titles, stock, purchase dates and stock notes are never edited. Deleted stock is never recreated. Product descriptions and barcodes are snapshotted before applying, and reruns are idempotent. Future Ocado metadata refreshes preserve the enrichment even after Grocy sanitizes the description HTML.

Private caches, snapshots and reports live in history/openfoodfacts/. report.json contains the preview and up to ten ranked candidates per product; applied-report.json records writes and failures. Search results are only candidates: Open Food Facts is crowdsourced, and matching by name cannot prove which physical barcode was on a previously purchased pack. Ambiguous matches are left unchanged. Check the current package for authoritative allergen information.

For a manually verified match, create a JSON mapping of Grocy product ID to barcode string (retain leading zeroes), then run:

.venv/bin/ocado-grocy-off --mapping history/openfoodfacts/reviewed.json
.venv/bin/ocado-grocy-off --mapping history/openfoodfacts/reviewed.json --apply

Mappings explicitly override name/size matching, so verify the variant and retail pack first. Existing barcode conflicts are reported instead of reassigned. Errors can leave an earlier barcode addition completed; rerunning reconciles it without duplicating the barcode. Open Food Facts data carries ODbL/database-content attribution, and image links carry CC BY-SA attribution.

Product pictures

Copy the saved Ocado image URLs into native Grocy product pictures:

.venv/bin/ocado-grocy-images          # preview
.venv/bin/ocado-grocy-images --apply  # upload missing pictures

This covers all existing journal-linked imported products, including non-food products and products whose stock has been used up. It keeps existing pictures and skips deleted products. The main import command runs this step automatically; the standalone command can also be rerun at any time, optionally with --order-id ORDER_ID. Downloads use a separate session so Grocy credentials never go to Ocado. Each uploaded file is read back and verified before assigning it to the product, using the Grocy files API. Only the picture field is changed; titles, descriptions and stock remain untouched. Results are saved in history/images/report.json. Failed updates can be retried safely.

License

The project code is available under the MIT License. Dependencies, Ocado content and downloaded Open Food Facts data and images retain their respective licences; the MIT licence does not relicense that material.