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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user