bfe099322f
Build and Deploy / Build & Push (push) Successful in 1m3s
Join Fast Lane waits from the Six Flags /wait-times endpoint onto Queue-Times rides by name. A new toggle on the live ride panel swaps the shown wait to the Fast Lane number; regular waits and open status still come from Queue-Times. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
164 lines
4.8 KiB
TypeScript
164 lines
4.8 KiB
TypeScript
/**
|
|
* 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 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),
|
|
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<FastLaneResult | null> {
|
|
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 Fast Lane data 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 the matched ride's Fast Lane info, or null when no ride matches.
|
|
*/
|
|
export function lookupFastLane(
|
|
rideName: string,
|
|
result: FastLaneResult,
|
|
): { hasFastLane: boolean; fastLaneMinutes: 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 };
|
|
}
|