/** * 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; } function sleep(ms: number) { return new Promise((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 } interface ApiOperating { operatingTypeName: string; // "Park", "Special Event", etc. items: ApiOperatingItem[]; } interface ApiDay { date: string; isParkClosed: boolean; operatings?: ApiOperating[]; } /** "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): Promise { const res = await fetch(url, { headers: HEADERS }); if (res.status === 429 || res.status === 503) { const retryAfter = res.headers.get("Retry-After"); const waitMs = retryAfter ? parseInt(retryAfter) * 1000 : 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); throw new RateLimitError(totalWaitedMs + waitMs); } if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); return res.json() as Promise; } /** * 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 { 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; // If the API says open but no hours are available, treat as closed const isOpen = !d.isParkClosed && hoursLabel !== undefined; return { date, isOpen, hoursLabel }; }); } /** * 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 | 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)); }