From d659e42d0281f1cff9a726f3b4ffedaac54e8ff3 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 18 Jul 2026 02:14:53 +0200 Subject: [PATCH] feat: KoePi Prospekte zeigt alle Filialen mit Bier-Icon-Abgleich, Lidl/ALDI Sued/HORNBACH per Adress-Auto-Suche ergaenzt --- backend/src/tools/koepi/routes.js | 105 +++++++++++++++++++++++++++--- frontend/src/tools/koepi.jsx | 3 + 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index b6c79c6..218260d 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -18,18 +18,47 @@ const MG_SETTINGS = encodeURIComponent(JSON.stringify({ })); // ── Gewünschte Händler (grobe Vorfilterung, bevor die genaue Filiale geprüft wird) ─ -const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut']; +const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut','lidl','aldi','hornbach']; function isTargetRetailer(name) { if (!name) return false; const n = name.toLowerCase(); return TARGET_RETAILERS.some(r => n.includes(r)); } -// Die exakt gewünschten Filialen, als Prospekt-Link vom Nutzer bestätigt. -// Wird EINMALIG (oder per Admin-Knopf erneut) aufgelöst: aus jedem Leaflet wird -// per closestStore die echte, stabile Store-ID ermittelt und in der DB als -// Whitelist gespeichert. Nur Angebote, deren aufgelöste Store-ID hier drin ist, -// werden angezeigt — keine Adress-Rätselei mehr nötig. +// Straßennamen robust vergleichbar machen (Groß-/Kleinschreibung, "straße"/"str.", +// Kommas/Leerzeichen/Punkte egal) +function normalizeAddress(s) { + return (s || '') + .toLowerCase() + .replace(/straße|strasse/g, 'str') + .replace(/[^a-zäöüß0-9]/g, ''); +} +// Adressen mit mehreren kommagetrennten Hausnummern (z.B. "Straße 173,173a") +// in einzelne vollständige Varianten zerlegen ("Straße 173" / "Straße 173a"), +// damit ein Treffer gegen nur eine der beiden Hausnummern trotzdem zählt +function expandAddressVariants(addr) { + const m = (addr || '').match(/^(.*?)(\d.*)$/); + if (!m) return [addr]; + const streetPart = m[1].trim(); + const numbers = m[2].split(',').map(s => s.trim()).filter(Boolean); + if (numbers.length <= 1) return [addr]; + return numbers.map(n => `${streetPart} ${n}`); +} +function addressesMatch(a, b) { + for (const va of expandAddressVariants(a)) { + for (const vb of expandAddressVariants(b)) { + const na = normalizeAddress(va), nb = normalizeAddress(vb); + if (na && nb && (na.includes(nb) || nb.includes(na))) return true; + } + } + return false; +} + +// Die gewünschten Filialen. Zwei Wege, wie eine Filiale hier eingetragen wird: +// - leafletId bekannt (Prospekt-Link bestätigt): wird direkt aufgelöst. +// - nur addressMatch bekannt: wird automatisch unter allen AKTUELL laufenden +// Prospekten dieser Kette in der Umgebung gesucht — die closestStore-Adresse +// wird gegen die angegebene Zieladresse abgeglichen, kein Prospekt-Link nötig. const LOCAL_STORE_SEED_LEAFLETS = [ { retailer: 'Netto Marken-Discount', leafletId: 5653691 }, // Im Bonnefeld 19 { retailer: 'Netto Getränke-Discount', leafletId: 5627267 }, // Im Bonnefeld 21 @@ -39,6 +68,11 @@ const LOCAL_STORE_SEED_LEAFLETS = [ { retailer: 'Penny', leafletId: 5638286 }, // Sittardsberger Allee 10 { retailer: 'E center', leafletId: 5637005 }, // Düsseldorfer Landstr. 361 ]; +const LOCAL_STORE_SEED_BY_ADDRESS = [ + { retailer: 'Lidl', matchRetailerKey: 'lidl', addressMatch: 'Mündelheimer Str. 184' }, + { retailer: 'ALDI Süd', matchRetailerKey: 'aldi', addressMatch: 'Mündelheimer Straße 173' }, + { retailer: 'HORNBACH', matchRetailerKey: 'hornbach', addressMatch: 'Düsseldorfer Straße 400' }, +]; function isKnownLocalStore(storeId) { if (storeId == null) return true; // Store konnte nicht ermittelt werden -> lieber behalten als faelschlich verstecken @@ -147,6 +181,8 @@ function extractWeekInfo(name) { async function resolveLocalStores() { const cache = new Map(); let resolved = 0; + + // 1) Direkt bekannte Leaflet-IDs (per Prospekt-Link bestätigt) for (const seed of LOCAL_STORE_SEED_LEAFLETS) { const detail = await fetchLeafletDetail(seed.leafletId, cache); const store = detail.closestStore; @@ -161,6 +197,46 @@ async function resolveLocalStores() { console.log(`🍺 KöPi: Filiale NICHT auflösbar — ${seed.retailer} (Leaflet ${seed.leafletId}) — evtl. Prospekt abgelaufen, Link erneuern`); } } + + // 2) Nur Adresse bekannt — unter allen aktuell laufenden Prospekten dieser + // Kette in der Umgebung suchen, closestStore-Adresse gegen Zieladresse prüfen + if (LOCAL_STORE_SEED_BY_ADDRESS.length) { + try { + const flightsUrl = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`; + const flightsRes = await fetch(flightsUrl, { headers: mgHeaders }); + const flightsJson = flightsRes.ok ? await flightsRes.json() : { results: [] }; + + for (const target of LOCAL_STORE_SEED_BY_ADDRESS) { + const candidates = (flightsJson.results || []).filter(f => + (f.advertiser?.name || '').toLowerCase().includes(target.matchRetailerKey) + ); + console.log(`🍺 KöPi: Suche "${target.retailer}" per Adresse — ${candidates.length} laufende Prospekt(e) dieser Kette gefunden`); + let found = null; + for (const c of candidates) { + if (!c.mainLeafletId) continue; + const detail = await fetchLeafletDetail(c.mainLeafletId, cache); + const store = detail.closestStore; + if (store.id != null && addressesMatch(store.address, target.addressMatch)) { + found = store; + break; + } + } + if (found) { + db.prepare(` + INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at) + VALUES (?, ?, ?, datetime('now','localtime')) + `).run(found.id, target.retailer, found.address); + console.log(`🍺 KöPi: Filiale per Adresse gefunden — ${target.retailer} → Store-ID ${found.id} ("${found.address}")`); + resolved++; + } else { + console.log(`🍺 KöPi: Filiale per Adresse NICHT gefunden — ${target.retailer} (gesucht: "${target.addressMatch}") — evtl. gerade kein laufender Prospekt dieser Kette in der Gegend`); + } + } + } catch (e) { + console.log('🍺 KöPi: Adress-basierte Filial-Suche fehlgeschlagen:', e.message); + } + } + return resolved; } @@ -460,12 +536,23 @@ async function scrapeProspekte() { }); } + // Bier-Icon: mit den aktuellen König-Pilsener-Angeboten abgleichen, welche + // Filialen gerade wirklich König Pilsener im Angebot haben + try { + const offersResult = await scrapeMarktguru(false); + const storeIdsWithKoepi = new Set((offersResult.offers || []).map(o => o.storeId).filter(id => id != null)); + for (const b of all) b.hasKoenigPilsener = b.storeId != null && storeIdsWithKoepi.has(b.storeId); + } catch (e) { + console.log('🍺 KöPi Prospekte: Abgleich mit aktuellen Angeboten fehlgeschlagen (ignoriert):', e.message); + for (const b of all) b.hasKoenigPilsener = false; + } + const result = { targeted: all.filter(b => b.isTarget), others: all.filter(b => !b.isTarget), scrapedAt: new Date().toISOString(), }; - console.log(`🍺 KöPi Prospekte: ${result.targeted.length} eigene Filiale(n), ${result.others.length} andere Filialen derselben Ketten`); + console.log(`🍺 KöPi Prospekte: ${result.targeted.length} eigene Filiale(n), ${result.others.length} andere Filialen derselben Ketten, ${result.targeted.filter(b=>b.hasKoenigPilsener).length} davon mit König Pilsener im Angebot`); cacheSet(cacheKey, result); return result; } @@ -617,7 +704,7 @@ router.post('/clear-cache', authenticate, (req, res) => { router.get('/local-stores', authenticate, (req, res) => { if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all(); - res.json({ stores, seedCount: LOCAL_STORE_SEED_LEAFLETS.length }); + res.json({ stores, seedCount: LOCAL_STORE_SEED_LEAFLETS.length + LOCAL_STORE_SEED_BY_ADDRESS.length }); }); // POST /local-stores/resolve – Filial-Whitelist neu auflösen (Admin, z.B. wenn @@ -627,7 +714,7 @@ router.post('/local-stores/resolve', authenticate, async (req, res) => { try { const resolved = await resolveLocalStores(); const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all(); - res.json({ ok: true, resolved, total: LOCAL_STORE_SEED_LEAFLETS.length, stores }); + res.json({ ok: true, resolved, total: LOCAL_STORE_SEED_LEAFLETS.length + LOCAL_STORE_SEED_BY_ADDRESS.length, stores }); } catch(e) { res.status(500).json({ error: e.message }); } }); diff --git a/frontend/src/tools/koepi.jsx b/frontend/src/tools/koepi.jsx index 6465b4b..53cce12 100644 --- a/frontend/src/tools/koepi.jsx +++ b/frontend/src/tools/koepi.jsx @@ -115,6 +115,9 @@ function ProspektCard({ b }) { borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}> {b.publisher} + {b.hasKoenigPilsener && ( + 🍺 + )}
📍 {b.street}