feat: spin the wheel, objednavani scrape menu, historie cen
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

This commit is contained in:
batmanisko committed 2026-09-23 15:41:15 +02:00
1 parent 682deaf20b
commit e281615cc3
35 files changed
+3979 -15

No files matched your search

+505
View File
@@ -0,0 +1,505 @@
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;
});
}