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:
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