/** * Queue-Times.com live ride status scraper. * * API: https://queue-times.com/parks/{id}/queue_times.json * Updates every 5 minutes while the park is operating. * Attribution required per their terms: "Powered by Queue-Times.com" * See: https://queue-times.com/en-US/pages/api */ import { isCoasterMatch } from "../coaster-match"; const BASE = "https://queue-times.com/parks"; const HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", Accept: "application/json", }; export interface LiveRide { name: string; isOpen: boolean; waitMinutes: number; lastUpdated: string; // ISO 8601 /** True when the ride name appears in the RCDB coaster list for this park. */ isCoaster: boolean; } export interface LiveRidesResult { rides: LiveRide[]; /** ISO timestamp of when we fetched the data */ fetchedAt: string; } interface QTRide { id: number; name: string; is_open: boolean; wait_time: number; last_updated: string; } interface QTLand { id: number; name: string; rides: QTRide[]; } interface QTResponse { lands: QTLand[]; rides: QTRide[]; // top-level rides (usually empty, rides live in lands) } /** * Fetch live ride open/closed status and wait times for a park. * * Returns null when: * - The park has no Queue-Times mapping * - The request fails * - The response contains no rides * * Pass coasterNames (from RCDB static data) to classify rides accurately. * Matching is case-insensitive. When coasterNames is null no ride is * classified as a coaster and the "Coasters only" toggle is hidden. * * Pass revalidate (seconds) to control Next.js ISR cache lifetime. * Defaults to 300s (5 min) to match Queue-Times update frequency. */ export async function fetchLiveRides( queueTimesId: number, coasterNames: Set | null = null, revalidate = 300, ): Promise { const url = `${BASE}/${queueTimesId}/queue_times.json`; try { const res = await fetch(url, { headers: HEADERS, next: { revalidate }, signal: AbortSignal.timeout(10_000), } as RequestInit & { next: { revalidate: number } }); if (!res.ok) return null; const json = (await res.json()) as QTResponse; const rides: LiveRide[] = []; for (const land of json.lands ?? []) { for (const r of land.rides ?? []) { if (!r.name) continue; rides.push({ name: r.name, isOpen: r.is_open, waitMinutes: r.wait_time ?? 0, lastUpdated: r.last_updated, isCoaster: coasterNames ? isCoasterMatch(r.name, coasterNames) : false, }); } } // Also capture any top-level rides (rare but possible) for (const r of json.rides ?? []) { if (!r.name) continue; rides.push({ name: r.name, isOpen: r.is_open, waitMinutes: r.wait_time ?? 0, lastUpdated: r.last_updated, isCoaster: coasterNames ? isCoasterMatch(r.name, coasterNames) : false, }); } if (rides.length === 0) return null; // Open rides first, then alphabetical within each group rides.sort((a, b) => { if (a.isOpen !== b.isOpen) return a.isOpen ? -1 : 1; return a.name.localeCompare(b.name); }); return { rides, fetchedAt: new Date().toISOString() }; } catch { return null; } }