From 8e8a5ffea54c9147ebb6a2bb7d48f6ff72c0a94a Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 17 Jul 2026 21:20:10 +0200 Subject: [PATCH] feat: KoePi Filial-Filter jetzt ueber echte marktguru Store-ID-Whitelist, Adresse direkt von der API --- backend/src/db.js | 11 ++ backend/src/tools/koepi/routes.js | 238 ++++++++++++++++++++---------- 2 files changed, 175 insertions(+), 74 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index bfa933d..64a0b8a 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -650,4 +650,15 @@ db.exec(` ) `); +// KöPi: Whitelist der exakt gewünschten Filialen (echte marktguru Store-IDs, +// aufgelöst aus bestätigten Prospekt-Links — siehe koepi/routes.js) +db.exec(` + CREATE TABLE IF NOT EXISTS koepi_local_stores ( + store_id INTEGER PRIMARY KEY, + retailer TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + resolved_at DATETIME + ) +`); + module.exports = db; diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index 9ad9e3b..44de872 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -17,71 +17,43 @@ const MG_SETTINGS = encodeURIComponent(JSON.stringify({ locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35, })); -// ── Gewünschte Händler ──────────────────────────────────────────────────────── +// ── 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 RETAILER_ADDRESSES = { - 'edeka': 'Düsseldorfer Landstr. 361', - 'e center': 'Düsseldorfer Landstr. 361', - 'rewe': 'Mündelheimer Str. 132', - 'netto': 'Im Bonnefeld 19-21', - 'kaufland': 'Auf der Höhe 20', - 'penny': 'Sittardsberger Allee 10', - 'trinkgut': 'Keniastraße 39', -}; function isTargetRetailer(name) { if (!name) return false; const n = name.toLowerCase(); return TARGET_RETAILERS.some(r => n.includes(r)); } -function getAddress(retailer) { - if (!retailer) return ''; - const n = retailer.toLowerCase(); - for (const [key, addr] of Object.entries(RETAILER_ADDRESSES)) { - if (n.includes(key)) return addr; - } - return ''; + +// 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. +const LOCAL_STORE_SEED_LEAFLETS = [ + { retailer: 'Netto Marken-Discount', leafletId: 5653691 }, // Im Bonnefeld 19 + { retailer: 'Netto Getränke-Discount', leafletId: 5627267 }, // Im Bonnefeld 21 + { retailer: 'trinkgut', leafletId: 5634593 }, // Keniastraße 39 + { retailer: 'REWE', leafletId: 5616164 }, // Fischerstr. 110-112 + { retailer: 'Kaufland', leafletId: 5628827 }, // Auf der Höhe 20 + { retailer: 'Penny', leafletId: 5638286 }, // Sittardsberger Allee 10 + { retailer: 'E center', leafletId: 5637005 }, // Düsseldorfer Landstr. 361 +]; + +function isKnownLocalStore(storeId) { + if (storeId == null) return true; // Store konnte nicht ermittelt werden -> lieber behalten als faelschlich verstecken + return !!db.prepare('SELECT 1 FROM koepi_local_stores WHERE store_id=?').get(storeId); } -// Straßennamen robust vergleichbar machen (Groß-/Kleinschreibung, "straße"/"str.", -// Leerzeichen/Punkte egal — Hausnummer bleibt Teil des Vergleichs) -function normalizeAddress(s) { - return (s || '') - .toLowerCase() - .replace(/straße|strasse/g, 'str') - .replace(/[^a-zäöüß0-9]/g, ''); -} - -// Explizit unerwünschte Marken/Franchise-Label, unabhängig von der Adresse — -// "REWE Dortmund" ist auf marktguru ein eigener Franchise-Betreiber-Name, der -// auch dann raus soll, wenn seine Adresse zufällig mit einer lokalen Filiale -// übereinstimmt +// Explizit unerwünschte Marken/Franchise-Label, unabhängig von der Filiale — +// zusätzliches Sicherheitsnetz, greift aber durch die ID-Whitelist ohnehin kaum +// noch (REWE Dortmund taucht dort schlicht nicht auf) const EXCLUDED_RETAILER_NAMES = ['rewe dortmund']; function isExcludedRetailer(retailer) { const n = (retailer || '').toLowerCase(); return EXCLUDED_RETAILER_NAMES.some(x => n.includes(x)); } -// Prüft, ob die tatsächlich gescrapte Adresse zu einer der bekannten LOKALEN -// Filialen für diesen Händler passt. Ohne erkannten Händler oder ohne gescrapte -// Adresse wird NICHT gefiltert (lieber ein Angebot zuviel zeigen als eins zu -// Unrecht verstecken, falls marktguru das Layout mal ändert). -// -// STAND JETZT: Die verwendete Such-URL (/search/...?zipCode=...) liefert pro -// Angebot KEINE Adresse, daher ist diese Funktion aktuell praktisch immer -// "true" (kein echter Filter-Effekt). Bleibt drin für den Fall, dass sich das -// Seiten-Layout künftig ändert oder eine andere URL genutzt wird, die -// Adressen liefert (getestet: /bl/{marke}/{ort} tut das, bringt aber einen -// anderen/kleineren Angebots-Pool mit — siehe Git-Historie). -function isLocalBranch(retailer, realAddress) { - const n = (retailer || '').toLowerCase(); - const key = Object.keys(RETAILER_ADDRESSES).find(k => n.includes(k)); - if (!key) return true; - if (!realAddress) return true; - const expectedParts = RETAILER_ADDRESSES[key].split('/').map(p => normalizeAddress(p.trim())); - const real = normalizeAddress(realAddress); - return expectedParts.some(p => real.includes(p) || (p && p.includes(real))); -} - // ── Angebote von marktguru ──────────────────────────────────────────────────── // Primär über die öffentlich bekannte marktguru-JSON-API (schnell, kein Browser // nötig, kein Cloudflare-Risiko). Schlägt das fehl (z.B. weil die Keys mal @@ -101,36 +73,120 @@ function formatIsoDate(iso) { return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`; } +// Duisburg 47259 Zentrum — für den closestStore-Abgleich im leaflets-Detail-Call. +// Innerhalb der zipCode-Filterung spielt der genaue Punkt kaum eine Rolle. +const MG_LAT = '51.3543213'; +const MG_LON = '6.7151937'; + +const mgHeaders = { + 'x-clientkey': MARKTGURU_CLIENT_KEY, + 'x-apikey': MARKTGURU_API_KEY, + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', +}; + +// 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() { + 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); + } + console.log(`🍺 KöPi: ${map.size} leafletFlight→Leaflet Zuordnungen geladen (von ${json.totalResults} gesamt)`); + return map; +} + +// Holt für ein einzelnes Leaflet die closestStore-Adresse — das ist die +// eigentliche, bisher fehlende Information, um Filialen derselben Kette zu +// unterscheiden (z.B. "Netto Im Bonnefeld" vs. "Netto Hochstraße 10"). +// Holt für ein einzelnes Leaflet den kompletten closestStore-Datensatz von +// marktguru (id, address, name, distanceInMeters) — die Store-ID ist die +// stabile, exakte Grundlage für die Filial-Whitelist. +async function fetchClosestStore(leafletId, cache) { + if (cache.has(leafletId)) return cache.get(leafletId); + const empty = { id: null, address: '', name: '', distanceMeters: null }; + try { + const url = `https://api.marktguru.de/api/v1/leaflets/${leafletId}?as=mobiledetailed&latitude=${MG_LAT}&longitude=${MG_LON}&zipCode=${MG_ZIP}`; + const res = await fetch(url, { headers: mgHeaders }); + if (!res.ok) { cache.set(leafletId, empty); return empty; } + const json = await res.json(); + const store = json.closestStore; + const result = store + ? { id: store.id ?? null, address: store.address || '', name: store.name || '', distanceMeters: store.distanceInMeters ?? null } + : empty; + console.log(`🍺 KöPi: Leaflet ${leafletId} → closestStore: ${store ? `id=${store.id} "${store.address}" (${store.name}, ${Math.round(store.distanceInMeters||0)}m entfernt)` : 'keine'}`); + cache.set(leafletId, result); + return result; + } catch (e) { + console.log(`🍺 KöPi: closestStore-Abruf für Leaflet ${leafletId} fehlgeschlagen: ${e.message}`); + cache.set(leafletId, empty); + return empty; + } +} + +// Löst die feste Liste gewünschter Filialen (LOCAL_STORE_SEED_LEAFLETS) einmalig +// zu echten Store-IDs auf und speichert sie in der DB als Whitelist. Wird beim +// ersten Scrape automatisch ausgeführt (falls die Tabelle leer ist) und kann +// über den Admin-Button jederzeit erneut angestoßen werden, falls sich mal ein +// Prospekt-Link ändert oder eine Filiale ergänzt werden soll. +async function resolveLocalStores() { + const cache = new Map(); + let resolved = 0; + for (const seed of LOCAL_STORE_SEED_LEAFLETS) { + const store = await fetchClosestStore(seed.leafletId, cache); + if (store.id != null) { + db.prepare(` + INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at) + VALUES (?, ?, ?, datetime('now','localtime')) + `).run(store.id, seed.retailer, store.address); + console.log(`🍺 KöPi: Filiale aufgelöst — ${seed.retailer} → Store-ID ${store.id} ("${store.address}")`); + resolved++; + } else { + console.log(`🍺 KöPi: Filiale NICHT auflösbar — ${seed.retailer} (Leaflet ${seed.leafletId}) — evtl. Prospekt abgelaufen, Link erneuern`); + } + } + return resolved; +} + async function fetchOffersViaApi() { const url = `${MARKTGURU_API_URL}?as=web&limit=24&offset=0&q=${encodeURIComponent(MG_QUERY)}&zipCode=${MG_ZIP}`; - const res = await fetch(url, { - headers: { - 'x-clientkey': MARKTGURU_CLIENT_KEY, - 'x-apikey': MARKTGURU_API_KEY, - 'Accept': 'application/json', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - }, - }); + const res = await fetch(url, { headers: mgHeaders }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`API HTTP ${res.status}: ${text.slice(0, 200)}`); } const json = await res.json(); - console.log('🍺 KöPi API: totalResults=', json.totalResults, '| results.length=', json.results?.length); - if (json.results?.[0]) { - (json.results || []).forEach((r, i) => { - console.log(`🍺 KöPi API Rohdaten [${i}] (${r.advertisers?.[0]?.name || '?'}, € ${r.price}):`, JSON.stringify(r)); - }); + + const rawOffers = json.results || []; + + // Echte Filial-ID + Adresse pro Angebot auflösen (leafletFlightId -> mainLeafletId -> closestStore) + let flightToLeaflet = new Map(); + try { + flightToLeaflet = await fetchLeafletFlightToLeafletMap(); + } catch (e) { + console.log('🍺 KöPi: leafletflights-Abruf fehlgeschlagen, Filial-Filter bleibt diesmal wirkungslos:', e.message); + } + const leafletCache = new Map(); + const storeInfoByOfferId = new Map(); + for (const r of rawOffers) { + const leafletId = flightToLeaflet.get(r.leafletFlightId); + if (leafletId) { + const info = await fetchClosestStore(leafletId, leafletCache); + storeInfoByOfferId.set(r.id, info); + } } - return (json.results || []).map(r => { + return rawOffers.map(r => { const advertiser = r.advertisers?.[0] || {}; - const store = r.closestStore || r.store || {}; const priceNum = typeof r.price === 'number' ? r.price : parseFloat(r.price); const oldPriceNum = typeof r.oldPrice === 'number' ? r.oldPrice : parseFloat(r.oldPrice); - const realAddress = [store.street, store.streetNumber].filter(Boolean).join(' ').trim() - || store.address || advertiser.address || ''; + const storeInfo = storeInfoByOfferId.get(r.id) || { id: null, address: '', distanceMeters: null }; const validity0 = r.validityDates?.[0] || {}; return { name: r.description || r.title || advertiser.name || '', @@ -142,8 +198,11 @@ async function fetchOffersViaApi() { validity: '', description: r.description || '', badge: '', - address: '', - realAddress, + // Adresse kommt jetzt direkt von marktguru (closestStore.address), nicht mehr fest hinterlegt + address: storeInfo.address || '', + realAddress: storeInfo.address || '', + storeId: storeInfo.id, + storeDistanceMeters: storeInfo.distanceMeters, leafletFlightId: r.leafletFlightId || null, offerId: r.id || null, image: r.image?.url || r.images?.[0]?.url || null, @@ -259,19 +318,24 @@ function processOffers(offers) { console.log('🍺 KöPi: Angebote per Namens-Filter ausgeschlossen:', excludedNow.map(o => o.retailer).join(', ')); } + console.log('🍺 KöPi Filial-Whitelist-Diagnose:', filtered.map(o => + `${o.retailer} | Store-ID=${o.storeId ?? 'unbekannt'} | ${o.realAddress||'keine Adresse'} | bekannt=${isKnownLocalStore(o.storeId)}` + ).join('\n ')); + const localOnly = filtered .filter(o => !isExcludedRetailer(o.retailer)) - .filter(o => isLocalBranch(o.retailer, o.realAddress)); + .filter(o => isKnownLocalStore(o.storeId)); for (const o of localOnly) { o.retailerDisplay = o.retailer; } - // Deduplizieren: gleicher normalisierter Händler + gleicher Preis → ein Eintrag + // Deduplizieren: gleicher normalisierter Händler + gleicher Preis + gleiche + // Filiale → ein Eintrag const merged = new Map(); for (const o of localOnly) { const normalizedRetailer = o.retailer?.toLowerCase().replace('e center','edeka') || ''; - const key = `${normalizedRetailer}|${o.price}`; + const key = `${normalizedRetailer}|${o.price}|${o.storeId ?? ''}`; if (merged.has(key)) { const existing = merged.get(key); if (o.description && (!existing.description || o.description.length < existing.description.length)) { @@ -281,7 +345,7 @@ function processOffers(offers) { merged.set(key, { ...o }); } } - return [...merged.values()].map(o => ({ ...o, address: getAddress(o.retailer) })); + return [...merged.values()]; } async function scrapeMarktguru(forceRefresh = false) { @@ -291,6 +355,14 @@ async function scrapeMarktguru(forceRefresh = false) { if (cached) return cached; } + // Filial-Whitelist beim allerersten Lauf automatisch auflösen (Tabelle leer) + const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n; + if (knownCount === 0) { + console.log('🍺 KöPi: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...'); + try { await resolveLocalStores(); } + catch (e) { console.error('🍺 KöPi: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); } + } + let offers, source; try { offers = await fetchOffersViaApi(); @@ -530,6 +602,24 @@ router.post('/clear-cache', authenticate, (req, res) => { // Manueller Test des Tages-Checks (Admin) — sendet Pushover auch ohne echte // Änderung, aber NUR an den Admin selbst (nicht an alle Nutzer) +// GET /local-stores – aktuelle Filial-Whitelist anzeigen (Admin) +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 }); +}); + +// POST /local-stores/resolve – Filial-Whitelist neu auflösen (Admin, z.B. wenn +// sich ein Prospekt-Link geändert hat oder eine Filiale ergänzt wurde) +router.post('/local-stores/resolve', authenticate, async (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + 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 }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + router.post('/run-daily-check', authenticate, async (req, res) => { if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); try {