refactor: hardcode API IDs and coaster lists, remove Playwright discovery

Embed Six Flags API IDs directly in the park registry and snapshot
coaster lists from park-meta.json into a TypeScript module. This
eliminates the Playwright-based discovery script, RCDB scraper, and
runtime dependency on park-meta.json — preparing for the backend
API transition.

- Add apiId field to Park type and all 24 park entries
- Create lib/coaster-data.ts with hardcoded coaster lists
- Update page components to use park.apiId and new getCoasterSet()
- Remove scripts/discover.ts, lib/scrapers/rcdb.ts, lib/park-meta.ts
- Remove data/park-meta.json from shared volume
- Remove playwright devDependency and discover npm script
- Simplify scripts/scrape.ts (no RCDB, no discovery checks)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 21:25:53 -04:00
parent 757c2a8d4f
commit 4652a92c29
13 changed files with 381 additions and 866 deletions
+6 -10
View File
@@ -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<string, number> = {};
let coasterCounts: Record<string, number> = {};
@@ -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}
/>
);
+6 -27
View File
@@ -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) {
<RideList
ridesResult={ridesResult}
parkOpenToday={!!parkOpenToday}
apiIdMissing={apiId === null && !queueTimesId}
/>
)}
</section>
@@ -256,24 +249,10 @@ function LiveBadge() {
function RideList({
ridesResult,
parkOpenToday,
apiIdMissing,
}: {
ridesResult: RidesFetchResult | null;
parkOpenToday: boolean;
apiIdMissing: boolean;
}) {
if (apiIdMissing) {
return (
<Callout>
Park API ID not discovered yet. Run{" "}
<code style={{ background: "var(--color-surface-2)", padding: "1px 5px", borderRadius: 3, fontSize: "0.8em" }}>
npm run discover
</code>{" "}
to enable ride data.
</Callout>
);
}
if (!parkOpenToday) {
return <Callout>Park is closed today no ride schedule available.</Callout>;
}
-416
View File
@@ -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",
"Sirens 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",
"Snoopys 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",
"Snoopys 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",
"Woodstocks 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"
}
}
+332
View File
@@ -0,0 +1,332 @@
import { normalizeForMatch } from "./coaster-match";
export const COASTER_LISTS: Record<string, string[]> = {
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<string> | 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);
}
-67
View File
@@ -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<string, ParkMeta>;
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<string> | null {
const entry = meta[parkId];
if (!entry || entry.coasters.length === 0) return null;
return new Set(entry.coasters.map(normalizeForMatch));
}
+24
View File
@@ -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",
-91
View File
@@ -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<string[] | null> {
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 <section> tags.
* The operating section heading looks like:
* <h4>Operating Roller Coasters: <a href="...">16</a></h4>
*
* Each coaster is an <a> link to its detail page with an unquoted href:
* <td data-sort="Batman The Ride"><a href=/5.htm>Batman The Ride</a>
*
* We extract only those links (href=/DIGITS.htm) from within the
* operating section, stopping at the next <section> 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 <section> tag (e.g. "Defunct Roller Coasters").
const after = html.slice(opIdx);
const nextSection = after.search(/<section\b/i);
const sectionHtml = nextSection > 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 = /<a\s[^>]*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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.replace(/&[a-z]+;/gi, "");
}
+3 -8
View File
@@ -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
+1
View File
@@ -1,5 +1,6 @@
export interface Park {
id: string;
apiId: number;
name: string;
shortName: string;
chain: "sixflags" | string;
-2
View File
@@ -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"
+2 -13
View File
@@ -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);
-168
View File
@@ -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<number | null> {
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<number>();
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);
});
+7 -64
View File
@@ -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) => {