feat: KoePi Filial-Filter jetzt ueber echte marktguru Store-ID-Whitelist, Adresse direkt von der API
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user