CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 24s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m46s
CI / Build and push Docker image (push) Successful in 48s
CI / Notify (push) Successful in 3s
Wolt hlásí stav podniku, ne rozvozu — status zůstane na "ready" i celou dobu, co kurýr objednávku veze. Vyzvednutí proto poznáme podle pickup_eta: dokud je v budoucnosti, jídlo ještě čeká v podniku, po vyzvednutí se ustálí na skutečném čase. Dřív se stav odvozoval jen z příznaků kurýra, takže objednávka zůstala viset na kroku "Vyzvedávání", i když už byla na cestě. Kurýr má nově tři odvozené stavy: assigned (ještě nevyzvedl), delivering (veze ji k nám) a delivering_other (veze ji, ale nejdřív doručuje cizí objednávku — Wolt objednávky spojuje a kurýr se pak na mapě vzdaluje). Oba stavy delivering* znamenají krok "Na cestě", tooltip u nich vysvětlí objížďku.
253 lines
9.9 KiB
TypeScript
253 lines
9.9 KiB
TypeScript
import axios from 'axios';
|
|
import crypto from 'crypto';
|
|
import { TrackingProvider } from '../../types/gen/types.gen';
|
|
import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator';
|
|
|
|
/**
|
|
* Adaptéry rozvozových služeb pro sledování objednávek.
|
|
*
|
|
* Každá služba umí ze sdílecího odkazu vytáhnout svůj kód a dotázat se svého
|
|
* (nedokumentovaného) veřejného API. Výsledek normalizují do tvaru TrackedOrder,
|
|
* se kterým už dál pracuje jen scheduler v orderTracking.ts.
|
|
*/
|
|
|
|
/** Znormalizovaný stav sledované objednávky — společný jmenovatel všech služeb. */
|
|
export interface TrackedOrder {
|
|
/** Raw stav objednávky ze služby (Bolt order_state, Wolt status). */
|
|
orderState: string;
|
|
/** Raw stav kurýra, pokud ho služba poskytuje. */
|
|
courierState?: string;
|
|
/** Očekávaný čas doručení ve formátu HH:MM, pokud ho lze určit. */
|
|
deliveryAt?: string;
|
|
}
|
|
|
|
export interface DeliveryTracker {
|
|
provider: TrackingProvider;
|
|
/** Lidský název služby (do UI hlášek a logů). */
|
|
label: string;
|
|
/** Vytáhne kód sledování ze vstupu, nebo null, pokud vstup službě nepatří. */
|
|
extractCode(input: string): string | null;
|
|
/** Dotáže se API služby. Vrátí null, pokud objednávka už neexistuje. */
|
|
poll(code: string): Promise<TrackedOrder | null>;
|
|
}
|
|
|
|
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
|
|
const BOLT_SHARE_HOST = 'bolt.eu';
|
|
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
|
|
|
|
const WOLT_TRACKING_URL = 'https://consumer-api.wolt.com/order-tracking-api/v1/details/tracking-code/track/';
|
|
const WOLT_SHARE_HOST = 'wolt.com';
|
|
/** Wolt tracking code je base64url (v praxi 22 znaků); délku bereme s rezervou. */
|
|
const WOLT_CODE_REGEX = /^[A-Za-z0-9_-]{20,32}$/;
|
|
|
|
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
|
|
const DEVICE_ID = crypto.randomUUID();
|
|
|
|
/** Vrátí poslední neprázdný segment cesty URL (u odkazu bez URL tvaru vrátí null). */
|
|
function lastPathSegment(input: string): string | null {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(input);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const segments = url.pathname.split('/').filter(Boolean);
|
|
return segments[segments.length - 1] ?? null;
|
|
}
|
|
|
|
/** Patří hostitel odkazu dané službě (včetně subdomén)? */
|
|
function isHost(input: string, host: string): boolean {
|
|
try {
|
|
const hostname = new URL(input).hostname.toLowerCase();
|
|
return hostname === host || hostname.endsWith(`.${host}`);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */
|
|
export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string {
|
|
const eta = new Date(now.getTime() + seconds * 1000);
|
|
return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Zformátuje absolutní ISO čas na HH:MM. Wolt v odpovědi posílá i časovou zónu
|
|
* objednávky, takže se čas zobrazí správně i kdyby server běžel v jiné zóně.
|
|
*/
|
|
export function formatEtaHHMM(iso: string, timeZone?: string): string | undefined {
|
|
const eta = new Date(iso);
|
|
if (Number.isNaN(eta.getTime())) return undefined;
|
|
const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' };
|
|
try {
|
|
return new Intl.DateTimeFormat('cs-CZ', { ...options, timeZone }).format(eta);
|
|
} catch {
|
|
// Neznámá zóna z API — spadneme na lokální čas serveru (TZ=Europe/Prague)
|
|
return new Intl.DateTimeFormat('cs-CZ', options).format(eta);
|
|
}
|
|
}
|
|
|
|
interface BoltOrder {
|
|
order_id?: number;
|
|
order_state: string;
|
|
expected_time_to_client_in_seconds?: number;
|
|
courier?: { state?: string } | null;
|
|
}
|
|
|
|
/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */
|
|
async function pollBoltOrder(token: string): Promise<BoltOrder | null> {
|
|
// DEV simulace: simulované tokeny obsluhuje boltSimulator místo reálného Bolt API.
|
|
// V produkci je registr vždy prázdný, takže se sem nikdy nedostane.
|
|
if (isBoltSimulated(token)) {
|
|
return getSimulatedBoltOrder(token);
|
|
}
|
|
const res = await axios.post(BOLT_POLLING_URL, { token }, {
|
|
params: {
|
|
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,
|
|
},
|
|
headers: { 'Content-Type': 'application/json' },
|
|
timeout: 10_000,
|
|
});
|
|
if (res.data?.code !== 0) {
|
|
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
|
|
}
|
|
return res.data?.data?.orders?.[0] ?? null;
|
|
}
|
|
|
|
const boltTracker: DeliveryTracker = {
|
|
provider: TrackingProvider.BOLT,
|
|
label: 'Bolt Food',
|
|
|
|
/**
|
|
* Přijme sdílecí URL Bolt Food (https://food.bolt.eu/sharedActiveOrder/<token>)
|
|
* nebo samotný token (64 hex znaků).
|
|
*/
|
|
extractCode(input) {
|
|
if (BOLT_TOKEN_REGEX.test(input)) return input;
|
|
if (!isHost(input, BOLT_SHARE_HOST)) return null;
|
|
const last = lastPathSegment(input);
|
|
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
|
|
},
|
|
|
|
async poll(code) {
|
|
const order = await pollBoltOrder(code);
|
|
if (!order) return null;
|
|
const seconds = order.expected_time_to_client_in_seconds;
|
|
return {
|
|
orderState: order.order_state || '',
|
|
courierState: order.courier?.state || undefined,
|
|
deliveryAt: typeof seconds === 'number' ? computeDeliveryHHMM(seconds) : undefined,
|
|
};
|
|
},
|
|
};
|
|
|
|
/** Odpověď Wolt tracking API (jen pole, která používáme). */
|
|
interface WoltTrackingDetails {
|
|
status?: string;
|
|
delivery_eta?: string | null;
|
|
pickup_eta?: string | null;
|
|
couriers?: WoltCourier[];
|
|
timezone?: string;
|
|
}
|
|
|
|
interface WoltCourier {
|
|
/** Kurýr veze nějakou objednávku (nemusí to být ta naše — viz is_delivering_other_order). */
|
|
is_delivering?: boolean;
|
|
/** Kurýr právě doručuje cizí objednávku, tu naši teprve vyzvedne nebo veze až po ní. */
|
|
is_delivering_other_order?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Wolt nemá stav „vyzvednuto" — status zůstává celou dobu doručování na hodnotě
|
|
* ready (ta popisuje jen podnik: jídlo je hotové). Že kurýr jídlo skutečně veze,
|
|
* poznáme podle pickup_eta: dokud vyzvednutí teprve nastane, je v budoucnosti;
|
|
* po vyzvednutí se ustálí na skutečném čase a zůstane v minulosti (na rozdíl od
|
|
* delivery_eta, který se dál přepočítává).
|
|
*
|
|
* Odvozené stavy kurýra:
|
|
* - 'assigned' kurýr přiřazen, jídlo ještě nevyzvedl
|
|
* - 'delivering' veze naši objednávku k nám
|
|
* - 'delivering_other' veze naši objednávku, ale nejdřív doručuje cizí (Wolt
|
|
* spojuje objednávky — kurýr se pak na mapě vzdaluje)
|
|
*/
|
|
function woltCourierState(details: WoltTrackingDetails, now: number = Date.now()): string | undefined {
|
|
const courier = details.couriers?.[0];
|
|
if (!courier) return undefined;
|
|
const pickupEta = details.pickup_eta ? new Date(details.pickup_eta).getTime() : NaN;
|
|
const pickedUp = !Number.isNaN(pickupEta) && pickupEta <= now;
|
|
if (!courier.is_delivering || !pickedUp) return 'assigned';
|
|
return courier.is_delivering_other_order ? 'delivering_other' : 'delivering';
|
|
}
|
|
|
|
const woltTracker: DeliveryTracker = {
|
|
provider: TrackingProvider.WOLT,
|
|
label: 'Wolt',
|
|
|
|
/**
|
|
* Přijme odkaz na sledování Wolt (https://track.wolt.com/<code>, případně
|
|
* s jazykovým segmentem /en/<code>) nebo samotný tracking code.
|
|
*/
|
|
extractCode(input) {
|
|
if (WOLT_CODE_REGEX.test(input)) return input;
|
|
if (!isHost(input, WOLT_SHARE_HOST)) return null;
|
|
const last = lastPathSegment(input);
|
|
return last && WOLT_CODE_REGEX.test(last) ? last : null;
|
|
},
|
|
|
|
async poll(code) {
|
|
let details: WoltTrackingDetails;
|
|
try {
|
|
const res = await axios.get<WoltTrackingDetails>(`${WOLT_TRACKING_URL}${encodeURIComponent(code)}`, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Accept-Language': 'cs',
|
|
// Wolt API odpovídá jen na dotazy z jeho tracking stránky
|
|
Referer: 'https://track.wolt.com/',
|
|
Origin: 'https://track.wolt.com',
|
|
},
|
|
timeout: 10_000,
|
|
});
|
|
details = res.data ?? {};
|
|
} catch (e) {
|
|
// Neznámý/expirovaný kód → objednávka už neexistuje (stejně jako u Boltu)
|
|
if (axios.isAxiosError(e) && e.response?.status === 404) return null;
|
|
throw e;
|
|
}
|
|
if (!details.status) {
|
|
throw new Error('Wolt API vrátilo odpověď bez stavu objednávky');
|
|
}
|
|
return {
|
|
orderState: details.status,
|
|
courierState: woltCourierState(details),
|
|
deliveryAt: details.delivery_eta ? formatEtaHHMM(details.delivery_eta, details.timezone) : undefined,
|
|
};
|
|
},
|
|
};
|
|
|
|
export const TRACKERS: Record<TrackingProvider, DeliveryTracker> = {
|
|
[TrackingProvider.BOLT]: boltTracker,
|
|
[TrackingProvider.WOLT]: woltTracker,
|
|
};
|
|
|
|
/**
|
|
* Rozpozná službu podle sdílecího odkazu (nebo samotného kódu) a vytáhne z něj
|
|
* kód sledování. Vrátí null, pokud vstup nepatří žádné podporované službě.
|
|
*/
|
|
export function extractTracking(input: string): { provider: TrackingProvider; code: string } | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
for (const tracker of Object.values(TRACKERS)) {
|
|
const code = tracker.extractCode(trimmed);
|
|
if (code) return { provider: tracker.provider, code };
|
|
}
|
|
return null;
|
|
}
|