feat: KoePi Prospekte zeigt alle Filialen mit Bier-Icon-Abgleich, Lidl/ALDI Sued/HORNBACH per Adress-Auto-Suche ergaenzt

This commit is contained in:
2026-07-18 02:14:53 +02:00
parent 94616bc0d1
commit d659e42d02
2 changed files with 99 additions and 9 deletions

View File

@@ -18,18 +18,47 @@ const MG_SETTINGS = encodeURIComponent(JSON.stringify({
})); }));
// ── Gewünschte Händler (grobe Vorfilterung, bevor die genaue Filiale geprüft wird) ─ // ── 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) { function isTargetRetailer(name) {
if (!name) return false; if (!name) return false;
const n = name.toLowerCase(); const n = name.toLowerCase();
return TARGET_RETAILERS.some(r => n.includes(r)); return TARGET_RETAILERS.some(r => n.includes(r));
} }
// Die exakt gewünschten Filialen, als Prospekt-Link vom Nutzer bestätigt. // Straßennamen robust vergleichbar machen (Groß-/Kleinschreibung, "straße"/"str.",
// Wird EINMALIG (oder per Admin-Knopf erneut) aufgelöst: aus jedem Leaflet wird // Kommas/Leerzeichen/Punkte egal)
// per closestStore die echte, stabile Store-ID ermittelt und in der DB als function normalizeAddress(s) {
// Whitelist gespeichert. Nur Angebote, deren aufgelöste Store-ID hier drin ist, return (s || '')
// werden angezeigt — keine Adress-Rätselei mehr nötig. .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 = [ const LOCAL_STORE_SEED_LEAFLETS = [
{ retailer: 'Netto Marken-Discount', leafletId: 5653691 }, // Im Bonnefeld 19 { retailer: 'Netto Marken-Discount', leafletId: 5653691 }, // Im Bonnefeld 19
{ retailer: 'Netto Getränke-Discount', leafletId: 5627267 }, // Im Bonnefeld 21 { 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: 'Penny', leafletId: 5638286 }, // Sittardsberger Allee 10
{ retailer: 'E center', leafletId: 5637005 }, // Düsseldorfer Landstr. 361 { 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) { function isKnownLocalStore(storeId) {
if (storeId == null) return true; // Store konnte nicht ermittelt werden -> lieber behalten als faelschlich verstecken 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() { async function resolveLocalStores() {
const cache = new Map(); const cache = new Map();
let resolved = 0; let resolved = 0;
// 1) Direkt bekannte Leaflet-IDs (per Prospekt-Link bestätigt)
for (const seed of LOCAL_STORE_SEED_LEAFLETS) { for (const seed of LOCAL_STORE_SEED_LEAFLETS) {
const detail = await fetchLeafletDetail(seed.leafletId, cache); const detail = await fetchLeafletDetail(seed.leafletId, cache);
const store = detail.closestStore; 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`); 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; 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 = { const result = {
targeted: all.filter(b => b.isTarget), targeted: all.filter(b => b.isTarget),
others: all.filter(b => !b.isTarget), others: all.filter(b => !b.isTarget),
scrapedAt: new Date().toISOString(), 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); cacheSet(cacheKey, result);
return result; return result;
} }
@@ -617,7 +704,7 @@ router.post('/clear-cache', authenticate, (req, res) => {
router.get('/local-stores', authenticate, (req, res) => { router.get('/local-stores', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); 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(); 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 // 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 { try {
const resolved = await resolveLocalStores(); const resolved = await resolveLocalStores();
const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all(); 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 }); } } catch(e) { res.status(500).json({ error: e.message }); }
}); });

View File

@@ -115,6 +115,9 @@ function ProspektCard({ b }) {
borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}> borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>
{b.publisher} {b.publisher}
</div> </div>
{b.hasKoenigPilsener && (
<span title="König Pilsener gerade im Angebot" style={{ fontSize:13 }}>🍺</span>
)}
</div> </div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}> <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
📍 {b.street} 📍 {b.street}