/** * Six Flags live wait-times scraper — Fast Lane data. * * API: https://d18car1k0ff81h.cloudfront.net/wait-times/park/{apiId} * Sibling of the operating-hours endpoint in sixflags.ts. Exposes both a * regular and a Fast Lane wait per ride. We only consume the Fast Lane side; * regular waits + open status keep coming from Queue-Times. * * The response has no isOpen field, so Fast Lane numbers are joined onto the * Queue-Times ride list by name (see lookupFastLane) and gated on the * Queue-Times open status by the caller. */ import { normalizeForMatch } from "../coaster-match"; const WAIT_TIMES_BASE = "https://d18car1k0ff81h.cloudfront.net/wait-times/park"; // Conjunctions that join two ride names rather than extend one subtitle — // kept in sync with coaster-match.ts so the prefix match stays symmetric. const CONJUNCTIONS = new Set(["y", "and", "&", "with", "de", "del", "e", "et"]); 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", "Accept-Language": "en-US,en;q=0.9", Referer: "https://www.sixflags.com/", }; interface WTWaittime { createdDateTime: string; // "" when no data waitTime: number; } interface WTRideDetail { id: number; name: string; isFastLane: boolean; regularWaittime?: WTWaittime; fastlaneWaittime?: WTWaittime; fimsId: string; } interface WTVenue { venueId: number; venueName: string; details?: WTRideDetail[]; } export interface WTResponse { parkId?: number; parkName?: string; venues?: WTVenue[]; } interface FastLaneEntry { norm: string; compact: string; isFastLane: boolean; /** Current regular wait in minutes from Six Flags; null when the endpoint has no data. */ regularMinutes: number | null; /** Current Fast Lane wait in minutes; null when the endpoint has no data. */ fastLaneMinutes: number | null; } export interface FastLaneResult { entries: FastLaneEntry[]; /** ISO timestamp of when we fetched the data. */ fetchedAt: string; } /** * Parse a raw /wait-times response into Fast Lane entries. Pure and * network-free so it can be unit tested. Returns null when no ride rows * are present. */ export function parseWaitTimes(json: WTResponse): FastLaneResult | null { const entries: FastLaneEntry[] = []; for (const venue of (json.venues ?? []).filter((v) => v.venueName === "Rides")) { for (const d of venue.details ?? []) { if (!d.name) continue; const norm = normalizeForMatch(d.name); entries.push({ norm, compact: norm.replace(/\s/g, ""), isFastLane: Boolean(d.isFastLane), regularMinutes: d.regularWaittime?.createdDateTime ? d.regularWaittime.waitTime : null, fastLaneMinutes: d.fastlaneWaittime?.createdDateTime ? d.fastlaneWaittime.waitTime : null, }); } } if (entries.length === 0) return null; return { entries, fetchedAt: new Date().toISOString() }; } /** * Fetch Fast Lane wait times for a park. Returns null on any failure * (network/parse/timeout) or when the response carries no rides — same * contract as fetchLiveRides. * * Pass revalidate (seconds) to control Next.js ISR cache lifetime. */ export async function fetchFastLaneWaits( apiId: number, revalidate = 300, ): Promise { const url = `${WAIT_TIMES_BASE}/${apiId}`; 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; return parseWaitTimes((await res.json()) as WTResponse); } catch { return null; } } /** * Find the Six Flags wait-times row for a ride by name. Mirrors the * isCoasterMatch strategy (exact normalized → compact ≥5 → prefix ≥5 with * conjunction guard) so Queue-Times and Six Flags name conventions line up. * * Returns both the regular and Fast Lane wait when a match exists, or null * when no ride matches. (Function name is historical — it originally only * exposed Fast Lane data.) */ export function lookupFastLane( rideName: string, result: FastLaneResult, ): { hasFastLane: boolean; fastLaneMinutes: number | null; regularMinutes: number | null } | null { const norm = normalizeForMatch(rideName); const compact = norm.replace(/\s/g, ""); let match: FastLaneEntry | undefined = result.entries.find((e) => e.norm === norm); if (!match) { for (const e of result.entries) { // Compact comparison if (compact.length >= 5 && e.compact === compact) { match = e; break; } // Prefix comparison const shorter = norm.length <= e.norm.length ? norm : e.norm; const longer = norm.length <= e.norm.length ? e.norm : norm; if (shorter.length >= 5 && longer.startsWith(shorter)) { const nextWord = longer.slice(shorter.length).trim().split(" ")[0]; if (!CONJUNCTIONS.has(nextWord)) { match = e; break; } } } } if (!match) return null; return { hasFastLane: match.isFastLane, fastLaneMinutes: match.fastLaneMinutes, regularMinutes: match.regularMinutes, }; }