Files
Luncher/server/src/storeMenus.ts
T
batmanisko e281615cc3
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Build and push Docker image (push) Successful in 51s
CI / Notify (push) Successful in 2s
feat: spin the wheel, objednavani scrape menu, historie cen
2026-09-23 15:41:15 +02:00

506 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Record<StoreMenuProvider, ProviderRecord>>;
type StoreMenusData = Record<string, StoreMenuRecord>;
type StoreMenuHistoryData = Record<string, Partial<Record<StoreMenuProvider, StorePriceHistoryEntry[]>>>;
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<string>();
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<StoreMenuDish[]> {
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<string, BoltItem> = 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<StoreMenuDish[]> {
const res = await axios.get<WoltAssortment>(`${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<StoreMenuProvider, Promise<unknown>>();
const providerPausedUntil = new Map<StoreMenuProvider, number>();
/** 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<StoreMenuDish[]> {
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<void> {
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<StoreMenuHistoryData>(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<ProviderRecord> {
const now = new Date().toISOString();
let result: Partial<ProviderRecord>;
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<StoreMenusData>(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<StoreMenusData>(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<StorePriceHistory> {
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<StoreMenuHistoryData>(STORE_MENU_HISTORY_KEY),
storage.getData<StoreMenusData>(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<string, Promise<StoreMenu>>();
/**
* 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<StoreMenu> {
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<StoreMenu> {
const record = (await storage.getData<StoreMenusData>(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<StoreScrapeInfo[]> {
const [stores, data] = await Promise.all([getStores(), storage.getData<StoreMenusData>(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<StoreScrapeInfo> {
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<StoreMenusData>(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<void> {
await storage.updateData<StoreMenuHistoryData>(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<StoreMenusData>(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;
});
}