feat: KoePi Filial-Filter jetzt ueber echte marktguru Store-ID-Whitelist, Adresse direkt von der API

This commit is contained in:
2026-07-17 21:20:10 +02:00
parent 2b2a2dce2d
commit 8e8a5ffea5
2 changed files with 175 additions and 74 deletions

View File

@@ -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;

View File

@@ -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)}.`;
}
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: {
// 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: 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 {