feat: initial project scaffold with CI/CD and Docker deployment
Next.js 15 + Tailwind CSS v4 week calendar showing Six Flags park hours. Scrapes the internal CloudFront API, stores results in SQLite. Includes Dockerfile (Debian/Playwright-compatible), docker-compose, and Gitea Actions pipeline that builds and pushes to the container registry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
218
lib/db.ts
Normal file
218
lib/db.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const DB_PATH = path.join(DATA_DIR, "parks.db");
|
||||
|
||||
export type DbInstance = Database.Database;
|
||||
|
||||
export function openDb(): Database.Database {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS park_days (
|
||||
park_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
is_open INTEGER NOT NULL DEFAULT 0,
|
||||
hours_label TEXT,
|
||||
scraped_at TEXT NOT NULL,
|
||||
PRIMARY KEY (park_id, date)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS park_api_ids (
|
||||
park_id TEXT PRIMARY KEY,
|
||||
api_id INTEGER NOT NULL,
|
||||
api_abbreviation TEXT,
|
||||
api_name TEXT,
|
||||
discovered_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function upsertDay(
|
||||
db: Database.Database,
|
||||
parkId: string,
|
||||
date: string,
|
||||
isOpen: boolean,
|
||||
hoursLabel?: string
|
||||
) {
|
||||
db.prepare(`
|
||||
INSERT INTO park_days (park_id, date, is_open, hours_label, scraped_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (park_id, date) DO UPDATE SET
|
||||
is_open = excluded.is_open,
|
||||
hours_label = excluded.hours_label,
|
||||
scraped_at = excluded.scraped_at
|
||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, new Date().toISOString());
|
||||
}
|
||||
|
||||
export interface DayData {
|
||||
isOpen: boolean;
|
||||
hoursLabel: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns scraped data for all parks across a date range.
|
||||
* Shape: { parkId: { 'YYYY-MM-DD': DayData } }
|
||||
* Missing dates mean that date hasn't been scraped yet (not necessarily closed).
|
||||
*/
|
||||
export function getDateRange(
|
||||
db: Database.Database,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Record<string, Record<string, DayData>> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open, hours_label
|
||||
FROM park_days
|
||||
WHERE date >= ? AND date <= ?`
|
||||
)
|
||||
.all(startDate, endDate) as {
|
||||
park_id: string;
|
||||
date: string;
|
||||
is_open: number;
|
||||
hours_label: string | null;
|
||||
}[];
|
||||
|
||||
const result: Record<string, Record<string, DayData>> = {};
|
||||
for (const row of rows) {
|
||||
if (!result[row.park_id]) result[row.park_id] = {};
|
||||
result[row.park_id][row.date] = {
|
||||
isOpen: row.is_open === 1,
|
||||
hoursLabel: row.hours_label,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns a map of parkId → boolean[] (index 0 = day 1) for a given month. */
|
||||
export function getMonthCalendar(
|
||||
db: Database.Database,
|
||||
year: number,
|
||||
month: number
|
||||
): Record<string, boolean[]> {
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open
|
||||
FROM park_days
|
||||
WHERE date LIKE ? || '-%'
|
||||
ORDER BY date`
|
||||
)
|
||||
.all(prefix) as { park_id: string; date: string; is_open: number }[];
|
||||
|
||||
const result: Record<string, boolean[]> = {};
|
||||
for (const row of rows) {
|
||||
if (!result[row.park_id]) result[row.park_id] = [];
|
||||
const day = parseInt(row.date.slice(8), 10);
|
||||
result[row.park_id][day - 1] = row.is_open === 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** True if the DB already has at least one row for this park+month. */
|
||||
const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
|
||||
|
||||
/** True if the DB has data for this park+month scraped within the last week. */
|
||||
export function isMonthScraped(
|
||||
db: Database.Database,
|
||||
parkId: string,
|
||||
year: number,
|
||||
month: number
|
||||
): boolean {
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT MAX(scraped_at) AS last_scraped
|
||||
FROM park_days
|
||||
WHERE park_id = ? AND date LIKE ? || '-%'`
|
||||
)
|
||||
.get(parkId, prefix) as { last_scraped: string | null };
|
||||
|
||||
if (!row.last_scraped) return false;
|
||||
const ageMs = Date.now() - new Date(row.last_scraped).getTime();
|
||||
return ageMs < STALE_AFTER_MS;
|
||||
}
|
||||
|
||||
export function getApiId(db: Database.Database, parkId: string): number | null {
|
||||
const row = db
|
||||
.prepare("SELECT api_id FROM park_api_ids WHERE park_id = ?")
|
||||
.get(parkId) as { api_id: number } | undefined;
|
||||
return row?.api_id ?? null;
|
||||
}
|
||||
|
||||
export function setApiId(
|
||||
db: Database.Database,
|
||||
parkId: string,
|
||||
apiId: number,
|
||||
apiAbbreviation?: string,
|
||||
apiName?: string
|
||||
) {
|
||||
db.prepare(`
|
||||
INSERT INTO park_api_ids (park_id, api_id, api_abbreviation, api_name, discovered_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (park_id) DO UPDATE SET
|
||||
api_id = excluded.api_id,
|
||||
api_abbreviation = excluded.api_abbreviation,
|
||||
api_name = excluded.api_name,
|
||||
discovered_at = excluded.discovered_at
|
||||
`).run(
|
||||
parkId,
|
||||
apiId,
|
||||
apiAbbreviation ?? null,
|
||||
apiName ?? null,
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next park+month to scrape.
|
||||
* Priority: never-scraped first, then oldest scraped_at.
|
||||
* Considers current month through monthsAhead months into the future.
|
||||
*/
|
||||
export function getNextScrapeTarget(
|
||||
db: Database.Database,
|
||||
parkIds: string[],
|
||||
monthsAhead = 12
|
||||
): { parkId: string; year: number; month: number } | null {
|
||||
const now = new Date();
|
||||
|
||||
const candidates: {
|
||||
parkId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
lastScraped: string | null;
|
||||
}[] = [];
|
||||
|
||||
for (const parkId of parkIds) {
|
||||
for (let i = 0; i < monthsAhead; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||
const year = d.getFullYear();
|
||||
const month = d.getMonth() + 1;
|
||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT MAX(scraped_at) AS last_scraped
|
||||
FROM park_days
|
||||
WHERE park_id = ? AND date LIKE ? || '-%'`
|
||||
)
|
||||
.get(parkId, prefix) as { last_scraped: string | null };
|
||||
|
||||
candidates.push({ parkId, year, month, lastScraped: row.last_scraped });
|
||||
}
|
||||
}
|
||||
|
||||
// Never-scraped (null) first, then oldest scraped_at
|
||||
candidates.sort((a, b) => {
|
||||
if (!a.lastScraped && !b.lastScraped) return 0;
|
||||
if (!a.lastScraped) return -1;
|
||||
if (!b.lastScraped) return 1;
|
||||
return a.lastScraped.localeCompare(b.lastScraped);
|
||||
});
|
||||
|
||||
const top = candidates[0];
|
||||
return top ? { parkId: top.parkId, year: top.year, month: top.month } : null;
|
||||
}
|
||||
255
lib/parks.ts
Normal file
255
lib/parks.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import type { Park } from "./scrapers/types";
|
||||
|
||||
/**
|
||||
* All parks listed on https://www.sixflags.com/select-park
|
||||
* Slugs verified from that page — used in:
|
||||
* https://www.sixflags.com/{slug}/park-hours?date=YYYY-MM-DD
|
||||
*
|
||||
* Includes former Cedar Fair parks now under the Six Flags Entertainment Group umbrella.
|
||||
*/
|
||||
export const PARKS: Park[] = [
|
||||
// ── Six Flags branded parks ──────────────────────────────────────────────
|
||||
{
|
||||
id: "greatadventure",
|
||||
name: "Six Flags Great Adventure",
|
||||
shortName: "Great Adventure",
|
||||
chain: "sixflags",
|
||||
slug: "greatadventure",
|
||||
location: { lat: 40.1376, lng: -74.4388, city: "Jackson", state: "NJ" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "magicmountain",
|
||||
name: "Six Flags Magic Mountain",
|
||||
shortName: "Magic Mountain",
|
||||
chain: "sixflags",
|
||||
slug: "magicmountain",
|
||||
location: { lat: 34.4252, lng: -118.5973, city: "Valencia", state: "CA" },
|
||||
timezone: "America/Los_Angeles",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "greatamerica",
|
||||
name: "Six Flags Great America",
|
||||
shortName: "Great America",
|
||||
chain: "sixflags",
|
||||
slug: "greatamerica",
|
||||
location: { lat: 42.3702, lng: -87.9358, city: "Gurnee", state: "IL" },
|
||||
timezone: "America/Chicago",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "overgeorgia",
|
||||
name: "Six Flags Over Georgia",
|
||||
shortName: "Over Georgia",
|
||||
chain: "sixflags",
|
||||
slug: "overgeorgia",
|
||||
location: { lat: 33.7718, lng: -84.5494, city: "Austell", state: "GA" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "overtexas",
|
||||
name: "Six Flags Over Texas",
|
||||
shortName: "Over Texas",
|
||||
chain: "sixflags",
|
||||
slug: "overtexas",
|
||||
location: { lat: 32.7554, lng: -97.0639, city: "Arlington", state: "TX" },
|
||||
timezone: "America/Chicago",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "stlouis",
|
||||
name: "Six Flags St. Louis",
|
||||
shortName: "St. Louis",
|
||||
chain: "sixflags",
|
||||
slug: "stlouis",
|
||||
location: { lat: 38.5153, lng: -90.6751, city: "Eureka", state: "MO" },
|
||||
timezone: "America/Chicago",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "fiestatexas",
|
||||
name: "Six Flags Fiesta Texas",
|
||||
shortName: "Fiesta Texas",
|
||||
chain: "sixflags",
|
||||
slug: "fiestatexas",
|
||||
location: { lat: 29.6054, lng: -98.622, city: "San Antonio", state: "TX" },
|
||||
timezone: "America/Chicago",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "newengland",
|
||||
name: "Six Flags New England",
|
||||
shortName: "New England",
|
||||
chain: "sixflags",
|
||||
slug: "newengland",
|
||||
location: { lat: 42.037, lng: -72.6151, city: "Agawam", state: "MA" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "discoverykingdom",
|
||||
name: "Six Flags Discovery Kingdom",
|
||||
shortName: "Discovery Kingdom",
|
||||
chain: "sixflags",
|
||||
slug: "discoverykingdom",
|
||||
location: { lat: 38.136, lng: -122.2314, city: "Vallejo", state: "CA" },
|
||||
timezone: "America/Los_Angeles",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "mexico",
|
||||
name: "Six Flags Mexico",
|
||||
shortName: "Mexico",
|
||||
chain: "sixflags",
|
||||
slug: "mexico",
|
||||
location: { lat: 19.2982, lng: -99.2146, city: "Mexico City", state: "Mexico" },
|
||||
timezone: "America/Mexico_City",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "greatescape",
|
||||
name: "Six Flags Great Escape",
|
||||
shortName: "Great Escape",
|
||||
chain: "sixflags",
|
||||
slug: "greatescape",
|
||||
location: { lat: 43.3537, lng: -73.6776, city: "Queensbury", state: "NY" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "darienlake",
|
||||
name: "Six Flags Darien Lake",
|
||||
shortName: "Darien Lake",
|
||||
chain: "sixflags",
|
||||
slug: "darienlake",
|
||||
location: { lat: 42.9915, lng: -78.3895, city: "Darien Center", state: "NY" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
// ── Former Cedar Fair theme parks ─────────────────────────────────────────
|
||||
{
|
||||
id: "cedarpoint",
|
||||
name: "Cedar Point",
|
||||
shortName: "Cedar Point",
|
||||
chain: "sixflags",
|
||||
slug: "cedarpoint",
|
||||
location: { lat: 41.4784, lng: -82.6832, city: "Sandusky", state: "OH" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "knotts",
|
||||
name: "Knott's Berry Farm",
|
||||
shortName: "Knott's",
|
||||
chain: "sixflags",
|
||||
slug: "knotts",
|
||||
location: { lat: 33.8442, lng: -117.9989, city: "Buena Park", state: "CA" },
|
||||
timezone: "America/Los_Angeles",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "canadaswonderland",
|
||||
name: "Canada's Wonderland",
|
||||
shortName: "Canada's Wonderland",
|
||||
chain: "sixflags",
|
||||
slug: "canadaswonderland",
|
||||
location: { lat: 43.8426, lng: -79.5396, city: "Vaughan", state: "ON" },
|
||||
timezone: "America/Toronto",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "carowinds",
|
||||
name: "Carowinds",
|
||||
shortName: "Carowinds",
|
||||
chain: "sixflags",
|
||||
slug: "carowinds",
|
||||
location: { lat: 35.1043, lng: -80.9394, city: "Charlotte", state: "NC" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "kingsdominion",
|
||||
name: "Kings Dominion",
|
||||
shortName: "Kings Dominion",
|
||||
chain: "sixflags",
|
||||
slug: "kingsdominion",
|
||||
location: { lat: 37.8357, lng: -77.4463, city: "Doswell", state: "VA" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "kingsisland",
|
||||
name: "Kings Island",
|
||||
shortName: "Kings Island",
|
||||
chain: "sixflags",
|
||||
slug: "kingsisland",
|
||||
location: { lat: 39.3442, lng: -84.2696, city: "Mason", state: "OH" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "valleyfair",
|
||||
name: "Valleyfair",
|
||||
shortName: "Valleyfair",
|
||||
chain: "sixflags",
|
||||
slug: "valleyfair",
|
||||
location: { lat: 44.7227, lng: -93.4691, city: "Shakopee", state: "MN" },
|
||||
timezone: "America/Chicago",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "worldsoffun",
|
||||
name: "Worlds of Fun",
|
||||
shortName: "Worlds of Fun",
|
||||
chain: "sixflags",
|
||||
slug: "worldsoffun",
|
||||
location: { lat: 39.1947, lng: -94.5194, city: "Kansas City", state: "MO" },
|
||||
timezone: "America/Chicago",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "miadventure",
|
||||
name: "Michigan's Adventure",
|
||||
shortName: "Michigan's Adventure",
|
||||
chain: "sixflags",
|
||||
slug: "miadventure",
|
||||
location: { lat: 43.3281, lng: -86.2694, city: "Muskegon", state: "MI" },
|
||||
timezone: "America/Detroit",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "dorneypark",
|
||||
name: "Dorney Park",
|
||||
shortName: "Dorney Park",
|
||||
chain: "sixflags",
|
||||
slug: "dorneypark",
|
||||
location: { lat: 40.5649, lng: -75.6063, city: "Allentown", state: "PA" },
|
||||
timezone: "America/New_York",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "cagreatamerica",
|
||||
name: "California's Great America",
|
||||
shortName: "CA Great America",
|
||||
chain: "sixflags",
|
||||
slug: "cagreatamerica",
|
||||
location: { lat: 37.3979, lng: -121.9751, city: "Santa Clara", state: "CA" },
|
||||
timezone: "America/Los_Angeles",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
{
|
||||
id: "frontiercity",
|
||||
name: "Frontier City",
|
||||
shortName: "Frontier City",
|
||||
chain: "sixflags",
|
||||
slug: "frontiercity",
|
||||
location: { lat: 35.5739, lng: -97.4731, city: "Oklahoma City", state: "OK" },
|
||||
timezone: "America/Chicago",
|
||||
website: "https://www.sixflags.com",
|
||||
},
|
||||
];
|
||||
|
||||
export const PARK_MAP = new Map<string, Park>(PARKS.map((p) => [p.id, p]));
|
||||
167
lib/scrapers/sixflags.ts
Normal file
167
lib/scrapers/sixflags.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Six Flags scraper — calls the internal CloudFront operating-hours API directly.
|
||||
*
|
||||
* API: https://d18car1k0ff81h.cloudfront.net/operating-hours/park/{apiId}?date=YYYYMM
|
||||
* Returns full month data in one request — no browser needed.
|
||||
*
|
||||
* Each park has a numeric API ID that must be discovered first (see scripts/discover.ts).
|
||||
* Once stored in the DB, this scraper never touches a browser again.
|
||||
*
|
||||
* Rate limiting: on 429/503, exponential backoff (30s → 60s → 120s), MAX_RETRIES attempts.
|
||||
*/
|
||||
|
||||
const API_BASE = "https://d18car1k0ff81h.cloudfront.net/operating-hours/park";
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_BACKOFF_MS = 30_000;
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
constructor(public readonly waitedMs: number) {
|
||||
super(`Rate limited — exhausted ${MAX_RETRIES} retries after ${waitedMs / 1000}s total wait`);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
const HEADERS = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
Accept: "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
Referer: "https://www.sixflags.com/",
|
||||
};
|
||||
|
||||
export interface DayResult {
|
||||
date: string; // YYYY-MM-DD
|
||||
isOpen: boolean;
|
||||
hoursLabel?: string;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** "04/05/2026" → "2026-04-05" */
|
||||
function parseApiDate(d: string): string {
|
||||
const [m, day, y] = d.split("/");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
interface ApiOperatingItem {
|
||||
timeFrom: string; // "10:30" 24h
|
||||
timeTo: string; // "20:00" 24h
|
||||
}
|
||||
|
||||
interface ApiOperating {
|
||||
operatingTypeName: string; // "Park", "Special Event", etc.
|
||||
items: ApiOperatingItem[];
|
||||
}
|
||||
|
||||
interface ApiDay {
|
||||
date: string;
|
||||
isParkClosed: boolean;
|
||||
operatings?: ApiOperating[];
|
||||
}
|
||||
|
||||
/** "10:30" → "10:30am", "20:00" → "8pm", "12:00" → "12pm" */
|
||||
function fmt24(time: string): string {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
const period = h >= 12 ? "pm" : "am";
|
||||
const h12 = h % 12 || 12;
|
||||
return m === 0 ? `${h12}${period}` : `${h12}:${String(m).padStart(2, "0")}${period}`;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
parkId: number;
|
||||
parkAbbreviation: string;
|
||||
parkName: string;
|
||||
dates: ApiDay[];
|
||||
}
|
||||
|
||||
async function fetchApi(url: string, attempt = 0, totalWaitedMs = 0): Promise<ApiResponse> {
|
||||
const res = await fetch(url, { headers: HEADERS });
|
||||
|
||||
if (res.status === 429 || res.status === 503) {
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
const waitMs = retryAfter
|
||||
? parseInt(retryAfter) * 1000
|
||||
: BASE_BACKOFF_MS * Math.pow(2, attempt);
|
||||
console.log(
|
||||
` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`
|
||||
);
|
||||
await sleep(waitMs);
|
||||
if (attempt < MAX_RETRIES) return fetchApi(url, attempt + 1, totalWaitedMs + waitMs);
|
||||
throw new RateLimitError(totalWaitedMs + waitMs);
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
return res.json() as Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch operating hours for an entire month in a single API call.
|
||||
* apiId must be pre-discovered via scripts/discover.ts.
|
||||
*/
|
||||
export async function scrapeMonth(
|
||||
apiId: number,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<DayResult[]> {
|
||||
const dateParam = `${year}${String(month).padStart(2, "0")}`;
|
||||
const url = `${API_BASE}/${apiId}?date=${dateParam}`;
|
||||
|
||||
const data = await fetchApi(url);
|
||||
|
||||
return data.dates.map((d): DayResult => {
|
||||
const date = parseApiDate(d.date);
|
||||
// Prefer the "Park" operating entry; fall back to first entry
|
||||
const operating =
|
||||
d.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
||||
d.operatings?.[0];
|
||||
const item = operating?.items?.[0];
|
||||
const hoursLabel =
|
||||
item?.timeFrom && item?.timeTo
|
||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||
: undefined;
|
||||
// If the API says open but no hours are available, treat as closed
|
||||
const isOpen = !d.isParkClosed && hoursLabel !== undefined;
|
||||
return { date, isOpen, hoursLabel };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch park info for a given API ID (used during discovery to identify park type).
|
||||
* Uses the current month so there's always some data.
|
||||
*/
|
||||
export async function fetchParkInfo(
|
||||
apiId: number
|
||||
): Promise<Pick<ApiResponse, "parkId" | "parkAbbreviation" | "parkName"> | null> {
|
||||
const now = new Date();
|
||||
const dateParam = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
const url = `${API_BASE}/${apiId}?date=${dateParam}`;
|
||||
try {
|
||||
const data = await fetchApi(url);
|
||||
return {
|
||||
parkId: data.parkId,
|
||||
parkAbbreviation: data.parkAbbreviation,
|
||||
parkName: data.parkName,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the API park name looks like a main theme park (not a water park or safari). */
|
||||
export function isMainThemePark(parkName: string): boolean {
|
||||
const lower = parkName.toLowerCase();
|
||||
const waterParkKeywords = [
|
||||
"hurricane harbor",
|
||||
"safari",
|
||||
"water park",
|
||||
"waterpark",
|
||||
"schlitterbahn",
|
||||
"wave pool",
|
||||
"splash",
|
||||
"aquatic",
|
||||
];
|
||||
return !waterParkKeywords.some((kw) => lower.includes(kw));
|
||||
}
|
||||
37
lib/scrapers/types.ts
Normal file
37
lib/scrapers/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface Park {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
chain: "sixflags" | string;
|
||||
slug: string;
|
||||
location: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
city: string;
|
||||
state: string;
|
||||
};
|
||||
timezone: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
export interface DayStatus {
|
||||
day: number;
|
||||
isOpen: boolean;
|
||||
hoursLabel?: string;
|
||||
}
|
||||
|
||||
export interface MonthCalendar {
|
||||
parkId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
days: DayStatus[];
|
||||
}
|
||||
|
||||
export interface ScraperAdapter {
|
||||
readonly chain: string;
|
||||
getMonthCalendar(
|
||||
park: Park,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<MonthCalendar>;
|
||||
}
|
||||
Reference in New Issue
Block a user