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
+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;