aktueller stand

This commit is contained in:
2026-01-29 20:44:39 +01:00
parent ad32f299cf
commit e64edabf85
10 changed files with 1115 additions and 86 deletions

View File

@@ -8,6 +8,8 @@ const DEFAULT_SETTINGS = {
scheduleCron: '*/10 7-22 * * *',
pickupFallbackCron: '0 7,12,17,22 * * *',
pickupWindowOffsetsMinutes: [-1, -0.5, 0, 0.5, 1, 1.5],
regularPickupRefreshCron: '0 3 * * *',
dormantMembershipCron: '0 4 */14 * *',
randomDelayMinSeconds: 10,
randomDelayMaxSeconds: 120,
initialDelayMinSeconds: 5,
@@ -133,6 +135,8 @@ function readSettings() {
parsed.pickupWindowOffsetsMinutes,
DEFAULT_SETTINGS.pickupWindowOffsetsMinutes
),
regularPickupRefreshCron: parsed.regularPickupRefreshCron || DEFAULT_SETTINGS.regularPickupRefreshCron,
dormantMembershipCron: parsed.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron,
randomDelayMinSeconds: sanitizeNumber(parsed.randomDelayMinSeconds, DEFAULT_SETTINGS.randomDelayMinSeconds),
randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds),
initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds),
@@ -176,6 +180,8 @@ function writeSettings(patch = {}) {
patch.pickupWindowOffsetsMinutes,
current.pickupWindowOffsetsMinutes
),
regularPickupRefreshCron: patch.regularPickupRefreshCron || current.regularPickupRefreshCron,
dormantMembershipCron: patch.dormantMembershipCron || current.dormantMembershipCron,
randomDelayMinSeconds: sanitizeNumber(patch.randomDelayMinSeconds, current.randomDelayMinSeconds),
randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds),
initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds),

View File

@@ -1,12 +1,19 @@
const axios = require('axios');
const http = require('http');
const https = require('https');
const requestLogStore = require('./requestLogStore');
const sessionStore = require('./sessionStore');
const BASE_URL = 'https://foodsharing.de';
const keepAliveHttpAgent = new http.Agent({ keepAlive: true, maxSockets: 10 });
const keepAliveHttpsAgent = new https.Agent({ keepAlive: true, maxSockets: 10 });
const client = axios.create({
baseURL: BASE_URL,
timeout: 20000,
httpAgent: keepAliveHttpAgent,
httpsAgent: keepAliveHttpsAgent,
headers: {
'User-Agent': 'pickup-config/1.0 (+https://foodsharing.de)',
Accept: 'application/json, text/plain, */*'

View File

@@ -37,6 +37,23 @@ function formatDateOnly(dateInput) {
}
}
function extractFirstName(profileName) {
if (!profileName || typeof profileName !== 'string') {
return null;
}
const trimmed = profileName.trim();
if (!trimmed) {
return null;
}
if (trimmed.includes(',')) {
const parts = trimmed.split(',').map((part) => part.trim()).filter(Boolean);
if (parts.length > 1) {
return parts[1].split(/\s+/)[0] || null;
}
}
return trimmed.split(/\s+/)[0] || null;
}
async function sendNtfyNotification(adminNtfy, userNtfy, payload) {
if (!adminNtfy?.enabled || !userNtfy?.enabled || !userNtfy.topic) {
return;
@@ -66,12 +83,18 @@ async function sendTelegramNotification(adminTelegram, userTelegram, payload) {
return;
}
const endpoint = `https://api.telegram.org/bot${adminTelegram.botToken}/sendMessage`;
const profileLabel = extractFirstName(payload?.profileName);
const messageParts = [
payload?.title ? `*${payload.title}*` : null,
profileLabel ? `Profil: ${profileLabel}` : null,
payload?.message || null
].filter(Boolean);
await axios.post(
endpoint,
{
chat_id: userTelegram.chatId,
text: payload.title ? `*${payload.title}*\n${payload.message}` : payload.message,
parse_mode: payload.title ? 'Markdown' : undefined,
text: messageParts.join('\n'),
parse_mode: payload?.title ? 'Markdown' : undefined,
disable_web_page_preview: true
},
{ timeout: 15000 }
@@ -104,7 +127,7 @@ async function notifyChannels(profileId, template) {
}
}
async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNotify, booked, storeId }) {
async function sendSlotNotification({ profileId, profileName, storeName, pickupDate, onlyNotify, booked, storeId }) {
const dateLabel = formatDateLabel(pickupDate);
const title = onlyNotify
? `Slot verfügbar bei ${storeName}`
@@ -123,7 +146,8 @@ async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNoti
title,
message: fullMessage,
link: storeLink,
priority: booked ? 'high' : 'default'
priority: booked ? 'high' : 'default',
profileName
});
}
@@ -137,7 +161,7 @@ function formatStoreWatchStatus(status) {
return 'Status unbekannt';
}
async function sendStoreWatchNotification({ profileId, storeName, storeId, regionName }) {
async function sendStoreWatchNotification({ profileId, profileName, storeName, storeId, regionName }) {
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
const title = `Team sucht Verstärkung: ${storeName}`;
const regionText = regionName ? ` (${regionName})` : '';
@@ -147,11 +171,12 @@ async function sendStoreWatchNotification({ profileId, storeName, storeId, regio
title,
message,
link: storeLink,
priority: 'high'
priority: 'high',
profileName
});
}
async function sendStoreWatchSummaryNotification({ profileId, entries = [], triggeredBy = 'manual' }) {
async function sendStoreWatchSummaryNotification({ profileId, profileName, entries = [], triggeredBy = 'manual' }) {
if (!profileId || !Array.isArray(entries) || entries.length === 0) {
return;
}
@@ -174,11 +199,12 @@ async function sendStoreWatchSummaryNotification({ profileId, entries = [], trig
await notifyChannels(profileId, {
title,
message,
priority: 'default'
priority: 'default',
profileName
});
}
async function sendDesiredWindowMissedNotification({ profileId, storeName, desiredWindowLabel }) {
async function sendDesiredWindowMissedNotification({ profileId, profileName, storeName, desiredWindowLabel }) {
if (!profileId) {
return;
}
@@ -191,7 +217,8 @@ async function sendDesiredWindowMissedNotification({ profileId, storeName, desir
title,
message,
link: null,
priority: 'default'
priority: 'default',
profileName
});
}
@@ -228,7 +255,7 @@ async function sendTestNotification(profileId, channel) {
await Promise.all(tasks);
}
async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonLines = [] }) {
async function sendDormantPickupWarning({ profileId, profileName, storeName, storeId, reasonLines = [] }) {
if (!profileId || !Array.isArray(reasonLines) || reasonLines.length === 0) {
return;
}
@@ -242,12 +269,14 @@ async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonL
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
title,
message,
priority: 'high'
priority: 'high',
profileName
});
}
async function sendJournalReminderNotification({
profileId,
profileName,
storeName,
pickupDate,
reminderDate,
@@ -270,7 +299,8 @@ async function sendJournalReminderNotification({
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
title,
message: messageLines.join('\n'),
priority: 'default'
priority: 'default',
profileName
});
}

View File

@@ -1,4 +1,6 @@
const cron = require('node-cron');
const fs = require('fs');
const path = require('path');
const foodsharingClient = require('./foodsharingClient');
const sessionStore = require('./sessionStore');
const { DEFAULT_SETTINGS } = require('./adminConfig');
@@ -23,7 +25,96 @@ const pickupCheckInFlight = new Map();
const pickupCheckLastRun = new Map();
const PICKUP_CHECK_DEDUP_MS = 30 * 1000;
const regularPickupCache = new Map();
const REGULAR_PICKUP_CACHE_FILE = path.join(__dirname, '..', 'config', 'regular-pickup-cache.json');
const REGULAR_PICKUP_CACHE_WRITE_DEBOUNCE_MS = 2000;
let regularPickupCacheWriteTimer = null;
let regularPickupCacheDirty = false;
const REGULAR_PICKUP_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
const REGULAR_PICKUP_ERROR_CACHE_TTL_MS = 5 * 60 * 1000;
const REGULAR_PICKUP_TRANSIENT_ERROR_TTL_MS = 30 * 1000;
const REGULAR_PICKUP_MAX_CONCURRENT = 3;
const REGULAR_PICKUP_MIN_DELAY_MS = 150;
const REGULAR_PICKUP_MAX_DELAY_MS = 350;
const regularPickupInFlight = new Map();
const regularPickupQueue = [];
let regularPickupActive = 0;
let regularPickupRefreshJob = null;
const dormantWarningCooldowns = new Map();
const DORMANT_WARNING_COOLDOWN_MS = 6 * 60 * 60 * 1000;
function ensureRegularPickupCacheDir() {
const dir = path.dirname(REGULAR_PICKUP_CACHE_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function persistRegularPickupCache() {
if (!regularPickupCacheDirty) {
return;
}
try {
ensureRegularPickupCacheDir();
const now = Date.now();
const entries = {};
regularPickupCache.forEach((value, key) => {
if (value?.expiresAt && value.expiresAt < now) {
return;
}
entries[key] = value;
});
fs.writeFileSync(
REGULAR_PICKUP_CACHE_FILE,
JSON.stringify({ version: 1, entries }, null, 2)
);
regularPickupCacheDirty = false;
} catch (error) {
console.warn('[PICKUP] Regular-Pickup-Cache konnte nicht geschrieben werden:', error.message);
}
}
function scheduleRegularPickupCachePersist() {
if (regularPickupCacheWriteTimer) {
return;
}
regularPickupCacheWriteTimer = setTimeout(() => {
regularPickupCacheWriteTimer = null;
persistRegularPickupCache();
}, REGULAR_PICKUP_CACHE_WRITE_DEBOUNCE_MS);
}
function markRegularPickupCacheDirty() {
regularPickupCacheDirty = true;
scheduleRegularPickupCachePersist();
}
function loadRegularPickupCacheFromDisk() {
try {
if (!fs.existsSync(REGULAR_PICKUP_CACHE_FILE)) {
return;
}
const raw = fs.readFileSync(REGULAR_PICKUP_CACHE_FILE, 'utf8');
const parsed = JSON.parse(raw);
const entries = parsed?.entries && typeof parsed.entries === 'object' ? parsed.entries : {};
const now = Date.now();
Object.entries(entries).forEach(([key, value]) => {
if (!value || typeof value !== 'object') {
return;
}
if (value.expiresAt && value.expiresAt < now) {
return;
}
regularPickupCache.set(String(key), {
fetchedAt: Number(value.fetchedAt) || Date.now(),
expiresAt: Number(value.expiresAt) || null,
rules: Array.isArray(value.rules) ? value.rules : [],
error: value.error || null
});
});
} catch (error) {
console.warn('[PICKUP] Regular-Pickup-Cache konnte nicht geladen werden:', error.message);
}
}
const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000;
const TIME_ZONE = 'Europe/Berlin';
@@ -87,6 +178,12 @@ function randomDelayMs(minSeconds = 10, maxSeconds = 120) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randomDelayMsBetween(minMs, maxMs) {
const min = Math.max(0, Number(minMs) || 0);
const max = Math.max(min, Number(maxMs) || 0);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function getTimeZoneParts(date, timeZone) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
@@ -250,6 +347,7 @@ function resolveSettings(settings) {
initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds)
? settings.initialDelayMaxSeconds
: DEFAULT_SETTINGS.initialDelayMaxSeconds,
dormantMembershipCron: settings.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron,
storeWatchCron: settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron,
storeWatchInitialDelayMinSeconds: Number.isFinite(settings.storeWatchInitialDelayMinSeconds)
? settings.storeWatchInitialDelayMinSeconds
@@ -280,29 +378,121 @@ function resolveSettings(settings) {
};
}
async function fetchRegularPickupSchedule(session, storeId) {
function describeRegularPickupError(error) {
const status = error?.response?.status;
if (status) {
return `HTTP ${status}`;
}
const code = error?.code;
if (code) {
return code;
}
const message = error?.message;
if (message) {
return message;
}
return 'Unknown error';
}
function getRegularPickupErrorTtlMs(error) {
const status = error?.response?.status;
if (status === 403 || status === 404) {
return REGULAR_PICKUP_ERROR_CACHE_TTL_MS;
}
const code = error?.code;
if (code) {
return REGULAR_PICKUP_TRANSIENT_ERROR_TTL_MS;
}
return REGULAR_PICKUP_ERROR_CACHE_TTL_MS;
}
function runWithRegularPickupLimiter(task) {
if (regularPickupActive < REGULAR_PICKUP_MAX_CONCURRENT) {
regularPickupActive += 1;
return Promise.resolve()
.then(async () => {
const delayMs = randomDelayMsBetween(REGULAR_PICKUP_MIN_DELAY_MS, REGULAR_PICKUP_MAX_DELAY_MS);
if (delayMs > 0) {
await wait(delayMs);
}
return task();
})
.finally(() => {
regularPickupActive -= 1;
const next = regularPickupQueue.shift();
if (next) {
next();
}
});
}
return new Promise((resolve, reject) => {
regularPickupQueue.push(() => {
runWithRegularPickupLimiter(task).then(resolve).catch(reject);
});
});
}
function getRegularPickupCacheEntry(storeId) {
const entry = regularPickupCache.get(String(storeId));
if (!entry) {
return null;
}
if (entry.expiresAt && entry.expiresAt < Date.now()) {
regularPickupCache.delete(String(storeId));
markRegularPickupCacheDirty();
return null;
}
return entry;
}
function setRegularPickupCacheEntry(storeId, rules, ttlMs, error) {
regularPickupCache.set(String(storeId), {
fetchedAt: Date.now(),
expiresAt: Date.now() + ttlMs,
rules: Array.isArray(rules) ? rules : [],
error: error || null
});
markRegularPickupCacheDirty();
}
async function getRegularPickupSchedule(session, storeId) {
if (!session?.profile?.id || !storeId) {
return [];
return { rules: [], error: 'missing-session-or-store', fromCache: false };
}
const key = String(storeId);
const cached = regularPickupCache.get(key);
if (cached && Date.now() - cached.fetchedAt <= REGULAR_PICKUP_CACHE_TTL_MS) {
return cached.rules;
const cached = getRegularPickupCacheEntry(key);
if (cached) {
return { rules: cached.rules, error: cached.error, fromCache: true };
}
try {
const rules = await withSessionRetry(
session,
() => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session),
{ label: 'fetchRegularPickup' }
);
const normalized = Array.isArray(rules) ? rules : [];
regularPickupCache.set(key, { fetchedAt: Date.now(), rules: normalized });
return normalized;
} catch (error) {
if (cached?.rules) {
return cached.rules;
const existing = regularPickupInFlight.get(key);
if (existing) {
return existing;
}
const fetchPromise = runWithRegularPickupLimiter(async () => {
try {
const rules = await withSessionRetry(
session,
() => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session),
{ label: 'fetchRegularPickup' }
);
const normalized = Array.isArray(rules) ? rules : [];
setRegularPickupCacheEntry(key, normalized, REGULAR_PICKUP_CACHE_TTL_MS);
return { rules: normalized, error: null, fromCache: false };
} catch (error) {
const message = describeRegularPickupError(error);
const ttl = getRegularPickupErrorTtlMs(error);
setRegularPickupCacheEntry(key, [], ttl, message);
return { rules: [], error: message, fromCache: false };
}
});
regularPickupInFlight.set(key, fetchPromise);
try {
return await fetchPromise;
} finally {
if (regularPickupInFlight.get(key) === fetchPromise) {
regularPickupInFlight.delete(key);
}
return [];
}
}
@@ -327,7 +517,7 @@ async function getNextPickupCheckTime(session, entry, settings) {
return null;
}
const rules = await fetchRegularPickupSchedule(session, entry.id);
const { rules } = await getRegularPickupSchedule(session, entry.id);
if (!Array.isArray(rules) || rules.length === 0) {
return null;
}
@@ -518,6 +708,7 @@ async function handleExpiredDesiredWindow(session, entry) {
try {
await notificationService.sendDesiredWindowMissedNotification({
profileId,
profileName: session.profile?.name,
storeName,
desiredWindowLabel: desiredLabel
});
@@ -588,6 +779,7 @@ async function processBooking(session, entry, pickup) {
console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`);
await notificationService.sendSlotNotification({
profileId: session.profile.id,
profileName: session.profile?.name,
storeName,
pickupDate: pickup.date,
onlyNotify: true,
@@ -618,6 +810,7 @@ async function processBooking(session, entry, pickup) {
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
await notificationService.sendSlotNotification({
profileId: session.profile.id,
profileName: session.profile?.name,
storeName,
pickupDate: pickup.date,
onlyNotify: false,
@@ -775,6 +968,7 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
await notificationService.sendStoreWatchNotification({
profileId: session.profile.id,
profileName: session.profile?.name,
storeName: watcher.storeName,
storeId: watcher.storeId,
regionName: watcher.regionName
@@ -824,6 +1018,7 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
try {
await notificationService.sendStoreWatchSummaryNotification({
profileId: session.profile.id,
profileName: session.profile?.name,
entries: summary,
triggeredBy: options.triggeredBy || 'manual'
});
@@ -868,6 +1063,51 @@ function scheduleStoreWatchers(sessionId, settings) {
return true;
}
function scheduleRegularPickupRefresh(settings) {
if (regularPickupRefreshJob) {
regularPickupRefreshJob.stop();
regularPickupRefreshJob = null;
}
const cronExpression = settings.regularPickupRefreshCron || DEFAULT_SETTINGS.regularPickupRefreshCron;
if (!cronExpression) {
return null;
}
regularPickupRefreshJob = cron.schedule(
cronExpression,
async () => {
const sessions = sessionStore.list();
const storeSessionMap = new Map();
for (const session of sessions) {
if (!session?.profile?.id) {
continue;
}
const config = readConfig(session.profile.id);
const entries = Array.isArray(config) ? config : [];
const storeIds = Array.from(
new Set(entries.filter((entry) => entry?.id && !entry.hidden).map((entry) => String(entry.id)))
);
const sessionUpdatedAt = Number(session.updatedAt) || 0;
storeIds.forEach((storeId) => {
const existing = storeSessionMap.get(storeId);
if (!existing || (Number(existing.updatedAt) || 0) < sessionUpdatedAt) {
storeSessionMap.set(storeId, session);
}
});
}
for (const [storeId, session] of storeSessionMap.entries()) {
const ready = await ensureSession(session);
if (!ready) {
continue;
}
await getRegularPickupSchedule(session, storeId);
}
},
{ timezone: TIME_ZONE }
);
return regularPickupRefreshJob;
}
function scheduleFallbackPickupChecks(sessionId, settings) {
const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron;
if (!cronExpression) {
@@ -940,7 +1180,7 @@ function scheduleEntry(sessionId, entry, settings) {
function scheduleConfig(sessionId, config, settings) {
const resolvedSettings = resolveSettings(settings);
sessionStore.clearJobs(sessionId);
scheduleDormantMembershipCheck(sessionId);
scheduleDormantMembershipCheck(sessionId, resolvedSettings);
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
scheduleJournalReminders(sessionId);
@@ -1101,13 +1341,20 @@ async function checkDormantMembers(sessionId, options = {}) {
}
}
if (reasons.length > 0) {
const cooldownKey = `${profileId}:${storeId}`;
const lastSentAt = dormantWarningCooldowns.get(cooldownKey);
if (lastSentAt && Date.now() - lastSentAt < DORMANT_WARNING_COOLDOWN_MS) {
continue;
}
try {
await sendDormantPickupWarning({
profileId,
profileName: session.profile?.name,
storeName: target.storeName,
storeId,
reasonLines: reasons
});
dormantWarningCooldowns.set(cooldownKey, Date.now());
} catch (error) {
console.error(`[DORMANT] Warnung für Store ${storeId} konnte nicht versendet werden:`, error.message);
}
@@ -1122,8 +1369,9 @@ async function checkDormantMembers(sessionId, options = {}) {
}
}
function scheduleDormantMembershipCheck(sessionId) {
const cronExpression = '0 4 */14 * *';
function scheduleDormantMembershipCheck(sessionId, settings) {
const cronExpression =
settings?.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron || '0 4 */14 * *';
const job = cron.schedule(
cronExpression,
() => {
@@ -1165,6 +1413,7 @@ async function checkJournalReminders(sessionId) {
return;
}
const profileId = session.profile.id;
const profileName = session.profile?.name;
const entries = readJournal(profileId);
if (!Array.isArray(entries) || entries.length === 0) {
return;
@@ -1197,6 +1446,7 @@ async function checkJournalReminders(sessionId) {
}
await sendJournalReminderNotification({
profileId,
profileName,
storeName: entry.storeName || `Store ${entry.storeId || ''}`,
pickupDate: occurrence,
reminderDate,
@@ -1235,5 +1485,9 @@ module.exports = {
scheduleConfig,
runStoreWatchCheck,
runImmediatePickupCheck,
runDormantMembershipCheck
runDormantMembershipCheck,
getRegularPickupSchedule,
scheduleRegularPickupRefresh
};
loadRegularPickupCacheFromDisk();