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
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:
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"Kolo štěstí pro nerozhodnuté — vylosuje podnik (dnešní menu i rozvoz) a pak konkrétní jídlo z jeho nabídky (u rozvozu staženou z Bolt Food/Wolt), možnosti lze z kola vyřazovat",
|
||||
"Objednání: nový sloupec „Z nabídky\" — výběr jídla z aktuální nabídky podniku (doplní poznámku i cenu) a kontrola ceny podle nabídky",
|
||||
"Objednání → Obchody: historie cen nabídek podniků — kdy a jak se změnily ceny, nové a odebrané položky"
|
||||
]
|
||||
@@ -0,0 +1,347 @@
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import getStorage from "./storage";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/**
|
||||
* Postavy Umamusume pro konfety kola štěstí (styl Umamusume): chibi obrázky (případně
|
||||
* portrét s `round: true`) a typická hláška.
|
||||
* Zdroj: Umamusume Wiki (umamusume.fandom.com) — obrázky se do repozitáře neukládají,
|
||||
* server je při prvním použití stáhne a nacachuje ve storage (viz {@link getConfettiImage}).
|
||||
*/
|
||||
export const UMA_CHARACTERS: { name: string, quote?: string, round?: boolean, images: string[], icon?: string }[] = [
|
||||
{
|
||||
name: "Air Groove",
|
||||
quote: "Just as my esteemed mother once did, I must train to perfection and lead others along my path!",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/c/c3/Air_Groove_Original_Chibi_2.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/f/f9/Air_Groove_Original_Chibi_1.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Bamboo Memory",
|
||||
quote: "Let's keep the pecking order nice and neat. We don't want anything bad to happen, right?",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/c/c5/Bamboo_Memory_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/e/e7/Bamboo_Memory_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Cesario",
|
||||
quote: "I will not give up this Tiara. It's time for me to show the way.",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/7/74/Cesario_Original_Chibi_2.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/a/ad/Cesario_Original_Chibi_1.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "El Condor Pasa",
|
||||
quote: "Soaring towards victory with the heart of a wrestler! El's the world's strooongest!",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/7/72/El_Condor_Pasa_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/c/c0/El_Condor_Pasa_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Gold Ship",
|
||||
quote: "So long as it's fun, right? Right?!",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/8/84/Gold_Ship_Original_Chibi_2.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/d/db/Gold_Ship_Original_Chibi_1.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Manhattan Cafe",
|
||||
quote: "I'm... right here, with someone you can't see.",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/8/8f/Manhattan_Cafe_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/d/de/Manhattan_Cafe_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Matikanetannhauser",
|
||||
quote: "Y'know, I kinda get the feeling I can really do it this time!",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/6/6b/Matikanetannhauser_Original_Chibi_2.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/9/90/Matikanetannhauser_Original_Chibi_1.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Mejiro Bright",
|
||||
quote: "Oh, is it time for my race already? Well then, I suppose I shall get ready.",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/3/3d/Mejiro_Bright_Original_Chibi_2.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/c/cc/Mejiro_Bright_Original_Chibi_1.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Nakayama Festa",
|
||||
quote: "Cold… is that so? When my passion heats up it'll be hot enough to burn you.",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/1/16/Nakayama_Festa_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/5/57/Nakayama_Festa_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Neo Universe",
|
||||
quote: "Neo Universe would like to request a friendly \"ENCOUNTER\". In other words, I'm in your hands.",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/3/34/Neo_Universe_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/4/40/Neo_Universe_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Phalaenopsis",
|
||||
quote: "Frail health, poor form—what are they to me? Legs that dull at such trifles have no business claiming victory.",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/6/65/Phalaenopsis_Original_Chibi_2.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/9/95/Phalaenopsis_Original_Chibi_1.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Satono Diamond",
|
||||
quote: "Everyone believes in my ability to win. I shall shine on like a brilliant diamond for them!",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/3/3b/Satono_Diamond_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Satono_Diamond_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Silence Suzuka",
|
||||
quote: "I just want to see... that scenery from the lead.",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/1/1f/Silence_Suzuka_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/d/df/Silence_Suzuka_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Special Week",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/4/4f/Special_Week_Original_Chibi_2.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/6/6c/Special_Week_Original_Chibi_1.png',
|
||||
],
|
||||
// Portrét pro odznak „jedlíka" (chibi je v malé velikosti nečitelný)
|
||||
icon: 'https://static.wikia.nocookie.net/umamusume/images/3/3a/Special_Week_%28icon%29.png/revision/latest/scale-to-width-down/128',
|
||||
},
|
||||
{
|
||||
name: "Tokai Teio",
|
||||
quote: "Here begins the legend of the invincible Teio!",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/3/36/Tokai_Teio_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/9/9e/Tokai_Teio_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Tosen Jordan",
|
||||
quote: "My dream is... actually, serious topics are NG~☆ Anyways, look at my nails! Aren't they cool?",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/3/3e/Tosen_Jordan_Original_Chibi_1.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/7/7b/Tosen_Jordan_Original_Chibi_2.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Yaeno Muteki",
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Yaeno_Muteki_Original_Chibi_2.png',
|
||||
'https://static.wikia.nocookie.net/umamusume/images/d/d7/Yaeno_Muteki_Original_Chibi_1.png',
|
||||
],
|
||||
},
|
||||
// Postavy bez chibi obrázku na wiki — čtvercový portrét (ikona), klient ho zobrazí jako kulatou samolepku
|
||||
{
|
||||
name: "Kitasan Black",
|
||||
quote: "I'll give it a go! You'll never know unless you try, right?",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/c/c8/Kitasan_Black_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Oguri Cap",
|
||||
quote: "I'm going to win this for the folks back home... But first, sustenance.",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/5/57/Oguri_Cap_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Haru Urara",
|
||||
quote: "I won't let losing get me down! Urara, la la la! ♪",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/7/7b/Haru_Urara_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Mejiro McQueen",
|
||||
quote: "I'll bring honor and glory to the Mejiro family!",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/1/15/Mejiro_McQueen_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Rice Shower",
|
||||
quote: "Rice only brings misfortune to everyone... I really... really hope I can change that!",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/0/07/Rice_Shower_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Daiwa Scarlet",
|
||||
quote: "Second-best won't cut it. Everything I do is for the sake of being number one!",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/d/d7/Daiwa_Scarlet_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Vodka",
|
||||
quote: "I've just gotta be the coolest to ever do it!",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/0/0d/Vodka_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Agnes Tachyon",
|
||||
quote: "Life is an experiment, and we its guinea pigs. You, me, the other Umamusume—everyone!",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Agnes_Tachyon_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Twin Turbo",
|
||||
quote: "Turbo's my name, and speed's my game! Let's race, right now! Hey! You listenin' to me?!",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/9/97/Twin_Turbo_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Symboli Rudolf",
|
||||
quote: "Fortune smiles upon the brave. Let us create a world where all Umamusume can live in bliss.",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/5/56/Symboli_Rudolf_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Nice Nature",
|
||||
quote: "Yup, I'm happy with a third place finish—it's all an extra like me can ask for.",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/7/77/Nice_Nature_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Maruzensky",
|
||||
quote: "The track's like a dance floor, and I'm feelin' groovy today!♪",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/b/b4/Maruzensky_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Grass Wonder",
|
||||
quote: "Noble and gallant, I march on... towards my ambitions.",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/d/d0/Grass_Wonder_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Mihono Bourbon",
|
||||
quote: "Target acquired. Commencing Operation: Triple Crown.",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/3/37/Mihono_Bourbon_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Curren Chan",
|
||||
quote: "New day, same adorable Curren! ☆ Spread the word with #CurrenChan!",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/9/91/Curren_Chan_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Super Creek",
|
||||
quote: "I can spoil you as much as you'd like. Aww, there, there. ♪",
|
||||
round: true,
|
||||
images: [
|
||||
'https://static.wikia.nocookie.net/umamusume/images/5/5e/Super_Creek_%28icon%29.png/revision/latest/scale-to-width-down/256',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Po neúspěšném stažení obrázku se další pokus smí provést až po této době. */
|
||||
const RETRY_AFTER_MS = 10 * 60 * 1000;
|
||||
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
type CachedImage = { contentType: string, data: string };
|
||||
|
||||
/** Stabilní ID obrázku z jeho URL — nemění se při úpravách seznamu (prohlížeč obrázky cachuje natrvalo). */
|
||||
const imageId = (url: string) => crypto.createHash('sha1').update(url).digest('hex').slice(0, 16);
|
||||
const imageKey = (url: string) => `confetti_img_${imageId(url)}`;
|
||||
const imageUrl = (url: string) => `/api/confetti/image/${imageId(url)}`;
|
||||
|
||||
/** Povolené obrázky: ID → zdrojová URL (nic jiného server nestáhne). */
|
||||
const ALLOWED_IMAGES = new Map(UMA_CHARACTERS.flatMap(c => c.icon ? [...c.images, c.icon] : c.images).map(url => [imageId(url), url]));
|
||||
const failedAt = new Map<string, number>();
|
||||
const inFlight = new Map<string, Promise<CachedImage | undefined>>();
|
||||
|
||||
/** Seznam postav pro klienta — obrázky vedou na cachující proxy tohoto serveru. */
|
||||
export function getConfettiCharacters() {
|
||||
return UMA_CHARACTERS.map(c => ({
|
||||
name: c.name,
|
||||
...(c.quote && { quote: c.quote }),
|
||||
...(c.round && { round: true }),
|
||||
images: c.images.map(imageUrl),
|
||||
...(c.icon && { icon: imageUrl(c.icon) }),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí obrázek konfet dle jeho ID. Poprvé ho stáhne a uloží do storage, další
|
||||
* požadavky (i při výpadku internetu) obslouží z cache. Stahují se jen URL ze seznamu výše.
|
||||
*/
|
||||
export async function getConfettiImage(id: string): Promise<{ contentType: string, data: Buffer } | undefined> {
|
||||
const url = ALLOWED_IMAGES.get(id);
|
||||
if (!url) return undefined;
|
||||
const key = imageKey(url);
|
||||
const cached = await storage.getData<CachedImage>(key);
|
||||
if (cached?.data) {
|
||||
return { contentType: cached.contentType, data: Buffer.from(cached.data, 'base64') };
|
||||
}
|
||||
const lastFailure = failedAt.get(id);
|
||||
if (lastFailure && Date.now() - lastFailure < RETRY_AFTER_MS) return undefined;
|
||||
|
||||
let pending = inFlight.get(id);
|
||||
if (!pending) {
|
||||
pending = downloadImage(url, key)
|
||||
.catch(e => {
|
||||
console.warn(`Konfety: obrázek ${url} se nepodařilo stáhnout — ${e?.message ?? e}`);
|
||||
failedAt.set(id, Date.now());
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => inFlight.delete(id));
|
||||
inFlight.set(id, pending);
|
||||
}
|
||||
const image = await pending;
|
||||
return image && { contentType: image.contentType, data: Buffer.from(image.data, 'base64') };
|
||||
}
|
||||
|
||||
async function downloadImage(url: string, key: string): Promise<CachedImage> {
|
||||
const res = await axios.get<ArrayBuffer>(url, { responseType: 'arraybuffer', timeout: 10_000, maxContentLength: MAX_IMAGE_BYTES });
|
||||
const contentType = String(res.headers['content-type'] ?? '');
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw new Error(`Neočekávaný typ obsahu ${contentType}`);
|
||||
}
|
||||
const image = { contentType, data: Buffer.from(res.data).toString('base64') };
|
||||
await storage.setData<CachedImage>(key, image);
|
||||
return image;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import changelogRoutes from "./routes/changelogRoutes";
|
||||
import groupRoutes from "./routes/groupRoutes";
|
||||
import storeRoutes from "./routes/storeRoutes";
|
||||
import butterflyRoutes from "./routes/butterflyRoutes";
|
||||
import { getConfettiCharacters, getConfettiImage } from "./confetti";
|
||||
|
||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||
@@ -200,6 +201,25 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
// Konfety kola štěstí — veřejné (načítá je <img>), obrázky jen z pevného seznamu, cachované ve storage
|
||||
app.get("/api/confetti/umamusume", (_req, res) => {
|
||||
// Seznam je malý — vždy revalidovat (ETag → 304), aby se změny projevily hned po nasazení
|
||||
res.set('Cache-Control', 'no-cache').json(getConfettiCharacters());
|
||||
});
|
||||
|
||||
app.get("/api/confetti/image/:id", async (req, res, next) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const image = /^[0-9a-f]{16}$/.test(id) ? await getConfettiImage(id) : undefined;
|
||||
if (!image) {
|
||||
return res.status(404).json({ error: 'Obrázek není dostupný' });
|
||||
}
|
||||
res.set('Content-Type', image.contentType);
|
||||
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
res.end(image.data);
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
app.use("/api/", (req, res, next) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import express from "express";
|
||||
import { getStores, addStore, updateStore, removeStore } from "../stores";
|
||||
import { getStoreMenu, getStoreMenuHistory, getStoreMenuStatus, refreshStoreMenu, renameStoreMenus } from "../storeMenus";
|
||||
import { StoreMenuProvider } from "../../../types/gen/types.gen";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -10,6 +12,69 @@ router.get("/", async (_req, res, next) => {
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
router.get("/menu", async (req, res, next) => {
|
||||
const name = req.query.name;
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||
}
|
||||
try {
|
||||
res.status(200).json(await getStoreMenu(name));
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Obchod nebyl nalezen') {
|
||||
return res.status(404).json({ error: e.message });
|
||||
}
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/menu/status", async (_req, res, next) => {
|
||||
try {
|
||||
res.status(200).json(await getStoreMenuStatus());
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
router.get("/menu/history", async (req, res, next) => {
|
||||
const name = req.query.name;
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||
}
|
||||
try {
|
||||
res.status(200).json(await getStoreMenuHistory(name));
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Obchod nebyl nalezen') {
|
||||
return res.status(404).json({ error: e.message });
|
||||
}
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/menu/refresh", async (req, res, next) => {
|
||||
const { name, provider, heslo } = req.body ?? {};
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||
}
|
||||
if (!Object.values<string>(StoreMenuProvider).includes(provider)) {
|
||||
return res.status(400).json({ error: 'Neplatná dovozová služba' });
|
||||
}
|
||||
if (!heslo || typeof heslo !== 'string') {
|
||||
return res.status(400).json({ error: 'Nebylo předáno heslo' });
|
||||
}
|
||||
try {
|
||||
res.status(200).json(await refreshStoreMenu(name, provider, heslo));
|
||||
} catch (e: any) {
|
||||
if (e.message === 'UNAUTHORIZED') {
|
||||
return res.status(403).json({ error: 'Nesprávné heslo' });
|
||||
}
|
||||
if (e.message === 'Obchod nebyl nalezen') {
|
||||
return res.status(404).json({ error: e.message });
|
||||
}
|
||||
if (e.message?.startsWith('Obchod nemá URL')) {
|
||||
return res.status(400).json({ error: e.message });
|
||||
}
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/add", async (req, res, next) => {
|
||||
const { name, heslo, urls } = req.body ?? {};
|
||||
if (!name || typeof name !== 'string') {
|
||||
@@ -48,6 +113,12 @@ router.post("/update", async (req, res, next) => {
|
||||
}
|
||||
try {
|
||||
const stores = await updateStore(name, heslo, newName ?? undefined, urls ?? undefined);
|
||||
// Uložené nabídky jsou vázané na název obchodu
|
||||
const renamed = newName && stores.find(s => s.name.toLowerCase() === newName.trim().toLowerCase());
|
||||
const original = stores.find(s => s.name.toLowerCase() === name.trim().toLowerCase());
|
||||
if (renamed && !original) {
|
||||
await renameStoreMenus(name.trim(), renamed.name);
|
||||
}
|
||||
res.status(200).json(stores);
|
||||
} catch (e: any) {
|
||||
if (e.message === 'UNAUTHORIZED') {
|
||||
@@ -67,6 +138,7 @@ router.post("/delete", async (req, res, next) => {
|
||||
}
|
||||
try {
|
||||
const stores = await removeStore(name, heslo);
|
||||
await renameStoreMenus(name.trim());
|
||||
res.status(200).json(stores);
|
||||
} catch (e: any) {
|
||||
if (e.message === 'UNAUTHORIZED') {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
+57
-1
@@ -1,4 +1,4 @@
|
||||
import { Store } from "../../types/gen/types.gen";
|
||||
import { ClientData, Store } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
|
||||
const storage = getStorage();
|
||||
@@ -151,3 +151,59 @@ export async function removeStore(name: string, heslo: string): Promise<Store[]>
|
||||
await storage.setData(STORES_KEY, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Maximální počet jídel vrácených pro jeden obchod v {@link getStoreDishes}. */
|
||||
const MAX_DISHES_PER_STORE = 40;
|
||||
|
||||
/** Rozpoznání polévek v poznámkách objednávek — ty se do kola štěstí nezahrnují. */
|
||||
/** Jídla historicky objednaná u daného obchodu. */
|
||||
export type StoreDishes = { store: string, dishes: string[] };
|
||||
|
||||
const SOUP_REGEX = /pol[eé]vk|v[ýy]var|soup|\bpho\b|ramen|tom yum|kulajda|gul[aá][sš]ovka/i;
|
||||
|
||||
/**
|
||||
* Vrátí pro každý povolený obchod seznam jídel, která si u něj uživatelé v minulosti
|
||||
* objednali (z poznámek členů skupinových objednávek). Slouží jako záloha pro druhé
|
||||
* kolo „kola štěstí", pokud se nabídku z Bolt Food/Wolt nepodaří stáhnout.
|
||||
* Jídla jsou seřazena dle četnosti, bez duplicit a bez polévek.
|
||||
*/
|
||||
export async function getStoreDishes(): Promise<StoreDishes[]> {
|
||||
const stores = await getStores();
|
||||
const counts = new Map<string, Map<string, { name: string, count: number }>>();
|
||||
for (const store of stores) {
|
||||
counts.set(store.name.toLowerCase(), new Map());
|
||||
}
|
||||
const keys = await storage.listKeys('_extra');
|
||||
for (const key of keys) {
|
||||
if (!key.endsWith('_extra')) continue;
|
||||
const data = await storage.getData<ClientData>(key);
|
||||
for (const group of data?.groups ?? []) {
|
||||
const dishes = counts.get(group.name?.toLowerCase());
|
||||
if (!dishes) continue;
|
||||
for (const member of Object.values(group.members ?? {})) {
|
||||
const dish = normalizeDish(member?.note);
|
||||
if (!dish || SOUP_REGEX.test(dish)) continue;
|
||||
const dishKey = dish.toLowerCase();
|
||||
const existing = dishes.get(dishKey);
|
||||
if (existing) existing.count++;
|
||||
else dishes.set(dishKey, { name: dish, count: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return stores.map(store => ({
|
||||
store: store.name,
|
||||
dishes: [...counts.get(store.name.toLowerCase())!.values()]
|
||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
||||
.slice(0, MAX_DISHES_PER_STORE)
|
||||
.map(d => d.name),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Očistí poznámku člena objednávky (mezery, číslo položky z menu typu „28. ") na název jídla; poznámky nezačínající písmenem/číslicí zahodí. */
|
||||
function normalizeDish(note?: string): string | undefined {
|
||||
if (!note) return undefined;
|
||||
const cleaned = note.replace(/\s+/g, ' ').replace(/^\d+\s*[.)]\s*/, '').trim();
|
||||
// Poznámky typu „⬇️ totéž" nebo „(bez cibule)" nejsou názvy jídel
|
||||
if (!/^[\p{L}\p{N}]/u.test(cleaned)) return undefined;
|
||||
return cleaned.length >= 2 && cleaned.length <= 120 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import axios from 'axios';
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { getConfettiCharacters, getConfettiImage } from '../confetti';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
mockedAxios.get.mockReset();
|
||||
});
|
||||
|
||||
test('postavy odkazují jen na cachující proxy serveru', () => {
|
||||
const images = getConfettiCharacters().flatMap(c => c.images);
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
expect(images.every(u => /^\/api\/confetti\/image\/[0-9a-f]{16}$/.test(u))).toBe(true);
|
||||
// ID jsou stabilní (hash URL) a unikátní
|
||||
expect(new Set(images).size).toBe(images.length);
|
||||
});
|
||||
|
||||
const idAt = (i: number) => getConfettiCharacters().flatMap(c => c.images)[i].split('/').pop()!;
|
||||
|
||||
test('obrázek stáhne jen jednou, pak ho vrací ze storage', async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: Buffer.from('PNGDATA'), headers: { 'content-type': 'image/png' } });
|
||||
const first = await getConfettiImage(idAt(0));
|
||||
const second = await getConfettiImage(idAt(0));
|
||||
expect(first?.contentType).toBe('image/png');
|
||||
expect(second?.data.toString()).toBe('PNGDATA');
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('neznámé ID ani ne-obrázek nevrátí', async () => {
|
||||
expect(await getConfettiImage('ffffffffffffffff')).toBeUndefined();
|
||||
mockedAxios.get.mockResolvedValue({ data: Buffer.from('<html>'), headers: { 'content-type': 'text/html' } });
|
||||
expect(await getConfettiImage(idAt(1))).toBeUndefined();
|
||||
// po selhání se hned znovu nestahuje
|
||||
expect(await getConfettiImage(idAt(1))).toBeUndefined();
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import axios from 'axios';
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import getStorage from '../storage';
|
||||
import { diffMenus, fetchBoltMenu, fetchWoltMenu, getStoreMenu, getStoreMenuHistory, getStoreMenuStatus, parseBoltProviderId, parseWoltSlug, refreshStoreMenu, renameStoreMenus, resetProviderThrottle } from '../storeMenus';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
const BOLT_URL = 'https://food.bolt.eu/cs-cz/427-plzen/p/27744-new-york-burger-chicken/';
|
||||
const WOLT_URL = 'https://wolt.com/cs/cze/pilsen/restaurant/new-york-burger-chicken';
|
||||
|
||||
/** Odpověď Bolt getMenuCategories ve zkrácené podobě (plochá mapa položek). */
|
||||
const boltResponse = () => ({
|
||||
data: {
|
||||
code: 0,
|
||||
message: 'OK',
|
||||
data: {
|
||||
items: {
|
||||
1: { type: 'menu', id: 1, parent_id: null },
|
||||
10: { type: 'category', id: 10, parent_id: 1, index: 1, name: { value: 'Burgery 🍔' } },
|
||||
11: { type: 'category', id: 11, parent_id: 1, index: 2, name: { value: 'Přílohy 🍟' } },
|
||||
12: { type: 'category', id: 12, parent_id: 1, index: 0, name: { value: 'Polévky' } },
|
||||
100: { type: 'dish', id: 100, parent_id: 10, index: 1, name: { value: 'Big Cheeseburger ' }, price: { value: 189 }, availability: 'in_stock' },
|
||||
101: { type: 'dish', id: 101, parent_id: 10, index: 0, name: { value: 'Chicken Burger' }, price: { value: 169 }, availability: 'in_stock' },
|
||||
102: { type: 'dish', id: 102, parent_id: 10, index: 2, name: { value: 'Vyprodaný burger' }, price: { value: 1 }, availability: 'out_of_stock' },
|
||||
103: { type: 'dish', id: 103, parent_id: 11, index: 0, name: { value: 'Hranolky' }, price: { value: 59 }, availability: 'in_stock' },
|
||||
104: { type: 'dish', id: 104, parent_id: 12, index: 0, name: { value: 'Gulášovka' }, price: { value: 79 }, availability: 'in_stock' },
|
||||
13: { type: 'category', id: 13, parent_id: 1, index: 3, name: { value: 'Nealko' } },
|
||||
105: { type: 'dish', id: 105, parent_id: 13, index: 0, name: { value: 'Red Bull 0.25' }, price: { value: 69 }, availability: 'in_stock' },
|
||||
106: { type: 'dish', id: 106, parent_id: 10, index: 3, name: { value: 'Půlená pizza (příplatek)' }, price: { value: 20 }, availability: 'in_stock' },
|
||||
107: { type: 'dish', id: 107, parent_id: 10, index: 4, name: { value: 'DRŠŤKOVÁ' }, price: { value: 89 }, availability: 'in_stock' },
|
||||
200: { type: 'option_select', id: 200, parent_id: 100, name: { value: 'Extra sýr' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Odpověď Wolt assortment ve zkrácené podobě (ceny v haléřích). */
|
||||
const woltResponse = () => ({
|
||||
data: {
|
||||
categories: [
|
||||
{ name: '🥩 BEEF BURGER', item_ids: ['a', 'b'], subcategories: [] },
|
||||
{ name: '🥤 Nápoje', item_ids: ['c'] },
|
||||
{ name: 'Menu', item_ids: [], subcategories: [{ name: 'Tortilly', item_ids: ['d', 'e'] }] },
|
||||
],
|
||||
items: [
|
||||
{ id: 'a', name: 'Cheeseburger', price: 18900, disabled_info: null },
|
||||
{ id: 'b', name: 'Nedostupný burger', price: 100, disabled_info: { reason: 'sold out' } },
|
||||
{ id: 'c', name: 'Cola', price: 4900, disabled_info: null },
|
||||
{ id: 'd', name: 'Kuřecí tortilla', price: 15950, disabled_info: null },
|
||||
{ id: 'e', name: 'Kuřecí polévka', price: 6900, disabled_info: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
resetProviderThrottle();
|
||||
mockedAxios.get.mockReset();
|
||||
mockedAxios.isAxiosError.mockImplementation((e: any) => !!e?.isAxiosError);
|
||||
});
|
||||
|
||||
/** Chyba axiosu s HTTP 429 (omezení počtu dotazů). */
|
||||
const tooManyRequests = (retryAfter?: string) =>
|
||||
Object.assign(new Error('Request failed with status code 429'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 429, headers: retryAfter ? { 'retry-after': retryAfter } : {} },
|
||||
});
|
||||
|
||||
describe('parsování URL', () => {
|
||||
test('ID podniku z Bolt URL', () => {
|
||||
expect(parseBoltProviderId(BOLT_URL)).toBe('27744');
|
||||
expect(parseBoltProviderId(WOLT_URL)).toBeUndefined();
|
||||
expect(parseBoltProviderId('nesmysl')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('slug podniku z Wolt URL', () => {
|
||||
expect(parseWoltSlug(WOLT_URL)).toBe('new-york-burger-chicken');
|
||||
expect(parseWoltSlug(BOLT_URL)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchBoltMenu', () => {
|
||||
test('vrátí dostupné položky dle pořadí v nabídce, hlavní jídla označí (ne přílohy, nápoje, příplatky, polévky)', async () => {
|
||||
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||
const dishes = await fetchBoltMenu('27744');
|
||||
expect(dishes).toEqual([
|
||||
{ name: 'Gulášovka', price: 79, category: 'Polévky', isMain: false },
|
||||
{ name: 'Chicken Burger', price: 169, category: 'Burgery 🍔', isMain: true },
|
||||
{ name: 'Big Cheeseburger', price: 189, category: 'Burgery 🍔', isMain: true },
|
||||
{ name: 'Půlená pizza (příplatek)', price: 20, category: 'Burgery 🍔', isMain: false },
|
||||
{ name: 'DRŠŤKOVÁ', price: 89, category: 'Burgery 🍔', isMain: false },
|
||||
{ name: 'Hranolky', price: 59, category: 'Přílohy 🍟', isMain: false },
|
||||
{ name: 'Red Bull 0.25', price: 69, category: 'Nealko', isMain: false },
|
||||
]);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(expect.stringContaining('getMenuCategories'),
|
||||
expect.objectContaining({ params: expect.objectContaining({ provider_id: '27744' }) }));
|
||||
});
|
||||
|
||||
test('chybový kód Bolt API vyhodí výjimku', async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { code: 702, message: 'INVALID_REQUEST' } });
|
||||
await expect(fetchBoltMenu('1')).rejects.toThrow('702');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWoltMenu', () => {
|
||||
test('vrátí dostupné položky včetně podkategorií, ceny v Kč, hlavní jídla označí', async () => {
|
||||
mockedAxios.get.mockResolvedValue(woltResponse());
|
||||
const dishes = await fetchWoltMenu('new-york-burger-chicken');
|
||||
expect(dishes).toEqual([
|
||||
{ name: 'Cheeseburger', price: 189, category: '🥩 BEEF BURGER', isMain: true },
|
||||
{ name: 'Cola', price: 49, category: '🥤 Nápoje', isMain: false },
|
||||
{ name: 'Kuřecí tortilla', price: 160, category: 'Menu / Tortilly', isMain: true },
|
||||
{ name: 'Kuřecí polévka', price: 69, category: 'Menu / Tortilly', isMain: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const ADMIN_PW = 'testadmin';
|
||||
|
||||
describe('getStoreMenu', () => {
|
||||
beforeEach(async () => {
|
||||
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||
await getStorage().setData('stores', [{ name: 'New York', urls: [BOLT_URL, WOLT_URL] }, { name: 'Bez URL' }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
delete process.env.ADMIN_PASSWORD;
|
||||
});
|
||||
|
||||
test('stáhne Bolt jen jednou — uložená nabídka nemá expiraci', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
|
||||
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||
const menu = await getStoreMenu('new york');
|
||||
expect(menu.source).toBe('bolt');
|
||||
expect(menu.dishes.filter(d => d.isMain)).toHaveLength(2);
|
||||
jest.setSystemTime(new Date('2026-12-01T10:00:00Z'));
|
||||
await getStoreMenu('New York');
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('při selhání Boltu použije Wolt', async () => {
|
||||
mockedAxios.get
|
||||
.mockRejectedValueOnce(new Error('timeout'))
|
||||
.mockResolvedValueOnce(woltResponse());
|
||||
const menu = await getStoreMenu('New York');
|
||||
expect(menu.source).toBe('wolt');
|
||||
expect(menu.dishes.filter(d => d.isMain).map(d => d.name)).toEqual(['Cheeseburger', 'Kuřecí tortilla']);
|
||||
const [status] = await getStoreMenuStatus();
|
||||
expect(status.providers).toEqual([
|
||||
expect.objectContaining({ provider: 'bolt', state: 'failed', failures: 1, lastError: 'timeout' }),
|
||||
expect.objectContaining({ provider: 'wolt', state: 'ok', dishCount: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('po selhání zkouší nejdřív za 3 dny a po 2 neúspěších službu zablokuje', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
|
||||
await getStorage().setData('stores', [{ name: 'Jen Bolt', urls: [BOLT_URL] }]);
|
||||
mockedAxios.get.mockRejectedValue(new Error('HTTP 500'));
|
||||
|
||||
await getStoreMenu('Jen Bolt');
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||
jest.setSystemTime(Date.now() + 2 * DAY);
|
||||
await getStoreMenu('Jen Bolt'); // příliš brzy — bez pokusu
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||
jest.setSystemTime(Date.now() + 1 * DAY);
|
||||
await getStoreMenu('Jen Bolt'); // druhý pokus → zablokováno
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
||||
jest.setSystemTime(Date.now() + 30 * DAY);
|
||||
const menu = await getStoreMenu('Jen Bolt');
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
||||
expect(menu.source).toBe('none');
|
||||
const [status] = await getStoreMenuStatus();
|
||||
expect(status.providers[0]).toEqual(expect.objectContaining({ state: 'blocked', failures: 2, lastError: 'HTTP 500' }));
|
||||
});
|
||||
|
||||
test('HTTP 429 se nepočítá jako selhání a službu dočasně pozastaví', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
|
||||
await getStorage().setData('stores', [{ name: 'Jen Bolt', urls: [BOLT_URL] }, { name: 'Jiný Bolt', urls: ['https://food.bolt.eu/cs-cz/427-plzen/p/2-jiny/'] }]);
|
||||
mockedAxios.get.mockRejectedValueOnce(tooManyRequests('120'));
|
||||
|
||||
await getStoreMenu('Jen Bolt');
|
||||
// Během pauzy se na Bolt vůbec nedotazujeme — ani pro jiný obchod
|
||||
await getStoreMenu('Jiný Bolt');
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
||||
const status = await getStoreMenuStatus();
|
||||
expect(status[0].providers[0]).toEqual(expect.objectContaining({ state: 'pending', failures: 0 }));
|
||||
expect(status[0].providers[0].lastError).toContain('429');
|
||||
|
||||
// Po uplynutí Retry-After se hned zkusí znovu (žádný 3denní odklad)
|
||||
jest.setSystemTime(Date.now() + 121 * 1000);
|
||||
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||
const menu = await getStoreMenu('Jen Bolt');
|
||||
expect(menu.source).toBe('bolt');
|
||||
});
|
||||
|
||||
test('ruční obnovení stáhne i zablokovanou službu a zablokování zruší', async () => {
|
||||
await getStorage().setData('store_menus', {
|
||||
'New York': { bolt: { url: BOLT_URL, failures: 2, blocked: true, lastError: 'x', lastAttemptAt: new Date().toISOString() } },
|
||||
});
|
||||
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||
const info = await refreshStoreMenu('new york', 'bolt', ADMIN_PW);
|
||||
expect(info.providers[0]).toEqual(expect.objectContaining({ provider: 'bolt', state: 'ok', dishCount: 2, failures: 0 }));
|
||||
expect(info.providers[0].lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
test('neúspěšné ruční obnovení ponechá dříve staženou nabídku', async () => {
|
||||
mockedAxios.get.mockResolvedValueOnce(boltResponse()).mockRejectedValueOnce(new Error('HTTP 403'));
|
||||
await getStoreMenu('New York');
|
||||
const info = await refreshStoreMenu('New York', 'bolt', ADMIN_PW);
|
||||
expect(info.providers[0]).toEqual(expect.objectContaining({ state: 'ok', dishCount: 2, failures: 1, lastError: 'HTTP 403' }));
|
||||
});
|
||||
|
||||
test('ruční obnovení vyžaduje heslo a URL služby', async () => {
|
||||
await expect(refreshStoreMenu('New York', 'bolt', 'spatne')).rejects.toThrow('UNAUTHORIZED');
|
||||
await expect(refreshStoreMenu('Bez URL', 'wolt', ADMIN_PW)).rejects.toThrow('Obchod nemá URL');
|
||||
});
|
||||
|
||||
test('změna URL služby zahodí uloženou nabídku', async () => {
|
||||
await getStorage().setData('store_menus', {
|
||||
'New York': { bolt: { url: 'https://food.bolt.eu/cs-cz/427-plzen/p/1-stary/', dishes: [{ name: 'Staré' }], failures: 0, blocked: false } },
|
||||
});
|
||||
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||
const menu = await getStoreMenu('New York');
|
||||
expect(menu.dishes.filter(d => d.isMain).map(d => d.name)).toEqual(['Chicken Burger', 'Big Cheeseburger']);
|
||||
});
|
||||
|
||||
test('přejmenování obchodu přenese uloženou nabídku', async () => {
|
||||
await getStorage().setData('store_menus', { 'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Burger' }], failures: 0, blocked: false } } });
|
||||
await renameStoreMenus('new york', 'NY Burger');
|
||||
expect(await getStorage().getData('store_menus')).toEqual({ 'NY Burger': expect.any(Object) });
|
||||
});
|
||||
|
||||
test('bez stažitelné nabídky použije historii objednávek', async () => {
|
||||
await getStorage().setData('2026-09-01_extra', {
|
||||
groups: [{ id: 'g', name: 'Bez URL', creatorLogin: 'a', state: 'delivered', members: { a: { note: 'Kebab box' } } }],
|
||||
});
|
||||
const menu = await getStoreMenu('Bez URL');
|
||||
expect(menu).toEqual({ store: 'Bez URL', source: 'history', dishes: [{ name: 'Kebab box', isMain: true }] });
|
||||
expect(mockedAxios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('neznámý obchod vyhodí chybu', async () => {
|
||||
await expect(getStoreMenu('Neexistuje')).rejects.toThrow('Obchod nebyl nalezen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('historie cen', () => {
|
||||
beforeEach(async () => {
|
||||
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||
await getStorage().setData('stores', [{ name: 'New York', urls: [BOLT_URL] }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ADMIN_PASSWORD;
|
||||
});
|
||||
|
||||
test('diffMenus najde změněné ceny, nové i odebrané položky', () => {
|
||||
expect(diffMenus(
|
||||
[{ name: 'Burger', price: 150 }, { name: 'Hranolky', price: 59 }, { name: 'Salát', price: 99 }],
|
||||
[{ name: 'burger', price: 165 }, { name: 'Hranolky', price: 59 }, { name: 'Wrap', price: 129 }],
|
||||
)).toEqual([
|
||||
{ name: 'burger', before: 150, after: 165 },
|
||||
{ name: 'Wrap', after: 129 },
|
||||
{ name: 'Salát', before: 99 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('první stažení uloží výchozí stav, další jen změny (beze změn nic)', async () => {
|
||||
const changed = boltResponse();
|
||||
changed.data.data.items[100 as any].price = { value: 199 };
|
||||
|
||||
mockedAxios.get.mockResolvedValueOnce(boltResponse()).mockResolvedValueOnce(boltResponse()).mockResolvedValueOnce(changed);
|
||||
await getStoreMenu('New York');
|
||||
await refreshStoreMenu('New York', 'bolt', ADMIN_PW); // beze změny
|
||||
await refreshStoreMenu('New York', 'bolt', ADMIN_PW); // Big Cheeseburger 189 → 199
|
||||
|
||||
const history = await getStoreMenuHistory('new york');
|
||||
expect(history.store).toBe('New York');
|
||||
const entries = history.providers[0].entries;
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].changes).toEqual([{ name: 'Big Cheeseburger', before: 189, after: 199 }]);
|
||||
expect(entries[1]).toEqual(expect.objectContaining({ initial: true, itemCount: 7, changes: [] }));
|
||||
});
|
||||
|
||||
test('neúspěšné stažení historii nemění', async () => {
|
||||
mockedAxios.get.mockRejectedValue(new Error('HTTP 500'));
|
||||
await getStoreMenu('New York');
|
||||
expect((await getStoreMenuHistory('New York')).providers).toEqual([]);
|
||||
});
|
||||
|
||||
test('nabídka stažená před zavedením historie se zobrazí jako výchozí verze', async () => {
|
||||
await getStorage().setData('store_menus', {
|
||||
'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Burger', price: 150 }], failures: 0, blocked: false, lastSuccessAt: '2026-09-01T10:00:00.000Z' } },
|
||||
});
|
||||
const history = await getStoreMenuHistory('New York');
|
||||
expect(history.providers).toEqual([
|
||||
{ provider: 'bolt', entries: [{ at: '2026-09-01T10:00:00.000Z', initial: true, itemCount: 1, changes: [] }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('první změna u nabídky stažené před zavedením historie zachová výchozí verzi', async () => {
|
||||
await getStorage().setData('store_menus', {
|
||||
'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Chicken Burger', price: 150, isMain: true }], failures: 0, blocked: false, lastSuccessAt: '2026-09-01T10:00:00.000Z' } },
|
||||
});
|
||||
mockedAxios.get.mockResolvedValue(boltResponse());
|
||||
await refreshStoreMenu('New York', 'bolt', ADMIN_PW);
|
||||
const entries = (await getStoreMenuHistory('New York')).providers[0].entries;
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].changes).toContainEqual({ name: 'Chicken Burger', before: 150, after: 169 });
|
||||
expect(entries[1]).toEqual({ at: '2026-09-01T10:00:00.000Z', initial: true, itemCount: 1, changes: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import getStorage from '../storage';
|
||||
import { getStores, addStore, updateStore, removeStore } from '../stores';
|
||||
import { getStores, addStore, updateStore, removeStore, getStoreDishes } from '../stores';
|
||||
|
||||
const ADMIN_PW = 'testadmin';
|
||||
|
||||
@@ -213,3 +213,27 @@ describe('removeStore', () => {
|
||||
expect(names(stores)).toContain('McDonald\'s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoreDishes', () => {
|
||||
/** Uloží skupinové objednávky pro daný den ve formátu `YYYY-MM-DD_extra`. */
|
||||
const saveGroups = (date: string, groups: { name: string, notes: string[] }[]) =>
|
||||
getStorage().setData(`${date}_extra`, {
|
||||
groups: groups.map((g, gi) => ({
|
||||
id: `${date}-${gi}`, name: g.name, creatorLogin: 'a', state: 'delivered',
|
||||
members: Object.fromEntries(g.notes.map((note, i) => [`user${i}`, { note }])),
|
||||
})),
|
||||
});
|
||||
|
||||
test('vrátí jídla dle četnosti, bez duplicit, polévek a čísel položek', async () => {
|
||||
await getStorage().setData('stores', [{ name: 'China Express' }, { name: 'Everest' }]);
|
||||
await saveGroups('2026-09-01', [{ name: 'China Express', notes: ['28. Vepřové v pekingské omáčce', 'Kuře na medu', 'Polévka Pho'] }]);
|
||||
await saveGroups('2026-09-02', [{ name: 'china express', notes: ['kuře na medu', ' ', '⬇️ (to samé)', 'Hovězí se zeleninou'] }]);
|
||||
await saveGroups('2026-09-03', [{ name: 'Neznámý podnik', notes: ['Burger'] }]);
|
||||
|
||||
const result = await getStoreDishes();
|
||||
expect(result).toEqual([
|
||||
{ store: 'China Express', dishes: ['Kuře na medu', 'Hovězí se zeleninou', 'Vepřové v pekingské omáčce'] },
|
||||
{ store: 'Everest', dishes: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user