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
506 lines
23 KiB
TypeScript
506 lines
23 KiB
TypeScript
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;
|
||
});
|
||
}
|