From 017decf59f08dd35740021fb7a0475e867f232d4 Mon Sep 17 00:00:00 2001 From: Dicken Date: Wed, 8 Jul 2026 17:09:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20K=C3=B6Pi=20-=20echte=20Angebote=20mit?= =?UTF-8?q?=20Bild,=20Preis,=20Markt=20direkt=20in=20der=20App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/koepi/routes.js | 99 ++++--- frontend/src/tools/koepi.jsx | 413 ++++++++++++++++-------------- 2 files changed, 270 insertions(+), 242 deletions(-) diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index 7c0dc6a..f2b2ca5 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -64,7 +64,7 @@ function isTargetPublisher(name) { return TARGET_PUBLISHERS.some(p => n.includes(p)); } -// ── Angebote via Puppeteer ──────────────────────────────────────────────────── +// ── Angebote via Puppeteer (BrochureBox_Basic + OfferGrid) ────────────────── async function scrapeOffersWithPuppeteer() { const cacheKey = 'koepi:offers'; const cached = cacheGet(cacheKey); @@ -77,70 +77,69 @@ async function scrapeOffersWithPuppeteer() { const browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium-browser', - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + 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.setExtraHTTPHeaders({ 'Accept-Language': 'de-DE,de;q=0.9' }); + await page.goto( + 'https://www.kaufda.de/Angebote/Koenig-Pilsener?lat=51.4332&lng=6.7625&zip=47051&city=Duisburg', + { waitUntil: 'domcontentloaded', timeout: 25000 } + ); + await new Promise(r => setTimeout(r, 3000)); - await page.goto('https://www.kaufda.de/Angebote/Koenig-Pilsener?lat=51.4332&lng=6.7625&zip=47051&city=Duisburg', { - waitUntil: 'networkidle2', - timeout: 30000, - }); + const data = await page.evaluate(() => { + // ── BrochureBox: Prospekte wo König Pilsener drin ist ────────────────── + const brochureOffers = Array.from(document.querySelectorAll('[data-testid="BrochureBox_Basic"]')).map(b => { + const text = b.textContent.trim(); + const imgs = Array.from(b.querySelectorAll('img')); + const productImg = imgs[0]?.src || null; + const publisherImg = imgs[1]?.src || null; - // Warte auf Produktkacheln - await page.waitForSelector('[data-testid="offer-card"], .offer-card, article, [class*="offer"]', { - timeout: 10000, - }).catch(() => {}); + // Text parsen: "neuKönig Pilsener bei Trinkgut"Aktuelle Angebote", Seite 6Mo. 6.7. - Sa. 11.7.2026..." + const storeMatch = text.match(/bei\s+(.+?)\s*[""„]/); + const titleMatch = text.match(/[""„](.+?)[""„]/); + const pageMatch = text.match(/Seite\s+(\d+)/); + const dateMatch = text.match(/(\w+\.\s+\d+\.\d+\.)\s*-\s*(\w+\.\s+\d+\.\d+\.\d+)/); + const validMatch = text.match(/(Noch \d+ Tage? gültig|Gültig bis \S+)/); + const isNew = text.startsWith('neu'); - // Alle Angebotsdaten extrahieren - const offers = await page.evaluate(() => { - const results = []; - - // Methode 1: Schema.org - document.querySelectorAll('script[type="application/ld+json"]').forEach(s => { - try { - const data = JSON.parse(s.textContent); - const items = Array.isArray(data) ? data : [data]; - items.forEach(item => { - if (item['@type'] === 'Product') { - const offer = Array.isArray(item.offers) ? item.offers[0] : item.offers; - results.push({ - name: item.name, - price: offer?.price || null, - store: offer?.seller?.name || null, - image: Array.isArray(item.image) ? item.image[0] : item.image || null, - validFrom: offer?.validFrom || null, - validTo: offer?.validThrough || null, - url: item.url || window.location.href, - }); - } - }); - } catch {} + return { + type: 'brochure', + store: storeMatch?.[1]?.trim() || null, + title: titleMatch?.[1]?.trim() || null, + page: pageMatch?.[1] ? parseInt(pageMatch[1]) : null, + dateRange: dateMatch ? `${dateMatch[1]} - ${dateMatch[2]}` : null, + validity: validMatch?.[1] || null, + isNew, + productImage: productImg, + publisherLogo: publisherImg, + }; }); - // Methode 2: DOM-Kacheln - const cards = document.querySelectorAll('[class*="offer-card"], [class*="OfferCard"], [data-testid*="offer"], article[class*="offer"]'); - cards.forEach(card => { - const name = card.querySelector('[class*="title"], [class*="name"], h2, h3')?.textContent?.trim(); - const price = card.querySelector('[class*="price"], [class*="Price"]')?.textContent?.trim(); - const store = card.querySelector('[class*="retailer"], [class*="publisher"], [class*="store"]')?.textContent?.trim(); - const img = card.querySelector('img')?.src; - if (name) results.push({ name, price, store, image: img, url: window.location.href }); - }); + // ── OfferGrid: einzelne Produktkacheln mit Preisen ────────────────────── + 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 = item.querySelectorAll('p'); + const brand = ps[0]?.textContent?.trim() || null; + const name = ps[1]?.textContent?.trim() || null; + const store = ps[2]?.textContent?.trim() || null; + const price = item.querySelector('.text-primary, [class*="text-primary"]')?.textContent?.trim() || null; + const baseUnit = ps[3]?.textContent?.trim() || null; + return { type:'offer', brand, name, store, price, baseUnit, image: img?.src || null }; + }).filter(o => o.name) : []; - return results; + return { brochureOffers, gridOffers }; }); const result = { - offers: offers.filter((o, i, arr) => - arr.findIndex(x => x.name === o.name && x.store === o.store) === i - ), - scrapedAt: new Date().toISOString(), - source: 'kaufda.de (puppeteer)', + brochureOffers: data.brochureOffers, + gridOffers: data.gridOffers, + scrapedAt: new Date().toISOString(), + source: 'kaufda.de', }; cacheSet(cacheKey, result); diff --git a/frontend/src/tools/koepi.jsx b/frontend/src/tools/koepi.jsx index e3bfba0..e8b52c5 100644 --- a/frontend/src/tools/koepi.jsx +++ b/frontend/src/tools/koepi.jsx @@ -1,102 +1,158 @@ import { useState, useEffect } from 'react'; import { api, S } from '../lib.js'; -const MY_COLOR = '#f59e0b'; +const GOLD = '#f59e0b'; function getMyRole() { try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; } } -// ── Händler-Farben ──────────────────────────────────────────────────────────── -const PUBLISHER_COLORS = { - 'rewe': '#e2001a', - 'edeka': '#ffd700', - 'netto': '#0057a8', - 'trinkgut': '#e87722', - 'penny': '#e2001a', - 'aldi': '#00538f', - 'lidl': '#f5c900', -}; - -function publisherColor(name) { - if (!name) return '#666'; - const n = name.toLowerCase(); - for (const [key, color] of Object.entries(PUBLISHER_COLORS)) { - if (n.includes(key)) return color; - } - return '#555'; -} - -// ── Datum formatieren ───────────────────────────────────────────────────────── function fmtDate(str) { if (!str) return null; try { return new Date(str).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); } catch { return str; } } -function isExpiringSoon(validTo) { - if (!validTo) return false; - const diff = new Date(validTo) - new Date(); - return diff > 0 && diff < 3 * 24 * 60 * 60 * 1000; +function publisherColor(name) { + if (!name) return '#555'; + const n = name.toLowerCase(); + 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'; } -function isNew(validFrom) { - if (!validFrom) return false; - const diff = new Date() - new Date(validFrom); - return diff >= 0 && diff < 3 * 24 * 60 * 60 * 1000; -} - -// ── Angebots-Karte ──────────────────────────────────────────────────────────── -function OfferCard({ offer }) { +// ── Prospekt-Angebot Karte ──────────────────────────────────────────────────── +function BrochureOfferCard({ offer }) { const color = publisherColor(offer.store); return ( - -
- {offer.image && ( - {offer.name} + {/* Prospektseite als Bild */} + {offer.productImage && ( +
+ {`König { e.target.style.display='none'; }} /> - )} -
-
- {offer.store || 'Unbekannt'} -
-
- {offer.name} -
- {offer.price && ( -
- {typeof offer.price === 'number' ? `${offer.price.toFixed(2)} €` : offer.price} -
- )} - {(offer.validFrom || offer.validTo) && ( -
- {offer.validFrom && `Ab ${fmtDate(offer.validFrom)}`} - {offer.validFrom && offer.validTo && ' · '} - {offer.validTo && `Bis ${fmtDate(offer.validTo)}`} -
+ {offer.isNew && ( + + NEU + )}
+ )} +
+ {/* Händler + Logo */} +
+ {offer.publisherLogo && ( + {offer.store} { e.target.style.display='none'; }} + /> + )} + + {offer.store} + +
+ {/* Prospekttitel + Seite */} + {offer.title && ( +
+ 📄 „{offer.title}" + {offer.page && Seite {offer.page}} +
+ )} + {/* Gültigkeit */} + {offer.validity && ( +
+ 🕐 {offer.validity} +
+ )} + {offer.dateRange && ( +
+ {offer.dateRange} +
+ )}
-
+
); } -// ── Prospekt-Karte ──────────────────────────────────────────────────────────── -function ProspektCard({ b }) { - const color = publisherColor(b.publisher); - const expiring = isExpiringSoon(b.validTo); - const fresh = isNew(b.validFrom) || b.badges?.includes('new'); +// ── Grid-Angebot Karte (mit Preis) ──────────────────────────────────────────── +function GridOfferCard({ offer }) { + const color = publisherColor(offer.store); + return ( +
+ {offer.image && ( +
+ {offer.name} { e.target.parentElement.style.display='none'; }} + /> +
+ )} +
+ {offer.brand && ( +
+ {offer.brand} +
+ )} +
+ {offer.name} +
+ {offer.price && ( +
+ {offer.price} +
+ )} + {offer.baseUnit && ( +
+ {offer.baseUnit} +
+ )} + {offer.store && ( +
+ {offer.store} +
+ )} +
+
+ ); +} +// ── Prospekt-Karte (shelf) ──────────────────────────────────────────────────── +function ProspektCard({ b }) { + const color = publisherColor(b.publisher); + const expiring = b.badges?.includes('expiring_soon'); + const fresh = b.badges?.includes('new'); return (
{b.image && ( {b.title} { e.target.style.display='none'; }} /> )}
- {fresh && ( - NEU - )} - {expiring && ( - ENDET BALD - )} + {fresh && NEU} + {expiring && ENDET BALD}
-
+ color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}> {b.publisher}
-
- {b.title} -
+
{b.title}
📍 {b.store}{b.city && b.store !== b.city ? ` · ${b.city}` : ''}
- {b.street && ( -
- {b.street}{b.zip ? `, ${b.zip}` : ''} -
- )} + {b.street &&
{b.street}
} {(b.validFrom || b.validTo) && ( -
- {b.validFrom && `Ab ${fmtDate(b.validFrom)}`} - {b.validFrom && b.validTo && ' · '} - {b.validTo && `Bis ${fmtDate(b.validTo)}`} -
- )} - {b.pageCount && ( -
- {b.pageCount} Seiten +
+ {b.validFrom && `Ab ${fmtDate(b.validFrom)}`}{b.validFrom && b.validTo && ' · '}{b.validTo && `Bis ${fmtDate(b.validTo)}`}
)}
@@ -158,151 +195,143 @@ function ProspektCard({ b }) { // ── Hauptkomponente ─────────────────────────────────────────────────────────── export default function Koepi({ toast }) { - const [tab, setTab] = useState('prospekte'); - const [offers, setOffers] = useState(null); - const [prospekte, setProspekte] = useState(null); - const [loading, setLoading] = useState(false); - const [lastUpdate, setLastUpdate] = useState(null); - const [showAll, setShowAll] = useState(false); + const [tab, setTab] = useState('angebote'); + const [offers, setOffers] = useState(null); + const [prospekte, setProspekte] = useState(null); + const [loading, setLoading] = useState(false); + const [showAll, setShowAll] = useState(false); const isAdmin = getMyRole() === 'admin'; - const load = async (t) => { + const loadOffers = async () => { + if (offers) return; setLoading(true); - try { - if (t === 'offers' && !offers) { - const d = await api('/tools/koepi/offers'); - setOffers(d); - setLastUpdate(d.scrapedAt); - } else if (t === 'prospekte' && !prospekte) { - const d = await api('/tools/koepi/prospekte'); - setProspekte(d); - setLastUpdate(d.scrapedAt); - } - } catch(e) { - toast?.(e.message || 'Fehler beim Laden', 'error'); - } finally { - setLoading(false); - } + try { setOffers(await api('/tools/koepi/offers')); } + catch(e) { toast?.(e.message || 'Fehler', 'error'); } + finally { setLoading(false); } }; - useEffect(() => { load(tab); }, [tab]); + const loadProspekte = async () => { + if (prospekte) return; + setLoading(true); + try { setProspekte(await api('/tools/koepi/prospekte')); } + catch(e) { toast?.(e.message || 'Fehler', 'error'); } + finally { setLoading(false); } + }; + + useEffect(() => { + if (tab === 'angebote') loadOffers(); + else loadProspekte(); + }, [tab]); const clearCache = async () => { try { await api('/tools/koepi/clear-cache', { body:{} }); - setOffers(null); - setProspekte(null); - setLastUpdate(null); - toast('Cache geleert — Daten werden neu geladen'); - load(tab); + setOffers(null); setProspekte(null); + toast('Cache geleert'); + if (tab === 'angebote') { setLoading(true); setOffers(await api('/tools/koepi/offers')); setLoading(false); } + else { setLoading(true); setProspekte(await api('/tools/koepi/prospekte')); setLoading(false); } } catch(e) { toast?.(e.message, 'error'); } }; - const currentProspekte = prospekte ? [...(prospekte.targeted || []), ...(showAll ? (prospekte.others || []) : [])] : []; - return (
- {/* Header */} -
+

- 🍺 KÖPI + 🍺 KÖPI — König Pilsener Angebote

{isAdmin && ( - + )}
- {/* Subheader */} -
- König Pilsener Angebote im Raum Duisburg · Daten von kaufDA.de - {lastUpdate && · Stand: {new Date(lastUpdate).toLocaleString('de-DE')}} +
+ Raum Duisburg/Essen/Düsseldorf · Quelle: kaufDA.de · Cache 1h
{/* Tabs */}
- {[['prospekte','📋 Prospekte'],['offers','🏷️ Angebote']].map(([t, label]) => ( + {[['angebote','🍺 Angebote'],['prospekte','📋 Alle Prospekte']].map(([t, label]) => ( ))}
- {/* Laden */} {loading && (
- ⏳ Lade Daten von kaufDA.de… + ⏳ Lade Angebote… (kann bis zu 15 Sekunden dauern)
)} - {/* Prospekte */} - {!loading && tab === 'prospekte' && prospekte && ( + {/* Angebote Tab */} + {!loading && tab === 'angebote' && offers && (
- {prospekte.targeted?.length > 0 ? ( + {/* Grid-Angebote mit Preisen */} + {offers.gridOffers?.length > 0 && ( <> -
- REWE · EDEKA · NETTO · TRINKGUT & mehr ({prospekte.targeted.length}) -
-
- {prospekte.targeted.map(b => )} +
AKTUELLE ANGEBOTE MIT PREIS ({offers.gridOffers.length})
+
+ {offers.gridOffers.map((o, i) => )}
- ) : ( -
+ )} + + {/* Prospekte Tab */} + {!loading && tab === 'prospekte' && prospekte && ( +
+ {prospekte.targeted?.length > 0 && ( + <> +
REWE · EDEKA · NETTO · TRINKGUT & mehr ({prospekte.targeted.length})
+
+ {prospekte.targeted.map(b => )} +
+ + )} {prospekte.others?.length > 0 && ( <> - {showAll && ( -
+
{prospekte.others.map(b => )}
)} )} - -
- Klick auf einen Prospekt öffnet ihn auf kaufDA.de · Cache: 1 Stunde -
-
- )} - - {/* Angebote */} - {!loading && tab === 'offers' && offers && ( -