Files
Luncher/server/src/trackingProviders.ts
T
batmanisko 936ce2a9e4
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
fix: přesnější stav kurýra u sledování Wolt
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.
2026-08-24 12:06:43 +02:00

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;
}