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:
2026-04-04 00:48:09 -04:00
parent af6aa29474
commit 548c7ae09e
26 changed files with 9602 additions and 0 deletions

218
lib/db.ts Normal file
View 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
View 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
View 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
View 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>;
}