feat: KöPi - marktguru.de als Quelle, echte Angebote mit Flaschengröße
This commit is contained in:
@@ -3,101 +3,37 @@ const https = require('https');
|
|||||||
const { authenticate } = require('../../middleware/auth');
|
const { authenticate } = require('../../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ── Cache (in-memory, 1h) ─────────────────────────────────────────────────────
|
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
const cache = {};
|
const cache = {};
|
||||||
function cacheGet(key) {
|
function cacheGet(k) { const e=cache[k]; if(!e||Date.now()>e.ex) { delete cache[k]; return null; } return e.d; }
|
||||||
const e = cache[key];
|
function cacheSet(k,d,ms=60*60*1000) { cache[k]={d,ex:Date.now()+ms}; }
|
||||||
if (!e) return null;
|
|
||||||
if (Date.now() > e.expires) { delete cache[key]; return null; }
|
|
||||||
return e.data;
|
|
||||||
}
|
|
||||||
function cacheSet(key, data, ttlMs = 60 * 60 * 1000) {
|
|
||||||
cache[key] = { data, expires: Date.now() + ttlMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP-Helper ───────────────────────────────────────────────────────────────
|
// ── marktguru Cookie (Standort Duisburg 47259) ────────────────────────────────
|
||||||
function fetchUrl(url, maxRedirects = 5) {
|
const MG_SETTINGS = encodeURIComponent(JSON.stringify({
|
||||||
return new Promise((resolve, reject) => {
|
location: { name:'Duisburg', uniqueName:'duisburg', longitude:6.7625, latitude:51.4332, zipCodes:['47259'] },
|
||||||
const doGet = (u, remaining) => {
|
locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35,
|
||||||
const req = https.get(u, {
|
}));
|
||||||
headers: {
|
|
||||||
'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',
|
|
||||||
'Accept': 'text/html,application/xhtml+xml',
|
|
||||||
'Accept-Language': 'de-DE,de;q=0.9',
|
|
||||||
}
|
|
||||||
}, (res) => {
|
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && remaining > 0) {
|
|
||||||
const next = res.headers.location.startsWith('http')
|
|
||||||
? res.headers.location
|
|
||||||
: `https://www.kaufda.de${res.headers.location}`;
|
|
||||||
res.resume();
|
|
||||||
doGet(next, remaining - 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let d = '';
|
|
||||||
res.on('data', c => d += c);
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode, body: d }));
|
|
||||||
});
|
|
||||||
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Timeout')); });
|
|
||||||
req.on('error', reject);
|
|
||||||
};
|
|
||||||
doGet(url, maxRedirects);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── NEXT_DATA extrahieren ─────────────────────────────────────────────────────
|
// ── Gewünschte Händler ────────────────────────────────────────────────────────
|
||||||
function extractNextData(html) {
|
const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut'];
|
||||||
const marker = 'application/json">';
|
function isTargetRetailer(name) {
|
||||||
const nidx = html.indexOf('NEXT_DATA');
|
|
||||||
if (nidx === -1) return null;
|
|
||||||
const start = html.indexOf(marker, nidx) + marker.length;
|
|
||||||
const end = html.indexOf('</script>', start);
|
|
||||||
if (start <= marker.length || end === -1) return null;
|
|
||||||
try { return JSON.parse(html.slice(start, end)); } catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Gewünschte Filialen ──────────────────────────────────────────────────────
|
|
||||||
const TARGET_STORES = [
|
|
||||||
{ name: 'edeka', street: 'düsseldorfer landstr', label: 'EDEKA E center Angerbogen' },
|
|
||||||
{ name: 'edeka', street: 'angerbogen', label: 'EDEKA E center Angerbogen' },
|
|
||||||
{ name: 'e center', street: 'düsseldorfer', label: 'EDEKA E center Angerbogen' },
|
|
||||||
{ name: 'rewe', street: 'mündelheimer', label: 'REWE Mündelheimer Str.' },
|
|
||||||
{ name: 'rewe', street: 'keniastr', label: 'REWE XXL Schwinning' },
|
|
||||||
{ name: 'netto', street: 'bonnefeld', label: 'Netto Im Bonnefeld' },
|
|
||||||
{ name: 'kaufland', street: 'auf der höhe', label: 'Kaufland Kaßlerfeld' },
|
|
||||||
{ name: 'penny', street: 'sittardsberger', label: 'Penny Sittardsberger Allee' },
|
|
||||||
{ name: 'trinkgut', street: 'keniastr', label: 'Trinkgut Keniastraße' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function matchesTargetStore(publisherName, storeName, street) {
|
|
||||||
const p = (publisherName || '').toLowerCase();
|
|
||||||
const s = (storeName || '').toLowerCase();
|
|
||||||
const r = (street || '').toLowerCase();
|
|
||||||
for (const t of TARGET_STORES) {
|
|
||||||
if ((p.includes(t.name) || s.includes(t.name)) && r.includes(t.street)) return t.label;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTargetPublisher(name) {
|
|
||||||
if (!name) return false;
|
if (!name) return false;
|
||||||
const n = name.toLowerCase();
|
const n = name.toLowerCase();
|
||||||
return ['rewe','edeka','e center','netto','kaufland','penny','trinkgut'].some(p => n.includes(p));
|
return TARGET_RETAILERS.some(r => n.includes(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Angebote via Puppeteer (BrochureBox_Basic + OfferGrid) ──────────────────
|
// ── Angebote via Puppeteer von marktguru ─────────────────────────────────────
|
||||||
async function scrapeOffersWithPuppeteer() {
|
async function scrapeMarktguru() {
|
||||||
const cacheKey = 'koepi:offers';
|
const cacheKey = 'koepi:mg';
|
||||||
const cached = cacheGet(cacheKey);
|
const cached = cacheGet(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
let puppeteer;
|
let puppeteer;
|
||||||
try { puppeteer = require('puppeteer-core'); } catch(e) {
|
try { puppeteer = require('puppeteer-core'); }
|
||||||
return { offers: [], error: 'puppeteer-core nicht installiert', scrapedAt: new Date().toISOString() };
|
catch(e) { return { offers:[], error:'puppeteer-core nicht installiert', scrapedAt: new Date().toISOString() }; }
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium-browser',
|
executablePath: '/usr/bin/chromium-browser',
|
||||||
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--disable-gpu','--single-process'],
|
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--disable-gpu','--single-process'],
|
||||||
headless: true,
|
headless: true,
|
||||||
});
|
});
|
||||||
@@ -105,80 +41,85 @@ async function scrapeOffersWithPuppeteer() {
|
|||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
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.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.goto(
|
await page.setViewport({ width:1280, height:800 });
|
||||||
'https://www.kaufda.de/Angebote/Koenig-Pilsener?lat=51.4332&lng=6.7625&zip=47051&city=Duisburg',
|
await page.setCookie({ name:'mg_user-settings', value:MG_SETTINGS, domain:'.marktguru.de', path:'/' });
|
||||||
{ waitUntil: 'domcontentloaded', timeout: 25000 }
|
|
||||||
);
|
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
|
||||||
|
|
||||||
const data = await page.evaluate(() => {
|
await page.goto('https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259', {
|
||||||
// ── BrochureBox: Prospekte wo König Pilsener drin ist ──────────────────
|
waitUntil: 'networkidle2', timeout: 30000,
|
||||||
const brochureOffers = Array.from(document.querySelectorAll('[data-testid="BrochureBox_Basic"]')).map(b => {
|
});
|
||||||
const text = b.textContent.trim();
|
await new Promise(r => setTimeout(r, 4000));
|
||||||
const imgs = Array.from(b.querySelectorAll('img'));
|
|
||||||
const productImg = imgs[0]?.src || null;
|
|
||||||
const publisherImg = imgs[1]?.src || null;
|
|
||||||
|
|
||||||
// Text parsen
|
const offers = await page.evaluate(() => {
|
||||||
const storeMatch = text.match(/bei\s+([^""„]+?)\s*[""„]/);
|
const results = [];
|
||||||
const titleMatch = text.match(/[""„]([^""„]+?)[""„]/);
|
// Angebots-Karten selektieren
|
||||||
const pageMatch = text.match(/Seite\s+(\d+)/);
|
const cards = document.querySelectorAll('.offer-item, [class*="offer-item"], .search-offer, [class*="searchOffer"]');
|
||||||
const validMatch = text.match(/(Noch \d+ Tag[e]? g.ltig|G.ltig bis [\d\.]+)/);
|
|
||||||
const dateMatch = text.match(/(\d{1,2}\.\d{1,2}\.(?:\d{4})?)\s*-\s*(\w+\.?\s+\d{1,2}\.\d{1,2}\.\d{4})/);
|
|
||||||
const isNew = text.startsWith('neu');
|
|
||||||
|
|
||||||
// URL aus Link holen
|
// Fallback: alle Container die Preis + Händler enthalten
|
||||||
const link = b.querySelector('a');
|
const containers = cards.length > 0 ? cards :
|
||||||
const url = link?.href || null;
|
document.querySelectorAll('[class*="offer"]:not([class*="more"]):not([class*="list"]):not([class*="section"])');
|
||||||
|
|
||||||
return {
|
containers.forEach(card => {
|
||||||
type: 'brochure',
|
const text = card.innerText || card.textContent || '';
|
||||||
store: storeMatch?.[1]?.trim() || null,
|
if (!text.includes('König') && !text.includes('Pilsen')) return;
|
||||||
title: titleMatch?.[1]?.trim() || null,
|
|
||||||
page: pageMatch?.[1] ? parseInt(pageMatch[1]) : null,
|
const img = card.querySelector('img');
|
||||||
dateRange: dateMatch ? `${dateMatch[1]} - ${dateMatch[2]}` : null,
|
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
validity: validMatch?.[1] || null,
|
|
||||||
isNew,
|
// Felder aus Text extrahieren
|
||||||
url,
|
let name='', brand='', price='', retailer='', validity='', dateRange='', description='', oldPrice='';
|
||||||
productImage: productImg,
|
|
||||||
publisherLogo: publisherImg,
|
lines.forEach((line, i) => {
|
||||||
};
|
if (line.match(/^€\s*\d/) && !price) price = line;
|
||||||
|
if (line.match(/^\d+[,\.]\d+\s*€/) && !price) price = line;
|
||||||
|
if (line.includes('Händler:')) retailer = lines[i+1] || line.replace('Händler:','').trim();
|
||||||
|
if (line.includes('Marke:')) brand = lines[i+1] || line.replace('Marke:','').trim();
|
||||||
|
if (line.includes('Gültig:') || line.includes('gültig:')) dateRange = lines[i+1] || line.replace(/G.ltig:/i,'').trim();
|
||||||
|
if (line.includes('Noch') && line.includes('Tag')) validity = line;
|
||||||
|
if (line.includes('/ l') || line.includes('/l') || line.includes('Kasten') || line.includes('Flasche')) description = line;
|
||||||
|
if (line.includes('Brandneu')) validity = 'Neu';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Name ist meist die erste Zeile
|
||||||
|
name = lines[0] || '';
|
||||||
|
|
||||||
|
// Direkte DOM-Selektion als Alternative
|
||||||
|
const priceEl = card.querySelector('[class*="price"],[class*="Price"]');
|
||||||
|
const retailEl = card.querySelector('[class*="retailer"],[class*="Retailer"],[class*="store"],[class*="Store"]');
|
||||||
|
const nameEl = card.querySelector('[class*="title"],[class*="name"],h3,h2');
|
||||||
|
const brandEl = card.querySelector('[class*="brand"],[class*="Brand"]');
|
||||||
|
const descEl = card.querySelector('[class*="desc"],[class*="info"],[class*="detail"]');
|
||||||
|
|
||||||
|
if (priceEl) price = priceEl.textContent.trim();
|
||||||
|
if (retailEl) retailer = retailEl.textContent.trim();
|
||||||
|
if (nameEl) name = nameEl.textContent.trim();
|
||||||
|
if (brandEl) brand = brandEl.textContent.trim();
|
||||||
|
if (descEl) description = descEl.textContent.trim();
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name, brand, price, oldPrice, retailer, validity, dateRange, description,
|
||||||
|
image: img?.src || null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── OfferGrid: einzelne Produktkacheln mit Preisen ──────────────────────
|
return results;
|
||||||
const grid = document.querySelector('[data-testid="OfferGrid"]');
|
|
||||||
const gridOffers = grid ? Array.from(grid.querySelectorAll('[role="listitem"]')).map(item => {
|
|
||||||
const img = item.querySelector('img');
|
|
||||||
const ps = Array.from(item.querySelectorAll('p')).map(p => p.textContent.trim());
|
|
||||||
const brand = ps[0] || null;
|
|
||||||
const name = ps[1] || null;
|
|
||||||
const store = ps[2] || null;
|
|
||||||
const price = item.querySelector('[class*="text-primary"]')?.textContent?.trim() || null;
|
|
||||||
const baseUnit = ps[5] || ps[4] || null; // z.B. "(1 L = 1,20)"
|
|
||||||
const imgTitle = img?.title || '';
|
|
||||||
const imgAlt = img?.alt || '';
|
|
||||||
// Flaschengröße aus imgTitle/imgAlt extrahieren
|
|
||||||
const sizeMatch = (imgTitle + ' ' + imgAlt).match(/(\d+[\.,]\d*\s*[lLxX×]\s*\d*\s*(?:Fl\.?|Flasche|Flaschen|Dose|Dosen|Pack|Kiste)?|\d+\s*(?:×|x)\s*\d+[\.,]\d*\s*[lL]|\d+\s*[lL]|\d+\s*ml)/i);
|
|
||||||
const size = sizeMatch?.[0]?.trim() || null;
|
|
||||||
return { type:'offer', brand, name, store, price, baseUnit, size, image: img?.src || null, imgTitle };
|
|
||||||
}).filter(o => {
|
|
||||||
if (!o.name) return false;
|
|
||||||
// Nur König Pilsener — kein König Ludwig, König der Löwen etc.
|
|
||||||
const fullText = (o.imgTitle + ' ' + (o.brand||'') + ' ' + (o.name||'')).toLowerCase();
|
|
||||||
return fullText.includes('pilsener') || fullText.includes('pilsen') ||
|
|
||||||
(fullText.includes('könig') && !fullText.includes('ludwig') && !fullText.includes('löwen') && !fullText.includes('weiss'));
|
|
||||||
}) : [];
|
|
||||||
|
|
||||||
return { brochureOffers, gridOffers };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = {
|
// Filtern auf Ziel-Händler + nur König Pilsener
|
||||||
brochureOffers: data.brochureOffers,
|
const filtered = offers.filter(o => {
|
||||||
gridOffers: data.gridOffers,
|
const isKP = (o.name + ' ' + o.brand + ' ' + o.description).toLowerCase();
|
||||||
scrapedAt: new Date().toISOString(),
|
const hasKP = isKP.includes('pilsen') || isKP.includes('pilsener');
|
||||||
source: 'kaufda.de',
|
return hasKP && isTargetRetailer(o.retailer);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// Deduplizieren
|
||||||
|
const seen = new Set();
|
||||||
|
const unique = filtered.filter(o => {
|
||||||
|
const k = `${o.retailer}|${o.price}|${o.description?.slice(0,30)}`;
|
||||||
|
if (seen.has(k)) return false;
|
||||||
|
seen.add(k); return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = { offers: unique, total: unique.length, scrapedAt: new Date().toISOString(), source: 'marktguru.de' };
|
||||||
cacheSet(cacheKey, result);
|
cacheSet(cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -186,121 +127,111 @@ async function scrapeOffersWithPuppeteer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Prospekte scrapen ─────────────────────────────────────────────────────────
|
// ── Prospekte von kaufda ──────────────────────────────────────────────────────
|
||||||
|
function fetchUrl(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.get(url, {
|
||||||
|
headers: {
|
||||||
|
'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',
|
||||||
|
'Accept': 'text/html', 'Accept-Language': 'de-DE,de;q=0.9',
|
||||||
|
}
|
||||||
|
}, res => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', c => d += c);
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: d }));
|
||||||
|
});
|
||||||
|
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNextData(html) {
|
||||||
|
const marker = 'application/json">';
|
||||||
|
const ni = html.indexOf('NEXT_DATA');
|
||||||
|
if (ni === -1) return null;
|
||||||
|
const st = html.indexOf(marker, ni) + marker.length;
|
||||||
|
const en = html.indexOf('</script>', st);
|
||||||
|
try { return JSON.parse(html.slice(st, en)); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
async function scrapeProspekte() {
|
async function scrapeProspekte() {
|
||||||
const cacheKey = 'koepi:prospekte';
|
const cacheKey = 'koepi:prospekte';
|
||||||
const cached = cacheGet(cacheKey);
|
const cached = cacheGet(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// Koordinaten rund um deine Filialen (alle im Süden Duisburgs)
|
const { body } = await fetchUrl(
|
||||||
const locations = [
|
'https://www.kaufda.de/shelf?query=K%C3%B6nig+Pilsener&lat=51.3750&lng=6.7680&zip=47259&city=Duisburg'
|
||||||
{ lat: 51.3750, lng: 6.7680, city: 'Duisburg', zip: '47259' }, // Im Bonnefeld / Netto
|
);
|
||||||
{ lat: 51.4020, lng: 6.7616, city: 'Duisburg', zip: '47055' }, // REWE Mündelheimer
|
const nd = extractNextData(body);
|
||||||
{ lat: 51.4406, lng: 6.7555, city: 'Duisburg', zip: '47059' }, // Kaufland / REWE Kenia / Trinkgut
|
const contents = nd?.props?.pageProps?.pageInformation?.shelfContents?.contents || [];
|
||||||
{ lat: 51.3900, lng: 6.7900, city: 'Duisburg', zip: '47259' }, // Penny Sittardsberger
|
|
||||||
];
|
|
||||||
|
|
||||||
const allBrochures = new Map();
|
const seen = new Map();
|
||||||
|
for (const item of contents) {
|
||||||
for (const loc of locations) {
|
const c = item.content;
|
||||||
try {
|
if (!c || item.contentType !== 'brochure') continue;
|
||||||
const { body } = await fetchUrl(
|
const id = c.contentId;
|
||||||
`https://www.kaufda.de/shelf?query=K%C3%B6nig+Pilsener&lat=${loc.lat}&lng=${loc.lng}&zip=${loc.zip}&city=${encodeURIComponent(loc.city)}`
|
if (!id || seen.has(id)) continue;
|
||||||
);
|
const pub = c.publisher?.name || '';
|
||||||
const nextData = extractNextData(body);
|
const store = c.closestStore;
|
||||||
if (!nextData) continue;
|
seen.set(id, {
|
||||||
|
id, publisher: pub,
|
||||||
const pp = nextData.props?.pageProps;
|
title: c.title || '',
|
||||||
const contents = pp?.pageInformation?.shelfContents?.contents || [];
|
store: store?.name || pub,
|
||||||
|
city: store?.city || 'Duisburg',
|
||||||
for (const item of contents) {
|
street: store ? `${store.street||''} ${store.streetNumber||''}`.trim() : '',
|
||||||
const c = item.content;
|
zip: store?.zip || '47259',
|
||||||
if (!c || item.contentType !== 'brochure') continue;
|
image: c.brochureImages?.find(i => i.size === '260x270')?.url || c.brochureImage?.url || null,
|
||||||
const id = c.contentId;
|
validFrom: c.validFrom || null,
|
||||||
if (!id || allBrochures.has(id)) continue;
|
validTo: c.validUntil || null,
|
||||||
|
pageCount: c.pageCount || null,
|
||||||
const publisher = c.publisher?.name || '';
|
url: `https://www.kaufda.de/webapp/brochure/${id}`,
|
||||||
const store = c.closestStore;
|
isTarget: isTargetRetailer(pub),
|
||||||
|
badges: c.contentBadges?.map(b => b.name) || [],
|
||||||
const storeStreet = store ? `${store.street || ''} ${store.streetNumber || ''}`.trim() : '';
|
});
|
||||||
const matchLabel = matchesTargetStore(publisher, store?.name, storeStreet);
|
|
||||||
|
|
||||||
allBrochures.set(id, {
|
|
||||||
id,
|
|
||||||
publisher,
|
|
||||||
title: c.title || '',
|
|
||||||
store: store?.name || publisher,
|
|
||||||
storeLabel: matchLabel || store?.name || publisher,
|
|
||||||
city: store?.city || loc.city,
|
|
||||||
street: storeStreet,
|
|
||||||
zip: store?.zip || loc.zip,
|
|
||||||
image: c.brochureImages?.find(i => i.size === '260x270')?.url || c.brochureImage?.url || null,
|
|
||||||
validFrom: c.validFrom || null,
|
|
||||||
validTo: c.validUntil || null,
|
|
||||||
pageCount: c.pageCount || null,
|
|
||||||
url: `https://www.kaufda.de/webapp/brochure/${id}`,
|
|
||||||
isTarget: !!matchLabel,
|
|
||||||
badges: c.contentBadges?.map(b => b.name) || [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
console.error('KöPi prospekte error:', loc.city, e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const brochures = [...allBrochures.values()];
|
const all = [...seen.values()];
|
||||||
const result = {
|
const result = {
|
||||||
targeted: brochures.filter(b => b.isTarget),
|
targeted: all.filter(b => b.isTarget),
|
||||||
others: brochures.filter(b => !b.isTarget),
|
others: all.filter(b => !b.isTarget),
|
||||||
total: brochures.length,
|
|
||||||
scrapedAt: new Date().toISOString(),
|
scrapedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
cacheSet(cacheKey, result);
|
cacheSet(cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bild-Proxy ────────────────────────────────────────────────────────────────
|
||||||
|
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','cdn.marktguru.de','www.marktguru.de'];
|
||||||
|
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(); }
|
||||||
|
});
|
||||||
|
|
||||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
router.get('/offers', authenticate, async (req, res) => {
|
router.get('/offers', authenticate, async (req, res) => {
|
||||||
try {
|
try { res.json(await scrapeMarktguru()); }
|
||||||
const data = await scrapeOffersWithPuppeteer();
|
catch(e) { console.error('KöPi offers:', e.message); res.status(500).json({ error: e.message }); }
|
||||||
res.json(data);
|
|
||||||
} catch(e) {
|
|
||||||
console.error('KöPi offers error:', e.message);
|
|
||||||
res.status(500).json({ error: e.message });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/prospekte', authenticate, async (req, res) => {
|
router.get('/prospekte', authenticate, async (req, res) => {
|
||||||
try {
|
try { res.json(await scrapeProspekte()); }
|
||||||
const data = await scrapeProspekte();
|
catch(e) { res.status(500).json({ error: e.message }); }
|
||||||
res.json(data);
|
|
||||||
} catch(e) {
|
|
||||||
console.error('KöPi prospekte error:', e.message);
|
|
||||||
res.status(500).json({ error: e.message });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/clear-cache', authenticate, (req, res) => {
|
router.post('/clear-cache', 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' });
|
||||||
delete cache['koepi:offers'];
|
['koepi:mg','koepi:prospekte'].forEach(k => delete cache[k]);
|
||||||
delete cache['koepi:prospekte'];
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bild-Proxy (umgeht CSP) ──────────────────────────────────────────────────
|
|
||||||
router.get('/img', authenticate, (req, res) => {
|
|
||||||
const url = req.query.url;
|
|
||||||
if (!url || !url.startsWith('https://')) return res.status(400).end();
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const allowed = ['content-media.bonial.biz','publisher-media.bonial.biz','web-assets.kaufda.de'];
|
|
||||||
if (!allowed.some(h => parsed.hostname === h)) return res.status(403).end();
|
|
||||||
const reqImg = 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);
|
|
||||||
});
|
|
||||||
reqImg.on('error', () => res.status(502).end());
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -7,160 +7,103 @@ function getMyRole() {
|
|||||||
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
|
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publisherColor(name) {
|
||||||
|
if (!name) return '#555';
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
if (n.includes('rewe')) return '#e2001a';
|
||||||
|
if (n.includes('edeka') || n.includes('e center')) return '#e8a800';
|
||||||
|
if (n.includes('netto')) return '#0057a8';
|
||||||
|
if (n.includes('trinkgut')) return '#e87722';
|
||||||
|
if (n.includes('penny')) return '#e2001a';
|
||||||
|
if (n.includes('kaufland')) return '#e2001a';
|
||||||
|
return '#888';
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(str) {
|
function fmtDate(str) {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
try { return new Date(str).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); }
|
try { return new Date(str).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); }
|
||||||
catch { return str; }
|
catch { return str; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function publisherColor(name) {
|
// ── Angebots-Karte (marktguru) ────────────────────────────────────────────────
|
||||||
if (!name) return '#555';
|
function OfferCard({ offer }) {
|
||||||
const n = name.toLowerCase();
|
const color = publisherColor(offer.retailer);
|
||||||
if (n.includes('rewe')) return '#e2001a';
|
|
||||||
if (n.includes('edeka')) return '#e8a800';
|
|
||||||
if (n.includes('e center')) return '#e8a800';
|
|
||||||
if (n.includes('netto')) return '#0057a8';
|
|
||||||
if (n.includes('trinkgut')) return '#e87722';
|
|
||||||
if (n.includes('penny')) return '#e2001a';
|
|
||||||
if (n.includes('aldi')) return '#00538f';
|
|
||||||
if (n.includes('lidl')) return '#f5c900';
|
|
||||||
if (n.includes('hit')) return '#e2001a';
|
|
||||||
if (n.includes('alldrink')) return '#2ecc71';
|
|
||||||
if (n.includes('getränke')) return '#3498db';
|
|
||||||
return '#666';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Prospekt-Angebot Karte ────────────────────────────────────────────────────
|
|
||||||
function BrochureOfferCard({ offer }) {
|
|
||||||
const color = publisherColor(offer.store);
|
|
||||||
const Wrapper = offer.url
|
|
||||||
? ({children}) => <a href={offer.url} target="_blank" rel="noopener noreferrer" style={{textDecoration:'none'}}>{children}</a>
|
|
||||||
: ({children}) => <div>{children}</div>;
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<div style={{
|
|
||||||
...S.card, padding:0, overflow:'hidden', cursor: offer.url ? 'pointer' : 'default',
|
|
||||||
border:'1px solid rgba(255,255,255,0.08)',
|
|
||||||
display:'flex', flexDirection:'column',
|
|
||||||
}}>
|
|
||||||
{/* Prospektseite als Bild */}
|
|
||||||
{offer.productImage && (
|
|
||||||
<div style={{ position:'relative', height:160, overflow:'hidden', flexShrink:0 }}>
|
|
||||||
<img src={`/api/tools/koepi/img?url=${encodeURIComponent(offer.productImage)}`}
|
|
||||||
alt={`König Pilsener bei ${offer.store}`}
|
|
||||||
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }}
|
|
||||||
onError={e => { e.target.style.display='none'; }}
|
|
||||||
/>
|
|
||||||
{offer.isNew && (
|
|
||||||
<span style={{ position:'absolute', top:8, left:8,
|
|
||||||
background:'#4ecdc4', color:'#000', borderRadius:4,
|
|
||||||
padding:'2px 7px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>
|
|
||||||
NEU
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ padding:'10px 12px', flex:1 }}>
|
|
||||||
{/* Händler + Logo */}
|
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
|
|
||||||
{offer.publisherLogo && (
|
|
||||||
<img src={offer.publisherLogo} alt={offer.store}
|
|
||||||
style={{ height:20, width:'auto', objectFit:'contain', flexShrink:0 }}
|
|
||||||
onError={e => { e.target.style.display='none'; }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span style={{
|
|
||||||
background:`${color}22`, border:`1px solid ${color}44`,
|
|
||||||
borderRadius:4, padding:'2px 8px',
|
|
||||||
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
|
|
||||||
}}>
|
|
||||||
{offer.store}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Prospekttitel + Seite */}
|
|
||||||
{offer.title && (
|
|
||||||
<div style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11, marginBottom:4 }}>
|
|
||||||
📄 „{offer.title}"
|
|
||||||
{offer.page && <span style={{ color:'rgba(255,255,255,0.35)', marginLeft:4 }}>Seite {offer.page}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Gültigkeit */}
|
|
||||||
{offer.dateRange && (
|
|
||||||
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10, marginTop:4 }}>
|
|
||||||
📅 {offer.dateRange}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{offer.validity && (
|
|
||||||
<div style={{ color: offer.validity.includes('Noch') ? '#f59e0b' : '#4ecdc4',
|
|
||||||
fontFamily:'monospace', fontSize:10, marginTop:2 }}>
|
|
||||||
⏰ {offer.validity}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Grid-Angebot Karte (mit Preis) ────────────────────────────────────────────
|
|
||||||
function GridOfferCard({ offer }) {
|
|
||||||
const color = publisherColor(offer.store);
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
...S.card, padding:0, overflow:'hidden',
|
...S.card, padding:0, overflow:'hidden',
|
||||||
border:'1px solid rgba(255,255,255,0.08)',
|
border:'1px solid rgba(255,255,255,0.1)',
|
||||||
display:'flex', flexDirection:'column',
|
display:'flex', flexDirection:'column',
|
||||||
background:'rgba(255,255,255,0.03)',
|
|
||||||
}}>
|
}}>
|
||||||
{offer.image && (
|
{/* Produktbild */}
|
||||||
<div style={{ height:130, overflow:'hidden', flexShrink:0, background:'#fff', display:'flex', alignItems:'center', justifyContent:'center' }}>
|
<div style={{ height:150, background:'#fff', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0, overflow:'hidden' }}>
|
||||||
<img src={`/api/tools/koepi/img?url=${encodeURIComponent(offer.image)}`}
|
{offer.image
|
||||||
alt={offer.name}
|
? <img src={`/api/tools/koepi/img?url=${encodeURIComponent(offer.image)}`}
|
||||||
style={{ width:'auto', height:'100%', maxWidth:'100%', objectFit:'contain', display:'block' }}
|
alt={offer.name} style={{ maxHeight:'100%', maxWidth:'100%', objectFit:'contain' }}
|
||||||
onError={e => { e.target.parentElement.style.display='none'; }}
|
onError={e => { e.target.parentElement.innerHTML = '<div style="color:#555;font-size:40px">🍺</div>'; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
: <div style={{ fontSize:40 }}>🍺</div>
|
||||||
)}
|
}
|
||||||
<div style={{ padding:'10px 12px', flex:1 }}>
|
</div>
|
||||||
{offer.brand && (
|
|
||||||
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
|
<div style={{ padding:'12px 14px', flex:1, display:'flex', flexDirection:'column', gap:6 }}>
|
||||||
{offer.brand}
|
{/* Händler Badge */}
|
||||||
</div>
|
<div style={{
|
||||||
)}
|
display:'inline-block', alignSelf:'flex-start',
|
||||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12, fontWeight:600, marginBottom:6, lineHeight:1.3 }}>
|
background:`${color}22`, border:`1px solid ${color}55`,
|
||||||
|
borderRadius:4, padding:'2px 8px',
|
||||||
|
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
|
||||||
|
}}>{offer.retailer}</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:600, lineHeight:1.3 }}>
|
||||||
{offer.name}
|
{offer.name}
|
||||||
|
{offer.brand && offer.brand !== offer.name && (
|
||||||
|
<span style={{ color:'rgba(255,255,255,0.4)', fontSize:10, marginLeft:6 }}>{offer.brand}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{offer.price && (
|
|
||||||
<div style={{ color:GOLD, fontFamily:'Space Mono,monospace', fontSize:18, fontWeight:700, marginBottom:2 }}>
|
{/* Beschreibung/Flaschengröße */}
|
||||||
|
{offer.description && (
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10, lineHeight:1.4 }}>
|
||||||
|
{offer.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preis */}
|
||||||
|
<div style={{ display:'flex', alignItems:'baseline', gap:8, marginTop:4 }}>
|
||||||
|
<span style={{ color:GOLD, fontFamily:'Space Mono,monospace', fontSize:20, fontWeight:700 }}>
|
||||||
{offer.price}
|
{offer.price}
|
||||||
</div>
|
</span>
|
||||||
)}
|
{offer.oldPrice && (
|
||||||
{(offer.size || offer.baseUnit) && (
|
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textDecoration:'line-through' }}>
|
||||||
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10, marginTop:2 }}>
|
{offer.oldPrice}
|
||||||
{offer.size && <span>{offer.size} · </span>}
|
</span>
|
||||||
{offer.baseUnit}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{offer.store && (
|
{/* Gültigkeit */}
|
||||||
<div style={{
|
<div style={{ marginTop:'auto', paddingTop:6, borderTop:'1px solid rgba(255,255,255,0.06)' }}>
|
||||||
marginTop:8, display:'inline-block',
|
{offer.validity && (
|
||||||
background:`${color}22`, border:`1px solid ${color}44`,
|
<div style={{ color: offer.validity.includes('Neu') ? '#4ecdc4' : '#f59e0b', fontFamily:'monospace', fontSize:10 }}>
|
||||||
borderRadius:4, padding:'2px 7px',
|
⏰ {offer.validity}
|
||||||
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
|
</div>
|
||||||
}}>
|
)}
|
||||||
{offer.store}
|
{offer.dateRange && (
|
||||||
</div>
|
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10, marginTop:2 }}>
|
||||||
)}
|
📅 {offer.dateRange}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Prospekt-Karte (shelf) ────────────────────────────────────────────────────
|
// ── Prospekt-Karte (kaufda) ───────────────────────────────────────────────────
|
||||||
function ProspektCard({ b }) {
|
function ProspektCard({ b }) {
|
||||||
const color = publisherColor(b.publisher);
|
const color = publisherColor(b.publisher);
|
||||||
|
const isNew = b.badges?.includes('new');
|
||||||
const expiring = b.badges?.includes('expiring_soon');
|
const expiring = b.badges?.includes('expiring_soon');
|
||||||
const fresh = b.badges?.includes('new');
|
|
||||||
return (
|
return (
|
||||||
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
|
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -169,30 +112,28 @@ function ProspektCard({ b }) {
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ position:'relative' }}>
|
<div style={{ position:'relative' }}>
|
||||||
{b.image && (
|
{b.image && (
|
||||||
<img src={b.image} alt={b.title}
|
<img src={`/api/tools/koepi/img?url=${encodeURIComponent(b.image)}`}
|
||||||
style={{ width:'100%', height:110, objectFit:'cover', display:'block' }}
|
alt={b.title} style={{ width:'100%', height:110, objectFit:'cover', display:'block' }}
|
||||||
onError={e => { e.target.style.display='none'; }}
|
onError={e => { e.target.style.display='none'; }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div style={{ position:'absolute', top:6, left:6, display:'flex', gap:4 }}>
|
<div style={{ position:'absolute', top:6, left:6, display:'flex', gap:4 }}>
|
||||||
{fresh && <span style={{ background:'#4ecdc4', color:'#000', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>NEU</span>}
|
{isNew && <span style={{ background:'#4ecdc4', color:'#000', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>NEU</span>}
|
||||||
{expiring && <span style={{ background:'#ef4444', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>ENDET BALD</span>}
|
{expiring && <span style={{ background:'#ef4444', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>ENDET BALD</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding:'10px 12px' }}>
|
<div style={{ padding:'10px 12px' }}>
|
||||||
<div style={{ display:'inline-block', background:`${color}22`, border:`1px solid ${color}44`,
|
<div style={{ display:'inline-block', background:`${color}22`, border:`1px solid ${color}44`,
|
||||||
borderRadius:4, padding:'2px 8px', marginBottom:6,
|
borderRadius:4, padding:'2px 8px', marginBottom:6, color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>
|
||||||
color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>
|
{b.publisher}
|
||||||
{b.storeLabel || b.publisher}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:11, marginBottom:3, lineHeight:1.4 }}>{b.title}</div>
|
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:11, marginBottom:3 }}>{b.title}</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.store}{b.city ? ` · ${b.city}` : ''}
|
📍 {b.street || b.store}
|
||||||
</div>
|
</div>
|
||||||
{b.street && <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9 }}>{b.street}</div>}
|
|
||||||
{(b.validFrom || b.validTo) && (
|
{(b.validFrom || b.validTo) && (
|
||||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginTop:4 }}>
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginTop:4 }}>
|
||||||
{b.validFrom && `Ab ${fmtDate(b.validFrom)}`}{b.validFrom && b.validTo && ' · '}{b.validTo && `Bis ${fmtDate(b.validTo)}`}
|
{b.validFrom && `Ab ${fmtDate(b.validFrom)}`}{b.validFrom&&b.validTo&&' · '}{b.validTo&&`Bis ${fmtDate(b.validTo)}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -210,19 +151,19 @@ export default function Koepi({ toast }) {
|
|||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const isAdmin = getMyRole() === 'admin';
|
const isAdmin = getMyRole() === 'admin';
|
||||||
|
|
||||||
const loadOffers = async () => {
|
const loadOffers = async (force=false) => {
|
||||||
if (offers) return;
|
if (offers && !force) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { setOffers(await api('/tools/koepi/offers')); }
|
try { setOffers(await api('/tools/koepi/offers')); }
|
||||||
catch(e) { toast?.(e.message || 'Fehler', 'error'); }
|
catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadProspekte = async () => {
|
const loadProspekte = async (force=false) => {
|
||||||
if (prospekte) return;
|
if (prospekte && !force) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { setProspekte(await api('/tools/koepi/prospekte')); }
|
try { setProspekte(await api('/tools/koepi/prospekte')); }
|
||||||
catch(e) { toast?.(e.message || 'Fehler', 'error'); }
|
catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,101 +177,86 @@ export default function Koepi({ toast }) {
|
|||||||
await api('/tools/koepi/clear-cache', { body:{} });
|
await api('/tools/koepi/clear-cache', { body:{} });
|
||||||
setOffers(null); setProspekte(null);
|
setOffers(null); setProspekte(null);
|
||||||
toast('Cache geleert');
|
toast('Cache geleert');
|
||||||
if (tab === 'angebote') { setLoading(true); setOffers(await api('/tools/koepi/offers')); setLoading(false); }
|
setTimeout(() => { if (tab==='angebote') loadOffers(true); else loadProspekte(true); }, 100);
|
||||||
else { setLoading(true); setProspekte(await api('/tools/koepi/prospekte')); setLoading(false); }
|
} catch(e) { toast?.(e.message,'error'); }
|
||||||
} catch(e) { toast?.(e.message, 'error'); }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth:700, margin:'0 auto' }}>
|
<div style={{ maxWidth:700, margin:'0 auto' }}>
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:20, flexWrap:'wrap' }}>
|
{/* Header */}
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:20 }}>
|
||||||
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
|
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
|
||||||
🍺 KÖPI — König Pilsener Angebote
|
🍺 KÖPI — König Pilsener
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ flex:1 }}/>
|
<div style={{ flex:1 }}/>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button onClick={clearCache} style={{ ...S.btn('#666666', true), fontSize:10 }}>🗑 Cache</button>
|
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10 }}>🗑 Cache leeren</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
|
||||||
Raum Duisburg/Essen/Düsseldorf · Quelle: kaufDA.de · Cache 1h
|
Raum Duisburg 47259 · EDEKA · REWE · Netto · Kaufland · Penny · Trinkgut · Cache 1h
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
|
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
|
||||||
{[['angebote','🍺 Angebote'],['prospekte','📋 Alle Prospekte']].map(([t, label]) => (
|
{[['angebote','🍺 Angebote'],['prospekte','📋 Prospekte']].map(([t,l]) => (
|
||||||
<button key={t} onClick={() => setTab(t)} style={{
|
<button key={t} onClick={()=>setTab(t)} style={{ ...S.btn(tab===t?GOLD:'#444444',true), fontSize:12, padding:'6px 16px' }}>{l}</button>
|
||||||
...S.btn(tab===t ? GOLD : '#444444', true), fontSize:12, padding:'6px 14px',
|
|
||||||
}}>{label}</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Laden */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
||||||
⏳ Lade Angebote… (kann bis zu 15 Sekunden dauern)
|
⏳ Lade… (kann bis zu 20 Sekunden dauern)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Angebote Tab */}
|
{/* Angebote */}
|
||||||
{!loading && tab === 'angebote' && offers && (
|
{!loading && tab==='angebote' && offers && (
|
||||||
<div>
|
<div>
|
||||||
{/* Grid-Angebote mit Preisen */}
|
{offers.offers?.length > 0 ? (
|
||||||
{offers.gridOffers?.length > 0 && (
|
|
||||||
<>
|
<>
|
||||||
<div style={{ ...S.head, marginBottom:10 }}>AKTUELLE ANGEBOTE MIT PREIS ({offers.gridOffers.length})</div>
|
<div style={{ ...S.head, marginBottom:12 }}>
|
||||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(160px,1fr))', gap:12, marginBottom:24 }}>
|
{offers.offers.length} AKTUELLE ANGEBOTE · Quelle: marktguru.de
|
||||||
{offers.gridOffers.map((o, i) => <GridOfferCard key={i} offer={o} />)}
|
</div>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:14 }}>
|
||||||
|
{offers.offers.map((o,i) => <OfferCard key={i} offer={o}/>)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{/* BrochureBox: Prospektseiten mit KöPi */}
|
|
||||||
{offers.brochureOffers?.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ ...S.head, marginBottom:10 }}>
|
|
||||||
KÖNIG PILSENER IN DIESEN PROSPEKTEN ({offers.brochureOffers.length})
|
|
||||||
</div>
|
|
||||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12 }}>
|
|
||||||
{offers.brochureOffers.map((o, i) => <BrochureOfferCard key={i} offer={o} />)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!offers.gridOffers?.length && !offers.brochureOffers?.length && (
|
|
||||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
||||||
Aktuell keine König Pilsener Angebote gefunden.
|
Aktuell keine König Pilsener Angebote bei deinen Märkten gefunden.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop:20, textAlign:'center' }}>
|
<div style={{ marginTop:20, textAlign:'center' }}>
|
||||||
<a href="https://www.kaufda.de/Angebote/Koenig-Pilsener" target="_blank" rel="noopener noreferrer"
|
<a href="https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259" target="_blank" rel="noopener noreferrer"
|
||||||
style={{ color:GOLD, fontFamily:'monospace', fontSize:11 }}>
|
style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>
|
||||||
→ Alle Angebote auf kaufDA.de
|
→ Alle Angebote auf marktguru.de
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prospekte Tab */}
|
{/* Prospekte */}
|
||||||
{!loading && tab === 'prospekte' && prospekte && (
|
{!loading && tab==='prospekte' && prospekte && (
|
||||||
<div>
|
<div>
|
||||||
{prospekte.targeted?.length > 0 && (
|
{prospekte.targeted?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ ...S.head, marginBottom:10 }}>REWE · EDEKA · NETTO · TRINKGUT & mehr ({prospekte.targeted.length})</div>
|
<div style={{ ...S.head, marginBottom:12 }}>DEINE MÄRKTE ({prospekte.targeted.length})</div>
|
||||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12, marginBottom:16 }}>
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12, marginBottom:16 }}>
|
||||||
{prospekte.targeted.map(b => <ProspektCard key={b.id} b={b} />)}
|
{prospekte.targeted.map(b=><ProspektCard key={b.id} b={b}/>)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{prospekte.others?.length > 0 && (
|
{prospekte.others?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setShowAll(v => !v)} style={{ ...S.btn('#444444',true), marginBottom:10, fontSize:11 }}>
|
<button onClick={()=>setShowAll(v=>!v)} style={{ ...S.btn('#444444',true), marginBottom:10, fontSize:11 }}>
|
||||||
{showAll ? '▾ Weniger' : `▸ Weitere Prospekte (${prospekte.others.length})`}
|
{showAll ? '▾ Weniger' : `▸ Weitere Prospekte (${prospekte.others.length})`}
|
||||||
</button>
|
</button>
|
||||||
{showAll && (
|
{showAll && (
|
||||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12 }}>
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(180px,1fr))', gap:12 }}>
|
||||||
{prospekte.others.map(b => <ProspektCard key={b.id} b={b} />)}
|
{prospekte.others.map(b=><ProspektCard key={b.id} b={b}/>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user