feat: KoePi Prospekte zeigt alle Filialen mit Bier-Icon-Abgleich, Lidl/ALDI Sued/HORNBACH per Adress-Auto-Suche ergaenzt
This commit is contained in:
@@ -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 }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ function ProspektCard({ b }) {
|
||||
borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>
|
||||
{b.publisher}
|
||||
</div>
|
||||
{b.hasKoenigPilsener && (
|
||||
<span title="König Pilsener gerade im Angebot" style={{ fontSize:13 }}>🍺</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
|
||||
📍 {b.street}
|
||||
|
||||
Reference in New Issue
Block a user