diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index a60e677..7bb0e7a 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -73,16 +73,16 @@ const RETAILER_FAMILIES = { }; const LOCAL_STORE_TARGETS = [ - { retailer: 'Netto Marken-Discount', uniqueName: 'netto-marken-discount', addressMatch: 'Im Bonnefeld 19' }, - { retailer: 'Netto Getränke-Discount', uniqueName: 'netto-getraenke-discount', addressMatch: 'Im Bonnefeld 21' }, - { retailer: 'trinkgut', uniqueName: 'trinkgut', addressMatch: 'Keniastraße 39', lat: '51.377915761179864', lon: '6.773658722208071' }, - { retailer: 'REWE', uniqueName: 'rewe', addressMatch: 'Fischerstr. 110-112' }, - { retailer: 'Kaufland', uniqueName: 'kaufland', addressMatch: 'Auf der Höhe 20' }, - { retailer: 'Penny', uniqueName: 'penny', addressMatch: 'Sittardsberger Allee 10' }, - { retailer: 'E center', uniqueName: 'edeka-center', addressMatch: 'Düsseldorfer Landstr. 361' }, - { retailer: 'Lidl', uniqueName: 'lidl', addressMatch: 'Mündelheimer Str. 184' }, - { retailer: 'ALDI Süd', uniqueName: 'aldi-sued', addressMatch: 'Mündelheimer Straße 173' }, - { retailer: 'HORNBACH', uniqueName: 'hornbach', addressMatch: 'Düsseldorfer Straße 400' }, + { retailer: 'Netto Marken-Discount', uniqueName: 'netto-marken-discount', addressMatch: 'Im Bonnefeld 19', lat: '51.35915096583635', lon: '6.6915196270147' }, + { retailer: 'Netto Getränke-Discount', uniqueName: 'netto-getraenke-discount', addressMatch: 'Im Bonnefeld 21', lat: '51.35915096583635', lon: '6.690317997389038' }, + { retailer: 'trinkgut', uniqueName: 'trinkgut', addressMatch: 'Keniastraße 39', lat: '51.377915761179864', lon: '6.773658722208071' }, + { retailer: 'REWE', uniqueName: 'rewe', addressMatch: 'Fischerstr. 110-112', lat: '51.36997126248043', lon: '6.737183637515018' }, + { retailer: 'Kaufland', uniqueName: 'kaufland', addressMatch: 'Auf der Höhe 20', lat: '51.44256930913753', lon: '6.755098946717344' }, + { retailer: 'Penny', uniqueName: 'penny', addressMatch: 'Sittardsberger Allee 10', lat: '51.37578298269326', lon: '6.76058440570406' }, + { retailer: 'E center', uniqueName: 'edeka-center', addressMatch: 'Düsseldorfer Landstr. 361', lat: '51.36258097015671', lon: '6.749197849046385' }, + { retailer: 'Lidl', uniqueName: 'lidl', addressMatch: 'Mündelheimer Str. 184', lat: '51.36955776603641', lon: '6.732140924604027' }, + { retailer: 'ALDI Süd', uniqueName: 'aldi-sued', addressMatch: 'Mündelheimer Straße 173', lat: '51.36884052650826', lon: '6.737198258598788' }, + { retailer: 'HORNBACH', uniqueName: 'hornbach', addressMatch: 'Düsseldorfer Straße 400', lat: '51.40899066754543', lon: '6.767177282148136' }, ]; function isKnownLocalStore(storeId) { @@ -105,7 +105,7 @@ function isExcludedRetailer(retailer) { // ungültig werden), fällt der Code automatisch auf das alte Puppeteer/HTML- // Scraping zurück, damit KöPi nicht komplett ausfällt. const MARKTGURU_API_URL = 'https://api.marktguru.de/api/v1/offers/search'; -const MARKTGURU_CLIENT_KEY = 'WU/RH+PMGDi+gkZer3WbMelt6zcYHSTytNB7VpTia90='; +const MARKTGURU_CLIENT_KEY = '7ptL9QPkhVYaQZbfDuoTtrg9nRF0i/T8FCwYi8M6/MQ='; const MARKTGURU_API_KEY = '8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE='; const MG_ZIP = '47259'; const MG_QUERY = 'könig pilsener'; @@ -145,36 +145,20 @@ async function fetchLeafletsForRetailer(uniqueName) { } } -// Holt für eine PLZ alle laufenden Prospekt-Kampagnen (leafletFlights) und baut -// eine Zuordnung leafletFlightId -> mainLeafletId, die wir brauchen, um darüber -// später das leaflet-Detail (inkl. closestStore) abzurufen. -async function fetchLeafletFlightToLeafletMap(extraSearchKeys = []) { - const url = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`; - const res = await fetch(url, { headers: mgHeaders }); - if (!res.ok) throw new Error(`leafletflights HTTP ${res.status}`); - const json = await res.json(); - const map = new Map(); - for (const f of (json.results || [])) { - if (f.id && f.mainLeafletId) map.set(f.id, f.mainLeafletId); +// Fragt marktguru direkt: "Welches Leaflet ist für DIESEN Flight am +// angegebenen Standort das beste/richtige?" — viel zuverlässiger als das +// Durchsuchen einer allgemeinen Liste, da marktguru selbst die Zuordnung +// Flight -> exaktes, standortgenaues Leaflet übernimmt. +async function fetchBestLeafletId(flightId, lat, lon) { + try { + const url = `https://api.marktguru.de/api/v1/leafletflights/${flightId}/bestleaflet?as=mobiledetailed&latitude=${lat}&longitude=${lon}&zipCode=${MG_ZIP}`; + const res = await fetch(url, { headers: mgHeaders }); + if (!res.ok) return null; + const json = await res.json(); + return json.id || json.mainLeafletId || null; + } catch { + return null; } - console.log(`🍺 KöPi: ${map.size} leafletFlight→Leaflet Zuordnungen über die breite Suche geladen (von ${json.totalResults} gesamt)`); - - // Die breite Umkreis-Suche zeigt manchmal nicht jede laufende Kampagne einer - // Kette (siehe Lidl-Fall). Für die Ketten, die gerade tatsächlich unter den - // Angeboten auftauchen, zusätzlich direkt über ihre eigene Prospekt-Liste - // nachfragen (zuverlässiger als Umkreis-Raten). - if (extraSearchKeys.length) { - const relevantTargets = LOCAL_STORE_TARGETS.filter(t => - extraSearchKeys.some(key => t.retailer.toLowerCase().includes(key)) - ); - const extraResults = await Promise.all(relevantTargets.map(t => fetchLeafletsForRetailer(t.uniqueName))); - let added = 0; - for (const f of extraResults.flat()) { - if (f.id && f.mainLeafletId && !map.has(f.id)) { map.set(f.id, f.mainLeafletId); added++; } - } - if (added) console.log(`🍺 KöPi: +${added} zusätzliche Zuordnungen durch direkte Ketten-Abfrage (${map.size} insgesamt)`); - } - return map; } // Holt für ein einzelnes Leaflet den kompletten Datensatz von marktguru: @@ -268,16 +252,22 @@ async function resolveLocalStores() { leafletsByUniqueName.set(uniqueName, await fetchLeafletsForRetailer(uniqueName)); } + const now = new Date(); for (const target of LOCAL_STORE_TARGETS) { - // Bei verwandten Ketten (siehe RETAILER_FAMILIES) alle Prospekte der - // ganzen Familie gemeinsam durchsuchen, sonst nur die eigene Liste + // Bei verwandten Ketten (siehe RETAILER_FAMILIES) alle Kampagnen der + // ganzen Familie gemeinsam durchsuchen, sonst nur die eigenen const familyNames = RETAILER_FAMILIES[target.uniqueName] || [target.uniqueName]; - const leaflets = familyNames.flatMap(name => leafletsByUniqueName.get(name) || []); - console.log(`🍺 KöPi: "${target.retailer}" (${target.uniqueName}) — ${leaflets.length} Prospekt(e) durchsucht${familyNames.length>1?` (Familie: ${familyNames.join(', ')})`:''}`); + const flights = familyNames.flatMap(name => leafletsByUniqueName.get(name) || []); + // Nur noch aktuell gültige Kampagnen betrachten (validTo in der Zukunft) + const validFlights = flights.filter(f => f.validTo && new Date(f.validTo) >= now); + console.log(`🍺 KöPi: "${target.retailer}" (${target.uniqueName}) — ${validFlights.length} aktuell gültige von ${flights.length} Kampagnen${familyNames.length>1?` (Familie: ${familyNames.join(', ')})`:''}`); + let found = null; - for (const l of leaflets) { - if (!l.mainLeafletId) continue; - const detail = await fetchLeafletDetail(l.mainLeafletId, cache, target.lat || MG_LAT, target.lon || MG_LON); + for (const flight of validFlights) { + const lat = target.lat || MG_LAT, lon = target.lon || MG_LON; + const bestId = await fetchBestLeafletId(flight.id, lat, lon); + if (!bestId) continue; + const detail = await fetchLeafletDetail(bestId, cache, lat, lon); const store = detail.closestStore; if (store.id != null && addressesMatch(store.address, target.addressMatch)) { found = store; @@ -292,7 +282,7 @@ async function resolveLocalStores() { console.log(`🍺 KöPi: Filiale gefunden — ${target.retailer} → Store-ID ${found.id} ("${found.address}")`); resolved++; } else { - console.log(`🍺 KöPi: Filiale NICHT gefunden — ${target.retailer} (gesucht: "${target.addressMatch}" unter ${leaflets.length} Prospekten)`); + console.log(`🍺 KöPi: Filiale NICHT gefunden — ${target.retailer} (gesucht: "${target.addressMatch}" unter ${validFlights.length} gültigen Kampagnen)`); } } @@ -311,25 +301,20 @@ async function fetchOffersViaApi() { const rawOffers = json.results || []; - // Echte Filial-ID + Adresse pro Angebot auflösen (leafletFlightId -> mainLeafletId -> closestStore) - const offerRetailerKeys = [...new Set(rawOffers.map(r => { - const name = (r.advertisers?.[0]?.name || '').toLowerCase(); - return TARGET_RETAILERS.find(k => name.includes(k)); - }).filter(Boolean))]; - let flightToLeaflet = new Map(); - try { - flightToLeaflet = await fetchLeafletFlightToLeafletMap(offerRetailerKeys); - } catch (e) { - console.log('🍺 KöPi: leafletflights-Abruf fehlgeschlagen, Filial-Filter bleibt diesmal wirkungslos:', e.message); - } + // Pro Angebot direkt über bestleaflet die exakte Filiale auflösen — nutzt + // dabei den präzisen, filial-eigenen Standort (LOCAL_STORE_TARGETS), damit + // marktguru selbst die richtige Filiale herausfindet statt dass wir raten. const leafletCache = new Map(); const detailByOfferId = new Map(); for (const r of rawOffers) { - const leafletId = flightToLeaflet.get(r.leafletFlightId); - if (leafletId) { - const detail = await fetchLeafletDetail(leafletId, leafletCache); - detailByOfferId.set(r.id, detail); - } + if (!r.leafletFlightId) continue; + const uniqueName = r.advertisers?.[0]?.uniqueName || ''; + const target = LOCAL_STORE_TARGETS.find(t => t.uniqueName === uniqueName); + const lat = target?.lat || MG_LAT, lon = target?.lon || MG_LON; + const bestId = await fetchBestLeafletId(r.leafletFlightId, lat, lon); + if (!bestId) continue; + const detail = await fetchLeafletDetail(bestId, leafletCache, lat, lon); + detailByOfferId.set(r.id, detail); } return rawOffers.map(r => { @@ -372,6 +357,8 @@ async function fetchOffersViaApi() { storeId: storeInfo.id, storeDistanceMeters: storeInfo.distanceMeters, leafletFlightId: r.leafletFlightId || null, + quantity: r.quantity ?? null, + volume: r.volume ?? null, offerId: r.id || null, leafletUrl, image: r.image?.url || r.images?.[0]?.url || null, @@ -451,39 +438,29 @@ async function fetchOffersViaPuppeteer() { } } -// Prüft, ob "COUNT x VOL" (z.B. "20 x 0,5") in der Beschreibung vorkommt — -// robust gegen Leerzeichen um das "x", Komma/Punkt als Dezimaltrennzeichen, -// und verhindert Fehltreffer wie "0,5" innerhalb von "0,55" -function hasQty(desc, count, vol) { - const volPattern = vol.replace(',', '[,.]'); - const re = new RegExp(`${count}\\s*x\\s*${volPattern}(?!\\d)`, 'i'); - return re.test(desc); -} +// Bild pro Angebot anhand der ECHTEN Mengen-/Volumen-Felder wählen (zuverlässiger +// als Text-Parsing der Beschreibung, da marktguru quantity/volume strukturiert +// mitliefert) +function bestimmeBild(menge, volumen) { + const m = parseInt(menge, 10); + const v = parseFloat(volumen); -// Bild pro Angebot exakt nach Mengenangabe wählen (siehe Vorgabe): -// 20x0,5 -> kasten_050 | 20x0,33 -> kasten_steini | 24x0,33 -> kasten_033 -// 11x0,5 -> kasten_11er | 6x0,33 -> traeger | 24x0,5 -> palette_050 -// 20x0,5 UND 24x0,33 gleichzeitig -> kasten_033_05 | sonst -> koepi_platzhalter -function pickOfferImage(description) { - const d = (description || '').toLowerCase(); - const q2005 = hasQty(d, 20, '0,5'); - const q2433 = hasQty(d, 24, '0,33'); - if (q2005 && q2433) return '/koepi/kasten_033_05.png'; - if (hasQty(d, 20, '0,33')) return '/koepi/kasten_steini.png'; - if (q2433) return '/koepi/kasten_033.png'; - if (hasQty(d, 11, '0,5')) return '/koepi/kasten_11er.png'; - if (hasQty(d, 6, '0,33')) return '/koepi/traeger.png'; - if (hasQty(d, 24, '0,5')) return '/koepi/palette_050.png'; - if (q2005) return '/koepi/kasten_050.png'; - return '/koepi/koepi_platzhalter.png'; + if (m === 11 && v === 0.5) return '11erkasten0,5L.png'; + if (m === 20 && v === 0.5) return '20erkasten0,5L.png'; + if (m === 24 && v === 0.33) return '24erkasten0,33L.png'; + if (m === 1 && v === 0.5) return 'flasche0,5L.png'; + if (m === 1 && v === 0.33) return 'flasche0,33L.png'; + if (m === 1 && v === 0.25) return 'dose0,25L.png'; + + return 'standard_bier.png'; } // Gemeinsame Weiterverarbeitung, egal ob die Rohdaten über die API oder per // Puppeteer/HTML-Scraping ermittelt wurden function processOffers(offers) { - // Lokale Bilder auswählen basierend auf Beschreibung + // Lokale Bilder auswählen basierend auf echter Menge/Volumen for (const offer of offers) { - offer.imageLocal = pickOfferImage(offer.description); + offer.imageLocal = '/koepi/' + bestimmeBild(offer.quantity, offer.volume); offer.image = null; // CDN nicht mehr nötig } @@ -580,15 +557,32 @@ async function scrapeProspekte() { catch (e) { console.error('🍺 KöPi Prospekte: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); } } - // Für jede der 10 Zielfilialen direkt und zuverlässig deren eigene Prospekte - // abfragen — kein Umkreis-Raten mehr nötig + // Für jede der 10 Zielfilialen die aktuell gültigen Prospekte abfragen und + // über bestleaflet pro Kampagne die exakt richtige Filiale ermitteln (statt + // wie zuvor über eine ganze Liste zu raten) const leafletCache = new Map(); const all = []; + const uniqueNames = [...new Set(LOCAL_STORE_TARGETS.map(t => t.uniqueName))]; + const leafletsByUniqueName = new Map(); + for (const uniqueName of uniqueNames) { + leafletsByUniqueName.set(uniqueName, await fetchLeafletsForRetailer(uniqueName)); + } + const nowForProspekte = new Date(); + const processedFlightIds = new Set(); // verhindert Doppel-Verarbeitung bei Familien (z.B. beide Netto-Varianten teilen sich einen Kampagnen-Pool) + for (const target of LOCAL_STORE_TARGETS) { - const leaflets = await fetchLeafletsForRetailer(target.uniqueName); - for (const f of leaflets) { - if (!f.mainLeafletId) continue; - const detail = await fetchLeafletDetail(f.mainLeafletId, leafletCache, target.lat || MG_LAT, target.lon || MG_LON); + const familyNames = RETAILER_FAMILIES[target.uniqueName] || [target.uniqueName]; + const flights = familyNames.flatMap(name => leafletsByUniqueName.get(name) || []); + const validFlights = flights.filter(f => f.validTo && new Date(f.validTo) >= nowForProspekte); + const lat = target.lat || MG_LAT, lon = target.lon || MG_LON; + + for (const f of validFlights) { + if (processedFlightIds.has(f.id)) continue; + processedFlightIds.add(f.id); + + const bestId = await fetchBestLeafletId(f.id, lat, lon); + if (!bestId) continue; + const detail = await fetchLeafletDetail(bestId, leafletCache, lat, lon); const store = detail.closestStore; const weekInfo = weekInfoWithFallback(detail.name, f.validFrom); // Store-spezifische Bezeichnung aus der Whitelist bevorzugen (z.B. "Netto @@ -610,7 +604,7 @@ async function scrapeProspekte() { validFrom: f.validFrom || null, validTo: f.validTo || null, pageCount: f.pageCount || null, - url: `https://www.marktguru.de/leaflets/${f.mainLeafletId}`, + url: `https://www.marktguru.de/leaflets/${bestId}`, isTarget: isKnownLocalStore(store.id) && store.id != null, badges: [], }); @@ -632,7 +626,7 @@ async function scrapeProspekte() { // WICHTIG: gegen rawName (den echten Leaflet-Namen) prüfen, nicht gegen // title/publisher — die zeigen nur noch den sauberen Kettennamen und würden // "Sondermagazin"/"Bestellmagazin" im echten Namen sonst nie mehr finden. - const EXCLUDED_TITLE_KEYWORDS = ['sondermagazin', 'reisen', 'bestellmagazin']; + const EXCLUDED_TITLE_KEYWORDS = ['magazin', 'online', 'reisen']; const withoutSondermagazin = all.filter(b => { const t = (b.rawName || '').toLowerCase(); return !EXCLUDED_TITLE_KEYWORDS.some(kw => t.includes(kw)); diff --git a/frontend/public/koepi/11erkasten0,5L.png b/frontend/public/koepi/11erkasten0,5L.png new file mode 100644 index 0000000..cbd3235 Binary files /dev/null and b/frontend/public/koepi/11erkasten0,5L.png differ diff --git a/frontend/public/koepi/20erkasten0,5L.png b/frontend/public/koepi/20erkasten0,5L.png new file mode 100644 index 0000000..332b5e5 Binary files /dev/null and b/frontend/public/koepi/20erkasten0,5L.png differ diff --git a/frontend/public/koepi/24erkasten0,33L.png b/frontend/public/koepi/24erkasten0,33L.png new file mode 100644 index 0000000..21279ef Binary files /dev/null and b/frontend/public/koepi/24erkasten0,33L.png differ diff --git a/frontend/public/koepi/dose0,25L.png b/frontend/public/koepi/dose0,25L.png new file mode 100644 index 0000000..3c54c30 Binary files /dev/null and b/frontend/public/koepi/dose0,25L.png differ diff --git a/frontend/public/koepi/flasche0,33L.png b/frontend/public/koepi/flasche0,33L.png new file mode 100644 index 0000000..3c54c30 Binary files /dev/null and b/frontend/public/koepi/flasche0,33L.png differ diff --git a/frontend/public/koepi/flasche0,5L.png b/frontend/public/koepi/flasche0,5L.png new file mode 100644 index 0000000..3c54c30 Binary files /dev/null and b/frontend/public/koepi/flasche0,5L.png differ