Files
dickendock/backend/src/tools/koepi/routes.js

733 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const https = require('https');
const db = require('../../db');
const mqttClient = require('../../mqtt');
const { logPush } = require('../../pushLog');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── Cache ─────────────────────────────────────────────────────────────────────
const cache = {};
function cacheGet(k) { const e=cache[k]; if(!e||Date.now()>e.ex) { delete cache[k]; return null; } return e.d; }
function cacheSet(k,d,ms=60*60*1000) { cache[k]={d,ex:Date.now()+ms}; }
// ── marktguru Cookie (Standort Duisburg 47259) ────────────────────────────────
const MG_SETTINGS = encodeURIComponent(JSON.stringify({
location: { name:'Duisburg', uniqueName:'duisburg', longitude:6.7625, latitude:51.4332, zipCodes:['47259'] },
locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35,
}));
// ── Gewünschte Händler (grobe Vorfilterung, bevor die genaue Filiale geprüft wird) ─
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));
}
// 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
{ 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
];
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
return !!db.prepare('SELECT 1 FROM koepi_local_stores WHERE store_id=?').get(storeId);
}
// 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));
}
// ── 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
// 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_API_KEY = '8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE=';
const MG_ZIP = '47259';
const MG_QUERY = 'könig pilsener';
function formatIsoDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`;
}
// 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 den kompletten Datensatz von marktguru:
// closestStore (id/address/distanceInMeters — Grundlage der Filial-Whitelist),
// name (enthält oft "(KW29 Do-Sa)" o.ä.) und children (Liste aller Angebote
// mit ihrem pageIndex, für den seiten-genauen Direktlink).
async function fetchLeafletDetail(leafletId, cache) {
if (cache.has(leafletId)) return cache.get(leafletId);
const empty = { id: leafletId, name: '', closestStore: { id: null, address: '', distanceMeters: null }, children: [] };
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 = {
id: json.id ?? leafletId,
name: json.name || '',
closestStore: store
? { id: store.id ?? null, address: store.address || '', distanceMeters: store.distanceInMeters ?? null }
: { id: null, address: '', distanceMeters: null },
children: json.children || [],
};
console.log(`🍺 KöPi: Leaflet ${leafletId} ("${result.name}") → closestStore: ${store ? `id=${store.id} "${store.address}" (${Math.round(store.distanceInMeters||0)}m entfernt)` : 'keine'}`);
cache.set(leafletId, result);
return result;
} catch (e) {
console.log(`🍺 KöPi: Leaflet-Detail-Abruf für ${leafletId} fehlgeschlagen: ${e.message}`);
cache.set(leafletId, empty);
return empty;
}
}
// Extrahiert "(KW29 Do-Sa)"-artige Klammerzusätze aus dem Leaflet-Namen, falls
// vorhanden (nicht jede Filiale/jeder Prospekt hat sowas)
function extractWeekInfo(name) {
const m = (name || '').match(/\([^)]*KW\s*\d+[^)]*\)/i);
return m ? m[0] : '';
}
// 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;
// 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;
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`);
}
}
// 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;
}
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);
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 detailByOfferId = new Map();
for (const r of rawOffers) {
const leafletId = flightToLeaflet.get(r.leafletFlightId);
if (leafletId) {
const detail = await fetchLeafletDetail(leafletId, leafletCache);
detailByOfferId.set(r.id, detail);
}
}
return rawOffers.map(r => {
const advertiser = r.advertisers?.[0] || {};
const priceNum = typeof r.price === 'number' ? r.price : parseFloat(r.price);
const oldPriceNum = typeof r.oldPrice === 'number' ? r.oldPrice : parseFloat(r.oldPrice);
const detail = detailByOfferId.get(r.id);
const storeInfo = detail?.closestStore || { id: null, address: '', distanceMeters: null };
const validity0 = r.validityDates?.[0] || {};
const weekInfo = extractWeekInfo(detail?.name);
const baseDateRange = validity0.from && validity0.to
? `${formatIsoDate(validity0.from)} - ${formatIsoDate(validity0.to)}`
: formatIsoDate(validity0.to);
// Seiten-genauer Direktlink zum Angebot innerhalb des Prospekts — wird bei
// jedem Scrape frisch aus den aktuellen API-Daten ermittelt (children[].id
// == "offers/{id}"), funktioniert also automatisch weiter bei neuen
// Prospekten, ohne dass irgendwas hartkodiert werden muss.
const child = detail?.children?.find(c => c.id === `offers/${r.id}`);
const leafletUrl = detail
? `https://www.marktguru.de/leaflets/${detail.id}${child ? `/page/${child.pageIndex}` : ''}`
: null;
return {
name: r.description || r.title || advertiser.name || '',
brand: r.brand?.name || '',
price: Number.isFinite(priceNum) ? `${priceNum.toFixed(2).replace('.', ',')}` : (r.price || ''),
oldPrice: Number.isFinite(oldPriceNum) ? `${oldPriceNum.toFixed(2).replace('.', ',')}` : (r.oldPrice || ''),
retailer: advertiser.name || '',
dateRange: weekInfo ? `${baseDateRange} ${weekInfo}` : baseDateRange,
weekInfo,
validity: '',
description: r.description || '',
badge: '',
// 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,
leafletUrl,
image: r.image?.url || r.images?.[0]?.url || null,
};
});
}
async function fetchOffersViaPuppeteer() {
let puppeteer;
try { puppeteer = require('puppeteer-core'); }
catch(e) { throw new Error('puppeteer-core nicht installiert'); }
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium-browser',
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--disable-gpu','--single-process'],
headless: true,
});
try {
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setViewport({ width:1280, height:800 });
await page.setCookie({ name:'mg_user-settings', value:MG_SETTINGS, domain:'.marktguru.de', path:'/' });
await page.goto('https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259', {
waitUntil: 'networkidle2', timeout: 30000,
});
// Seite nach unten scrollen damit lazy-loaded Bilder geladen werden
await page.evaluate(async () => {
await new Promise(resolve => {
let totalHeight = 0;
const distance = 300;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= document.body.scrollHeight) {
clearInterval(timer);
resolve();
}
}, 200);
});
});
await new Promise(r => setTimeout(r, 3000));
const offers = await page.evaluate(() => {
// Exakte Selektoren basierend auf marktguru HTML-Struktur
const cards = document.querySelectorAll('li.offer-list-item');
return Array.from(cards).map(card => {
const name = card.querySelector('h3')?.textContent?.trim() || '';
const brand = card.querySelector('dd.brand a')?.textContent?.trim() || '';
const price = card.querySelector('span.price')?.textContent?.trim() || '';
const oldPrice = card.querySelector('.price-bubble .old-price, .crossed')?.textContent?.trim() || '';
const retailer = card.querySelector('dd.retailer-name a')?.textContent?.trim() || '';
const dateRange= card.querySelector('dd.valid')?.textContent?.trim() || '';
const daysLeft = card.querySelector('dd.time-left span')?.textContent?.trim() || '';
const validity = daysLeft ? `Noch ${daysLeft} Tag${daysLeft==='1'?'':'e'}` :
card.querySelector('dt.time-left')?.textContent?.includes('Brandneu') ? 'Neu' : '';
const descEl = card.querySelector('.info div, .info');
const description = descEl?.textContent?.trim() || '';
const img = card.querySelector('img.offer-list-item-img');
const badge = card.querySelector('.badge, .discount-badge')?.textContent?.trim() || '';
const imgSrc = img?.src?.replace('/medium.webp', '/large.webp') || img?.src || null;
const allDds = Array.from(card.querySelectorAll('dd'));
const retailerIdx = allDds.findIndex(d => d.classList.contains('retailer-name'));
let realAddress = '';
if (retailerIdx !== -1 && allDds[retailerIdx + 1]) {
const next = allDds[retailerIdx + 1];
const isOtherField = next.classList.contains('valid') || next.classList.contains('time-left');
if (!isOtherField) realAddress = next.textContent?.trim() || '';
}
return { name, brand, price, oldPrice, retailer, dateRange, validity, description, badge, address: '', realAddress, image: imgSrc };
});
});
return offers;
} finally {
await browser.close();
}
}
// 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
for (const offer of offers) {
const desc = (offer.description || '').toLowerCase();
if (desc.includes('steini')) {
offer.imageLocal = '/koepi/kasten_steini.png';
} else if (desc.includes('11 x 0,5') || desc.includes('11x0,5')) {
offer.imageLocal = '/koepi/kasten_lang.png';
} else if (desc.includes('6 x') || desc.includes('6x') || (desc.includes('flasche') && !desc.includes('kasten'))) {
offer.imageLocal = '/koepi/traeger.png';
} else if (desc.includes('24 x 0,33') || desc.includes('24x0,33')) {
offer.imageLocal = '/koepi/kasten_033.png';
} else if (desc.includes('0,5')) {
offer.imageLocal = '/koepi/kasten_050.png';
} else if (desc.includes('0,33')) {
offer.imageLocal = '/koepi/kasten_033.png';
} else {
offer.imageLocal = '/koepi/kasten_050.png';
}
offer.image = null; // CDN nicht mehr nötig
}
// Filtern auf Ziel-Händler + nur König Pilsener
const filtered = offers.filter(o => {
const isKP = (o.name + ' ' + o.brand + ' ' + o.description).toLowerCase();
const hasKP = isKP.includes('pilsen') || isKP.includes('pilsener');
return hasKP && isTargetRetailer(o.retailer);
});
const excludedNow = filtered.filter(o => isExcludedRetailer(o.retailer));
if (excludedNow.length) {
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 => isKnownLocalStore(o.storeId));
for (const o of localOnly) {
o.retailerDisplay = o.retailer;
}
// 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}|${o.storeId ?? ''}`;
if (merged.has(key)) {
const existing = merged.get(key);
if (o.description && (!existing.description || o.description.length < existing.description.length)) {
existing.description = o.description;
}
} else {
merged.set(key, { ...o });
}
}
return [...merged.values()];
}
async function scrapeMarktguru(forceRefresh = false) {
const cacheKey = 'koepi:mg';
if (!forceRefresh) {
const cached = cacheGet(cacheKey);
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();
source = 'marktguru.de (API)';
console.log(`🍺 KöPi: ${offers.length} Rohtreffer über die marktguru-API`);
} catch (apiErr) {
console.error('🍺 KöPi: API-Weg fehlgeschlagen, falle zurück auf Puppeteer:', apiErr.message);
try {
offers = await fetchOffersViaPuppeteer();
source = 'marktguru.de (Puppeteer-Fallback)';
console.log(`🍺 KöPi: ${offers.length} Rohtreffer per Puppeteer (Fallback)`);
} catch (ppErr) {
console.error('🍺 KöPi: auch Puppeteer-Fallback fehlgeschlagen:', ppErr.message);
return { offers: [], error: `API: ${apiErr.message} | Puppeteer: ${ppErr.message}`, scrapedAt: new Date().toISOString() };
}
}
const unique = processOffers(offers);
const result = { offers: unique, total: unique.length, scrapedAt: new Date().toISOString(), source };
cacheSet(cacheKey, result);
return result;
}
// ── Prospekte von marktguru (dieselbe Filial-Whitelist wie bei den Angeboten) ──
async function scrapeProspekte() {
const cacheKey = 'koepi:prospekte';
const cached = cacheGet(cacheKey);
if (cached) return cached;
const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n;
if (knownCount === 0) {
console.log('🍺 KöPi Prospekte: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...');
try { await resolveLocalStores(); }
catch (e) { console.error('🍺 KöPi Prospekte: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); }
}
const flightsUrl = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`;
const flightsRes = await fetch(flightsUrl, { headers: mgHeaders });
if (!flightsRes.ok) throw new Error(`leafletflights HTTP ${flightsRes.status}`);
const flightsJson = await flightsRes.json();
console.log(`🍺 KöPi Prospekte: ${flightsJson.results?.length} von ${flightsJson.totalResults} Prospekt-Kampagnen geladen`);
// Nur Kampagnen der gewünschten Ketten überhaupt im Detail auflösen (spart API-Calls)
const candidates = (flightsJson.results || []).filter(f => isTargetRetailer(f.advertiser?.name));
const leafletCache = new Map();
const all = [];
for (const f of candidates) {
if (!f.mainLeafletId) continue;
const detail = await fetchLeafletDetail(f.mainLeafletId, leafletCache);
const store = detail.closestStore;
const weekInfo = extractWeekInfo(detail.name);
all.push({
id: f.id,
publisher: f.advertiser?.name || '',
title: detail.name || f.advertiser?.name || '',
weekInfo,
street: store.address || '',
zip: MG_ZIP,
city: 'Duisburg',
storeId: store.id,
image: null,
validFrom: f.validFrom || null,
validTo: f.validTo || null,
pageCount: f.pageCount || null,
url: `https://www.marktguru.de/leaflets/${f.mainLeafletId}`,
isTarget: isKnownLocalStore(store.id) && store.id != null,
badges: [],
});
}
// 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, ${result.targeted.filter(b=>b.hasKoenigPilsener).length} davon mit König Pilsener im Angebot`);
cacheSet(cacheKey, result);
return result;
}
// ── Bild-Proxy (für evtl. externe Bilder) ───────────────────────────────────
router.get('/img', authenticate, (req, res) => {
const url = req.query.url;
if (!url || !url.startsWith('https://')) return res.status(400).end();
try {
const parsed = new URL(url);
const allowed = ['content-media.bonial.biz','publisher-media.bonial.biz'];
if (!allowed.some(h => parsed.hostname === h)) return res.status(403).end();
const r = https.get(url, { headers: {'User-Agent':'Mozilla/5.0'} }, imgRes => {
res.setHeader('Content-Type', imgRes.headers['content-type'] || 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=3600');
imgRes.pipe(res);
});
r.on('error', () => res.status(502).end());
} catch { res.status(400).end(); }
});
// ── Tages-Cron: neue/geänderte Angebote per Pushover melden ─────────────────
const KOEPI_SETTINGS_KEY = 'koepi_last_offers';
// Vergleichbare, stabile Signatur eines Angebots (Reihenfolge-unabhängig).
// Bewusst NUR dateRange (echtes Datum), NICHT validity ("Noch X Tage") — der
// Countdown-Text ändert sich täglich von allein und würde sonst jeden Tag
// eine Pushover-Benachrichtigung auslösen, auch wenn sich am Angebot nichts
// geändert hat.
function offerSignature(offers) {
return offers
.map(o => `${(o.retailer || '').toLowerCase().trim()}|${(o.price || '').trim()}|${(o.dateRange || '').trim()}`)
.sort()
.join('\n');
}
function formatOfferMessage(offers) {
if (!offers.length) return 'Aktuell keine König Pilsener Angebote gefunden.';
return offers
.map(o => `${o.retailer || '?'}: ${o.price || '?'} (${o.dateRange || 'Gültigkeit unbekannt'})`)
.join('\n');
}
// onlyUserId gesetzt: nur an diesen einen Nutzer schicken (z.B. Admin-Testklick),
// sonst an alle Nutzer mit hinterlegtem Pushover (echter Tages-Cron)
async function sendKoepiPushover(offers, onlyUserId = null) {
const recipients = onlyUserId
? db.prepare(`
SELECT user_id, user_key, app_token FROM pushover_settings
WHERE user_id = ? AND user_key IS NOT NULL AND app_token IS NOT NULL
`).all(onlyUserId)
: db.prepare(`
SELECT user_id, user_key, app_token FROM pushover_settings
WHERE user_key IS NOT NULL AND app_token IS NOT NULL
`).all();
const message = formatOfferMessage(offers);
const title = '🍺 KöPi Angebote';
for (const r of recipients) {
try {
await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: r.app_token,
user: r.user_key,
title,
message,
priority: 0,
}),
});
logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi' });
} catch (e) {
console.error('KöPi Pushover Fehler:', e.message);
logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi', success: false });
}
}
}
// Reiner Anzeige-Refresh für den HA-Button "KöPi Check jetzt" — scraped frisch
// und aktualisiert nur die MQTT-Entitäten. Sendet NIE Pushover und rührt den
// in admin_settings gespeicherten Vergleichsstand nicht an (der bleibt exklusiv
// dem 06:00-Cron vorbehalten, damit dessen Diff-Erkennung korrekt bleibt).
async function refreshOffersOnly() {
try {
const { offers } = await scrapeMarktguru(true);
const currentSig = offerSignature(offers);
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
const changed = currentSig !== lastSig; // nur zur Anzeige im "geändert"-Sensor, löst nichts aus
mqttClient.publishKoepiState({ offers, changed });
console.log(`🍺 KöPi-Refresh (HA-Button): ${offers.length} Angebot(e) angezeigt, keine Pushover.`);
return { changed, sent: false, offerCount: offers.length };
} catch (e) {
console.error('KöPi Refresh Fehler:', e.message);
return { error: e.message };
}
}
// Wird täglich um 06:00 vom Scheduler in index.js aufgerufen (ohne onlyUserId
// → geht an alle). forceNotify=true (manueller Admin-Test): Pushover wird auch
// ohne Änderung verschickt; onlyUserId gesetzt: nur an diesen einen Nutzer.
async function runDailyCheck(forceNotify = false, onlyUserId = null) {
try {
const { offers } = await scrapeMarktguru(true);
const currentSig = offerSignature(offers);
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
const changed = currentSig !== lastSig;
if (!changed && !forceNotify) {
console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.');
mqttClient.publishKoepiState({ offers, changed: false });
return { changed: false, sent: false, offerCount: offers.length };
}
await sendKoepiPushover(offers, onlyUserId);
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)')
.run(KOEPI_SETTINGS_KEY, currentSig);
console.log(`🍺 KöPi-Check: ${offers.length} Angebot(e)${changed ? ' geändert' : ' (Test, unverändert)'} Pushover versendet${onlyUserId ? ` (nur an User ${onlyUserId})` : ''}.`);
mqttClient.publishKoepiState({ offers, changed });
return { changed, sent: true, offerCount: offers.length };
} catch (e) {
console.error('KöPi Tages-Check Fehler:', e.message);
return { error: e.message };
}
}
// ── Routes ────────────────────────────────────────────────────────────────────
router.get('/offers', authenticate, async (req, res) => {
try { res.json(await scrapeMarktguru()); }
catch(e) { console.error('KöPi offers:', e.message); res.status(500).json({ error: e.message }); }
});
router.get('/prospekte', authenticate, async (req, res) => {
try { res.json(await scrapeProspekte()); }
catch(e) {
console.error('🍺 KöPi Prospekte-Fehler:', e.message);
res.status(500).json({ error: e.message });
}
});
router.post('/clear-cache', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
['koepi:mg','koepi:prospekte'].forEach(k => delete cache[k]);
res.json({ ok: true });
});
// 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 + LOCAL_STORE_SEED_BY_ADDRESS.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 + LOCAL_STORE_SEED_BY_ADDRESS.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 {
const result = await runDailyCheck(true, req.user.id);
if (result.error) return res.status(500).json({ error: result.error });
res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;
module.exports.runDailyCheck = runDailyCheck;
module.exports.refreshOffersOnly = refreshOffersOnly;