feat: KoePi komplett auf bestleaflet-Endpoint umgestellt, neue Bildlogik mit echten quantity/volume-Feldern

This commit is contained in:
2026-07-27 00:15:32 +02:00
parent 99ac6cf068
commit 12d1cb3052
7 changed files with 92 additions and 98 deletions

View File

@@ -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: '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' },
{ 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: '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}`;
// 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) throw new Error(`leafletflights HTTP ${res.status}`);
if (!res.ok) return null;
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);
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,26 +301,21 @@ 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);
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 => {
const advertiser = r.advertisers?.[0] || {};
@@ -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));

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB