diff --git a/app/page.tsx b/app/page.tsx index d94cae3..f2bbb53 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,11 @@ import { HomePageClient } from "@/components/HomePageClient"; import { PARKS } from "@/lib/parks"; -import { openDb, getDateRange, getApiId } from "@/lib/db"; +import { openDb, getDateRange } from "@/lib/db"; import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env"; import { fetchLiveRides } from "@/lib/scrapers/queuetimes"; import { fetchToday } from "@/lib/scrapers/sixflags"; import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map"; -import { readParkMeta, getCoasterSet } from "@/lib/park-meta"; +import { getCoasterSet, hasCoasterData } from "@/lib/coaster-data"; import type { DayData } from "@/lib/db"; interface PageProps { @@ -58,9 +58,7 @@ export default async function HomePage({ searchParams }: PageProps) { if (weekDates.includes(today)) { const todayResults = await Promise.all( PARKS.map(async (p) => { - const apiId = getApiId(db, p.id); - if (!apiId) return null; - const live = await fetchToday(apiId, 300); // 5-min ISR cache + const live = await fetchToday(p.apiId, 300); // 5-min ISR cache return live ? { parkId: p.id, live } : null; }) ); @@ -83,9 +81,7 @@ export default async function HomePage({ searchParams }: PageProps) { 0 ); - // Always fetch both ride and coaster counts — the client decides which to display. - const parkMeta = readParkMeta(); - const hasCoasterData = PARKS.some((p) => (parkMeta[p.id]?.coasters.length ?? 0) > 0); + const coasterDataAvailable = hasCoasterData(); let rideCounts: Record = {}; let coasterCounts: Record = {}; @@ -112,7 +108,7 @@ export default async function HomePage({ searchParams }: PageProps) { const trackedParks = openTodayParks.filter((p) => QUEUE_TIMES_IDS[p.id]); const results = await Promise.all( trackedParks.map(async (p) => { - const coasterSet = getCoasterSet(p.id, parkMeta); + const coasterSet = getCoasterSet(p.id); const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet, 300); const rideCount = result ? result.rides.filter((r) => r.isOpen).length : null; const coasterCount = result ? result.rides.filter((r) => r.isOpen && r.isCoaster).length : 0; @@ -143,7 +139,7 @@ export default async function HomePage({ searchParams }: PageProps) { openParkIds={openParkIds} closingParkIds={closingParkIds} weatherDelayParkIds={weatherDelayParkIds} - hasCoasterData={hasCoasterData} + hasCoasterData={coasterDataAvailable} scrapedCount={scrapedCount} /> ); diff --git a/app/park/[id]/page.tsx b/app/park/[id]/page.tsx index 6cd4e13..93a28ce 100644 --- a/app/park/[id]/page.tsx +++ b/app/park/[id]/page.tsx @@ -2,12 +2,12 @@ import Link from "next/link"; import { BackToCalendarLink } from "@/components/BackToCalendarLink"; import { notFound } from "next/navigation"; import { PARK_MAP } from "@/lib/parks"; -import { openDb, getParkMonthData, getApiId } from "@/lib/db"; +import { openDb, getParkMonthData } from "@/lib/db"; import { scrapeRidesForDay } from "@/lib/scrapers/sixflags"; import { fetchLiveRides } from "@/lib/scrapers/queuetimes"; import { fetchToday } from "@/lib/scrapers/sixflags"; import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map"; -import { readParkMeta, getCoasterSet } from "@/lib/park-meta"; +import { getCoasterSet } from "@/lib/coaster-data"; import { ParkMonthCalendar } from "@/components/ParkMonthCalendar"; import { LiveRidePanel } from "@/components/LiveRidePanel"; import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags"; @@ -42,13 +42,9 @@ export default async function ParkPage({ params, searchParams }: PageProps) { const db = openDb(); const monthData = getParkMonthData(db, id, year, month); - const apiId = getApiId(db, id); db.close(); - // Prefer live today data from the Six Flags API (5-min ISR cache) so that - // weather delays and hour changes surface immediately rather than showing - // stale DB values. Fall back to DB if the API call fails. - const liveToday = apiId !== null ? await fetchToday(apiId, 300).catch(() => null) : null; + const liveToday = await fetchToday(park.apiId, 300).catch(() => null); const todayData = liveToday ? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null } : monthData[today]; @@ -56,8 +52,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) { // ── Ride data: try live Queue-Times first, fall back to schedule ────────── const queueTimesId = QUEUE_TIMES_IDS[id]; - const parkMeta = readParkMeta(); - const coasterSet = getCoasterSet(id, parkMeta); + const coasterSet = getCoasterSet(id); let liveRides: LiveRidesResult | null = null; let ridesResult: RidesFetchResult | null = null; @@ -87,9 +82,8 @@ export default async function ParkPage({ params, searchParams }: PageProps) { liveRides.rides.length > 0 && liveRides.rides.every((r) => !r.isOpen); - // Only hit the schedule API as a fallback when Queue-Times live data is unavailable. - if (!liveRides && apiId !== null) { - ridesResult = await scrapeRidesForDay(apiId, today); + if (!liveRides) { + ridesResult = await scrapeRidesForDay(park.apiId, today); } return ( @@ -175,7 +169,6 @@ export default async function ParkPage({ params, searchParams }: PageProps) { )} @@ -256,24 +249,10 @@ function LiveBadge() { function RideList({ ridesResult, parkOpenToday, - apiIdMissing, }: { ridesResult: RidesFetchResult | null; parkOpenToday: boolean; - apiIdMissing: boolean; }) { - if (apiIdMissing) { - return ( - - Park API ID not discovered yet. Run{" "} - - npm run discover - {" "} - to enable ride data. - - ); - } - if (!parkOpenToday) { return Park is closed today — no ride schedule available.; } diff --git a/data/park-meta.json b/data/park-meta.json deleted file mode 100644 index a67b830..0000000 --- a/data/park-meta.json +++ /dev/null @@ -1,416 +0,0 @@ -{ - "greatadventure": { - "rcdb_id": 4534, - "coasters": [ - "Superman - Ultimate Flight", - "El Toro", - "Dark Knight", - "Joker", - "Jersey Devil Coaster", - "Lil' Devil Coaster", - "Flash: Vertical Velocity", - "Batman The Ride", - "Skull Mountain", - "Runaway Mine Train", - "Medusa", - "Harley Quinn Crazy Train", - "Nitro" - ], - "coasters_scraped_at": "2026-04-04T17:40:09.731Z" - }, - "magicmountain": { - "rcdb_id": 4532, - "coasters": [ - "Ninja", - "New Revolution", - "Batman The Ride", - "Viper", - "Gold Rusher", - "Riddler's Revenge", - "Canyon Blaster", - "Goliath", - "X2", - "Scream!", - "Tatsu", - "Apocalypse the Ride", - "Road Runner Express", - "Speedy Gonzales Hot Rod Racers", - "Full Throttle", - "Twisted Colossus", - "West Coast Racers", - "Wonder Woman Flight of Courage" - ], - "coasters_scraped_at": "2026-04-04T17:45:43.666Z" - }, - "greatamerica": { - "rcdb_id": 4530, - "coasters": [ - "Demon", - "Batman The Ride", - "American Eagle", - "Viper", - "Whizzer", - "Sprocket Rockets", - "Raging Bull", - "Flash: Vertical Velocity", - "Superman - Ultimate Flight", - "Dark Knight", - "Little Dipper", - "Goliath", - "X-Flight", - "Joker", - "Maxx Force", - "Wrath of Rakshasa" - ], - "coasters_scraped_at": "2026-04-04T17:29:24.092Z" - }, - "overgeorgia": { - "rcdb_id": 4535, - "coasters": [ - "Blue Hawk", - "Great American Scream Machine", - "Dahlonega Mine Train", - "Batman The Ride", - "Georgia Scorcher", - "Superman - Ultimate Flight", - "Joker Funhouse Coaster", - "Goliath", - "Dare Devil Dive", - "Twisted Cyclone", - "Riddler Mindbender", - "Georgia Gold Rusher" - ], - "coasters_scraped_at": "2026-04-04T17:29:26.121Z" - }, - "overtexas": { - "rcdb_id": 4531, - "coasters": [ - "Pandemonium", - "New Texas Giant", - "Joker", - "Aquaman: Power Wave", - "Shock Wave", - "Judge Roy Scream", - "Runaway Mine Train", - "Runaway Mountain", - "Mini Mine Train", - "Mr. Freeze", - "Batman The Ride", - "Titan", - "Wile E. Coyote's Grand Canyon Blaster" - ], - "coasters_scraped_at": "2026-04-04T17:45:45.715Z" - }, - "stlouis": { - "rcdb_id": 4536, - "coasters": [ - "Ninja", - "River King Mine Train", - "Mr. Freeze Reverse Blast", - "Batman The Ride", - "Screamin' Eagle", - "Boss", - "Pandemonium", - "American Thunder", - "Boomerang", - "Rookie Racer" - ], - "coasters_scraped_at": "2026-04-04T17:45:47.770Z" - }, - "fiestatexas": { - "rcdb_id": 4538, - "coasters": [ - "Batgirl Coaster Chase", - "Road Runner Express", - "Poltergeist", - "Boomerang Coast to Coaster", - "Superman Krypton Coaster", - "Pandemonium", - "Chupacabra", - "Iron Rattler", - "Batman The Ride", - "Wonder Woman Golden Lasso Coaster", - "Dr. Diabolical's Cliffhanger" - ], - "coasters_scraped_at": "2026-04-04T17:45:49.819Z" - }, - "newengland": { - "rcdb_id": 4565, - "coasters": [ - "Joker", - "Thunderbolt", - "Great Chase", - "Riddler Revenge", - "Superman the Ride", - "Flashback", - "Catwoman's Whip", - "Pandemonium", - "Batman - The Dark Knight", - "Wicked Cyclone", - "Gotham City Gauntlet Escape from Arkham Asylum" - ], - "coasters_scraped_at": "2026-04-04T17:45:51.866Z" - }, - "discoverykingdom": { - "rcdb_id": 4711, - "coasters": [ - "Roadrunner Express", - "Medusa", - "Cobra", - "Flash: Vertical Velocity", - "Kong", - "Boomerang", - "Superman Ultimate Flight", - "Joker", - "Batman The Ride", - "Sidewinder Safari" - ], - "coasters_scraped_at": "2026-04-04T17:45:53.909Z" - }, - "mexico": { - "rcdb_id": 4629, - "coasters": [ - "Tsunami", - "Superman Krypton Coaster", - "Batgirl Batarang", - "Batman The Ride", - "Superman el Último Escape", - "Dark Knight", - "Joker", - "Medusa Steel Coaster", - "Wonder Woman", - "Speedway Stunt Coaster" - ], - "coasters_scraped_at": "2026-04-04T17:45:55.963Z" - }, - "greatescape": { - "rcdb_id": 4596, - "coasters": [ - "Comet", - "Steamin' Demon", - "Flashback", - "Canyon Blaster", - "Frankie's Mine Train", - "Bobcat" - ], - "coasters_scraped_at": "2026-04-04T17:45:58.013Z" - }, - "darienlake": { - "rcdb_id": 4581, - "coasters": [ - "Predator", - "Viper", - "Mind Eraser", - "Boomerang", - "Ride of Steel", - "Hoot N Holler", - "Moto Coaster", - "Tantrum" - ], - "coasters_scraped_at": "2026-04-04T17:46:00.042Z" - }, - "cedarpoint": { - "rcdb_id": 4529, - "coasters": [ - "Raptor", - "Rougarou", - "Magnum XL-200", - "Blue Streak", - "Corkscrew", - "Gemini", - "Wilderness Run", - "Woodstock Express", - "Millennium Force", - "Iron Dragon", - "Cedar Creek Mine Ride", - "Maverick", - "GateKeeper", - "Valravn", - "Steel Vengeance", - "Top Thrill 2", - "Wild Mouse", - "Siren’s Curse" - ], - "coasters_scraped_at": "2026-04-04T17:46:02.082Z" - }, - "knotts": { - "rcdb_id": 4546, - "coasters": [ - "Jaguar!", - "GhostRider", - "Xcelerator", - "Silver Bullet", - "Sierra Sidewinder", - "Pony Express", - "Coast Rider", - "HangTime", - "Snoopy’s Tenderpaw Twister Coaster" - ], - "coasters_scraped_at": "2026-04-04T17:46:04.120Z" - }, - "canadaswonderland": { - "rcdb_id": 4539, - "coasters": [ - "Flight Deck", - "Dragon Fyre", - "Mighty Canadian Minebuster", - "Wilde Beast", - "Ghoster Coaster", - "Thunder Run", - "Bat", - "Vortex", - "Taxi Jam", - "Fly", - "Silver Streak", - "Backlot Stunt Coaster", - "Behemoth", - "Leviathan", - "Wonder Mountain's Guardian", - "Yukon Striker", - "Snoopy's Racing Railway", - "AlpenFury" - ], - "coasters_scraped_at": "2026-04-04T17:46:06.152Z" - }, - "carowinds": { - "rcdb_id": 4542, - "coasters": [ - "Carolina Cyclone", - "Woodstock Express", - "Carolina Goldrusher", - "Hurler", - "Vortex", - "Wilderness Run", - "Afterburn", - "Flying Cobras", - "Thunder Striker", - "Fury 325", - "Copperhead Strike", - "Snoopy’s Racing Railway", - "Ricochet", - "Kiddy Hawk" - ], - "coasters_scraped_at": "2026-04-04T17:46:08.185Z" - }, - "kingsdominion": { - "rcdb_id": 4544, - "coasters": [ - "Racer 75", - "Woodstock Express", - "Grizzly", - "Flight of Fear", - "Reptilian", - "Great Pumpkin Coaster", - "Apple Zapple", - "Backlot Stunt Coaster", - "Dominator", - "Pantherian", - "Twisted Timbers", - "Tumbili", - "Rapterra" - ], - "coasters_scraped_at": "2026-04-04T17:46:10.223Z" - }, - "kingsisland": { - "rcdb_id": 4540, - "coasters": [ - "Flight of Fear", - "Beast", - "Racer", - "Adventure Express", - "Woodstock Express", - "Bat", - "Great Pumpkin Coaster", - "Invertigo", - "Diamondback", - "Banshee", - "Orion", - "Mystic Timbers", - "Snoopy's Soap Box Racers", - "Woodstock’s Air Rail", - "Queen City Stunt Coaster" - ], - "coasters_scraped_at": "2026-04-04T17:46:12.251Z" - }, - "valleyfair": { - "rcdb_id": 4552, - "coasters": [ - "High Roller", - "Corkscrew", - "Excalibur", - "Wild Thing", - "Mad Mouse", - "Steel Venom", - "Renegade", - "Cosmic Coaster" - ], - "coasters_scraped_at": "2026-04-04T17:46:14.298Z" - }, - "worldsoffun": { - "rcdb_id": 4533, - "coasters": [ - "Timber Wolf", - "Cosmic Coaster", - "Mamba", - "Spinning Dragons", - "Patriot", - "Prowler", - "Zambezi Zinger", - "Boomerang" - ], - "coasters_scraped_at": "2026-04-04T17:46:16.328Z" - }, - "miadventure": { - "rcdb_id": 4578, - "coasters": [ - "Corkscrew", - "Wolverine Wildcat", - "Zach's Zoomer", - "Shivering Timbers", - "Mad Mouse", - "Thunderhawk", - "Woodstock Express" - ], - "coasters_scraped_at": "2026-04-04T17:46:18.370Z" - }, - "dorneypark": { - "rcdb_id": 4588, - "coasters": [ - "Thunderhawk", - "Steel Force", - "Wild Mouse", - "Woodstock Express", - "Talon", - "Hydra the Revenge", - "Possessed", - "Iron Menace" - ], - "coasters_scraped_at": "2026-04-04T17:46:20.413Z" - }, - "cagreatamerica": { - "rcdb_id": 4541, - "coasters": [ - "Demon", - "Grizzly", - "Woodstock Express", - "Patriot", - "Flight Deck", - "Lucy's Crabbie Cabbies", - "Psycho Mouse", - "Gold Striker", - "RailBlazer" - ], - "coasters_scraped_at": "2026-04-04T17:46:22.465Z" - }, - "frontiercity": { - "rcdb_id": 4559, - "coasters": [ - "Silver Bullet", - "Wildcat", - "Diamondback", - "Steel Lasso", - "Frankie's Mine Train" - ], - "coasters_scraped_at": "2026-04-04T17:46:24.519Z" - } -} diff --git a/lib/coaster-data.ts b/lib/coaster-data.ts new file mode 100644 index 0000000..de386d5 --- /dev/null +++ b/lib/coaster-data.ts @@ -0,0 +1,332 @@ +import { normalizeForMatch } from "./coaster-match"; + +export const COASTER_LISTS: Record = { + greatadventure: [ + "Superman - Ultimate Flight", + "El Toro", + "Dark Knight", + "Joker", + "Jersey Devil Coaster", + "Lil' Devil Coaster", + "Flash: Vertical Velocity", + "Batman The Ride", + "Skull Mountain", + "Runaway Mine Train", + "Medusa", + "Harley Quinn Crazy Train", + "Nitro", + ], + magicmountain: [ + "Ninja", + "New Revolution", + "Batman The Ride", + "Viper", + "Gold Rusher", + "Riddler's Revenge", + "Canyon Blaster", + "Goliath", + "X2", + "Scream!", + "Tatsu", + "Apocalypse the Ride", + "Road Runner Express", + "Speedy Gonzales Hot Rod Racers", + "Full Throttle", + "Twisted Colossus", + "West Coast Racers", + "Wonder Woman Flight of Courage", + ], + greatamerica: [ + "Demon", + "Batman The Ride", + "American Eagle", + "Viper", + "Whizzer", + "Sprocket Rockets", + "Raging Bull", + "Flash: Vertical Velocity", + "Superman - Ultimate Flight", + "Dark Knight", + "Little Dipper", + "Goliath", + "X-Flight", + "Joker", + "Maxx Force", + "Wrath of Rakshasa", + ], + overgeorgia: [ + "Blue Hawk", + "Great American Scream Machine", + "Dahlonega Mine Train", + "Batman The Ride", + "Georgia Scorcher", + "Superman - Ultimate Flight", + "Joker Funhouse Coaster", + "Goliath", + "Dare Devil Dive", + "Twisted Cyclone", + "Riddler Mindbender", + "Georgia Gold Rusher", + ], + overtexas: [ + "Pandemonium", + "New Texas Giant", + "Joker", + "Aquaman: Power Wave", + "Shock Wave", + "Judge Roy Scream", + "Runaway Mine Train", + "Runaway Mountain", + "Mini Mine Train", + "Mr. Freeze", + "Batman The Ride", + "Titan", + "Wile E. Coyote's Grand Canyon Blaster", + ], + stlouis: [ + "Ninja", + "River King Mine Train", + "Mr. Freeze Reverse Blast", + "Batman The Ride", + "Screamin' Eagle", + "Boss", + "Pandemonium", + "American Thunder", + "Boomerang", + "Rookie Racer", + ], + fiestatexas: [ + "Batgirl Coaster Chase", + "Road Runner Express", + "Poltergeist", + "Boomerang Coast to Coaster", + "Superman Krypton Coaster", + "Pandemonium", + "Chupacabra", + "Iron Rattler", + "Batman The Ride", + "Wonder Woman Golden Lasso Coaster", + "Dr. Diabolical's Cliffhanger", + ], + newengland: [ + "Joker", + "Thunderbolt", + "Great Chase", + "Riddler Revenge", + "Superman the Ride", + "Flashback", + "Catwoman's Whip", + "Pandemonium", + "Batman - The Dark Knight", + "Wicked Cyclone", + "Gotham City Gauntlet Escape from Arkham Asylum", + ], + discoverykingdom: [ + "Roadrunner Express", + "Medusa", + "Cobra", + "Flash: Vertical Velocity", + "Kong", + "Boomerang", + "Superman Ultimate Flight", + "Joker", + "Batman The Ride", + "Sidewinder Safari", + ], + mexico: [ + "Tsunami", + "Superman Krypton Coaster", + "Batgirl Batarang", + "Batman The Ride", + "Superman el Último Escape", + "Dark Knight", + "Joker", + "Medusa Steel Coaster", + "Wonder Woman", + "Speedway Stunt Coaster", + ], + greatescape: [ + "Comet", + "Steamin' Demon", + "Flashback", + "Canyon Blaster", + "Frankie's Mine Train", + "Bobcat", + ], + darienlake: [ + "Predator", + "Viper", + "Mind Eraser", + "Boomerang", + "Ride of Steel", + "Hoot N Holler", + "Moto Coaster", + "Tantrum", + ], + cedarpoint: [ + "Raptor", + "Rougarou", + "Magnum XL-200", + "Blue Streak", + "Corkscrew", + "Gemini", + "Wilderness Run", + "Woodstock Express", + "Millennium Force", + "Iron Dragon", + "Cedar Creek Mine Ride", + "Maverick", + "GateKeeper", + "Valravn", + "Steel Vengeance", + "Top Thrill 2", + "Wild Mouse", + "Siren's Curse", + ], + knotts: [ + "Jaguar!", + "GhostRider", + "Xcelerator", + "Silver Bullet", + "Sierra Sidewinder", + "Pony Express", + "Coast Rider", + "HangTime", + "Snoopy's Tenderpaw Twister Coaster", + ], + canadaswonderland: [ + "Flight Deck", + "Dragon Fyre", + "Mighty Canadian Minebuster", + "Wilde Beast", + "Ghoster Coaster", + "Thunder Run", + "Bat", + "Vortex", + "Taxi Jam", + "Fly", + "Silver Streak", + "Backlot Stunt Coaster", + "Behemoth", + "Leviathan", + "Wonder Mountain's Guardian", + "Yukon Striker", + "Snoopy's Racing Railway", + "AlpenFury", + ], + carowinds: [ + "Carolina Cyclone", + "Woodstock Express", + "Carolina Goldrusher", + "Hurler", + "Vortex", + "Wilderness Run", + "Afterburn", + "Flying Cobras", + "Thunder Striker", + "Fury 325", + "Copperhead Strike", + "Snoopy's Racing Railway", + "Ricochet", + "Kiddy Hawk", + ], + kingsdominion: [ + "Racer 75", + "Woodstock Express", + "Grizzly", + "Flight of Fear", + "Reptilian", + "Great Pumpkin Coaster", + "Apple Zapple", + "Backlot Stunt Coaster", + "Dominator", + "Pantherian", + "Twisted Timbers", + "Tumbili", + "Rapterra", + ], + kingsisland: [ + "Flight of Fear", + "Beast", + "Racer", + "Adventure Express", + "Woodstock Express", + "Bat", + "Great Pumpkin Coaster", + "Invertigo", + "Diamondback", + "Banshee", + "Orion", + "Mystic Timbers", + "Snoopy's Soap Box Racers", + "Woodstock's Air Rail", + "Queen City Stunt Coaster", + ], + valleyfair: [ + "High Roller", + "Corkscrew", + "Excalibur", + "Wild Thing", + "Mad Mouse", + "Steel Venom", + "Renegade", + "Cosmic Coaster", + ], + worldsoffun: [ + "Timber Wolf", + "Cosmic Coaster", + "Mamba", + "Spinning Dragons", + "Patriot", + "Prowler", + "Zambezi Zinger", + "Boomerang", + ], + miadventure: [ + "Corkscrew", + "Wolverine Wildcat", + "Zach's Zoomer", + "Shivering Timbers", + "Mad Mouse", + "Thunderhawk", + "Woodstock Express", + ], + dorneypark: [ + "Thunderhawk", + "Steel Force", + "Wild Mouse", + "Woodstock Express", + "Talon", + "Hydra the Revenge", + "Possessed", + "Iron Menace", + ], + cagreatamerica: [ + "Demon", + "Grizzly", + "Woodstock Express", + "Patriot", + "Flight Deck", + "Lucy's Crabbie Cabbies", + "Psycho Mouse", + "Gold Striker", + "RailBlazer", + ], + frontiercity: [ + "Silver Bullet", + "Wildcat", + "Diamondback", + "Steel Lasso", + "Frankie's Mine Train", + ], +}; + +export function getCoasterSet(parkId: string): Set | null { + const coasters = COASTER_LISTS[parkId]; + if (!coasters || coasters.length === 0) return null; + return new Set(coasters.map(normalizeForMatch)); +} + +export function hasCoasterData(): boolean { + return Object.values(COASTER_LISTS).some((list) => list.length > 0); +} diff --git a/lib/park-meta.ts b/lib/park-meta.ts deleted file mode 100644 index 4cdee7f..0000000 --- a/lib/park-meta.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * park-meta.json — persisted alongside the SQLite DB in data/ - * - * This file stores per-park metadata that doesn't belong in the schedule DB: - * - rcdb_id: user-supplied RCDB park ID (fills into https://rcdb.com/{id}.htm) - * - coasters: list of operating roller coaster names scraped from RCDB - * - coasters_scraped_at: ISO timestamp of last RCDB scrape - * - * discover.ts: ensures every park has a skeleton entry (rcdb_id null by default) - * scrape.ts: populates coasters[] for parks with a known rcdb_id (30-day staleness) - */ - -import fs from "fs"; -import path from "path"; - -const META_PATH = path.join(process.cwd(), "data", "park-meta.json"); - -export interface ParkMeta { - /** RCDB park page ID — user fills this in manually after discover creates the skeleton */ - rcdb_id: number | null; - /** Operating roller coaster names scraped from RCDB */ - coasters: string[]; - /** ISO timestamp of when coasters was last scraped from RCDB */ - coasters_scraped_at: string | null; -} - -export type ParkMetaMap = Record; - -export function readParkMeta(): ParkMetaMap { - try { - return JSON.parse(fs.readFileSync(META_PATH, "utf8")) as ParkMetaMap; - } catch { - return {}; - } -} - -export function writeParkMeta(meta: ParkMetaMap): void { - fs.mkdirSync(path.dirname(META_PATH), { recursive: true }); - fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2) + "\n"); -} - -/** Default skeleton entry for a park that has never been configured. */ -export function defaultParkMeta(): ParkMeta { - return { rcdb_id: null, coasters: [], coasters_scraped_at: null }; -} - -const COASTER_STALE_MS = parseStalenessHours(process.env.COASTER_STALENESS_HOURS, 720) * 60 * 60 * 1000; - -/** Returns true when the coaster list needs to be re-scraped from RCDB. */ -export function areCoastersStale(entry: ParkMeta): boolean { - if (!entry.coasters_scraped_at) return true; - return Date.now() - new Date(entry.coasters_scraped_at).getTime() > COASTER_STALE_MS; -} - -import { normalizeForMatch } from "./coaster-match"; -export { normalizeForMatch as normalizeRideName } from "./coaster-match"; -import { parseStalenessHours } from "./env"; - -/** - * Returns a Set of normalized coaster names for fast membership checks. - * Returns null when no coaster data exists for the park. - */ -export function getCoasterSet(parkId: string, meta: ParkMetaMap): Set | null { - const entry = meta[parkId]; - if (!entry || entry.coasters.length === 0) return null; - return new Set(entry.coasters.map(normalizeForMatch)); -} diff --git a/lib/parks.ts b/lib/parks.ts index 6cd358d..f8060bf 100644 --- a/lib/parks.ts +++ b/lib/parks.ts @@ -11,6 +11,7 @@ export const PARKS: Park[] = [ // ── Six Flags branded parks ────────────────────────────────────────────── { id: "greatadventure", + apiId: 905, name: "Six Flags Great Adventure", shortName: "Great Adventure", chain: "sixflags", @@ -22,6 +23,7 @@ export const PARKS: Park[] = [ }, { id: "magicmountain", + apiId: 906, name: "Six Flags Magic Mountain", shortName: "Magic Mountain", chain: "sixflags", @@ -33,6 +35,7 @@ export const PARKS: Park[] = [ }, { id: "greatamerica", + apiId: 910, name: "Six Flags Great America", shortName: "Great America", chain: "sixflags", @@ -44,6 +47,7 @@ export const PARKS: Park[] = [ }, { id: "overgeorgia", + apiId: 902, name: "Six Flags Over Georgia", shortName: "Over Georgia", chain: "sixflags", @@ -55,6 +59,7 @@ export const PARKS: Park[] = [ }, { id: "overtexas", + apiId: 901, name: "Six Flags Over Texas", shortName: "Over Texas", chain: "sixflags", @@ -66,6 +71,7 @@ export const PARKS: Park[] = [ }, { id: "stlouis", + apiId: 903, name: "Six Flags St. Louis", shortName: "St. Louis", chain: "sixflags", @@ -77,6 +83,7 @@ export const PARKS: Park[] = [ }, { id: "fiestatexas", + apiId: 914, name: "Six Flags Fiesta Texas", shortName: "Fiesta Texas", chain: "sixflags", @@ -88,6 +95,7 @@ export const PARKS: Park[] = [ }, { id: "newengland", + apiId: 935, name: "Six Flags New England", shortName: "New England", chain: "sixflags", @@ -99,6 +107,7 @@ export const PARKS: Park[] = [ }, { id: "discoverykingdom", + apiId: 936, name: "Six Flags Discovery Kingdom", shortName: "Discovery Kingdom", chain: "sixflags", @@ -110,6 +119,7 @@ export const PARKS: Park[] = [ }, { id: "mexico", + apiId: 960, name: "Six Flags Mexico", shortName: "Mexico", chain: "sixflags", @@ -121,6 +131,7 @@ export const PARKS: Park[] = [ }, { id: "greatescape", + apiId: 924, name: "Six Flags Great Escape", shortName: "Great Escape", chain: "sixflags", @@ -132,6 +143,7 @@ export const PARKS: Park[] = [ }, { id: "darienlake", + apiId: 945, name: "Six Flags Darien Lake", shortName: "Darien Lake", chain: "sixflags", @@ -144,6 +156,7 @@ export const PARKS: Park[] = [ // ── Former Cedar Fair theme parks ───────────────────────────────────────── { id: "cedarpoint", + apiId: 1, name: "Cedar Point", shortName: "Cedar Point", chain: "sixflags", @@ -155,6 +168,7 @@ export const PARKS: Park[] = [ }, { id: "knotts", + apiId: 4, name: "Knott's Berry Farm", shortName: "Knott's", chain: "sixflags", @@ -166,6 +180,7 @@ export const PARKS: Park[] = [ }, { id: "canadaswonderland", + apiId: 40, name: "Canada's Wonderland", shortName: "Canada's Wonderland", chain: "sixflags", @@ -177,6 +192,7 @@ export const PARKS: Park[] = [ }, { id: "carowinds", + apiId: 30, name: "Carowinds", shortName: "Carowinds", chain: "sixflags", @@ -188,6 +204,7 @@ export const PARKS: Park[] = [ }, { id: "kingsdominion", + apiId: 25, name: "Kings Dominion", shortName: "Kings Dominion", chain: "sixflags", @@ -199,6 +216,7 @@ export const PARKS: Park[] = [ }, { id: "kingsisland", + apiId: 20, name: "Kings Island", shortName: "Kings Island", chain: "sixflags", @@ -210,6 +228,7 @@ export const PARKS: Park[] = [ }, { id: "valleyfair", + apiId: 14, name: "Valleyfair", shortName: "Valleyfair", chain: "sixflags", @@ -221,6 +240,7 @@ export const PARKS: Park[] = [ }, { id: "worldsoffun", + apiId: 6, name: "Worlds of Fun", shortName: "Worlds of Fun", chain: "sixflags", @@ -232,6 +252,7 @@ export const PARKS: Park[] = [ }, { id: "miadventure", + apiId: 12, name: "Michigan's Adventure", shortName: "Michigan's Adventure", chain: "sixflags", @@ -243,6 +264,7 @@ export const PARKS: Park[] = [ }, { id: "dorneypark", + apiId: 8, name: "Dorney Park", shortName: "Dorney Park", chain: "sixflags", @@ -254,6 +276,7 @@ export const PARKS: Park[] = [ }, { id: "cagreatamerica", + apiId: 35, name: "California's Great America", shortName: "CA Great America", chain: "sixflags", @@ -265,6 +288,7 @@ export const PARKS: Park[] = [ }, { id: "frontiercity", + apiId: 943, name: "Frontier City", shortName: "Frontier City", chain: "sixflags", diff --git a/lib/scrapers/rcdb.ts b/lib/scrapers/rcdb.ts deleted file mode 100644 index b660ac5..0000000 --- a/lib/scrapers/rcdb.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * RCDB (Roller Coaster DataBase) scraper. - * - * Fetches a park's RCDB page (https://rcdb.com/{id}.htm) and extracts the - * names of operating roller coasters from the "Operating Roller Coasters" - * section. - * - * RCDB has no public API. This scraper reads the static HTML page. - * Please scrape infrequently (30-day staleness window) to be respectful. - */ - -const BASE = "https://rcdb.com"; - -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: "text/html,application/xhtml+xml", - "Accept-Language": "en-US,en;q=0.9", -}; - -/** - * Scrape operating roller coaster names for a park. - * - * Returns an array of coaster names on success, or null when the page - * cannot be fetched or contains no operating coasters. - */ -export async function scrapeRcdbCoasters(rcdbId: number): Promise { - const url = `${BASE}/${rcdbId}.htm`; - try { - const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15_000) }); - if (!res.ok) { - console.error(` RCDB ${rcdbId}: HTTP ${res.status}`); - return null; - } - const html = await res.text(); - return parseOperatingCoasters(html); - } catch (err) { - console.error(` RCDB ${rcdbId}: ${err}`); - return null; - } -} - -/** - * Parse operating roller coaster names from RCDB park page HTML. - * - * RCDB park pages list coasters in sections bounded by
tags. - * The operating section heading looks like: - *

Operating Roller Coasters: 16

- * - * Each coaster is an link to its detail page with an unquoted href: - * Batman The Ride - * - * We extract only those links (href=/DIGITS.htm) from within the - * operating section, stopping at the next
tag. - */ -function parseOperatingCoasters(html: string): string[] { - // Find the "Operating Roller Coasters" section heading. - const opIdx = html.search(/Operating\s+Roller\s+Coasters/i); - if (opIdx === -1) return []; - - // The section ends at the next
tag (e.g. "Defunct Roller Coasters"). - const after = html.slice(opIdx); - const nextSection = after.search(/ 0 ? after.slice(0, nextSection) : after; - - // Extract coaster names from links to RCDB detail pages. - // RCDB uses unquoted href attributes: href=/1234.htm - // General links (/g.htm, /r.htm, /location.htm, etc.) won't match \d+\.htm. - const names: string[] = []; - const linkPattern = /]*href=["']?\/(\d+)\.htm["']?[^>]*>([^<]+)<\/a>/gi; - let match: RegExpExecArray | null; - - while ((match = linkPattern.exec(sectionHtml)) !== null) { - const name = decodeHtmlEntities(match[2].trim()); - if (name) names.push(name); - } - - // Deduplicate while preserving order - return [...new Set(names)]; -} - -function decodeHtmlEntities(text: string): string { - return text - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) - .replace(/&[a-z]+;/gi, ""); -} diff --git a/lib/scrapers/sixflags.ts b/lib/scrapers/sixflags.ts index 4a3c98f..1710186 100644 --- a/lib/scrapers/sixflags.ts +++ b/lib/scrapers/sixflags.ts @@ -1,11 +1,8 @@ /** - * Six Flags scraper — calls the internal CloudFront operating-hours API directly. + * Six Flags API client — calls the internal CloudFront operating-hours API. * * 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. + * Returns full month data in one request. * * Rate limiting: on 429/503, exponential backoff (30s → 60s → 120s), MAX_RETRIES attempts. */ @@ -309,7 +306,6 @@ export async function scrapeRidesForDay( /** * 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, @@ -325,8 +321,7 @@ export async function scrapeMonth( } /** - * 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. + * Fetch park info for a given API ID. Uses the current month so there's always some data. */ export async function fetchParkInfo( apiId: number diff --git a/lib/scrapers/types.ts b/lib/scrapers/types.ts index 242eae3..03c02e0 100644 --- a/lib/scrapers/types.ts +++ b/lib/scrapers/types.ts @@ -1,5 +1,6 @@ export interface Park { id: string; + apiId: number; name: string; shortName: string; chain: "sixflags" | string; diff --git a/package.json b/package.json index 2c47375..bcc2352 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "lint": "next lint", "scrape": "tsx scripts/scrape.ts", "scrape:force": "tsx scripts/scrape.ts --rescrape", - "discover": "tsx scripts/discover.ts", "debug": "tsx scripts/debug.ts", "test": "tsx --test tests/*.test.ts" }, @@ -27,7 +26,6 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^15.3.0", - "playwright": "^1.59.1", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5" diff --git a/scripts/debug.ts b/scripts/debug.ts index 5c45ed3..e0f2cc4 100644 --- a/scripts/debug.ts +++ b/scripts/debug.ts @@ -9,7 +9,6 @@ import fs from "fs"; import path from "path"; -import { openDb, getApiId } from "../lib/db"; import { PARKS } from "../lib/parks"; import { scrapeMonthRaw } from "../lib/scrapers/sixflags"; @@ -52,16 +51,6 @@ async function main() { const month = parseInt(monthStr); const day = parseInt(dayStr); - const db = openDb(); - const apiId = getApiId(db, park.id); - db.close(); - - if (apiId === null) { - console.error(`No API ID found for ${park.name} — run: npm run discover`); - process.exit(1); - } - - // Collect all output so we can write it to a file as well const lines: string[] = []; const out = (...args: string[]) => { const line = args.join(" "); @@ -70,13 +59,13 @@ async function main() { }; out(`Park : ${park.name} (${park.id})`); - out(`API ID : ${apiId}`); + out(`API ID : ${park.apiId}`); out(`Date : ${dateStr}`); out(`Fetched : ${new Date().toISOString()}`); out(""); out(`Fetching ${year}-${String(month).padStart(2, "0")} from API...`); - const raw = await scrapeMonthRaw(apiId, year, month); + const raw = await scrapeMonthRaw(park.apiId, year, month); const targetDate = `${String(month).padStart(2, "0")}/${String(day).padStart(2, "0")}/${year}`; const dayData = raw.dates.find((d) => d.date === targetDate); diff --git a/scripts/discover.ts b/scripts/discover.ts deleted file mode 100644 index c0e9a9a..0000000 --- a/scripts/discover.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * One-time discovery script — finds the CloudFront API ID for each park. - * - * Run this once before using scrape.ts: - * npx tsx scripts/discover.ts - * - * For each park in the registry it: - * 1. Opens the park's hours page in a headless browser - * 2. Intercepts all calls to the operating-hours CloudFront API - * 3. Identifies the main theme park ID (filters out water parks, safari, etc.) - * 4. Stores the ID in the database - * - * Re-running is safe — already-discovered parks are skipped. - */ - -import { chromium } from "playwright"; -import { openDb, getApiId, setApiId, type DbInstance } from "../lib/db"; -import { PARKS } from "../lib/parks"; -import { fetchParkInfo, isMainThemePark } from "../lib/scrapers/sixflags"; -import { readParkMeta, writeParkMeta, defaultParkMeta } from "../lib/park-meta"; - -const CLOUDFRONT_PATTERN = /operating-hours\/park\/(\d+)/; - -async function discoverParkId(slug: string): Promise { - const browser = await chromium.launch({ headless: true }); - try { - const context = await browser.newContext({ - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - locale: "en-US", - }); - const page = await context.newPage(); - - const capturedIds = new Set(); - page.on("request", (req) => { - const match = req.url().match(CLOUDFRONT_PATTERN); - if (match) capturedIds.add(parseInt(match[1])); - }); - - await page - .goto(`https://www.sixflags.com/${slug}/park-hours?date=2026-05-01`, { - waitUntil: "networkidle", - timeout: 30_000, - }) - .catch(() => null); - - await context.close(); - - if (capturedIds.size === 0) return null; - - // Check each captured ID — pick the main theme park (not water park / safari) - for (const id of capturedIds) { - const info = await fetchParkInfo(id); - if (info && isMainThemePark(info.parkName)) { - console.log( - ` → ID ${id} | ${info.parkAbbreviation} | ${info.parkName}` - ); - return id; - } - } - - // Fallback: return the lowest ID (usually the main park) - const fallback = Math.min(...capturedIds); - console.log(` → fallback to lowest ID: ${fallback}`); - return fallback; - } finally { - await browser.close(); - } -} - -function purgeRemovedParks(db: DbInstance) { - const knownIds = new Set(PARKS.map((p) => p.id)); - - const staleParkIds = ( - db.prepare("SELECT DISTINCT park_id FROM park_api_ids").all() as { park_id: string }[] - ) - .map((r) => r.park_id) - .filter((id) => !knownIds.has(id)); - - if (staleParkIds.length === 0) return; - - console.log(`\nRemoving ${staleParkIds.length} park(s) no longer in registry:`); - for (const parkId of staleParkIds) { - const days = ( - db.prepare("SELECT COUNT(*) AS n FROM park_days WHERE park_id = ?").get(parkId) as { n: number } - ).n; - db.prepare("DELETE FROM park_days WHERE park_id = ?").run(parkId); - db.prepare("DELETE FROM park_api_ids WHERE park_id = ?").run(parkId); - console.log(` removed ${parkId} (${days} day rows deleted)`); - } - console.log(); -} - -async function main() { - const db = openDb(); - - purgeRemovedParks(db); - - for (const park of PARKS) { - const existing = getApiId(db, park.id); - if (existing !== null) { - console.log(`${park.name}: already known (API ID ${existing}) — skip`); - continue; - } - - process.stdout.write(`${park.name} (${park.slug})... `); - - try { - const apiId = await discoverParkId(park.slug); - if (apiId === null) { - console.log("FAILED — no API IDs captured"); - continue; - } - - // Fetch full info to store name/abbreviation - const info = await fetchParkInfo(apiId); - setApiId(db, park.id, apiId, info?.parkAbbreviation, info?.parkName); - } catch (err) { - console.log(`ERROR: ${err}`); - } - - // Small delay between parks to be polite - await new Promise((r) => setTimeout(r, 2000)); - } - - // ── Ensure park-meta.json has a skeleton entry for every park ──────────── - // Users fill in rcdb_id manually; scrape.ts populates coasters[] from RCDB. - const meta = readParkMeta(); - let metaChanged = false; - - for (const park of PARKS) { - if (!meta[park.id]) { - meta[park.id] = defaultParkMeta(); - metaChanged = true; - } - } - // Remove entries for parks no longer in the registry - for (const id of Object.keys(meta)) { - if (!PARKS.find((p) => p.id === id)) { - delete meta[id]; - metaChanged = true; - } - } - - if (metaChanged) { - writeParkMeta(meta); - console.log("\nUpdated data/park-meta.json"); - console.log(" → Set rcdb_id for each park to enable the coaster filter."); - console.log(" Find a park's RCDB ID from: https://rcdb.com (the number in the URL)."); - } - - // Print summary - console.log("\n── Discovered IDs ──"); - for (const park of PARKS) { - const id = getApiId(db, park.id); - const rcdbId = meta[park.id]?.rcdb_id; - const rcdbStr = rcdbId ? `rcdb:${rcdbId}` : "rcdb:?"; - console.log(` ${park.id.padEnd(30)} api:${String(id ?? "?").padEnd(8)} ${rcdbStr}`); - } - - db.close(); -} - -main().catch((err) => { - console.error("Fatal:", err); - process.exit(1); -}); diff --git a/scripts/scrape.ts b/scripts/scrape.ts index 6c7ae6a..fa8c472 100644 --- a/scripts/scrape.ts +++ b/scripts/scrape.ts @@ -1,17 +1,13 @@ /** * Scrape job — fetches 2026 operating hours for all parks from the Six Flags API. * - * Prerequisite: run `npm run discover` first to populate API IDs. - * - * npm run scrape — skips months scraped within the last 7 days + * npm run scrape — skips months scraped within the last 72h * npm run scrape:force — re-scrapes everything */ -import { openDb, upsertDay, getApiId, isMonthScraped } from "../lib/db"; +import { openDb, upsertDay, isMonthScraped } from "../lib/db"; import { PARKS } from "../lib/parks"; import { scrapeMonth, fetchToday, RateLimitError } from "../lib/scrapers/sixflags"; -import { readParkMeta, writeParkMeta, areCoastersStale } from "../lib/park-meta"; -import { scrapeRcdbCoasters } from "../lib/scrapers/rcdb"; const YEAR = 2026; const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; @@ -25,29 +21,13 @@ async function sleep(ms: number) { async function main() { const db = openDb(); - const ready = PARKS.filter((p) => getApiId(db, p.id) !== null); - const needsDiscovery = PARKS.filter((p) => getApiId(db, p.id) === null); - - if (needsDiscovery.length > 0) { - console.log( - `⚠ ${needsDiscovery.length} park(s) need discovery first: ${needsDiscovery.map((p) => p.id).join(", ")}\n` - ); - } - - if (ready.length === 0) { - console.log("No parks ready — run: npm run discover"); - db.close(); - return; - } - - console.log(`Scraping ${YEAR} — ${ready.length} parks\n`); + console.log(`Scraping ${YEAR} — ${PARKS.length} parks\n`); let totalFetched = 0; let totalSkipped = 0; let totalErrors = 0; - for (const park of ready) { - const apiId = getApiId(db, park.id)!; + for (const park of PARKS) { const label = park.shortName.padEnd(22); let openDays = 0; @@ -65,7 +45,7 @@ async function main() { } try { - const days = await scrapeMonth(apiId, YEAR, month); + const days = await scrapeMonth(park.apiId, YEAR, month); db.transaction(() => { for (const d of days) upsertDay(db, park.id, d.date, d.isOpen, d.hoursLabel, d.specialType); })(); @@ -102,11 +82,10 @@ async function main() { // ── Today scrape (always fresh — dateless endpoint returns current day) ──── console.log("\n── Today's data ──"); - for (const park of ready) { - const apiId = getApiId(db, park.id)!; + for (const park of PARKS) { process.stdout.write(` ${park.shortName.padEnd(22)} `); try { - const today = await fetchToday(apiId); + const today = await fetchToday(park.apiId); if (today) { upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType); console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed"); @@ -120,42 +99,6 @@ async function main() { } db.close(); - - // ── RCDB coaster scrape (30-day staleness) ──────────────────────────────── - const meta = readParkMeta(); - const rcdbParks = PARKS.filter((p) => { - const entry = meta[p.id]; - return entry?.rcdb_id && (FORCE || areCoastersStale(entry)); - }); - - if (rcdbParks.length === 0) { - console.log("\nCoaster data up to date."); - return; - } - - console.log(`\n── RCDB coaster scrape — ${rcdbParks.length} park(s) ──`); - - for (const park of rcdbParks) { - const entry = meta[park.id]; - const rcdbId = entry.rcdb_id!; - process.stdout.write(` ${park.shortName.padEnd(30)} `); - - const coasters = await scrapeRcdbCoasters(rcdbId); - if (coasters === null) { - console.log("FAILED"); - continue; - } - - entry.coasters = coasters; - entry.coasters_scraped_at = new Date().toISOString(); - console.log(`${coasters.length} coasters`); - - // Polite delay between RCDB requests - await new Promise((r) => setTimeout(r, 2000)); - } - - writeParkMeta(meta); - console.log(" Saved to data/park-meta.json"); } main().catch((err) => {