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;