From e98e48cf5cb65b3b79da626d13eb37c70ff304b1 Mon Sep 17 00:00:00 2001 From: fretuks Date: Wed, 1 Jul 2026 10:39:09 +0200 Subject: [PATCH] Use Open Food Facts for barcode lookup --- README.md | 14 ++- src/screens/ScanScreen.js | 6 +- src/services/productApi.js | 218 ++++++++++++++++++++++++++++++++++--- 3 files changed, 214 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7dc5d50..53333da 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Die App richtet sich an Privatpersonen, die im Supermarkt schnell bessere Kaufen - Manuelle Produktsuche, falls der Barcode nicht erkannt wird - Lokale Einkaufsliste mit Menge, Preis, Abhaken und Löschen - Lokaler Produktcache und Offline-Modus für gespeicherte Produkte +- Produktdaten per Barcode aus der Open-Food-Facts-API - Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge ## One-Pager @@ -40,6 +41,7 @@ Der One-Pager aus Aufgabe 6.1 beschreibt das Go-to-Market-Konzept: - React Native - JavaScript - Smartphone-Kamera und Barcode-Scanner +- Open Food Facts API für Barcode-Produktdaten - Lokale Speicherung persönlicher Präferenzen - GitHub oder GitLab für Versionsverwaltung und Abgabe @@ -66,21 +68,21 @@ Shoppy/ Die App enthält jetzt einen MVP-Flow mit Bottom-Tabs: - Home: Startseite und letzte Scans -- Scan: Kamera-Scanner, Beispiel-Barcodes und manuelle Suche +- Scan: Kamera-Scanner mit Open Food Facts, Beispiel-Barcodes und manuelle Suche - Produktdetail: Ampelbewertung nach Allergien, Budget, Ernährung und Nachhaltigkeit - Alternativen: bessere Produktvorschläge aus dem lokalen Beispieldatensatz - Einkaufsliste: Produkte hinzufügen, abhaken, löschen und Gesamtsumme sehen - Profil: Präferenzen, Allergien, Budget, Bio-Vorliebe und Offline-Modus verwalten -Beispiel-Barcodes für Tests ohne echte Produkte: +Beispiel-Barcodes für Tests über Open Food Facts: ```text -7610200336707 - Bio Haferdrink -7612345001112 - Erdnuss Schoko-Riegel -7612345002225 - Protein Joghurt Beeren -7612345003338 - Vegane Linsen-Bowl +3017620422003 - Nutella +737628064502 - Open-Food-Facts-Beispielprodukt ``` +Die App nutzt für Barcodes `https://world.openfoodfacts.org/api/v2/product/{barcode}.json`. Wenn ein Produkt bereits lokal gespeichert ist oder der Offline-Modus aktiv ist, wird zuerst der lokale Cache verwendet. + ## Runbook Projekt installieren: diff --git a/src/screens/ScanScreen.js b/src/screens/ScanScreen.js index 22c1b86..125bbcc 100644 --- a/src/screens/ScanScreen.js +++ b/src/screens/ScanScreen.js @@ -80,11 +80,11 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o Scanner-Simulation - Auf Web oder ohne Kamera kannst du mit Beispiel-Barcodes testen. + Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen. - handleBarcodeScanned({ data: '7610200336707' })} /> - handleBarcodeScanned({ data: '7612345001112' })} /> + handleBarcodeScanned({ data: '3017620422003' })} /> + handleBarcodeScanned({ data: '737628064502' })} /> )} diff --git a/src/services/productApi.js b/src/services/productApi.js index 464b6c8..8775544 100644 --- a/src/services/productApi.js +++ b/src/services/productApi.js @@ -1,13 +1,33 @@ import { mockProducts } from '../data/mockProducts'; -function delay(ms = 250) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +const openFoodFactsBaseUrl = 'https://world.openfoodfacts.org'; +const productFields = [ + 'code', + 'product_name', + 'product_name_de', + 'product_name_en', + 'brands', + 'categories', + 'categories_tags', + 'image_url', + 'ingredients_text', + 'ingredients_text_de', + 'ingredients_text_en', + 'allergens_tags', + 'nutriments', + 'nutriscore_grade', + 'ecoscore_grade', + 'labels_tags', + 'origins', + 'origins_tags', + 'countries_tags', + 'packaging' +].join(','); export async function lookupProductByBarcode(barcode, productCache = {}, offlineModeEnabled = false) { - await delay(); + const normalizedBarcode = normalizeBarcode(barcode); + const cachedProduct = productCache[normalizedBarcode] || productCache[barcode]; - const cachedProduct = productCache[barcode]; if (cachedProduct) { return { status: 'found', product: cachedProduct, source: 'cache' }; } @@ -19,27 +39,113 @@ export async function lookupProductByBarcode(barcode, productCache = {}, offline }; } - const product = mockProducts.find((item) => item.barcode === barcode); - if (!product) { - return { status: 'notFound', errorMessage: 'Kein Produkt zu diesem Barcode gefunden.' }; - } + try { + const response = await fetch( + `${openFoodFactsBaseUrl}/api/v2/product/${encodeURIComponent(normalizedBarcode)}.json?fields=${productFields}` + ); - return { status: 'found', product, source: 'mock-api' }; + if (!response.ok) { + return { + status: 'error', + errorMessage: 'Open Food Facts konnte gerade nicht erreicht werden.' + }; + } + + const payload = await response.json(); + if (payload.status !== 1 || !payload.product) { + return { + status: 'notFound', + errorMessage: 'Kein Produkt zu diesem Barcode in Open Food Facts gefunden.' + }; + } + + return { + status: 'found', + product: mapOpenFoodFactsProduct(payload.product, normalizedBarcode), + source: 'open-food-facts' + }; + } catch (error) { + return { + status: 'error', + errorMessage: 'Netzwerkfehler beim Laden von Open Food Facts.' + }; + } } export async function searchProducts(query, productCache = {}, offlineModeEnabled = false) { - await delay(); - const normalizedQuery = query.trim().toLowerCase(); const cachedProducts = Object.values(productCache); - const sourceProducts = offlineModeEnabled ? cachedProducts : [...mockProducts, ...cachedProducts]; - const uniqueProducts = Array.from(new Map(sourceProducts.map((product) => [product.id, product])).values()); if (!normalizedQuery) { return []; } - return uniqueProducts.filter((product) => { + if (offlineModeEnabled) { + return filterProducts(cachedProducts, normalizedQuery); + } + + try { + const response = await fetch( + `${openFoodFactsBaseUrl}/cgi/search.pl?search_terms=${encodeURIComponent(query)}&search_simple=1&action=process&json=1&page_size=20&fields=${productFields}` + ); + + if (!response.ok) { + return filterProducts([...cachedProducts, ...mockProducts], normalizedQuery); + } + + const payload = await response.json(); + const apiProducts = (payload.products || []) + .filter((product) => product.code) + .map((product) => mapOpenFoodFactsProduct(product, product.code)); + const products = [...apiProducts, ...cachedProducts, ...mockProducts]; + + return uniqueProducts(filterProducts(products, normalizedQuery)); + } catch (error) { + return filterProducts([...cachedProducts, ...mockProducts], normalizedQuery); + } +} + +function mapOpenFoodFactsProduct(product, fallbackBarcode) { + const barcode = normalizeBarcode(product.code || fallbackBarcode); + const nutriments = product.nutriments || {}; + const now = new Date().toISOString(); + + return { + id: `off-${barcode}`, + barcode, + name: product.product_name_de || product.product_name || product.product_name_en || `Produkt ${barcode}`, + brand: firstTextValue(product.brands), + category: firstCategory(product.categories, product.categories_tags), + imageUrl: product.image_url, + price: undefined, + currency: 'CHF', + ingredients: parseIngredients(product.ingredients_text_de || product.ingredients_text || product.ingredients_text_en), + allergens: normalizeAllergens(product.allergens_tags || []), + nutrition: { + caloriesPer100g: numberOrUndefined(nutriments['energy-kcal_100g']), + sugarPer100g: numberOrUndefined(nutriments.sugars_100g), + fatPer100g: numberOrUndefined(nutriments.fat_100g), + proteinPer100g: numberOrUndefined(nutriments.proteins_100g), + saltPer100g: numberOrUndefined(nutriments.salt_100g), + nutriScore: normalizeGrade(product.nutriscore_grade) + }, + sustainability: { + ecoScore: normalizeGrade(product.ecoscore_grade), + isBio: hasOrganicLabel(product.labels_tags || []), + originCountry: firstOrigin(product.origins, product.origins_tags, product.countries_tags), + packaging: firstTextValue(product.packaging) + }, + createdAt: now, + updatedAt: now + }; +} + +function normalizeBarcode(barcode) { + return String(barcode || '').replace(/[^\d]/g, ''); +} + +function filterProducts(products, normalizedQuery) { + return uniqueProducts(products).filter((product) => { const haystack = [ product.name, product.brand, @@ -50,3 +156,85 @@ export async function searchProducts(query, productCache = {}, offlineModeEnable return haystack.includes(normalizedQuery); }); } + +function uniqueProducts(products) { + return Array.from(new Map(products.map((product) => [product.id, product])).values()); +} + +function firstTextValue(value) { + if (!value) return undefined; + return String(value).split(',').map((item) => item.trim()).filter(Boolean)[0]; +} + +function firstCategory(categories, categoryTags = []) { + const category = firstTextValue(categories); + if (category) return category; + + const tag = categoryTags.find(Boolean); + return tag ? cleanTag(tag) : undefined; +} + +function firstOrigin(origins, originTags = [], countryTags = []) { + return firstTextValue(origins) + || cleanTag(originTags.find(Boolean)) + || cleanTag(countryTags.find(Boolean)); +} + +function parseIngredients(ingredientsText) { + if (!ingredientsText) return []; + return ingredientsText + .split(/[,;.]/) + .map((ingredient) => ingredient.trim()) + .filter(Boolean) + .slice(0, 16); +} + +function normalizeAllergens(allergenTags) { + const allergenMap = { + 'en:peanuts': 'Erdnüsse', + 'de:erdnusse': 'Erdnüsse', + 'de:erdnüsse': 'Erdnüsse', + 'en:milk': 'Milch', + 'de:milch': 'Milch', + 'en:gluten': 'Gluten', + 'de:gluten': 'Gluten', + 'en:oats': 'Hafer', + 'de:hafer': 'Hafer', + 'en:soybeans': 'Soja', + 'de:soja': 'Soja', + 'en:nuts': 'Nüsse', + 'de:nusse': 'Nüsse', + 'de:nüsse': 'Nüsse', + 'en:eggs': 'Eier', + 'de:eier': 'Eier' + }; + + return allergenTags + .map((tag) => allergenMap[tag] || cleanTag(tag)) + .filter(Boolean); +} + +function normalizeGrade(value) { + const grade = String(value || '').trim().toUpperCase(); + return ['A', 'B', 'C', 'D', 'E'].includes(grade) ? grade : undefined; +} + +function hasOrganicLabel(labelTags) { + return labelTags.some((tag) => { + const normalizedTag = String(tag).toLowerCase(); + return normalizedTag.includes('organic') || normalizedTag.includes('bio'); + }); +} + +function numberOrUndefined(value) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : undefined; +} + +function cleanTag(tag) { + if (!tag) return undefined; + const withoutLanguage = String(tag).replace(/^[a-z]{2}:/, ''); + return withoutLanguage + .replace(/-/g, ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +}