import axios from "axios"; import crypto from "crypto"; import getStorage from "./storage"; import { getStoreDishes, getStores } from "./stores"; import { Store, StoreMenu, StoreMenuDish, StoreMenuProvider, StoreMenuSource, StorePriceChange, StorePriceHistory, StorePriceHistoryEntry, StoreScrapeInfo, StoreScrapeState, StoreScrapeStatus } from "../../types/gen/types.gen"; const storage = getStorage(); /** * Klíč ve storage s nabídkami všech obchodů: název obchodu → záznamy dle služby. * Nabídka se stahuje jen tehdy, když pro službu ještě žádná není (žádná expirace), * nebo při ručním obnovení adminem. */ const STORE_MENUS_KEY = 'store_menus'; /** Historie cen: název obchodu → služba → verze (nejnovější první). Ukládají se jen rozdíly. */ const STORE_MENU_HISTORY_KEY = 'store_menu_history'; /** Kolik verzí historie cen se drží na obchod a službu. */ const MAX_HISTORY_ENTRIES = 200; /** Kolikrát se po sobě automaticky zkusí stáhnout nabídku, než se služba pro obchod zablokuje. */ const MAX_AUTO_ATTEMPTS = 2; /** Minimální odstup automatických pokusů po selhání (tj. nejvýše ~2× týdně). */ const RETRY_AFTER_MS = 3 * 24 * 60 * 60 * 1000; /** Minimální rozestup požadavků na jednu službu — Bolt po ~24 rychlých dotazech vrací 429. */ const MIN_REQUEST_INTERVAL_MS = process.env.NODE_ENV === 'test' ? 0 : 1500; /** Pauza služby po odpovědi 429, pokud služba neuvede Retry-After. */ const RATE_LIMIT_PAUSE_MS = 5 * 60 * 1000; const BOLT_MENU_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getMenuCategories'; const WOLT_MENU_URL = 'https://consumer-api.wolt.com/consumer-api/consumer-assortment/v1/venues/slug/'; const DEVICE_ID = crypto.randomUUID(); /** * Kategorie, které nejsou hlavním jídlem — do kola štěstí je nezahrnujeme * (polévky, nápoje, omáčky/dipy, přílohy, dezerty, přídavky, příbory apod.). */ const EXCLUDED_CATEGORY_REGEX = /pol[eé]vk|soup|n[aá]poj|drink|nealko|alkohol|\bpit[ií]|limon[aá]d|\bpiv[oa]\b|\bv[ií]n[oa]\b|\bk[aá]v[ay]\b|coffee|om[aá][cč]k|sauce|\bdip|p[řr][ií]loh|\bsides?\b|p[řr]edkrm|starter|appetizer|dezert|dessert|sladk[eé]|p[řr][ií]davk|p[řr][ií]plat|\badd-?ons?\b|\bextras?\b|p[řr][ií]bor|cutlery|\bobal|krabic|d[ěe]tsk|chutney|nakl[aá]dan|raita|\bnaa?n\b|parant?ha|chl[ée]b|pe[čc]ivo|^\W*(basmati\s+)?r[ýy][žz]e\W*$/i; /** Jednotlivé položky mimo vyloučené kategorie, které nejsou hlavním jídlem (polévky, příplatky). */ const SOUP_ITEM_REGEX = /pol[eé]vk|v[ýy]var|soup|kulajd|gul[aá][sš]ovk|dr[šs][ťt]kov[aá]$|[čc]esne[čc]k|bor[šs][čc]|p[řr][ií]platek/i; /** Uložený stav stahování nabídky obchodu od jedné služby. */ type ProviderRecord = { /** URL podniku u služby — při změně URL se záznam zahodí */ url: string, dishes?: StoreMenuDish[], lastSuccessAt?: string, lastAttemptAt?: string, /** Počet neúspěšných pokusů od posledního úspěchu (resp. ručního obnovení) */ failures: number, blocked: boolean, lastError?: string, }; type StoreMenuRecord = Partial>; type StoreMenusData = Record; type StoreMenuHistoryData = Record>>; const PROVIDERS: StoreMenuProvider[] = [StoreMenuProvider.BOLT, StoreMenuProvider.WOLT]; /** Vytáhne ID podniku z URL Bolt Food (…/p/67293-ruds-pizza-skvrnany/). */ export function parseBoltProviderId(url: string): string | undefined { try { const u = new URL(url); if (!u.hostname.endsWith('bolt.eu')) return undefined; return u.pathname.match(/\/p\/(\d+)/)?.[1]; } catch { return undefined; } } /** Vytáhne slug podniku z URL Wolt (…/restaurant/ruds-pizza-prazska). */ export function parseWoltSlug(url: string): string | undefined { try { const u = new URL(url); if (!u.hostname.endsWith('wolt.com')) return undefined; return u.pathname.match(/\/(?:restaurant|venue)\/([^/?#]+)/)?.[1]; } catch { return undefined; } } const isMainDish = (name: string, category?: string) => !(category && EXCLUDED_CATEGORY_REGEX.test(category)) && !SOUP_ITEM_REGEX.test(name); /** Odstraní duplicity (stejný název) a prázdné názvy. */ function dedupe(dishes: StoreMenuDish[]): StoreMenuDish[] { const seen = new Set(); return dishes.filter(d => { const key = d.name.trim().toLowerCase(); if (!key || seen.has(key)) return false; seen.add(key); return true; }); } type BoltItem = { type: string, id: number, parent_id: number | null, index?: number, name?: { value?: string }, price?: { value?: number }, availability?: string, }; /** Stáhne nabídku podniku z Bolt Food — dostupné položky, hlavní jídla označená `isMain`. */ export async function fetchBoltMenu(providerId: string): Promise { const res = await axios.get(BOLT_MENU_URL, { params: { provider_id: providerId, version: 'FW.1.113', language: 'cs-CZ', country: 'cz', device_name: 'web', device_os_version: 'web', deviceType: 'web', session_id: DEVICE_ID, distinct_id: `$device:${DEVICE_ID}`, deviceId: DEVICE_ID, }, timeout: 10_000, }); if (res.data?.code !== 0) { throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`); } const items: Record = res.data?.data?.items ?? {}; const all = Object.values(items); const categories = new Map(all.filter(i => i.type === 'category').map(c => [c.id, c])); return dedupe(all .filter(i => i.type === 'dish' && i.name?.value && i.availability !== 'out_of_stock') .map(i => { const category = i.parent_id != null ? categories.get(i.parent_id) : undefined; return { dish: i, category }; }) .sort((a, b) => (a.category?.index ?? 0) - (b.category?.index ?? 0) || (a.dish.index ?? 0) - (b.dish.index ?? 0)) .map(({ dish, category }) => ({ name: dish.name!.value!.trim(), price: dish.price?.value, category: category?.name?.value?.trim(), isMain: isMainDish(dish.name!.value!, category?.name?.value), }))); } type WoltAssortment = { categories?: { name?: string, item_ids?: string[], subcategories?: { name?: string, item_ids?: string[] }[] }[], items?: { id: string, name?: string, price?: number, disabled_info?: unknown, is_cutlery?: boolean }[], }; /** Stáhne nabídku podniku z Wolt — dostupné položky, hlavní jídla označená `isMain`. */ export async function fetchWoltMenu(slug: string): Promise { const res = await axios.get(`${WOLT_MENU_URL}${encodeURIComponent(slug)}/assortment`, { params: { language: 'cs' }, headers: { Accept: 'application/json', 'Accept-Language': 'cs' }, timeout: 10_000, }); const items = new Map((res.data?.items ?? []).map(i => [i.id, i])); const dishes: StoreMenuDish[] = []; for (const category of res.data?.categories ?? []) { const groups = [{ name: category.name, item_ids: category.item_ids }, ...(category.subcategories ?? [])]; for (const group of groups) { const categoryName = [category.name, group.name !== category.name ? group.name : undefined].filter(Boolean).join(' / '); for (const id of group.item_ids ?? []) { const item = items.get(id); if (!item?.name || item.disabled_info || item.is_cutlery) continue; dishes.push({ name: item.name.trim(), // Wolt uvádí ceny v haléřích price: item.price != null ? Math.round(item.price / 100) : undefined, category: categoryName.trim() || undefined, isMain: isMainDish(item.name, categoryName), }); } } } return dedupe(dishes); } /** Vrátí URL podniku u dané služby (první odpovídající). */ function providerUrl(store: Store, provider: StoreMenuProvider): string | undefined { const parse = provider === StoreMenuProvider.BOLT ? parseBoltProviderId : parseWoltSlug; return (store.urls ?? []).find(url => parse(url)); } /** Služba odmítla požadavek kvůli omezení počtu dotazů (HTTP 429) — nejde o chybu podniku. */ export class RateLimitedError extends Error { constructor(provider: StoreMenuProvider, public readonly until: number) { super(`${provider === StoreMenuProvider.BOLT ? 'Bolt Food' : 'Wolt'} omezil počet dotazů (HTTP 429), další pokus po ${new Date(until).toLocaleTimeString('cs-CZ')}`); } } /** Fronta požadavků každé služby (sériově, s rozestupem) a případná pauza po 429. */ const providerQueue = new Map>(); const providerPausedUntil = new Map(); /** Délka pauzy dle hlavičky Retry-After (sekundy nebo datum), jinak výchozí. */ function retryAfterMs(header: unknown): number { const value = Array.isArray(header) ? header[0] : header; if (typeof value === 'string' || typeof value === 'number') { const seconds = Number(value); if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000; const date = Date.parse(String(value)); if (!Number.isNaN(date) && date > Date.now()) return date - Date.now(); } return RATE_LIMIT_PAUSE_MS; } const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); /** * Stáhne nabídku přes frontu dané služby: požadavky jdou po jednom s rozestupem * {@link MIN_REQUEST_INTERVAL_MS}; po 429 se služba pozastaví a další požadavky * rovnou selžou s {@link RateLimitedError} (nezatěžujeme službu dalšími dotazy). */ function fetchProviderMenu(provider: StoreMenuProvider, url: string): Promise { const run = async () => { const pausedUntil = providerPausedUntil.get(provider) ?? 0; if (Date.now() < pausedUntil) { throw new RateLimitedError(provider, pausedUntil); } try { return await (provider === StoreMenuProvider.BOLT ? fetchBoltMenu(parseBoltProviderId(url)!) : fetchWoltMenu(parseWoltSlug(url)!)); } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 429) { const until = Date.now() + retryAfterMs(e.response.headers?.['retry-after']); providerPausedUntil.set(provider, until); throw new RateLimitedError(provider, until); } throw e; } finally { if (MIN_REQUEST_INTERVAL_MS > 0) await sleep(MIN_REQUEST_INTERVAL_MS); } }; const previous = providerQueue.get(provider) ?? Promise.resolve(); const result = previous.catch(() => undefined).then(run); providerQueue.set(provider, result.catch(() => undefined)); return result; } /** Pro testy — zruší pauzy a frontu služeb. */ export function resetProviderThrottle(): void { providerQueue.clear(); providerPausedUntil.clear(); } /** Záznam služby pro aktuální URL obchodu — při změně URL začíná stav nanovo. */ function currentRecord(record: StoreMenuRecord | undefined, provider: StoreMenuProvider, url: string): ProviderRecord { const existing = record?.[provider]; return existing && existing.url === url ? existing : { url, failures: 0, blocked: false }; } function scrapeState(rec: ProviderRecord): StoreScrapeState { if (rec.dishes?.length) return StoreScrapeState.OK; if (rec.blocked) return StoreScrapeState.BLOCKED; return rec.failures > 0 ? StoreScrapeState.FAILED : StoreScrapeState.PENDING; } /** Smí se nabídka od služby stáhnout automaticky? Jen pokud chybí a služba není zablokovaná. */ function canAutoScrape(rec: ProviderRecord, now: number): boolean { if (rec.dishes?.length || rec.blocked) return false; return !rec.lastAttemptAt || now - Date.parse(rec.lastAttemptAt) >= RETRY_AFTER_MS; } /** * Rozdíl dvou verzí nabídky: změněné ceny, nové a odebrané položky (porovnání dle názvu). */ export function diffMenus(before: StoreMenuDish[], after: StoreMenuDish[]): StorePriceChange[] { const key = (d: StoreMenuDish) => d.name.trim().toLowerCase(); const old = new Map(before.map(d => [key(d), d])); const changes: StorePriceChange[] = []; for (const dish of after) { const prev = old.get(key(dish)); old.delete(key(dish)); if (!prev) { changes.push({ name: dish.name, ...(dish.price != null && { after: dish.price }) }); } else if (prev.price !== dish.price) { changes.push({ name: dish.name, ...(prev.price != null && { before: prev.price }), ...(dish.price != null && { after: dish.price }) }); } } for (const removed of old.values()) { changes.push({ name: removed.name, ...(removed.price != null && { before: removed.price }) }); } return changes; } /** Zapíše novou verzi do historie cen — výchozí stav při prvním stažení, dále jen při změně. */ async function recordPriceHistory(storeName: string, provider: StoreMenuProvider, previous: ProviderRecord | undefined, dishes: StoreMenuDish[], at: string): Promise { const before = previous?.dishes; const entry: StorePriceHistoryEntry = before?.length ? { at, itemCount: dishes.length, changes: diffMenus(before, dishes) } : { at, initial: true, itemCount: dishes.length, changes: [] }; if (!entry.initial && entry.changes.length === 0) return; await storage.updateData(STORE_MENU_HISTORY_KEY, current => { const all = current ?? {}; let existing = all[storeName]?.[provider] ?? []; // Nabídka stažená před zavedením historie — její původní stav uložíme jako výchozí verzi if (existing.length === 0 && !entry.initial && previous?.lastSuccessAt) { existing = [{ at: previous.lastSuccessAt, initial: true, itemCount: before!.length, changes: [] }]; } const entries = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES); all[storeName] = { ...all[storeName], [provider]: entries }; return all; }); } /** * Provede jeden pokus o stažení nabídky a výsledek zapíše do storage. * Při neúspěchu ponechá případnou dříve staženou nabídku; po {@link MAX_AUTO_ATTEMPTS} * neúspěších v řadě službu pro obchod zablokuje (automaticky se už nezkouší). */ async function attemptScrape(storeName: string, provider: StoreMenuProvider, url: string, manual: boolean): Promise { const now = new Date().toISOString(); let result: Partial; try { const dishes = await fetchProviderMenu(provider, url); if (!dishes.some(d => d.isMain)) { throw new Error('Nabídka neobsahuje žádná hlavní jídla'); } result = { dishes, lastSuccessAt: now, failures: 0, blocked: false, lastError: undefined }; } catch (e: any) { console.warn(`Nabídka podniku "${storeName}" (${provider}): stažení selhalo — ${e?.message ?? e}`); result = { lastError: String(e?.message ?? e).slice(0, 300) }; if (e instanceof RateLimitedError) { // Omezení dotazů není chyba podniku — nepočítá se do pokusů ani nespouští 3denní odklad const data = await storage.updateData(STORE_MENUS_KEY, current => { const all = current ?? {}; const rec = currentRecord(all[storeName], provider, url); all[storeName] = { ...all[storeName], [provider]: { ...rec, lastError: result.lastError } }; return all; }); return data[storeName]![provider]!; } } let previousRecord: ProviderRecord | undefined; const data = await storage.updateData(STORE_MENUS_KEY, current => { const all = current ?? {}; const rec = currentRecord(all[storeName], provider, url); previousRecord = rec; const next: ProviderRecord = { ...rec, ...result, lastAttemptAt: now }; if (!result.dishes) { next.failures = rec.failures + 1; // Ruční obnovení službu nezablokuje — admin to může zkoušet dál next.blocked = manual ? rec.blocked : next.failures >= MAX_AUTO_ATTEMPTS; } if (!next.lastError) delete next.lastError; all[storeName] = { ...all[storeName], [provider]: next }; return all; }); if (result.dishes) { await recordPriceHistory(storeName, provider, previousRecord, result.dishes, now); } return data[storeName]![provider]!; } /** Vrátí historii cen obchodu po službách (nejnovější verze první). */ export async function getStoreMenuHistory(name: string): Promise { const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase()); if (!store) { throw new Error('Obchod nebyl nalezen'); } const [historyData, menus] = await Promise.all([ storage.getData(STORE_MENU_HISTORY_KEY), storage.getData(STORE_MENUS_KEY), ]); const history = historyData?.[store.name] ?? {}; const record = menus?.[store.name]; return { store: store.name, providers: PROVIDERS .map(provider => { const entries = history[provider] ?? []; const rec = record?.[provider]; // Nabídka stažená dřív, než existovala historie — její stav je výchozí verzí if (entries.length === 0 && rec?.dishes?.length && rec.lastSuccessAt) { return { provider, entries: [{ at: rec.lastSuccessAt, initial: true, itemCount: rec.dishes.length, changes: [] }] }; } return { provider, entries }; }) .filter(p => p.entries.length > 0), }; } /** Souběžné požadavky na stejný obchod sdílí jedno stahování. */ const inFlight = new Map>(); /** * Vrátí nabídku hlavních jídel obchodu pro kolo štěstí. Použije uloženou nabídku * z Bolt Food (přednostně) nebo Wolt; pokud žádná není, zkusí ji stáhnout (s omezeným * počtem pokusů). Jinak použije jídla z historie objednávek. * * @param name název obchodu ze seznamu povolených */ export async function getStoreMenu(name: string): Promise { const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase()); if (!store) { throw new Error('Obchod nebyl nalezen'); } const pending = inFlight.get(store.name); if (pending) return pending; const promise = resolveStoreMenu(store).finally(() => inFlight.delete(store.name)); inFlight.set(store.name, promise); return promise; } async function resolveStoreMenu(store: Store): Promise { const record = (await storage.getData(STORE_MENUS_KEY))?.[store.name]; const candidates = PROVIDERS .map(provider => ({ provider, url: providerUrl(store, provider) })) .filter((c): c is { provider: StoreMenuProvider, url: string } => !!c.url) .map(c => ({ ...c, rec: currentRecord(record, c.provider, c.url) })); const stored = candidates.find(c => c.rec.dishes?.length); if (stored) { return { store: store.name, source: stored.provider, dishes: stored.rec.dishes! }; } const now = Date.now(); for (const c of candidates.filter(c => canAutoScrape(c.rec, now))) { const rec = await attemptScrape(store.name, c.provider, c.url, false); if (rec.dishes?.length) { return { store: store.name, source: c.provider, dishes: rec.dishes }; } } // Záloha — historie objednávek const history = (await getStoreDishes()).find(s => s.store === store.name)?.dishes ?? []; return { store: store.name, source: history.length > 0 ? StoreMenuSource.HISTORY : StoreMenuSource.NONE, dishes: history.map(dishName => ({ name: dishName, isMain: true })), }; } function toStatus(provider: StoreMenuProvider, rec: ProviderRecord): StoreScrapeStatus { return { provider, url: rec.url, state: scrapeState(rec), dishCount: rec.dishes?.filter(d => d.isMain !== false).length ?? 0, failures: rec.failures, ...(rec.lastSuccessAt && { lastSuccessAt: rec.lastSuccessAt }), ...(rec.lastAttemptAt && { lastAttemptAt: rec.lastAttemptAt }), ...(rec.lastError && { lastError: rec.lastError }), }; } function scrapeInfo(store: Store, record: StoreMenuRecord | undefined): StoreScrapeInfo { const providers: StoreScrapeStatus[] = []; for (const provider of PROVIDERS) { const url = providerUrl(store, provider); if (url) providers.push(toStatus(provider, currentRecord(record, provider, url))); } return { store: store.name, providers }; } /** Vrátí stav stahování nabídek pro všechny obchody (pro správu obchodů). */ export async function getStoreMenuStatus(): Promise { const [stores, data] = await Promise.all([getStores(), storage.getData(STORE_MENUS_KEY)]); return stores.map(store => scrapeInfo(store, data?.[store.name])); } /** * Ručně znovu stáhne nabídku obchodu od dané služby (admin). Při úspěchu nahradí * uloženou nabídku a zruší zablokování; při neúspěchu ponechá původní nabídku. */ export async function refreshStoreMenu(name: string, provider: StoreMenuProvider, heslo: string): Promise { const adminPassword = process.env.ADMIN_PASSWORD; if (!adminPassword || heslo !== adminPassword) { throw new Error('UNAUTHORIZED'); } if (!PROVIDERS.includes(provider)) { throw new Error('Nepodporovaná služba'); } const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase()); if (!store) { throw new Error('Obchod nebyl nalezen'); } const url = providerUrl(store, provider); if (!url) { throw new Error(`Obchod nemá URL pro ${provider === StoreMenuProvider.BOLT ? 'Bolt Food' : 'Wolt'}`); } await attemptScrape(store.name, provider, url, true); const data = await storage.getData(STORE_MENUS_KEY); return scrapeInfo(store, data?.[store.name]); } /** Přejmenuje/odebere uložené nabídky obchodu (volá se při úpravě seznamu obchodů). */ export async function renameStoreMenus(oldName: string, newName?: string): Promise { await storage.updateData(STORE_MENU_HISTORY_KEY, current => { const all = current ?? {}; const key = Object.keys(all).find(k => k.toLowerCase() === oldName.trim().toLowerCase()); if (!key) return all; const record = all[key]; delete all[key]; if (newName) all[newName] = record; return all; }); await storage.updateData(STORE_MENUS_KEY, current => { const all = current ?? {}; const key = Object.keys(all).find(k => k.toLowerCase() === oldName.trim().toLowerCase()); if (!key) return all; const record = all[key]; delete all[key]; if (newName) all[newName] = record; return all; }); }