From c637560f3fbb489580651864cd2183078d228f7c Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 18 Jul 2026 13:57:45 +0200 Subject: [PATCH] fix: KoePi Prospekt-Titel aus advertiser.name statt Freitext, REWE-KW aus Datum berechnet, KoePi oeffentlicher, schreibgeschuetzter Teilen-Link ohne Login, KoePi oeffentlicher Link redirected bei ungueltigem Token zu Google, wie bei den anderen Public-Links --- backend/src/tools/koepi/routes.js | 110 ++++++++++++++- frontend/src/App.jsx | 219 ++++++++++++++++++++++++++++++ frontend/src/tools/koepi.jsx | 62 +++++++++ 3 files changed, 384 insertions(+), 7 deletions(-) diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index 6740cf4..e168cd9 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -1,5 +1,6 @@ const express = require('express'); const https = require('https'); +const crypto = require('crypto'); const db = require('../../db'); const mqttClient = require('../../mqtt'); const { logPush } = require('../../pushLog'); @@ -180,16 +181,34 @@ function extractWeekInfo(name) { return m ? `(${m[0].replace(/\s+/g, ' ').trim()})` : ''; } -// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel (z.B. -// "(weekly)" bei ALDI). Liste bei Bedarf einfach erweitern, falls andere -// Ketten ähnlichen Text im name-Feld mitliefern. -const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi]; +// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel: +// "(weekly)"-Zusätze und jede KW-Klammer (die steht ja schon separat als +// eigene weekInfo daneben, muss also nicht nochmal im Titel stehen — und dort +// hängt bei manchen Ketten zusätzlich technischer Datensatz-Text mit dran). +const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi, /\([^)]*KW[^)]*\)/gi]; function cleanTitle(name) { let t = name || ''; for (const p of TITLE_NOISE_PATTERNS) t = t.replace(p, ''); return t.replace(/\s{2,}/g, ' ').trim(); } +// Fallback, falls eine Kette (z.B. REWE) gar keine KW-Angabe im Namen mitliefert: +// Kalenderwoche selbst aus dem Gültig-ab-Datum berechnen +function getISOWeekNumber(dateStr) { + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return null; + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + 4 - (d.getDay() || 7)); + const yearStart = new Date(d.getFullYear(), 0, 1); + return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); +} +function weekInfoWithFallback(name, validFromIso) { + const found = extractWeekInfo(name); + if (found) return found; + const week = validFromIso ? getISOWeekNumber(validFromIso) : null; + return week ? `(KW${week})` : ''; +} + // 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 @@ -293,7 +312,7 @@ async function fetchOffersViaApi() { 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 weekInfo = weekInfoWithFallback(detail?.name, validity0.from); const baseDateRange = validity0.from && validity0.to ? `${formatIsoDate(validity0.from)} - ${formatIsoDate(validity0.to)}` : formatIsoDate(validity0.to); @@ -545,11 +564,11 @@ async function scrapeProspekte() { if (!f.mainLeafletId) continue; const detail = await fetchLeafletDetail(f.mainLeafletId, leafletCache); const store = detail.closestStore; - const weekInfo = extractWeekInfo(detail.name); + const weekInfo = weekInfoWithFallback(detail.name, f.validFrom); all.push({ id: f.id, publisher: f.advertiser?.name || '', - title: cleanTitle(detail.name) || f.advertiser?.name || '', + title: f.advertiser?.name || cleanTitle(detail.name) || '', weekInfo, street: store.address || '', zip: MG_ZIP, @@ -779,6 +798,83 @@ router.post('/run-daily-check', authenticate, async (req, res) => { } catch(e) { res.status(500).json({ error: e.message }); } }); +// ── Öffentlicher Teilen-Link (kein Login nötig) ───────────────────────────── +// Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH +// bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein +// Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP +// ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten +// marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten. +const KOEPI_SHARE_TOKEN_KEY = 'koepi_share_token'; + +function getShareToken() { + return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SHARE_TOKEN_KEY)?.value || null; +} + +// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst +// simpel gehalten, reicht für dieses Nutzungsszenario, kein Redis o.ä. nötig +const publicRateLimits = new Map(); +function publicRateLimit(req, res, next) { + const ip = req.ip || 'unknown'; + const now = Date.now(); + const windowMs = 60000, maxReq = 30; + const arr = (publicRateLimits.get(ip) || []).filter(t => now - t < windowMs); + if (arr.length >= maxReq) return res.status(429).json({ error: 'Zu viele Anfragen, bitte kurz warten.' }); + arr.push(now); + publicRateLimits.set(ip, arr); + next(); +} +// Gelegentliches Aufräumen alter IP-Einträge, damit die Map nicht unbegrenzt wächst +setInterval(() => { + const now = Date.now(); + for (const [ip, arr] of publicRateLimits) { + const fresh = arr.filter(t => now - t < 60000); + if (fresh.length) publicRateLimits.set(ip, fresh); else publicRateLimits.delete(ip); + } +}, 5 * 60 * 1000); + +// GET /share-link – aktuellen Link anzeigen (Admin) +router.get('/share-link', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const token = getShareToken(); + res.json({ token, url: token ? `${req.protocol}://${req.get('host')}/kp/${token}` : null }); +}); + +// POST /share-link – neuen Link erzeugen (macht einen evtl. vorhandenen alten +// automatisch ungültig, da nur ein Token gleichzeitig gespeichert wird) +router.post('/share-link', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const token = crypto.randomBytes(20).toString('hex'); + db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(KOEPI_SHARE_TOKEN_KEY, token); + res.json({ token, url: `${req.protocol}://${req.get('host')}/kp/${token}` }); +}); + +// POST /share-link/disable – Link deaktivieren +router.post('/share-link/disable', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + db.prepare('DELETE FROM admin_settings WHERE key=?').run(KOEPI_SHARE_TOKEN_KEY); + res.json({ ok: true }); +}); + +// GET /public/:token/offers – öffentlich, nur Cache, kein Login +router.get('/public/:token/offers', publicRateLimit, async (req, res) => { + const active = getShareToken(); + if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' }); + try { + const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route + res.json(result); + } catch (e) { res.status(500).json({ error: 'Angebote gerade nicht verfügbar' }); } +}); + +// GET /public/:token/prospekte – öffentlich, nur Cache, kein Login +router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => { + const active = getShareToken(); + if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' }); + try { + const result = await scrapeProspekte(); + res.json(result); + } catch (e) { res.status(500).json({ error: 'Prospekte gerade nicht verfügbar' }); } +}); + module.exports = router; module.exports.runDailyCheck = runDailyCheck; module.exports.refreshOffersOnly = refreshOffersOnly; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 36003c9..1141369 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3862,12 +3862,231 @@ function ScrollToTop({ scrollRef }) { } +// ── Öffentliche, schreibgeschützte KöPi-Ansicht (kein Login) ───────────────── +function koepiPublisherColor(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 koepiRetailerLogo(name) { + if (!name) return null; + const n = name.toLowerCase(); + if (n.includes('netto') && n.includes('getränke')) return '/koepi/netto_getraenkemarkt.png'; + if (n.includes('netto')) return '/koepi/netto.png'; + if (n.includes('rewe dortmund')) return null; + if (n.includes('rewe')) return '/koepi/rewe.png'; + if (n.includes('edeka') || n.includes('e center')) return '/koepi/edeka.png'; + if (n.includes('trinkgut')) return '/koepi/trinkgut.png'; + if (n.includes('penny')) return '/koepi/penny.png'; + if (n.includes('lidl')) return '/koepi/lidl.png'; + if (n.includes('aldi')) return '/koepi/aldi.png'; + if (n.includes('hornbach')) return '/koepi/hornbach.png'; + if (n.includes('kaufland')) return '/koepi/kaufland.png'; + return null; +} +function koepiFmtDate(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 koepiSortBeerFirst(arr) { + return [...arr].sort((a, b) => (b.hasKoenigPilsener?1:0) - (a.hasKoenigPilsener?1:0)); +} +function koepiGetISOWeek(dateStr) { + const d = new Date(dateStr); + d.setHours(0,0,0,0); + d.setDate(d.getDate() + 4 - (d.getDay() || 7)); + const yearStart = new Date(d.getFullYear(), 0, 1); + return { week: Math.ceil((((d - yearStart) / 86400000) + 1) / 7), year: d.getFullYear() }; +} +function koepiGroupByWeek(arr) { + const groups = new Map(); + for (const b of arr) { + if (!b.validFrom) { + if (!groups.has('unbekannt')) groups.set('unbekannt', { label:'Ohne Datum', sortKey:Infinity, items:[] }); + groups.get('unbekannt').items.push(b); + continue; + } + const { week, year } = koepiGetISOWeek(b.validFrom); + const key = `${year}-${week}`; + if (!groups.has(key)) groups.set(key, { label:`KW${week}`, sortKey:year*100+week, items:[] }); + groups.get(key).items.push(b); + } + return [...groups.values()].sort((a,b) => a.sortKey - b.sortKey); +} + +function PublicKoepiOfferCard({ offer }) { + const color = koepiPublisherColor(offer.retailer); + const logo = koepiRetailerLogo(offer.retailer); + return ( +
+ {offer.imageLocal && ( +
+ König Pilsener +
+ )} +
+ {logo ? {offer.retailer} + : {offer.retailer}} +
+ {offer.address &&
📍 {offer.address}
} +
{offer.name}
+
{offer.price}
+ {offer.dateRange &&
📅 {offer.dateRange}
} +
+ ); +} + +function PublicKoepiProspektCard({ b }) { + const color = koepiPublisherColor(b.publisher); + const logo = koepiRetailerLogo(b.publisher); + return ( + +
+
+ {logo ? {b.publisher} + :
{b.publisher}
} + {b.hasKoenigPilsener && 🍺} +
+
📍 {b.street}
+ {b.title &&
{b.title}
} + {(b.validFrom || b.validTo || b.weekInfo) && ( +
+ {b.validFrom && `Ab ${koepiFmtDate(b.validFrom)}`}{b.validFrom&&b.validTo&&' · '}{b.validTo&&`Bis ${koepiFmtDate(b.validTo)}`} + {b.weekInfo && ` ${b.weekInfo}`} +
+ )} +
+
+ ); +} + +function PublicKoepi({ token }) { + const [tab, setTab] = useState('prospekte'); + const [prospekte, setProspekte] = useState(null); + const [offers, setOffers] = useState(null); + const [loadingOffers, setLoadingOffers] = useState(false); + const [invalid, setInvalid] = useState(false); + const BASE = '/api/tools/koepi/public/' + token; + + useEffect(() => { + fetch(BASE + '/prospekte') + .then(async r => { if (!r.ok) throw new Error(); return r.json(); }) + .then(setProspekte) + .catch(() => setInvalid(true)); + }, [token]); + + useEffect(() => { + if (tab !== 'angebote' || offers || invalid) return; + setLoadingOffers(true); + fetch(BASE + '/offers') + .then(async r => { if (!r.ok) throw new Error(); return r.json(); }) + .then(d => setOffers(d.offers || [])) + .catch(() => setInvalid(true)) + .finally(() => setLoadingOffers(false)); + }, [tab, token]); + + if (invalid) { + window.location.replace('https://www.google.de'); + return null; + } + + const tabBtn = (id, label) => ( + + ); + + return ( +
+
+

+ 🍺 KÖPI — König Pilsener +

+
+ Öffentlich geteilte Ansicht · nur Lesezugriff +
+ +
+ {tabBtn('prospekte', '📋 Prospekte')} + {tabBtn('angebote', '🍺 Angebote')} +
+ + {tab === 'prospekte' && !prospekte && ( +
Lädt…
+ )} + {tab === 'prospekte' && prospekte && ( +
+ {prospekte.current?.length > 0 && ( + <> +
+ AKTUELL ({prospekte.current.length}) +
+
+ {koepiSortBeerFirst(prospekte.current).map(b => )} +
+ + )} + {prospekte.future?.length > 0 && ( + <> +
+ KOMMENDE PROSPEKTE ({prospekte.future.length}) +
+ {koepiGroupByWeek(prospekte.future).map(group => ( +
+
{group.label}
+
+ {koepiSortBeerFirst(group.items).map(b => )} +
+
+ ))} + + )} + {!prospekte.current?.length && !prospekte.future?.length && ( +
Keine Prospekte gefunden.
+ )} +
+ )} + + {tab === 'angebote' && ( +
+ {loadingOffers && ( +
Lädt…
+ )} + {!loadingOffers && offers && offers.length === 0 && ( +
+ Aktuell keine König Pilsener Angebote bei diesen Märkten gefunden. +
+ )} + {!loadingOffers && offers && offers.length > 0 && ( +
+ {offers.map((o, i) => )} +
+ )} +
+ )} +
+
+ ); +} + export default function App() { // Öffentliche Upload-Seite – kein Login, kein Auth const _uploadMatch = window.location.pathname.match(/^\/u\/(.+)$/); if (_uploadMatch) return ; const _shareMatch = window.location.pathname.match(/^\/s\/(.+)$/); if (_shareMatch) return ; + const _koepiMatch = window.location.pathname.match(/^\/kp\/(.+)$/); + if (_koepiMatch) return ; const mobile = useIsMobile(); const mainRef = useRef(null); diff --git a/frontend/src/tools/koepi.jsx b/frontend/src/tools/koepi.jsx index 5880a8c..c7e04b2 100644 --- a/frontend/src/tools/koepi.jsx +++ b/frontend/src/tools/koepi.jsx @@ -262,6 +262,37 @@ export default function Koepi({ toast }) { finally { setResolvingStores(false); } }; + const [shareInfo, setShareInfo] = useState(null); // { token, url } | null + const [shareOpen, setShareOpen] = useState(false); + const [shareBusy, setShareBusy] = useState(false); + useEffect(() => { + if (!isAdmin) return; + api('/tools/koepi/share-link').then(setShareInfo).catch(()=>{}); + }, [isAdmin]); + const createShareLink = async () => { + setShareBusy(true); + try { + const r = await api('/tools/koepi/share-link', { method:'POST', body:{} }); + setShareInfo(r); + toast('🔗 Neuer Link erzeugt — alter Link ist damit ungültig'); + } catch(e) { toast?.(e.message||'Fehler','error'); } + finally { setShareBusy(false); } + }; + const disableShareLink = async () => { + setShareBusy(true); + try { + await api('/tools/koepi/share-link/disable', { method:'POST', body:{} }); + setShareInfo({ token:null, url:null }); + toast('🔗 Link deaktiviert'); + } catch(e) { toast?.(e.message||'Fehler','error'); } + finally { setShareBusy(false); } + }; + const copyShareLink = async () => { + if (!shareInfo?.url) return; + try { await navigator.clipboard.writeText(shareInfo.url); toast('Link kopiert'); } + catch { toast?.('Kopieren nicht möglich — bitte manuell markieren','error'); } + }; + return (
{/* Header */} @@ -279,6 +310,37 @@ export default function Koepi({ toast }) { {resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'} + +
+ )} + {isAdmin && shareOpen && ( +
+
+ Öffentlicher, schreibgeschützter Link ohne Login — zeigt zuerst die Prospekte, man kann dort + auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diesen Link. +
+ {shareInfo?.token ? ( + <> +
+ e.target.select()} style={{ ...S.inp, flex:1, minWidth:200, fontSize:11 }}/> + +
+
+ + +
+ + ) : ( + + )}
)}