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:
+6
-10
@@ -1,11 +1,11 @@
|
|||||||
import { HomePageClient } from "@/components/HomePageClient";
|
import { HomePageClient } from "@/components/HomePageClient";
|
||||||
import { PARKS } from "@/lib/parks";
|
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 { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
|
||||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
import { fetchToday } from "@/lib/scrapers/sixflags";
|
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
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";
|
import type { DayData } from "@/lib/db";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -58,9 +58,7 @@ export default async function HomePage({ searchParams }: PageProps) {
|
|||||||
if (weekDates.includes(today)) {
|
if (weekDates.includes(today)) {
|
||||||
const todayResults = await Promise.all(
|
const todayResults = await Promise.all(
|
||||||
PARKS.map(async (p) => {
|
PARKS.map(async (p) => {
|
||||||
const apiId = getApiId(db, p.id);
|
const live = await fetchToday(p.apiId, 300); // 5-min ISR cache
|
||||||
if (!apiId) return null;
|
|
||||||
const live = await fetchToday(apiId, 300); // 5-min ISR cache
|
|
||||||
return live ? { parkId: p.id, live } : null;
|
return live ? { parkId: p.id, live } : null;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -83,9 +81,7 @@ export default async function HomePage({ searchParams }: PageProps) {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always fetch both ride and coaster counts — the client decides which to display.
|
const coasterDataAvailable = hasCoasterData();
|
||||||
const parkMeta = readParkMeta();
|
|
||||||
const hasCoasterData = PARKS.some((p) => (parkMeta[p.id]?.coasters.length ?? 0) > 0);
|
|
||||||
|
|
||||||
let rideCounts: Record<string, number> = {};
|
let rideCounts: Record<string, number> = {};
|
||||||
let coasterCounts: 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 trackedParks = openTodayParks.filter((p) => QUEUE_TIMES_IDS[p.id]);
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
trackedParks.map(async (p) => {
|
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 result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet, 300);
|
||||||
const rideCount = result ? result.rides.filter((r) => r.isOpen).length : null;
|
const rideCount = result ? result.rides.filter((r) => r.isOpen).length : null;
|
||||||
const coasterCount = result ? result.rides.filter((r) => r.isOpen && r.isCoaster).length : 0;
|
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}
|
openParkIds={openParkIds}
|
||||||
closingParkIds={closingParkIds}
|
closingParkIds={closingParkIds}
|
||||||
weatherDelayParkIds={weatherDelayParkIds}
|
weatherDelayParkIds={weatherDelayParkIds}
|
||||||
hasCoasterData={hasCoasterData}
|
hasCoasterData={coasterDataAvailable}
|
||||||
scrapedCount={scrapedCount}
|
scrapedCount={scrapedCount}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+6
-27
@@ -2,12 +2,12 @@ import Link from "next/link";
|
|||||||
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
|
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { PARK_MAP } from "@/lib/parks";
|
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 { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
||||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
import { fetchToday } from "@/lib/scrapers/sixflags";
|
import { fetchToday } from "@/lib/scrapers/sixflags";
|
||||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
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 { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
||||||
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
||||||
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
||||||
@@ -42,13 +42,9 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
|
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
const monthData = getParkMonthData(db, id, year, month);
|
const monthData = getParkMonthData(db, id, year, month);
|
||||||
const apiId = getApiId(db, id);
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
// Prefer live today data from the Six Flags API (5-min ISR cache) so that
|
const liveToday = await fetchToday(park.apiId, 300).catch(() => null);
|
||||||
// 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 todayData = liveToday
|
const todayData = liveToday
|
||||||
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
||||||
: monthData[today];
|
: 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 ──────────
|
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
||||||
const queueTimesId = QUEUE_TIMES_IDS[id];
|
const queueTimesId = QUEUE_TIMES_IDS[id];
|
||||||
const parkMeta = readParkMeta();
|
const coasterSet = getCoasterSet(id);
|
||||||
const coasterSet = getCoasterSet(id, parkMeta);
|
|
||||||
|
|
||||||
let liveRides: LiveRidesResult | null = null;
|
let liveRides: LiveRidesResult | null = null;
|
||||||
let ridesResult: RidesFetchResult | 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.length > 0 &&
|
||||||
liveRides.rides.every((r) => !r.isOpen);
|
liveRides.rides.every((r) => !r.isOpen);
|
||||||
|
|
||||||
// Only hit the schedule API as a fallback when Queue-Times live data is unavailable.
|
if (!liveRides) {
|
||||||
if (!liveRides && apiId !== null) {
|
ridesResult = await scrapeRidesForDay(park.apiId, today);
|
||||||
ridesResult = await scrapeRidesForDay(apiId, today);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,7 +169,6 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
<RideList
|
<RideList
|
||||||
ridesResult={ridesResult}
|
ridesResult={ridesResult}
|
||||||
parkOpenToday={!!parkOpenToday}
|
parkOpenToday={!!parkOpenToday}
|
||||||
apiIdMissing={apiId === null && !queueTimesId}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -256,24 +249,10 @@ function LiveBadge() {
|
|||||||
function RideList({
|
function RideList({
|
||||||
ridesResult,
|
ridesResult,
|
||||||
parkOpenToday,
|
parkOpenToday,
|
||||||
apiIdMissing,
|
|
||||||
}: {
|
}: {
|
||||||
ridesResult: RidesFetchResult | null;
|
ridesResult: RidesFetchResult | null;
|
||||||
parkOpenToday: boolean;
|
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) {
|
if (!parkOpenToday) {
|
||||||
return <Callout>Park is closed today — no ride schedule available.</Callout>;
|
return <Callout>Park is closed today — no ride schedule available.</Callout>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ export const PARKS: Park[] = [
|
|||||||
// ── Six Flags branded parks ──────────────────────────────────────────────
|
// ── Six Flags branded parks ──────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: "greatadventure",
|
id: "greatadventure",
|
||||||
|
apiId: 905,
|
||||||
name: "Six Flags Great Adventure",
|
name: "Six Flags Great Adventure",
|
||||||
shortName: "Great Adventure",
|
shortName: "Great Adventure",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -22,6 +23,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "magicmountain",
|
id: "magicmountain",
|
||||||
|
apiId: 906,
|
||||||
name: "Six Flags Magic Mountain",
|
name: "Six Flags Magic Mountain",
|
||||||
shortName: "Magic Mountain",
|
shortName: "Magic Mountain",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -33,6 +35,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "greatamerica",
|
id: "greatamerica",
|
||||||
|
apiId: 910,
|
||||||
name: "Six Flags Great America",
|
name: "Six Flags Great America",
|
||||||
shortName: "Great America",
|
shortName: "Great America",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -44,6 +47,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "overgeorgia",
|
id: "overgeorgia",
|
||||||
|
apiId: 902,
|
||||||
name: "Six Flags Over Georgia",
|
name: "Six Flags Over Georgia",
|
||||||
shortName: "Over Georgia",
|
shortName: "Over Georgia",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -55,6 +59,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "overtexas",
|
id: "overtexas",
|
||||||
|
apiId: 901,
|
||||||
name: "Six Flags Over Texas",
|
name: "Six Flags Over Texas",
|
||||||
shortName: "Over Texas",
|
shortName: "Over Texas",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -66,6 +71,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "stlouis",
|
id: "stlouis",
|
||||||
|
apiId: 903,
|
||||||
name: "Six Flags St. Louis",
|
name: "Six Flags St. Louis",
|
||||||
shortName: "St. Louis",
|
shortName: "St. Louis",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -77,6 +83,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fiestatexas",
|
id: "fiestatexas",
|
||||||
|
apiId: 914,
|
||||||
name: "Six Flags Fiesta Texas",
|
name: "Six Flags Fiesta Texas",
|
||||||
shortName: "Fiesta Texas",
|
shortName: "Fiesta Texas",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -88,6 +95,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "newengland",
|
id: "newengland",
|
||||||
|
apiId: 935,
|
||||||
name: "Six Flags New England",
|
name: "Six Flags New England",
|
||||||
shortName: "New England",
|
shortName: "New England",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -99,6 +107,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "discoverykingdom",
|
id: "discoverykingdom",
|
||||||
|
apiId: 936,
|
||||||
name: "Six Flags Discovery Kingdom",
|
name: "Six Flags Discovery Kingdom",
|
||||||
shortName: "Discovery Kingdom",
|
shortName: "Discovery Kingdom",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -110,6 +119,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mexico",
|
id: "mexico",
|
||||||
|
apiId: 960,
|
||||||
name: "Six Flags Mexico",
|
name: "Six Flags Mexico",
|
||||||
shortName: "Mexico",
|
shortName: "Mexico",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -121,6 +131,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "greatescape",
|
id: "greatescape",
|
||||||
|
apiId: 924,
|
||||||
name: "Six Flags Great Escape",
|
name: "Six Flags Great Escape",
|
||||||
shortName: "Great Escape",
|
shortName: "Great Escape",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -132,6 +143,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "darienlake",
|
id: "darienlake",
|
||||||
|
apiId: 945,
|
||||||
name: "Six Flags Darien Lake",
|
name: "Six Flags Darien Lake",
|
||||||
shortName: "Darien Lake",
|
shortName: "Darien Lake",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -144,6 +156,7 @@ export const PARKS: Park[] = [
|
|||||||
// ── Former Cedar Fair theme parks ─────────────────────────────────────────
|
// ── Former Cedar Fair theme parks ─────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: "cedarpoint",
|
id: "cedarpoint",
|
||||||
|
apiId: 1,
|
||||||
name: "Cedar Point",
|
name: "Cedar Point",
|
||||||
shortName: "Cedar Point",
|
shortName: "Cedar Point",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -155,6 +168,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "knotts",
|
id: "knotts",
|
||||||
|
apiId: 4,
|
||||||
name: "Knott's Berry Farm",
|
name: "Knott's Berry Farm",
|
||||||
shortName: "Knott's",
|
shortName: "Knott's",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -166,6 +180,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "canadaswonderland",
|
id: "canadaswonderland",
|
||||||
|
apiId: 40,
|
||||||
name: "Canada's Wonderland",
|
name: "Canada's Wonderland",
|
||||||
shortName: "Canada's Wonderland",
|
shortName: "Canada's Wonderland",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -177,6 +192,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "carowinds",
|
id: "carowinds",
|
||||||
|
apiId: 30,
|
||||||
name: "Carowinds",
|
name: "Carowinds",
|
||||||
shortName: "Carowinds",
|
shortName: "Carowinds",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -188,6 +204,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "kingsdominion",
|
id: "kingsdominion",
|
||||||
|
apiId: 25,
|
||||||
name: "Kings Dominion",
|
name: "Kings Dominion",
|
||||||
shortName: "Kings Dominion",
|
shortName: "Kings Dominion",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -199,6 +216,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "kingsisland",
|
id: "kingsisland",
|
||||||
|
apiId: 20,
|
||||||
name: "Kings Island",
|
name: "Kings Island",
|
||||||
shortName: "Kings Island",
|
shortName: "Kings Island",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -210,6 +228,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "valleyfair",
|
id: "valleyfair",
|
||||||
|
apiId: 14,
|
||||||
name: "Valleyfair",
|
name: "Valleyfair",
|
||||||
shortName: "Valleyfair",
|
shortName: "Valleyfair",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -221,6 +240,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "worldsoffun",
|
id: "worldsoffun",
|
||||||
|
apiId: 6,
|
||||||
name: "Worlds of Fun",
|
name: "Worlds of Fun",
|
||||||
shortName: "Worlds of Fun",
|
shortName: "Worlds of Fun",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -232,6 +252,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "miadventure",
|
id: "miadventure",
|
||||||
|
apiId: 12,
|
||||||
name: "Michigan's Adventure",
|
name: "Michigan's Adventure",
|
||||||
shortName: "Michigan's Adventure",
|
shortName: "Michigan's Adventure",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -243,6 +264,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dorneypark",
|
id: "dorneypark",
|
||||||
|
apiId: 8,
|
||||||
name: "Dorney Park",
|
name: "Dorney Park",
|
||||||
shortName: "Dorney Park",
|
shortName: "Dorney Park",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -254,6 +276,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cagreatamerica",
|
id: "cagreatamerica",
|
||||||
|
apiId: 35,
|
||||||
name: "California's Great America",
|
name: "California's Great America",
|
||||||
shortName: "CA Great America",
|
shortName: "CA Great America",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
@@ -265,6 +288,7 @@ export const PARKS: Park[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "frontiercity",
|
id: "frontiercity",
|
||||||
|
apiId: 943,
|
||||||
name: "Frontier City",
|
name: "Frontier City",
|
||||||
shortName: "Frontier City",
|
shortName: "Frontier City",
|
||||||
chain: "sixflags",
|
chain: "sixflags",
|
||||||
|
|||||||
@@ -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(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
|
|
||||||
.replace(/&[a-z]+;/gi, "");
|
|
||||||
}
|
|
||||||
@@ -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
|
* API: https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{apiId}?date=YYYYMM
|
||||||
* Returns full month data in one request — no browser needed.
|
* Returns full month data in one request.
|
||||||
*
|
|
||||||
* Each park has a numeric API ID that must be discovered first (see scripts/discover.ts).
|
|
||||||
* Once stored in the DB, this scraper never touches a browser again.
|
|
||||||
*
|
*
|
||||||
* Rate limiting: on 429/503, exponential backoff (30s → 60s → 120s), MAX_RETRIES attempts.
|
* 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.
|
* 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(
|
export async function scrapeMonth(
|
||||||
apiId: number,
|
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).
|
* Fetch park info for a given API ID. Uses the current month so there's always some data.
|
||||||
* Uses the current month so there's always some data.
|
|
||||||
*/
|
*/
|
||||||
export async function fetchParkInfo(
|
export async function fetchParkInfo(
|
||||||
apiId: number
|
apiId: number
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface Park {
|
export interface Park {
|
||||||
id: string;
|
id: string;
|
||||||
|
apiId: number;
|
||||||
name: string;
|
name: string;
|
||||||
shortName: string;
|
shortName: string;
|
||||||
chain: "sixflags" | string;
|
chain: "sixflags" | string;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"scrape": "tsx scripts/scrape.ts",
|
"scrape": "tsx scripts/scrape.ts",
|
||||||
"scrape:force": "tsx scripts/scrape.ts --rescrape",
|
"scrape:force": "tsx scripts/scrape.ts --rescrape",
|
||||||
"discover": "tsx scripts/discover.ts",
|
|
||||||
"debug": "tsx scripts/debug.ts",
|
"debug": "tsx scripts/debug.ts",
|
||||||
"test": "tsx --test tests/*.test.ts"
|
"test": "tsx --test tests/*.test.ts"
|
||||||
},
|
},
|
||||||
@@ -27,7 +26,6 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^15.3.0",
|
"eslint-config-next": "^15.3.0",
|
||||||
"playwright": "^1.59.1",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
+2
-13
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { openDb, getApiId } from "../lib/db";
|
|
||||||
import { PARKS } from "../lib/parks";
|
import { PARKS } from "../lib/parks";
|
||||||
import { scrapeMonthRaw } from "../lib/scrapers/sixflags";
|
import { scrapeMonthRaw } from "../lib/scrapers/sixflags";
|
||||||
|
|
||||||
@@ -52,16 +51,6 @@ async function main() {
|
|||||||
const month = parseInt(monthStr);
|
const month = parseInt(monthStr);
|
||||||
const day = parseInt(dayStr);
|
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 lines: string[] = [];
|
||||||
const out = (...args: string[]) => {
|
const out = (...args: string[]) => {
|
||||||
const line = args.join(" ");
|
const line = args.join(" ");
|
||||||
@@ -70,13 +59,13 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
out(`Park : ${park.name} (${park.id})`);
|
out(`Park : ${park.name} (${park.id})`);
|
||||||
out(`API ID : ${apiId}`);
|
out(`API ID : ${park.apiId}`);
|
||||||
out(`Date : ${dateStr}`);
|
out(`Date : ${dateStr}`);
|
||||||
out(`Fetched : ${new Date().toISOString()}`);
|
out(`Fetched : ${new Date().toISOString()}`);
|
||||||
out("");
|
out("");
|
||||||
out(`Fetching ${year}-${String(month).padStart(2, "0")} from API...`);
|
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 targetDate = `${String(month).padStart(2, "0")}/${String(day).padStart(2, "0")}/${year}`;
|
||||||
const dayData = raw.dates.find((d) => d.date === targetDate);
|
const dayData = raw.dates.find((d) => d.date === targetDate);
|
||||||
|
|||||||
@@ -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
@@ -1,17 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Scrape job — fetches 2026 operating hours for all parks from the Six Flags API.
|
* 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 72h
|
||||||
*
|
|
||||||
* npm run scrape — skips months scraped within the last 7 days
|
|
||||||
* npm run scrape:force — re-scrapes everything
|
* 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 { PARKS } from "../lib/parks";
|
||||||
import { scrapeMonth, fetchToday, RateLimitError } from "../lib/scrapers/sixflags";
|
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 YEAR = 2026;
|
||||||
const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
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() {
|
async function main() {
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
|
|
||||||
const ready = PARKS.filter((p) => getApiId(db, p.id) !== null);
|
console.log(`Scraping ${YEAR} — ${PARKS.length} parks\n`);
|
||||||
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`);
|
|
||||||
|
|
||||||
let totalFetched = 0;
|
let totalFetched = 0;
|
||||||
let totalSkipped = 0;
|
let totalSkipped = 0;
|
||||||
let totalErrors = 0;
|
let totalErrors = 0;
|
||||||
|
|
||||||
for (const park of ready) {
|
for (const park of PARKS) {
|
||||||
const apiId = getApiId(db, park.id)!;
|
|
||||||
const label = park.shortName.padEnd(22);
|
const label = park.shortName.padEnd(22);
|
||||||
|
|
||||||
let openDays = 0;
|
let openDays = 0;
|
||||||
@@ -65,7 +45,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const days = await scrapeMonth(apiId, YEAR, month);
|
const days = await scrapeMonth(park.apiId, YEAR, month);
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const d of days) upsertDay(db, park.id, d.date, d.isOpen, d.hoursLabel, d.specialType);
|
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) ────
|
// ── Today scrape (always fresh — dateless endpoint returns current day) ────
|
||||||
console.log("\n── Today's data ──");
|
console.log("\n── Today's data ──");
|
||||||
for (const park of ready) {
|
for (const park of PARKS) {
|
||||||
const apiId = getApiId(db, park.id)!;
|
|
||||||
process.stdout.write(` ${park.shortName.padEnd(22)} `);
|
process.stdout.write(` ${park.shortName.padEnd(22)} `);
|
||||||
try {
|
try {
|
||||||
const today = await fetchToday(apiId);
|
const today = await fetchToday(park.apiId);
|
||||||
if (today) {
|
if (today) {
|
||||||
upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType);
|
upsertDay(db, park.id, today.date, today.isOpen, today.hoursLabel, today.specialType);
|
||||||
console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed");
|
console.log(today.isOpen ? `open ${today.hoursLabel ?? ""}` : "closed");
|
||||||
@@ -120,42 +99,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.close();
|
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) => {
|
main().catch((err) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user