Django project that builds a local supermarket product inventory, starting with Albert Heijn, and uses it for promotion-aware, goal-conditioned meal planning.
The project currently focuses on:
- storing AH products in MySQL
- keeping the workflow admin-first via
/admin - importing AH inventory through verified mobile/API endpoints instead of brittle website scraping
- preserving source-language product and nutrition data where the API exposes it
Implemented today:
- lean Django project with one app:
catalog - MySQL + phpMyAdmin via Docker Compose
- Django admin for supermarkets, crawl sources, products, nutrition, and snapshots
- LLM-based product quality enrichment for shelf life, spoilage, and sensor-target signals
- local nutrition search UI at
/backed by the imported catalog - shopping-list price UI at
/shopping-list/ - recipe planner UI at
/recipe-planner/ - streaming recipe generation on
/recipe-planner/stream/ - direct BMI/BMR/TDEE calculation plus LLM-based goal explanation
- automatic startup sync that dedupes AH products, refreshes inventory, then backfills nutrition
- AH seed data
- AH API importer using:
- anonymous token auth
- product search
- product detail
- GraphQL nutrition fetch
- bonus metadata/sections
- query-partition import command for expanding inventory coverage
Not fully solved yet:
- exact full-assortment completeness for AH
- exact ingredients / RI% / declaration note for every product from API data
- support for other supermarkets
The original browser/page-scraping route hit Akamai challenge pages on AH product URLs.
The verified AH mobile API path works much better:
POST /mobile-auth/v1/auth/token/anonymousGET /mobile-services/product/search/v2GET /mobile-services/product/detail/v4/fir/{id}POST /graphqlfor nutrition rowsGET /mobile-services/bonuspage/v3/metadata
This project therefore uses the API as the primary import path.
- Python 3.12
- Django 6
- MySQL 8.4
- phpMyAdmin
- PyMySQL
- requests
- Playwright
- BeautifulSoup
- manage.py: Django entrypoint
- config/settings.py: lean project settings
- catalog/models.py: core schema
- catalog/admin.py: admin registrations and actions
- catalog/services/ah_api.py: current AH API importer
- catalog/views.py: product search API + nutrition calculator view
- catalog/services/nutrition.py: unit conversion and nutrition aggregation
- catalog/services/pricing.py: price, bonus, and basket-cost calculations
- catalog/services/recipe_planner.py: saved-plan context building and recipe-generation flow
- catalog/services/health.py: BMI, BMR, and TDEE estimation
- catalog/services/llm.py: vLLM client
- catalog/services/ah.py: older browser/page-fetch path kept as reference/fallback
- docker-compose.yml: MySQL + phpMyAdmin
Main tables:
catalog_datasource: registry of where stored data came from, with licence and trust orderingcatalog_supermarket: supermarket registrycatalog_productidentifier: per-source identifiers for a product, used for cross-source matchingcatalog_openfoodfactsproduct: staged OpenFoodFacts records, keyed by barcode and not yet linked to productscatalog_crawlsource: AH bonus/catalog source registrycatalog_importrun: bookkeeping for inventory import runscatalog_product: all products across supermarketscatalog_nutritionfacts: one nutrition summary row per productcatalog_nutritionentry: row-level nutrition values in source languagecatalog_productsnapshot: raw product snapshot + price over timecatalog_productqualityprofile: source-tracked shelf-life, spoilage, degradation, and sensor-relevant quality profilecatalog_goal: saved planning goals like lose weight or save moneycatalog_cultureoption: DB-backed culture dropdown optionscatalog_cuisineoption: DB-backed cuisine dropdown optionscatalog_plannerprofile: saved recipe-planning profile and constraintscatalog_ingredientplan: saved ingredient list and planning horizoncatalog_ingredientplanitem: saved products inside an ingredient plancatalog_recipesuggestionrun: persisted LLM-generated recipe suggestions
catalog_datasource records where data came from, so that a second source can be added without silently overwriting the first.
A source has a kind, because not all sources are the same shape:
retailer(Albert Heijn): authoritative for price, Bonus mechanics, and NL assortmentreference_db(OpenFoodFacts): describes products but carries no price or promotion datallm(the vLLM endpoint): estimates, not measurementsmanual/sensor: reserved for later
trust_rank orders sources when two of them describe the same field; lower wins. Seeded as Albert Heijn 10, OpenFoodFacts 20, vLLM 90.
license_name, license_url, attribution_required, and attribution_text exist because obligations differ per source. OpenFoodFacts data is ODbL and its attribution text must be reproduced wherever the data is redistributed.
catalog_productidentifier holds the identifiers each source uses for a product, keyed by id_type (gtin, ah_webshop_id, ah_hq_id, off_barcode, internal). Two properties matter:
match_methoddistinguishes identifiers reported by an API from ones derived by fuzzy name matching, so a weak match stays visibly weak(id_type, value)is indexed, so a barcode from another source resolves to a local product in one indexed lookup
Note: the AH mobile API does not expose a GTIN/EAN in any payload captured so far, so barcode-based joining to OpenFoodFacts is not yet possible from AH data alone.
Populate identifiers for products already in the database:
.venv/bin/python manage.py backfill_product_identifiers # AH webshop ids
.venv/bin/python manage.py backfill_product_identifiers --from-snapshots # also hqId from snapshots
.venv/bin/python manage.py backfill_product_identifiers --dry-run # report onlyThe command is idempotent and safe to re-run.
catalog_openfoodfactsproduct holds OpenFoodFacts records keyed by barcode. It has no foreign key to catalog_product, and that is deliberate:
- OFF describes millions of products that are not stocked locally
- matching an OFF record to a local product is a separate concern, handled through
catalog_productidentifier - so ingestion never blocks on matching, and re-matching later needs no re-ingestion
Nutrition columns use the same names and units as catalog_nutritionfacts (per 100 g / 100 ml) so sources can be compared field by field later.
Normalisation handled on ingest:
- OFF nutriment keys such as
energy-kcal_100gandsaturated-fat_100gare mapped to our field names - kJ and kcal are derived from each other when only one is present
- salt and sodium are derived from each other using the 2.5 factor
- negative and absurdly large values are discarded rather than stored
- records with a missing or all-zero barcode are skipped
- corrupt JSON lines are skipped rather than aborting a multi-million-row load
content_hash covers only the meaningful fields, so re-running an import rewrites nothing when a record has not actually changed. off_last_modified_at comes from OFF's last_modified_t and is the basis for delta ingestion.
Import from a dump:
.venv/bin/python manage.py import_openfoodfacts --dump off-products.jsonl.gz --country-tag en:netherlands
.venv/bin/python manage.py import_openfoodfacts --dump off-products.jsonl --country-tag '' # everything
.venv/bin/python manage.py import_openfoodfacts --dump off.jsonl --limit 5000 --store-rawUse the published OFF dumps for bulk loading, not the API. OFF is a volunteer-run project that publishes dumps and daily deltas precisely so bulk consumers do not hit the API product by product; reserve the API for targeted single-barcode lookups.
OFF data is ODbL licensed and its attribution text is stored on the openfoodfacts row in catalog_datasource. Reproduce it wherever the data is redistributed.
Linking OpenFoodFacts records to local products, and then deciding which source wins, are two separate steps.
.venv/bin/python manage.py match_openfoodfacts --country-tag en:netherlands
.venv/bin/python manage.py match_openfoodfacts --barcode-only # deterministic join only
.venv/bin/python manage.py match_openfoodfacts --threshold 0.9 --dry-runTwo paths, kept separate so their reliability stays visible:
- barcode join: deterministic, requires a
gtinidentifier on the local product, recorded asmatch_method=barcode - fuzzy name match: compares normalised brand/name/quantity signatures, recorded as
match_method=fuzzy_namewith a confidence label and the score innotes
Because the AH API has not been seen to expose a GTIN, the fuzzy path currently carries the load. Downstream code can require deterministic links only, via --trusted-matches-only on resolution or off_record_for_product(product, trusted_only=True).
Matching normalises away stopwords (ah, bio, biologisch, vers, unit words) and bare digits. One consequence worth knowing: AH Biologisch Komkommer and AH Komkommer both match a single OFF Komkommer record. Matching is many-to-one by design, since organic and standard variants are usually nutritionally equivalent, but it does mean a link is not a claim that the two are the same product.
Blocking keeps the matcher tractable: OFF signatures are indexed by token, tokens appearing in more than 5% of records are dropped as non-selective, and only candidates sharing a token are scored.
.venv/bin/python manage.py resolve_nutrition --dry-run
.venv/bin/python manage.py resolve_nutrition --only-linked
.venv/bin/python manage.py resolve_nutrition --trusted-matches-onlycatalog_nutritionfacts remains the single canonical row the app reads, so nothing downstream needs to know multiple sources exist. Resolution is field-level and trust-ordered:
- a higher-trust source is never overwritten by a lower-trust one
- a field the higher-trust source lacks is filled from the next source that has it
resolved_from_sourcenames the primary contributor;resolution_notelists every contributor and which fields were filled- derived diet metrics are recomputed afterwards
This is what makes gap-filling worthwhile: AH commonly publishes energy and macros but omits fibre, and OFF often has it. Products where AH has no nutrition at all can be populated entirely from OFF.
Rows that predate multi-source support were attributed to Albert Heijn by migration 0022, with resolved_at left NULL to mark the source as asserted rather than actually resolved.
Key design decision:
- AH products are not stored in a separate physical
ah_productstable - all products live in
catalog_product - supermarket-specific grouping is done through
catalog_supermarket - AH product uniqueness is enforced by both:
catalog_product.unique_product_url_per_supermarketcatalog_product.unique_product_external_id_per_supermarket
Currently captured from the verified API path when available:
- title
- brand
- package size / sales unit size
- price
- image URL
- description
- Nutri-Score
- row-level nutrition entries
Example stored correctly for banana:
AH Bananen tros- price
1.45 - Nutri-Score
A - nutrition rows including:
EnergieVetwaarvan verzadigdwaarvan onverzadigdKoolhydratenwaarvan suikersVoedingsvezelEiwittenZoutVitamine B6 / PyridoxineKalium/Potassium
Additional LLM-derived quality fields can now be stored per product:
- estimated ambient / refrigerated / frozen shelf-life ranges
- storage assumptions and notes
- nutrient degradation summary and structured degradation signals
- spoilage summary
- smell / odor changes
- color and texture changes
- visible spoilage signs
- likely airborne molecules or VOCs
- likely sensor targets and sensor types
- safety/discard guidance
Important caveat:
- shelf life is not a universal constant
- it varies by whole vs cut, raw vs cooked, opened vs unopened, ripe vs unripe, packaging, humidity, and storage temperature
- the LLM-backed quality profile should therefore be treated as an estimated source, not as lab-grade truth
Use the existing virtualenv or create one.
Install dependencies:
.venv/bin/python -m pip install -r requirements.txtCreate the runtime directories and local env file (both are gitignored):
mkdir -p logs media
cp .env.example .envdocker compose up -dServices:
- MySQL:
127.0.0.1:3315 - phpMyAdmin:
http://127.0.0.1:8091/ - OpenSearch:
http://127.0.0.1:9201/
set -a && source .env && set +a
.venv/bin/python manage.py migrateSet DJANGO_SUPERUSER_PASSWORD in .env first. The command has no default password and fails loudly if it is blank, because it grants superuser access. Re-running it resets the password for an existing user.
set -a && source .env && set +a
.venv/bin/python manage.py bootstrap_superuserset -a && source .env && set +a
.venv/bin/python manage.py runserverAdmin:
http://127.0.0.1:8000/admin/- Sync status:
http://127.0.0.1:8000/admin/sync-status/
- Nutrition search:
http://127.0.0.1:8000/
- Shopping list cost:
http://127.0.0.1:8000/shopping-list/
- Recipe planner:
http://127.0.0.1:8000/recipe-planner/
Important local env vars in .env (copy .env.example to get started):
MYSQL_HOSTMYSQL_PORTMYSQL_DATABASEMYSQL_USERMYSQL_PASSWORDDJANGO_SUPERUSER_USERNAMEDJANGO_SUPERUSER_EMAILDJANGO_SUPERUSER_PASSWORDOPENSEARCH_ENABLEDOPENSEARCH_URLOPENSEARCH_INDEX_NAMEOPENSEARCH_USERNAMEOPENSEARCH_PASSWORDOPENSEARCH_VERIFY_SSLOPENSEARCH_AUTO_INDEX_ON_SAVEFORKCAST_AUTO_OPENSEARCH_REINDEXFORKCAST_AUTO_OPENSEARCH_BATCH_SIZE
AH-related vars currently present:
AH_API_USER_AGENTAH_API_CLIENT_NAMEAH_API_CLIENT_VERSIONAH_COOKIEAH_BROWSER_*
Startup-sync vars, all optional. These are read by catalog/startup.py and fall back to the defaults shown below if absent from .env:
FORKCAST_AUTO_SYNC_ON_START(default1)FORKCAST_AUTO_BROAD_PAGES(default20)FORKCAST_AUTO_PARTITION_PAGES(default25)FORKCAST_AUTO_NUTRITION_BATCH_SIZE(default2000)FORKCAST_AUTO_PROGRESS_EVERY(default25)FORKCAST_AUTO_SYNC_LOCK_PATH(default/tmp/forkcast_ah_autosync.lock)FORKCAST_AUTO_SYNC_STATUS_PATH(default/tmp/forkcast_ah_autosync_status.json)FORKCAST_AUTO_OPENSEARCH_REINDEX(default0)FORKCAST_AUTO_OPENSEARCH_BATCH_SIZE(default1000)
Recipe-planning LLM vars:
RUNPOD_VLLM_HOSTVLLM_MODELVLLM_API_KEYVLLM_TIMEOUTPIPELINE_MAX_CHARS
The same vLLM configuration is used for product-quality enrichment unless you change the code to add a separate quality model endpoint.
Notes:
- the current primary importer is API-based and does not depend on browser scraping
- browser/cookie settings remain because the earlier path was explored and may still be useful as fallback/debugging
- product autocomplete/search can optionally use OpenSearch; if it is disabled or unavailable, the app falls back to DB-backed search automatically
If the startup worker is not enough, run the imports manually.
If you want a true clean start:
- reset the MySQL database
- rerun migrations
- recreate the superuser
- rerun the food-only AH imports
Commands used for the clean rebuild:
docker exec forkcast_mysql mysql -uroot -prootpass -e "DROP DATABASE IF EXISTS \`supermarkt\`; CREATE DATABASE \`supermarkt\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
.venv/bin/python manage.py migrate
.venv/bin/python manage.py bootstrap_superuser.venv/bin/python manage.py discover_ah_catalog --query '' --start-page 0 --max-pages 20 --sort-on RELEVANCE
.venv/bin/python manage.py discover_ah_catalog --query '' --start-page 0 --max-pages 20 --sort-on PRICEHIGHLOW
.venv/bin/python manage.py discover_ah_catalog --query '' --start-page 0 --max-pages 20 --sort-on PRICELOWHIGH
.venv/bin/python manage.py discover_ah_catalog --query '' --start-page 0 --max-pages 20 --sort-on NUTRISCOREThese imports now respect the DB-backed food-category scope and skip obvious non-food categories at import time.
The AH API supports an exact category filter using taxonomyId.
This project stores those taxonomy IDs in CategoryScope and can import category by category.
Because AH category IDs and labels are not perfectly stable, the importer also uses category-specific fallback queries and only keeps products whose returned mainCategory matches the target food category.
Run all active food categories:
.venv/bin/python manage.py import_ah_category_scope --max-pages-per-category 50 --sort-on RELEVANCERun a specific category only:
.venv/bin/python manage.py import_ah_category_scope --category-slug zuivel-eieren --max-pages-per-category 50
.venv/bin/python manage.py import_ah_category_scope --category-slug groente-aardappelen --max-pages-per-category 50Disable the fallback query pass if you only want raw taxonomy-based import:
.venv/bin/python manage.py import_ah_category_scope --max-pages-per-category 50 --disable-fallback-queriesThis is the preferred path when you want stronger completeness over the allowed food categories. Broad search imports are still useful, but category-scope import is the more systematic coverage path.
The importer now stores and uses a persistent category allowlist in the DB through CategoryScope.
Allowed food-category examples:
PasenGroente, aardappelenFruit, verse sappenBakkerijZuivel, eierenVleesVisVegetarisch, vegan en plantaardigMaaltijden, saladesKaasVleeswarenDiepvriesBorrel, chips, snacksKoek, snoep, chocoladeKoffie, theeFrisdrank, sappen, waterBier, wijn, aperitievenOntbijtgranen, belegPasta, rijst, wereldkeukenSoepen, sauzen, kruiden, olieTussendoortjesGlutenvrij
Blocked non-food examples:
Koken, tafelen, vrije tijdBaby en kindDrogisterijHuishoudenHuisdierGezondheid en sportAH Voordeelshop
You can review and edit these in /admin under Category scopes.
Useful when a category looks under-captured in the local DB.
Examples:
.venv/bin/python manage.py discover_ah_catalog --query 'eieren' --start-page 0 --max-pages 10 --sort-on RELEVANCE
.venv/bin/python manage.py discover_ah_catalog --query 'ei' --start-page 0 --max-pages 10 --sort-on RELEVANCEImportant:
- the current importer is strongest on API search/query coverage
- it does not yet do full taxonomy/category traversal from every AH category page
- so category URLs such as
/producten/2335/eierenmay need a targeted query import to improve coverage
.venv/bin/python manage.py import_ah_partitions --single-chars --sort-on RELEVANCE --max-pages-per-partition 25Nutrition backfill now skips obvious non-food rows and retries transient AH API failures with backoff.
If the AH API returns no nutrition rows for a product after a detail fetch, the product is marked as nutrition unavailable and removed from repeated nutrition retries.
For a small manual pass:
.venv/bin/python manage.py scrape_ah_products --missing-nutrition --limit 200For continuous batch backfill:
.venv/bin/python manage.py backfill_ah_nutrition --batch-size 2000 --pause-seconds 1 --progress-every 25Open:
http://127.0.0.1:8000/admin/sync-status/
This shows:
- whether the startup worker is running
- current phase
- food-candidate product counts
- remaining products without nutrition
- latest inventory run
- latest nutrition run
- latest failure
Read the startup status JSON:
.venv/bin/python manage.py shell -c "from catalog.startup import read_startup_status; import json; print(json.dumps(read_startup_status(), indent=2))"The manual nutrition backfill command now prints mid-batch progress every --progress-every products, for example:
Batch 3: 50/2000 processed, current=1234 AH Biologisch Eieren S M L
If you run the backfill detached:
nohup .venv/bin/python manage.py backfill_ah_nutrition --batch-size 2000 --pause-seconds 1 --progress-every 25 > logs/ah_nutrition_backfill.log 2>&1 &
tail -f logs/ah_nutrition_backfill.logAll commands below assume:
set -a && source .env && set +a.venv/bin/python manage.py check
.venv/bin/python manage.py test.venv/bin/python manage.py bootstrap_superuser.venv/bin/python manage.py scrape_ah_products --source-url https://www.ah.nl/producten/product/wi197393/ah-bananen-tros --limit 1Run until the local AH catalog is exhausted:
.venv/bin/python manage.py backfill_ah_nutrition --batch-size 2000 --pause-seconds 1Run a bounded number of batches:
.venv/bin/python manage.py backfill_ah_nutrition --batch-size 2000 --pause-seconds 1 --max-batches 3.venv/bin/python manage.py dedupe_ah_productsWhen Django starts through runserver, the catalog app now starts a guarded background worker that:
- deduplicates AH products by
external_id - refreshes inventory through broad API slices and single-character partitions
- deduplicates again
- backfills nutrition for products that still have no stored nutrition rows
The worker is guarded by a process lock file so it only starts once per server start/reload cycle.
Write behavior:
- products are reused by
external_idfirst, thensource_url - unchanged product snapshots are not duplicated
- nutrition entries are only rewritten when the incoming nutrition payload changes
The recipe planner at /recipe-planner/ stores:
- your primary and secondary goals
- DB-backed culture and cuisine selections
- profile details that affect recipe selection
- a saved ingredient list from the AH catalog
- generated recipe suggestions from the configured vLLM endpoint
The form explicitly asks for:
- primary and secondary goal
- gender and age
- height and weight
- culture dropdown
- cuisine dropdown
- culture / cuisine preference
- lifestyle
- fasting pattern
- diet preference
- allergies / exclusions
- extra planning context
Why those details are used:
- goals determine whether the planner should bias toward fat loss, muscle gain, cost savings, plant-forward meals, and similar outcomes
- height and weight support BMI, BMR, and TDEE estimation
- culture and cuisine bias recipe style and flavor direction
- lifestyle and fasting affect portioning and meal timing assumptions
- diet preference and allergies are treated as compatibility constraints
Spices and condiments:
- pantry staples, spices, and condiments can be marked directly on the saved ingredient list
- the planner assumes those are already available and excludes them from cost-sensitive missing-item logic by default
- the page makes that assumption explicit so recipe suggestions do not waste budget on cumin, salt, oil, and similar basics unless needed
Recipe output:
- BMI, BMR, and TDEE are computed directly in code
- the LLM receives those metrics as context
- the LLM is asked to explain what those values mean for the chosen goal
- the recipe response can include:
- metabolic context
- goal explanation
- calorie target
- protein, fat, carb, and fibre guidance
- sugar and salt guidance
- meal distribution guidance
Streaming behavior:
- the planner no longer shows raw streamed JSON
Suggest Recipesuses a streaming endpoint and updates the page progressively- structured recipe output is rendered below the form after completion
The easiest way is the admin status page:
http://127.0.0.1:8000/admin/sync-status/
That page shows:
- whether the startup worker is currently running
- the current phase, such as
dedupe,inventory_sync,nutrition_backfill,completed, orfailed - remaining products missing nutrition
- the latest inventory sync run
- the latest nutrition sync run
- the latest failure, if any
You can also inspect the worker status from the Django shell:
source .venv/bin/activate
.venv/bin/python manage.py shell -c "from catalog.startup import read_startup_status; import json; print(json.dumps(read_startup_status(), indent=2))"Example fields:
runningphasemessagebatch_numberremaining_missingupdated_at
And you can inspect historical runs in the database through:
/admin->Import runs
Current local example:
- if
phaseisnutrition_backfillandrunningistrue, then nutritional enrichment is actively happening - if
phaseisinventory_sync, then product discovery/import is happening - if
phaseiscompleted, the current startup sync cycle has finished
Relevance:
.venv/bin/python manage.py discover_ah_catalog --query '' --start-page 0 --max-pages 20 --sort-on RELEVANCEPrice high to low:
.venv/bin/python manage.py discover_ah_catalog --query '' --start-page 0 --max-pages 20 --sort-on PRICEHIGHLOWPrice low to high:
.venv/bin/python manage.py discover_ah_catalog --query '' --start-page 0 --max-pages 20 --sort-on PRICELOWHIGHNutri-Score:
.venv/bin/python manage.py discover_ah_catalog --query '' --start-page 0 --max-pages 20 --sort-on NUTRISCORESingle custom partitions:
.venv/bin/python manage.py import_ah_partitions --query aa --query sb --sort-on RELEVANCE --max-pages-per-partition 2All single-character partitions:
.venv/bin/python manage.py import_ah_partitions --single-chars --sort-on RELEVANCE --max-pages-per-partition 25If you explicitly want to use stored crawl sources:
.venv/bin/python manage.py discover_ah_catalog --source-id 1 --max-pages 10For already imported products:
.venv/bin/python manage.py scrape_ah_products --limit 100 --stale-onlyBackfill products still missing nutrition rows:
.venv/bin/python manage.py scrape_ah_products --missing-nutrition --limit 100Backfill products still missing descriptions:
.venv/bin/python manage.py scrape_ah_products --missing-description --limit 100Minimal local setup:
docker compose up -d forkcast_opensearchEnable it in .env:
OPENSEARCH_ENABLED=1
OPENSEARCH_URL=http://127.0.0.1:9201
OPENSEARCH_INDEX_NAME=supermarkt_products
OPENSEARCH_VERIFY_SSL=0
OPENSEARCH_AUTO_INDEX_ON_SAVE=1
FORKCAST_AUTO_OPENSEARCH_REINDEX=1
FORKCAST_AUTO_OPENSEARCH_BATCH_SIZE=1000Index the current local product catalog:
set -a && source .env && set +a
.venv/bin/python manage.py index_products_opensearch --all --batch-size 1000 --recreateIf you prefer manual chunks:
.venv/bin/python manage.py index_products_opensearch --limit 5000 --offset 0
.venv/bin/python manage.py index_products_opensearch --limit 5000 --offset 5000
.venv/bin/python manage.py index_products_opensearch --limit 5000 --offset 10000Once enabled and indexed, /api/products/search/ will prefer OpenSearch automatically and fall back to the DB search path if OpenSearch is unavailable.
Current indexed document includes:
- product identity and URL
- brand, package size, description, ingredients, allergens
- category and subcategory
- image URL
- Nutri-Score
- latest price and bonus metadata
- nutrition summary fields
- latest LLM-derived quality/spoilage summary when available
Automation options:
OPENSEARCH_AUTO_INDEX_ON_SAVE=1: update the index when products, nutrition, snapshots, or quality profiles changeFORKCAST_AUTO_OPENSEARCH_REINDEX=1: after startup sync finishes, bulk reindex the local catalog into OpenSearch
Use the configured LLM to estimate storage life, nutrient degradation, spoilage behavior, likely odor/color/texture changes, and sensor-relevant emitted compounds.
Typical run:
set -a && source .env && set +a
.venv/bin/python manage.py enrich_product_quality --limit 100 --missing-only --source-name vllm_defaultUseful variants:
# specific product
.venv/bin/python manage.py enrich_product_quality --product-id 123 --force
# focused subset
.venv/bin/python manage.py enrich_product_quality --name-contains avocado --limit 25 --missing-only
# rerun even if an LLM profile already exists
.venv/bin/python manage.py enrich_product_quality --limit 50 --forceImportant assumptions:
- this stores an estimated source, not a lab-validated truth
- shelf life varies by whole vs cut, raw vs cooked, opened vs unopened, ripe vs unripe, packaging, and real storage temperature
- frozen-life ranges are stored too, because fridge-vs-ambient alone is not enough for many foods
Available in /admin:
SupermarketsCrawl sourcesImport runsProductsProduct quality profilesNutrition factsProduct snapshots
Useful admin actions:
- seed default AH crawl sources
- discover products from selected crawl sources
- scrape selected AH products
- generate LLM quality profiles for selected products
API import commands now create ImportRun rows.
This gives you:
- a history of which query/sort slices were imported
- rough row counts and pages visited
- a simple way to see repeated vs new import activity from
/admin
Current bookkeeping is best-effort:
rows_importedtracks rows processed from the API sliceunique_products_addedis currently tracked on broad search imports- partition imports are recorded per partition, but not all per-partition uniqueness is computed yet
As of the latest local run in this workspace:
- AH inventory stored: about
12.8kproducts - API-based banana import verified
- broad import slices working across multiple sort orders
- query-partition import working and deduplicating correctly
Treat that count as a moving local state, not a hard-coded guarantee.
- AH API broad search is not a simple “all products in one request” interface
- some search strategies appear capped or behave non-intuitively
- two-character queries are not clean prefix filters in all cases
- exact ingredients and RI percentages are not consistently available from the verified API responses we use today
- completeness still needs iterative partitioning and validation
- keep expanding API partitions and sort slices
- add persistence for import runs / coverage bookkeeping
- backfill detailed product data in batches
- identify a safer, verifiable strategy for the remaining uncovered AH catalog slice
- add the next supermarket once the AH pipeline is stable