Use Open Food Facts for barcode lookup
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -80,11 +80,11 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
|
||||
<View style={styles.permissionBox}>
|
||||
<Text style={styles.permissionTitle}>Scanner-Simulation</Text>
|
||||
<Text style={styles.permissionCopy}>
|
||||
Auf Web oder ohne Kamera kannst du mit Beispiel-Barcodes testen.
|
||||
Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen.
|
||||
</Text>
|
||||
<View style={styles.demoRow}>
|
||||
<SecondaryButton label="Bio Haferdrink" onPress={() => handleBarcodeScanned({ data: '7610200336707' })} />
|
||||
<SecondaryButton label="Schoko-Riegel" onPress={() => handleBarcodeScanned({ data: '7612345001112' })} />
|
||||
<SecondaryButton label="Nutella" onPress={() => handleBarcodeScanned({ data: '3017620422003' })} />
|
||||
<SecondaryButton label="OpenFoodFacts Beispiel" onPress={() => handleBarcodeScanned({ data: '737628064502' })} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
+202
-14
@@ -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}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'error',
|
||||
errorMessage: 'Open Food Facts konnte gerade nicht erreicht werden.'
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'found', product, source: 'mock-api' };
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user