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

This commit is contained in:
2026-07-18 13:57:45 +02:00
parent 6b4b23ea6c
commit c637560f3f
3 changed files with 384 additions and 7 deletions

View File

@@ -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;

View File

@@ -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 (
<div style={{ background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.1)', borderRadius:12, padding:'12px 14px', display:'flex', flexDirection:'column', gap:8 }}>
{offer.imageLocal && (
<div style={{ margin:'-12px -14px 8px -14px', height:130, display:'flex', alignItems:'center', justifyContent:'center' }}>
<img src={offer.imageLocal} alt="König Pilsener" style={{ maxWidth:'90%', maxHeight:130, objectFit:'contain' }}/>
</div>
)}
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
{logo ? <img src={logo} alt={offer.retailer} style={{ height:20, maxWidth:100, objectFit:'contain', borderRadius:3 }}/>
: <span style={{ background:`${color}22`, border:`1px solid ${color}55`, borderRadius:4, padding:'2px 7px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>{offer.retailer}</span>}
</div>
{offer.address && <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9 }}>📍 {offer.address}</div>}
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12 }}>{offer.name}</div>
<div style={{ color:'#ffe66d', fontFamily:"'Space Mono',monospace", fontSize:18, fontWeight:700 }}>{offer.price}</div>
{offer.dateRange && <div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10, borderTop:'1px solid rgba(255,255,255,0.06)', paddingTop:6 }}>📅 {offer.dateRange}</div>}
</div>
);
}
function PublicKoepiProspektCard({ b }) {
const color = koepiPublisherColor(b.publisher);
const logo = koepiRetailerLogo(b.publisher);
return (
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
<div style={{ background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.08)', borderRadius:12, padding:'10px 12px' }}>
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:4, flexWrap:'wrap' }}>
{logo ? <img src={logo} alt={b.publisher} style={{ height:20, maxWidth:110, objectFit:'contain', borderRadius:3 }}/>
: <div style={{ background:`${color}22`, border:`1px solid ${color}44`, borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>{b.publisher}</div>}
{b.hasKoenigPilsener && <span title="König Pilsener gerade im Angebot" style={{ fontSize:13 }}>🍺</span>}
</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>📍 {b.street}</div>
{b.title && <div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginTop:2 }}>{b.title}</div>}
{(b.validFrom || b.validTo || b.weekInfo) && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginTop:4 }}>
{b.validFrom && `Ab ${koepiFmtDate(b.validFrom)}`}{b.validFrom&&b.validTo&&' · '}{b.validTo&&`Bis ${koepiFmtDate(b.validTo)}`}
{b.weekInfo && ` ${b.weekInfo}`}
</div>
)}
</div>
</a>
);
}
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) => (
<button onClick={()=>setTab(id)} style={{
flex:1, padding:'10px', borderRadius:8, border:'1px solid ' + (tab===id?'rgba(255,230,109,0.4)':'rgba(255,255,255,0.08)'),
background: tab===id ? 'rgba(255,230,109,0.08)' : 'transparent',
color: tab===id ? '#ffe66d' : 'rgba(255,255,255,0.4)',
fontFamily:'monospace', fontSize:12, fontWeight:700, cursor:'pointer',
}}>{label}</button>
);
return (
<div style={{ minHeight:'100vh', background:'#0f1117', padding:'20px 14px' }}>
<div style={{ maxWidth:700, margin:'0 auto' }}>
<h2 style={{ margin:'0 0 4px 0', fontSize:16, fontFamily:'monospace', color:'rgba(255,255,255,0.6)', letterSpacing:2, fontWeight:400 }}>
🍺 KÖPI König Pilsener
</h2>
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, marginBottom:16 }}>
Öffentlich geteilte Ansicht · nur Lesezugriff
</div>
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
{tabBtn('prospekte', '📋 Prospekte')}
{tabBtn('angebote', '🍺 Angebote')}
</div>
{tab === 'prospekte' && !prospekte && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textAlign:'center', padding:'40px 0' }}>Lädt</div>
)}
{tab === 'prospekte' && prospekte && (
<div>
{prospekte.current?.length > 0 && (
<>
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:12, letterSpacing:1, marginBottom:12 }}>
AKTUELL ({prospekte.current.length})
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, marginBottom:24 }}>
{koepiSortBeerFirst(prospekte.current).map(b => <PublicKoepiProspektCard key={b.id} b={b}/>)}
</div>
</>
)}
{prospekte.future?.length > 0 && (
<>
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:12, letterSpacing:1, marginBottom:12, paddingTop:12, borderTop:'1px solid rgba(255,255,255,0.08)' }}>
KOMMENDE PROSPEKTE ({prospekte.future.length})
</div>
{koepiGroupByWeek(prospekte.future).map(group => (
<div key={group.label} style={{ marginBottom:20 }}>
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, letterSpacing:1, marginBottom:8 }}>{group.label}</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, opacity:0.75 }}>
{koepiSortBeerFirst(group.items).map(b => <PublicKoepiProspektCard key={b.id} b={b}/>)}
</div>
</div>
))}
</>
)}
{!prospekte.current?.length && !prospekte.future?.length && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>Keine Prospekte gefunden.</div>
)}
</div>
)}
{tab === 'angebote' && (
<div>
{loadingOffers && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textAlign:'center', padding:'40px 0' }}>Lädt</div>
)}
{!loadingOffers && offers && offers.length === 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 bei diesen Märkten gefunden.
</div>
)}
{!loadingOffers && offers && offers.length > 0 && (
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:14 }}>
{offers.map((o, i) => <PublicKoepiOfferCard key={i} offer={o}/>)}
</div>
)}
</div>
)}
</div>
</div>
);
}
export default function App() {
// Öffentliche Upload-Seite kein Login, kein Auth
const _uploadMatch = window.location.pathname.match(/^\/u\/(.+)$/);
if (_uploadMatch) return <PublicUpload token={_uploadMatch[1]}/>;
const _shareMatch = window.location.pathname.match(/^\/s\/(.+)$/);
if (_shareMatch) return <PublicFileShare token={_shareMatch[1]}/>;
const _koepiMatch = window.location.pathname.match(/^\/kp\/(.+)$/);
if (_koepiMatch) return <PublicKoepi token={_koepiMatch[1]}/>;
const mobile = useIsMobile();
const mainRef = useRef(null);

View File

@@ -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 (
<div style={{ maxWidth:700, margin:'0 auto', padding:'0 14px' }}>
{/* Header */}
@@ -279,6 +310,37 @@ export default function Koepi({ toast }) {
{resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'}
</button>
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>🗑 Cache leeren</button>
<button onClick={()=>setShareOpen(v=>!v)} style={{ ...S.btn(shareInfo?.token?'#ffe66d':'#888888',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>
🔗 Teilen{shareInfo?.token ? ' (aktiv)' : ''}
</button>
</div>
)}
{isAdmin && shareOpen && (
<div style={{ ...S.card, marginTop:10 }}>
<div style={{ ...S.sub, marginBottom:10 }}>
Ö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.
</div>
{shareInfo?.token ? (
<>
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
<input readOnly value={shareInfo.url} onClick={e=>e.target.select()} style={{ ...S.inp, flex:1, minWidth:200, fontSize:11 }}/>
<button onClick={copyShareLink} style={{ ...S.btn('#4ecdc4') }}>📋 Kopieren</button>
</div>
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
<button onClick={createShareLink} disabled={shareBusy} style={{ ...S.btn('#f59e0b'), opacity:shareBusy?0.5:1 }}>
{shareBusy?'…':'🔄 Neuen Link erzeugen (alter wird ungültig)'}
</button>
<button onClick={disableShareLink} disabled={shareBusy} style={{ ...S.btn('#f87171'), opacity:shareBusy?0.5:1 }}>
{shareBusy?'…':'🚫 Link deaktivieren'}
</button>
</div>
</>
) : (
<button onClick={createShareLink} disabled={shareBusy} style={{ ...S.btn('#4ecdc4'), opacity:shareBusy?0.5:1 }}>
{shareBusy?'…':'🔗 Link erzeugen'}
</button>
)}
</div>
)}
</div>