All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m50s
- next.config.ts: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy - sixflags.ts: cap Retry-After at 5 min; add 15s AbortSignal.timeout() - queuetimes.ts: add 10s AbortSignal.timeout() - rcdb.ts: add 15s AbortSignal.timeout() - lib/env.ts: parseStalenessHours() guards against NaN from invalid env vars - db.ts + park-meta.ts: use parseStalenessHours() for staleness window config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
/**
|
||
* Six Flags scraper — calls the internal CloudFront operating-hours API directly.
|
||
*
|
||
* API: https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{apiId}?date=YYYYMM
|
||
* Returns full month data in one request — no browser needed.
|
||
*
|
||
* Each park has a numeric API ID that must be discovered first (see scripts/discover.ts).
|
||
* Once stored in the DB, this scraper never touches a browser again.
|
||
*
|
||
* Rate limiting: on 429/503, exponential backoff (30s → 60s → 120s), MAX_RETRIES attempts.
|
||
*/
|
||
|
||
const API_BASE = "https://d18car1k0ff81h.cloudfront.net/operating-hours/park";
|
||
const MAX_RETRIES = 3;
|
||
const BASE_BACKOFF_MS = 30_000;
|
||
|
||
export class RateLimitError extends Error {
|
||
constructor(public readonly waitedMs: number) {
|
||
super(`Rate limited — exhausted ${MAX_RETRIES} retries after ${waitedMs / 1000}s total wait`);
|
||
this.name = "RateLimitError";
|
||
}
|
||
}
|
||
|
||
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/",
|
||
};
|
||
|
||
export interface DayResult {
|
||
date: string; // YYYY-MM-DD
|
||
isOpen: boolean;
|
||
hoursLabel?: string;
|
||
specialType?: "passholder_preview";
|
||
}
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise<void>((r) => setTimeout(r, ms));
|
||
}
|
||
|
||
/** "04/05/2026" → "2026-04-05" */
|
||
function parseApiDate(d: string): string {
|
||
const [m, day, y] = d.split("/");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
interface ApiOperatingItem {
|
||
timeFrom: string; // "10:30" 24h
|
||
timeTo: string; // "20:00" 24h
|
||
isBuyout?: boolean;
|
||
}
|
||
|
||
interface ApiOperating {
|
||
operatingTypeName: string; // "Park", "Special Event", etc.
|
||
items: ApiOperatingItem[];
|
||
}
|
||
|
||
interface ApiEvent {
|
||
extEventName: string;
|
||
}
|
||
|
||
interface ApiRideDetail {
|
||
itemID: number;
|
||
itemName: string;
|
||
extLocationID: string;
|
||
operatingTimeFrom: string; // "" or "HH:MM" 24h — empty means not scheduled
|
||
operatingTimeTo: string;
|
||
}
|
||
|
||
interface ApiVenue {
|
||
venueId: number;
|
||
venueName: string;
|
||
detailHours: ApiRideDetail[];
|
||
}
|
||
|
||
interface ApiDay {
|
||
date: string;
|
||
isParkClosed: boolean;
|
||
events?: ApiEvent[];
|
||
operatings?: ApiOperating[];
|
||
venues?: ApiVenue[];
|
||
}
|
||
|
||
/** "10:30" → "10:30am", "20:00" → "8pm", "12:00" → "12pm" */
|
||
function fmt24(time: string): string {
|
||
const [h, m] = time.split(":").map(Number);
|
||
const period = h >= 12 ? "pm" : "am";
|
||
const h12 = h % 12 || 12;
|
||
return m === 0 ? `${h12}${period}` : `${h12}:${String(m).padStart(2, "0")}${period}`;
|
||
}
|
||
|
||
interface ApiResponse {
|
||
parkId: number;
|
||
parkAbbreviation: string;
|
||
parkName: string;
|
||
dates: ApiDay[];
|
||
}
|
||
|
||
async function fetchApi(
|
||
url: string,
|
||
attempt = 0,
|
||
totalWaitedMs = 0,
|
||
revalidate?: number,
|
||
): Promise<ApiResponse> {
|
||
const fetchOpts: RequestInit & { next?: { revalidate: number } } = { headers: HEADERS };
|
||
if (revalidate !== undefined) fetchOpts.next = { revalidate };
|
||
const res = await fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(15_000) });
|
||
|
||
if (res.status === 429 || res.status === 503) {
|
||
const retryAfter = res.headers.get("Retry-After");
|
||
const waitMs = retryAfter
|
||
? Math.min(parseInt(retryAfter, 10) * 1000, 5 * 60 * 1000) // cap at 5 min
|
||
: BASE_BACKOFF_MS * Math.pow(2, attempt);
|
||
console.log(
|
||
` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`
|
||
);
|
||
await sleep(waitMs);
|
||
if (attempt < MAX_RETRIES) return fetchApi(url, attempt + 1, totalWaitedMs + waitMs, revalidate);
|
||
throw new RateLimitError(totalWaitedMs + waitMs);
|
||
}
|
||
|
||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||
return res.json() as Promise<ApiResponse>;
|
||
}
|
||
|
||
/**
|
||
* Fetch the raw API response for a month — used by scripts/debug.ts and the park detail page.
|
||
* Pass `revalidate` (seconds) to enable Next.js ISR caching when called from a Server Component.
|
||
*/
|
||
export async function scrapeMonthRaw(
|
||
apiId: number,
|
||
year: number,
|
||
month: number,
|
||
revalidate?: number,
|
||
): Promise<ApiResponse> {
|
||
const dateParam = `${year}${String(month).padStart(2, "0")}`;
|
||
const url = `${API_BASE}/${apiId}?date=${dateParam}`;
|
||
return fetchApi(url, 0, 0, revalidate);
|
||
}
|
||
|
||
export interface RideStatus {
|
||
name: string;
|
||
isOpen: boolean;
|
||
hoursLabel?: string; // e.g. "10am – 10pm"
|
||
}
|
||
|
||
export interface RidesFetchResult {
|
||
rides: RideStatus[];
|
||
/** The date the ride data actually came from (YYYY-MM-DD). May differ from
|
||
* the requested date when the API has already dropped the current day and
|
||
* we fell back to the nearest upcoming open date. */
|
||
dataDate: string;
|
||
/** True when dataDate === requested date. False when we fell back. */
|
||
isExact: boolean;
|
||
/** Park-level operating hours for the data date (e.g. "10am – 6pm").
|
||
* Used by the UI to suppress per-ride hours that match the park hours. */
|
||
parkHoursLabel?: string;
|
||
}
|
||
|
||
/** Convert "MM/DD/YYYY" API date string to "YYYY-MM-DD". */
|
||
function apiDateToIso(apiDate: string): string {
|
||
const [mm, dd, yyyy] = apiDate.split("/");
|
||
return `${yyyy}-${mm}-${dd}`;
|
||
}
|
||
|
||
/**
|
||
* Fetch ride operating status for a given date.
|
||
*
|
||
* The Six Flags API drops dates that have already started (including today),
|
||
* returning only tomorrow onwards. When the requested date is missing, we fall
|
||
* back to the nearest available upcoming date in the same month's response so
|
||
* the UI can still show a useful (if approximate) schedule.
|
||
*
|
||
* Returns null if no ride data could be found at all (API error, pre-season,
|
||
* no venues in response).
|
||
*
|
||
* Pass `revalidate` (seconds) to enable Next.js ISR caching when called from
|
||
* a Server Component. Defaults to 1 hour.
|
||
*/
|
||
export async function scrapeRidesForDay(
|
||
apiId: number,
|
||
dateIso: string, // YYYY-MM-DD
|
||
revalidate = 3600,
|
||
): Promise<RidesFetchResult | null> {
|
||
const [yearStr, monthStr] = dateIso.split("-");
|
||
const year = parseInt(yearStr);
|
||
const month = parseInt(monthStr);
|
||
|
||
let raw: ApiResponse;
|
||
try {
|
||
raw = await scrapeMonthRaw(apiId, year, month, revalidate);
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
if (!raw.dates.length) return null;
|
||
|
||
// The API uses "MM/DD/YYYY" internally.
|
||
const [, mm, dd] = dateIso.split("-");
|
||
const apiDate = `${mm}/${dd}/${yearStr}`;
|
||
|
||
// Try exact match first; if the API has already dropped today, fall back to
|
||
// the chronologically nearest available date (always a future date here).
|
||
let dayData = raw.dates.find((d) => d.date === apiDate);
|
||
let isExact = true;
|
||
|
||
if (!dayData) {
|
||
// The API drops dates that have already started, so we need a future date.
|
||
// Prefer the nearest open day; fall back to the nearest date regardless.
|
||
// If the current month has no more dates (e.g. today is the 30th), also
|
||
// check next month — a month boundary is not unusual for this case.
|
||
const futureDates = [...raw.dates]
|
||
.filter((d) => apiDateToIso(d.date) > dateIso)
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
|
||
dayData = futureDates.find((d) => !d.isParkClosed) ?? futureDates[0];
|
||
|
||
if (!dayData) {
|
||
// Nothing left in the current month — fetch next month.
|
||
const nextMonthDate = new Date(`${dateIso}T00:00:00`);
|
||
nextMonthDate.setMonth(nextMonthDate.getMonth() + 1);
|
||
const nextYear = nextMonthDate.getFullYear();
|
||
const nextMonth = nextMonthDate.getMonth() + 1;
|
||
try {
|
||
const nextRaw = await scrapeMonthRaw(apiId, nextYear, nextMonth, revalidate);
|
||
const nextSorted = [...nextRaw.dates].sort((a, b) => a.date.localeCompare(b.date));
|
||
dayData = nextSorted.find((d) => !d.isParkClosed) ?? nextSorted[0];
|
||
} catch {
|
||
// If the next month fetch fails, we simply have no fallback data.
|
||
}
|
||
}
|
||
|
||
isExact = false;
|
||
}
|
||
|
||
if (!dayData) return null;
|
||
|
||
// Extract park-level hours from the selected day so the UI can suppress
|
||
// per-ride hours that simply repeat what the park is already showing.
|
||
const parkOperating =
|
||
dayData.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
||
dayData.operatings?.[0];
|
||
const parkItem = parkOperating?.items?.[0];
|
||
const parkHoursLabel =
|
||
parkItem?.timeFrom && parkItem?.timeTo
|
||
? `${fmt24(parkItem.timeFrom)} – ${fmt24(parkItem.timeTo)}`
|
||
: undefined;
|
||
|
||
const rides: RideStatus[] = [];
|
||
for (const venue of (dayData.venues ?? []).filter((v) => v.venueName === "Rides")) {
|
||
for (const ride of venue.detailHours ?? []) {
|
||
if (!ride.itemName) continue;
|
||
const isOpen = Boolean(ride.operatingTimeFrom && ride.operatingTimeTo);
|
||
const hoursLabel = isOpen
|
||
? `${fmt24(ride.operatingTimeFrom)} – ${fmt24(ride.operatingTimeTo)}`
|
||
: undefined;
|
||
rides.push({ name: ride.itemName, isOpen, hoursLabel });
|
||
}
|
||
}
|
||
|
||
if (rides.length === 0) return null;
|
||
|
||
// Sort: 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, dataDate: apiDateToIso(dayData.date), isExact, parkHoursLabel };
|
||
}
|
||
|
||
/**
|
||
* Fetch operating hours for an entire month in a single API call.
|
||
* apiId must be pre-discovered via scripts/discover.ts.
|
||
*/
|
||
export async function scrapeMonth(
|
||
apiId: number,
|
||
year: number,
|
||
month: number
|
||
): Promise<DayResult[]> {
|
||
const dateParam = `${year}${String(month).padStart(2, "0")}`;
|
||
const url = `${API_BASE}/${apiId}?date=${dateParam}`;
|
||
|
||
const data = await fetchApi(url);
|
||
|
||
return data.dates.map((d): DayResult => {
|
||
const date = parseApiDate(d.date);
|
||
// Prefer the "Park" operating entry; fall back to first entry
|
||
const operating =
|
||
d.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
||
d.operatings?.[0];
|
||
const item = operating?.items?.[0];
|
||
const hoursLabel =
|
||
item?.timeFrom && item?.timeTo
|
||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||
: undefined;
|
||
|
||
const isPassholderPreview = d.events?.some((e) =>
|
||
e.extEventName.toLowerCase().includes("passholder preview")
|
||
) ?? false;
|
||
|
||
const isBuyout = item?.isBuyout ?? false;
|
||
|
||
// Buyout days are private events — treat as closed unless it's a passholder preview
|
||
const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
||
const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined;
|
||
|
||
return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Fetch park info for a given API ID (used during discovery to identify park type).
|
||
* Uses the current month so there's always some data.
|
||
*/
|
||
export async function fetchParkInfo(
|
||
apiId: number
|
||
): Promise<Pick<ApiResponse, "parkId" | "parkAbbreviation" | "parkName"> | null> {
|
||
const now = new Date();
|
||
const dateParam = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||
const url = `${API_BASE}/${apiId}?date=${dateParam}`;
|
||
try {
|
||
const data = await fetchApi(url);
|
||
return {
|
||
parkId: data.parkId,
|
||
parkAbbreviation: data.parkAbbreviation,
|
||
parkName: data.parkName,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Returns true if the API park name looks like a main theme park (not a water park or safari). */
|
||
export function isMainThemePark(parkName: string): boolean {
|
||
const lower = parkName.toLowerCase();
|
||
const waterParkKeywords = [
|
||
"hurricane harbor",
|
||
"safari",
|
||
"water park",
|
||
"waterpark",
|
||
"schlitterbahn",
|
||
"wave pool",
|
||
"splash",
|
||
"aquatic",
|
||
];
|
||
return !waterParkKeywords.some((kw) => lower.includes(kw));
|
||
}
|