Files
SixFlagsSuperCalendar/lib/scrapers/sixflags-waittimes.ts
T
josh e1657f07d7
Build and Deploy / Lint, typecheck, test (push) Successful in 33s
Build and Deploy / Build & Push (push) Successful in 1m4s
fix: surface silent scraper failures and stop falsely claiming weather delay
The homepage was flagging every park as weather delay because calendar.ts
collapsed "fetchLiveRides returned null" into the same openRides=0 bucket as
"all rides actually closed." Meanwhile every scraper (queuetimes, sixflags
operating-hours, sixflags wait-times) was swallowing non-OK responses and
exceptions silently, so logs gave no signal which upstream was failing or how.

Add a small scraperWarn helper that emits in the same shape as backend/log.ts
(without importing it — lib/scrapers is shared with the Next frontend). Use it
in all three scrapers to record HTTP status and error name+message before each
return null. Add parksSkipped to the tier-5 summary log so we can tell when the
openParks filter is rejecting everyone vs the fetcher silently failing.

Convert calendar.ts ridesCache to a discriminated union { kind: "ok" | "unknown" }.
Weather delay only fires on { kind: "ok", openRides: 0 }; unknown entries get
a 30s TTL so we recover quickly when upstream comes back and don't thunder-herd
in the meantime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:28:25 -04:00

189 lines
5.6 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";
import { scraperWarn } from "./log";
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<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) {
scraperWarn("sixflags-waittimes", "fetchFastLaneWaits non-OK response", {
apiId,
status: res.status,
statusText: res.statusText,
});
return null;
}
return parseWaitTimes((await res.json()) as WTResponse);
} catch (err) {
const e = err as Error;
scraperWarn("sixflags-waittimes", "fetchFastLaneWaits threw", {
apiId,
name: e.name,
err: e.message,
});
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,
};
}