feat: add Hono backend API server with tiered scheduler
Standalone Node.js backend that owns the SQLite database and serves composed data via REST endpoints. Replaces the shell-scheduled scraper with in-process node-cron tiered scheduling. Backend structure: - Hono HTTP server on port 3001 with CORS and request logging - Singleton SQLite connection with WAL mode - In-memory TTL cache for Queue-Times and fetchToday responses - Comparison check on fetchToday (read-before-write, only upserts on change) API endpoints: - GET /api/calendar/week — week schedule + live ride counts for all parks - GET /api/calendar/:parkId/month — month calendar for one park - GET /api/parks — park list with metadata - GET /api/parks/:id — single park detail - GET /api/parks/:id/rides — live rides with Queue-Times/schedule fallback - GET /api/status — health check, scrape stats - POST /api/scrape/trigger — manual scrape (scope: today/month/upcoming/full) Scheduler tiers: - Tier 1: today — hourly (Mar-Dec) - Tier 2: current month — every 6 hours - Tier 3: upcoming — twice daily (3 AM + 3 PM) - Tier 4: full year — daily at 3 AM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/backend/node_modules
|
||||||
|
/backend/dist
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
.yarn/*
|
||||||
|
|||||||
Generated
+1109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "sixflags-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^2.0.0",
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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");
|
||||||
|
|
||||||
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (_db) return _db;
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
_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,
|
||||||
|
is_open INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hours_label TEXT,
|
||||||
|
special_type TEXT,
|
||||||
|
scraped_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (park_id, date)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
try {
|
||||||
|
_db.exec(`ALTER TABLE park_days ADD COLUMN special_type TEXT`);
|
||||||
|
} catch {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
return _db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDb(): void {
|
||||||
|
if (_db) {
|
||||||
|
_db.close();
|
||||||
|
_db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type Database from "better-sqlite3";
|
||||||
|
import { getDb } from "./index";
|
||||||
|
|
||||||
|
export interface DayData {
|
||||||
|
isOpen: boolean;
|
||||||
|
hoursLabel: string | null;
|
||||||
|
specialType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayRow {
|
||||||
|
park_id: string;
|
||||||
|
date: string;
|
||||||
|
is_open: number;
|
||||||
|
hours_label: string | null;
|
||||||
|
special_type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToDayData(row: DayRow): DayData {
|
||||||
|
return {
|
||||||
|
isOpen: row.is_open === 1,
|
||||||
|
hoursLabel: row.hours_label,
|
||||||
|
specialType: row.special_type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertDay(
|
||||||
|
parkId: string,
|
||||||
|
date: string,
|
||||||
|
isOpen: boolean,
|
||||||
|
hoursLabel?: string,
|
||||||
|
specialType?: string,
|
||||||
|
): void {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (park_id, date) DO UPDATE SET
|
||||||
|
is_open = excluded.is_open,
|
||||||
|
hours_label = excluded.hours_label,
|
||||||
|
special_type = excluded.special_type,
|
||||||
|
scraped_at = excluded.scraped_at
|
||||||
|
WHERE park_days.date >= date('now')`,
|
||||||
|
)
|
||||||
|
.run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDateRange(
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
): Record<string, Record<string, DayData>> {
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT park_id, date, is_open, hours_label, special_type
|
||||||
|
FROM park_days
|
||||||
|
WHERE date >= ? AND date <= ?`,
|
||||||
|
)
|
||||||
|
.all(startDate, endDate) as DayRow[];
|
||||||
|
|
||||||
|
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] = rowToDayData(row);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParkMonthData(
|
||||||
|
parkId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
): Record<string, DayData> {
|
||||||
|
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT park_id, date, is_open, hours_label, special_type
|
||||||
|
FROM park_days
|
||||||
|
WHERE park_id = ? AND date LIKE ? || '-%'
|
||||||
|
ORDER BY date`,
|
||||||
|
)
|
||||||
|
.all(parkId, prefix) as DayRow[];
|
||||||
|
|
||||||
|
const result: Record<string, DayData> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.date] = rowToDayData(row);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthCalendar(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
): Record<string, boolean[]> {
|
||||||
|
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
const rows = getDb()
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayData(parkId: string, date: string): DayData | null {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT park_id, date, is_open, hours_label, special_type
|
||||||
|
FROM park_days
|
||||||
|
WHERE park_id = ? AND date = ?`,
|
||||||
|
)
|
||||||
|
.get(parkId, date) as DayRow | undefined;
|
||||||
|
|
||||||
|
return row ? rowToDayData(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMonthScraped(
|
||||||
|
parkId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
staleAfterMs: number,
|
||||||
|
): boolean {
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
const lastDay = `${year}-${String(month).padStart(2, "0")}-${String(daysInMonth).padStart(2, "0")}`;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (lastDay < today) return true;
|
||||||
|
|
||||||
|
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
const row = getDb()
|
||||||
|
.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;
|
||||||
|
return Date.now() - new Date(row.last_scraped).getTime() < staleAfterMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastScrapeTime(): string | null {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare(`SELECT MAX(scraped_at) AS last_scraped FROM park_days`)
|
||||||
|
.get() as { last_scraped: string | null };
|
||||||
|
return row.last_scraped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParkDayCount(): number {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare(`SELECT COUNT(*) AS count FROM park_days`)
|
||||||
|
.get() as { count: number };
|
||||||
|
return row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transact(fn: () => void): void {
|
||||||
|
getDb().transaction(fn)();
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { logger } from "hono/logger";
|
||||||
|
|
||||||
|
import { getDb } from "./db/index";
|
||||||
|
import { startScheduler } from "./services/scheduler";
|
||||||
|
|
||||||
|
import calendarRoutes from "./routes/calendar";
|
||||||
|
import parksRoutes from "./routes/parks";
|
||||||
|
import ridesRoutes from "./routes/rides";
|
||||||
|
import statusRoutes from "./routes/status";
|
||||||
|
import scrapeRoutes from "./routes/scrape";
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT ?? "3001", 10);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.use("*", logger());
|
||||||
|
app.use("*", cors());
|
||||||
|
|
||||||
|
app.route("/api/calendar", calendarRoutes);
|
||||||
|
app.route("/api/parks", parksRoutes);
|
||||||
|
app.route("/api/parks", ridesRoutes);
|
||||||
|
app.route("/api/status", statusRoutes);
|
||||||
|
app.route("/api/scrape", scrapeRoutes);
|
||||||
|
|
||||||
|
// Initialize database on startup
|
||||||
|
getDb();
|
||||||
|
console.log("[backend] database initialized");
|
||||||
|
|
||||||
|
// Start cron scheduler
|
||||||
|
startScheduler();
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
||||||
|
console.log(`[backend] listening on http://localhost:${info.port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { PARKS } from "../../../lib/parks";
|
||||||
|
import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map";
|
||||||
|
import { getCoasterSet } from "../../../lib/coaster-data";
|
||||||
|
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "../../../lib/env";
|
||||||
|
import { fetchToday } from "../../../lib/scrapers/sixflags";
|
||||||
|
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
||||||
|
import { getDateRange, getParkMonthData, type DayData } from "../db/queries";
|
||||||
|
import { TtlCache } from "../services/cache";
|
||||||
|
|
||||||
|
const todayCache = new TtlCache<{ date: string; isOpen: boolean; hoursLabel?: string; specialType?: string } | null>(5 * 60 * 1000);
|
||||||
|
const ridesCache = new TtlCache<{ openRides: number; openCoasters: number } | null>(5 * 60 * 1000);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/week", async (c) => {
|
||||||
|
const startParam = c.req.query("start");
|
||||||
|
if (!startParam || !/^\d{4}-\d{2}-\d{2}$/.test(startParam)) {
|
||||||
|
return c.json({ error: "Missing or invalid ?start=YYYY-MM-DD" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekDates = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(startParam + "T00:00:00");
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
const endDate = weekDates[6];
|
||||||
|
const today = getTodayLocal();
|
||||||
|
|
||||||
|
const data = getDateRange(startParam, endDate);
|
||||||
|
|
||||||
|
// Merge live today data
|
||||||
|
if (weekDates.includes(today)) {
|
||||||
|
await Promise.all(
|
||||||
|
PARKS.map(async (p) => {
|
||||||
|
let live = todayCache.get(p.id);
|
||||||
|
if (live === null && !todayCache.get(p.id + "_checked")) {
|
||||||
|
live = await fetchToday(p.apiId).catch(() => null);
|
||||||
|
todayCache.set(p.id, live);
|
||||||
|
todayCache.set(p.id + "_checked", true as any);
|
||||||
|
}
|
||||||
|
if (!live) return;
|
||||||
|
if (!data[p.id]) data[p.id] = {};
|
||||||
|
data[p.id][today] = {
|
||||||
|
isOpen: live.isOpen,
|
||||||
|
hoursLabel: live.hoursLabel ?? null,
|
||||||
|
specialType: live.specialType ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWeekStart = (() => {
|
||||||
|
const d = new Date(today + "T00:00:00");
|
||||||
|
d.setDate(d.getDate() - d.getDay());
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
})();
|
||||||
|
const isCurrentWeek = startParam === currentWeekStart;
|
||||||
|
|
||||||
|
// Live status for today
|
||||||
|
let rideCounts: Record<string, number> = {};
|
||||||
|
let coasterCounts: Record<string, number> = {};
|
||||||
|
let openParkIds: string[] = [];
|
||||||
|
let closingParkIds: string[] = [];
|
||||||
|
let weatherDelayParkIds: string[] = [];
|
||||||
|
|
||||||
|
if (weekDates.includes(today)) {
|
||||||
|
const openTodayParks = PARKS.filter((p) => {
|
||||||
|
const dayData = data[p.id]?.[today];
|
||||||
|
if (!dayData?.isOpen || !dayData.hoursLabel) return false;
|
||||||
|
return isWithinOperatingWindow(dayData.hoursLabel, p.timezone);
|
||||||
|
});
|
||||||
|
openParkIds = openTodayParks.map((p) => p.id);
|
||||||
|
closingParkIds = openTodayParks
|
||||||
|
.filter((p) => {
|
||||||
|
const dayData = data[p.id]?.[today];
|
||||||
|
return dayData?.hoursLabel ? getOperatingStatus(dayData.hoursLabel, p.timezone) === "closing" : false;
|
||||||
|
})
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
|
const trackedParks = openTodayParks.filter((p) => QUEUE_TIMES_IDS[p.id]);
|
||||||
|
const results = await Promise.all(
|
||||||
|
trackedParks.map(async (p) => {
|
||||||
|
let cached = ridesCache.get(p.id);
|
||||||
|
if (cached === null) {
|
||||||
|
const coasterSet = getCoasterSet(p.id);
|
||||||
|
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet).catch(() => null);
|
||||||
|
cached = result
|
||||||
|
? {
|
||||||
|
openRides: result.rides.filter((r) => r.isOpen).length,
|
||||||
|
openCoasters: result.rides.filter((r) => r.isOpen && r.isCoaster).length,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
ridesCache.set(p.id, cached);
|
||||||
|
}
|
||||||
|
return { id: p.id, ...(cached ?? { openRides: 0, openCoasters: 0 }) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
weatherDelayParkIds = results.filter(({ openRides }) => openRides === 0).map(({ id }) => id);
|
||||||
|
rideCounts = Object.fromEntries(results.filter(({ openRides }) => openRides > 0).map(({ id, openRides }) => [id, openRides]));
|
||||||
|
coasterCounts = Object.fromEntries(results.filter(({ openCoasters }) => openCoasters > 0).map(({ id, openCoasters }) => [id, openCoasters]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrapedCount = Object.values(data).reduce((sum, parkData) => sum + Object.keys(parkData).length, 0);
|
||||||
|
|
||||||
|
c.header("Cache-Control", "public, max-age=120, stale-while-revalidate=300");
|
||||||
|
return c.json({
|
||||||
|
weekStart: startParam,
|
||||||
|
weekDates,
|
||||||
|
today,
|
||||||
|
isCurrentWeek,
|
||||||
|
data,
|
||||||
|
rideCounts,
|
||||||
|
coasterCounts,
|
||||||
|
openParkIds,
|
||||||
|
closingParkIds,
|
||||||
|
weatherDelayParkIds,
|
||||||
|
hasCoasterData: true,
|
||||||
|
scrapedCount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:parkId/month", async (c) => {
|
||||||
|
const parkId = c.req.param("parkId");
|
||||||
|
const monthParam = c.req.query("month");
|
||||||
|
|
||||||
|
if (!monthParam || !/^\d{4}-\d{2}$/.test(monthParam)) {
|
||||||
|
return c.json({ error: "Missing or invalid ?month=YYYY-MM" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [yearStr, monthStr] = monthParam.split("-");
|
||||||
|
const year = parseInt(yearStr);
|
||||||
|
const month = parseInt(monthStr);
|
||||||
|
|
||||||
|
if (month < 1 || month > 12) {
|
||||||
|
return c.json({ error: "Month must be 1-12" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthData = getParkMonthData(parkId, year, month);
|
||||||
|
const today = getTodayLocal();
|
||||||
|
|
||||||
|
// Merge live today if viewing current month
|
||||||
|
const park = PARKS.find((p) => p.id === parkId);
|
||||||
|
if (park) {
|
||||||
|
const liveToday = await fetchToday(park.apiId).catch(() => null);
|
||||||
|
if (liveToday) {
|
||||||
|
monthData[today] = {
|
||||||
|
isOpen: liveToday.isOpen,
|
||||||
|
hoursLabel: liveToday.hoursLabel ?? null,
|
||||||
|
specialType: liveToday.specialType ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.header("Cache-Control", "public, max-age=300, stale-while-revalidate=600");
|
||||||
|
return c.json({ parkId, year, month, monthData, today });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { PARKS, PARK_MAP } from "../../../lib/parks";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", (c) => {
|
||||||
|
c.header("Cache-Control", "public, max-age=3600");
|
||||||
|
return c.json({ parks: PARKS });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:id", (c) => {
|
||||||
|
const park = PARK_MAP.get(c.req.param("id"));
|
||||||
|
if (!park) return c.json({ error: "Park not found" }, 404);
|
||||||
|
|
||||||
|
c.header("Cache-Control", "public, max-age=3600");
|
||||||
|
return c.json(park);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { PARK_MAP } from "../../../lib/parks";
|
||||||
|
import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map";
|
||||||
|
import { getCoasterSet } from "../../../lib/coaster-data";
|
||||||
|
import { getTodayLocal, isWithinOperatingWindow } from "../../../lib/env";
|
||||||
|
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
||||||
|
import { scrapeRidesForDay } from "../../../lib/scrapers/sixflags";
|
||||||
|
import { getDayData } from "../db/queries";
|
||||||
|
import { TtlCache } from "../services/cache";
|
||||||
|
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
|
||||||
|
|
||||||
|
const liveRidesCache = new TtlCache<LiveRidesResult | null>(5 * 60 * 1000);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/:id/rides", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const park = PARK_MAP.get(id);
|
||||||
|
if (!park) return c.json({ error: "Park not found" }, 404);
|
||||||
|
|
||||||
|
const today = getTodayLocal();
|
||||||
|
const todayData = getDayData(id, today);
|
||||||
|
const withinWindow = todayData?.hoursLabel
|
||||||
|
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const queueTimesId = QUEUE_TIMES_IDS[id];
|
||||||
|
let liveRides: LiveRidesResult | null = null;
|
||||||
|
|
||||||
|
if (queueTimesId) {
|
||||||
|
liveRides = liveRidesCache.get(id);
|
||||||
|
if (liveRides === null) {
|
||||||
|
const coasterSet = getCoasterSet(id);
|
||||||
|
liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch(() => null);
|
||||||
|
if (liveRides) liveRidesCache.set(id, liveRides);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveRides && !withinWindow) {
|
||||||
|
liveRides = {
|
||||||
|
...liveRides,
|
||||||
|
rides: liveRides.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWeatherDelay =
|
||||||
|
withinWindow && liveRides !== null && liveRides.rides.length > 0 && liveRides.rides.every((r) => !r.isOpen);
|
||||||
|
|
||||||
|
let scheduleFallback = null;
|
||||||
|
if (!liveRides) {
|
||||||
|
scheduleFallback = await scrapeRidesForDay(park.apiId, today).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=120");
|
||||||
|
return c.json({
|
||||||
|
parkId: id,
|
||||||
|
today,
|
||||||
|
parkOpenToday: !!(todayData?.isOpen && todayData?.hoursLabel),
|
||||||
|
withinWindow,
|
||||||
|
isWeatherDelay,
|
||||||
|
liveRides,
|
||||||
|
scheduleFallback,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "../services/scraper";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.post("/trigger", async (c) => {
|
||||||
|
const scope = c.req.query("scope") ?? "today";
|
||||||
|
|
||||||
|
let result;
|
||||||
|
switch (scope) {
|
||||||
|
case "today":
|
||||||
|
result = await scrapeToday();
|
||||||
|
break;
|
||||||
|
case "month":
|
||||||
|
result = await scrapeCurrentMonth();
|
||||||
|
break;
|
||||||
|
case "upcoming":
|
||||||
|
result = await scrapeUpcomingMonths();
|
||||||
|
break;
|
||||||
|
case "full":
|
||||||
|
result = await scrapeFullYear();
|
||||||
|
break;
|
||||||
|
case "force":
|
||||||
|
result = await scrapeFullYear(true);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return c.json({ error: "Invalid scope. Use: today, month, upcoming, full, force" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { PARKS } from "../../../lib/parks";
|
||||||
|
import { getLastScrapeTime, getParkDayCount } from "../db/queries";
|
||||||
|
import { getLastScrapeResult } from "../services/scraper";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", (c) => {
|
||||||
|
return c.json({
|
||||||
|
status: "ok",
|
||||||
|
uptime: Math.floor(process.uptime()),
|
||||||
|
parks: PARKS.length,
|
||||||
|
database: {
|
||||||
|
totalDays: getParkDayCount(),
|
||||||
|
lastScrape: getLastScrapeTime(),
|
||||||
|
},
|
||||||
|
lastScrapeResult: getLastScrapeResult(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TtlCache<T> {
|
||||||
|
private store = new Map<string, CacheEntry<T>>();
|
||||||
|
|
||||||
|
constructor(private defaultTtlMs: number) {}
|
||||||
|
|
||||||
|
get(key: string): T | null {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.store.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, data: T, ttlMs?: number): void {
|
||||||
|
this.store.set(key, {
|
||||||
|
data,
|
||||||
|
expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.store.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper";
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
export function startScheduler(): void {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
// Tier 1: Today — every hour during operating season (Mar-Dec)
|
||||||
|
cron.schedule("0 * * 3-12 *", async () => {
|
||||||
|
console.log(`[scheduler] tier-1: scraping today @ ${new Date().toISOString()}`);
|
||||||
|
await scrapeToday().catch((err) => console.error("[scheduler] tier-1 error:", err));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 2: This week — every 6 hours, current month for all parks
|
||||||
|
cron.schedule("0 */6 * * *", async () => {
|
||||||
|
console.log(`[scheduler] tier-2: scraping current month @ ${new Date().toISOString()}`);
|
||||||
|
await scrapeCurrentMonth().catch((err) => console.error("[scheduler] tier-2 error:", err));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 3: Upcoming — twice daily (3 AM, 3 PM), current + next month
|
||||||
|
cron.schedule("0 3,15 * * *", async () => {
|
||||||
|
console.log(`[scheduler] tier-3: scraping upcoming months @ ${new Date().toISOString()}`);
|
||||||
|
await scrapeUpcomingMonths().catch((err) => console.error("[scheduler] tier-3 error:", err));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 4: Full season — once daily at 3 AM
|
||||||
|
cron.schedule("0 3 * * *", async () => {
|
||||||
|
console.log(`[scheduler] tier-4: scraping full year @ ${new Date().toISOString()}`);
|
||||||
|
await scrapeFullYear().catch((err) => console.error("[scheduler] tier-4 error:", err));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[scheduler] cron jobs registered");
|
||||||
|
console.log(" tier-1: today — hourly (Mar-Dec)");
|
||||||
|
console.log(" tier-2: current month — every 6h");
|
||||||
|
console.log(" tier-3: upcoming — 3 AM + 3 PM");
|
||||||
|
console.log(" tier-4: full year — 3 AM daily");
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { PARKS } from "../../../lib/parks";
|
||||||
|
import { scrapeMonth, fetchToday, RateLimitError } from "../../../lib/scrapers/sixflags";
|
||||||
|
import { upsertDay, isMonthScraped, getDayData, transact } from "../db/queries";
|
||||||
|
import { parseStalenessHours } from "../../../lib/env";
|
||||||
|
|
||||||
|
const DELAY_MS = 1000;
|
||||||
|
const STALE_AFTER_MS = parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72) * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrapeResult {
|
||||||
|
scope: string;
|
||||||
|
fetched: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: number;
|
||||||
|
updated: number;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastScrapeResult: ScrapeResult | null = null;
|
||||||
|
|
||||||
|
export function getLastScrapeResult(): ScrapeResult | null {
|
||||||
|
return lastScrapeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeToday(): Promise<ScrapeResult> {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
let fetched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const park of PARKS) {
|
||||||
|
try {
|
||||||
|
const live = await fetchToday(park.apiId);
|
||||||
|
if (!live) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fetched++;
|
||||||
|
|
||||||
|
const existing = getDayData(park.id, live.date);
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.isOpen === live.isOpen &&
|
||||||
|
existing.hoursLabel === (live.hoursLabel ?? null) &&
|
||||||
|
existing.specialType === (live.specialType ?? null)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertDay(park.id, live.date, live.isOpen, live.hoursLabel, live.specialType);
|
||||||
|
updated++;
|
||||||
|
console.log(`[today] ${park.shortName}: updated (${live.isOpen ? "open" : "closed"}${live.hoursLabel ? " " + live.hoursLabel : ""})`);
|
||||||
|
} catch {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ScrapeResult = {
|
||||||
|
scope: "today",
|
||||||
|
fetched,
|
||||||
|
skipped,
|
||||||
|
errors,
|
||||||
|
updated,
|
||||||
|
startedAt,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
lastScrapeResult = result;
|
||||||
|
console.log(`[today] done: ${fetched} fetched, ${updated} updated, ${skipped} skipped, ${errors} errors`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeMonths(monthList: { year: number; month: number }[], force = false): Promise<ScrapeResult> {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
let fetched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const park of PARKS) {
|
||||||
|
for (const { year, month } of monthList) {
|
||||||
|
if (!force && isMonthScraped(park.id, year, month, STALE_AFTER_MS)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const days = await scrapeMonth(park.apiId, year, month);
|
||||||
|
transact(() => {
|
||||||
|
for (const d of days) {
|
||||||
|
upsertDay(park.id, d.date, d.isOpen, d.hoursLabel, d.specialType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fetched++;
|
||||||
|
console.log(`[month] ${park.shortName} ${year}-${String(month).padStart(2, "0")}: ${days.filter((d) => d.isOpen).length} open days`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RateLimitError) {
|
||||||
|
console.log(`[month] ${park.shortName}: rate limited`);
|
||||||
|
} else {
|
||||||
|
console.error(`[month] ${park.shortName}: error — ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
await sleep(DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ScrapeResult = {
|
||||||
|
scope: `months(${monthList.map((m) => `${m.year}-${String(m.month).padStart(2, "0")}`).join(",")})`,
|
||||||
|
fetched,
|
||||||
|
skipped,
|
||||||
|
errors,
|
||||||
|
updated: fetched,
|
||||||
|
startedAt,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
lastScrapeResult = result;
|
||||||
|
console.log(`[month] done: ${fetched} fetched, ${skipped} skipped, ${errors} errors`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeCurrentMonth(): Promise<ScrapeResult> {
|
||||||
|
const now = new Date();
|
||||||
|
return scrapeMonths([{ year: now.getFullYear(), month: now.getMonth() + 1 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeUpcomingMonths(): Promise<ScrapeResult> {
|
||||||
|
const now = new Date();
|
||||||
|
const current = { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||||
|
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const nextMonth = { year: next.getFullYear(), month: next.getMonth() + 1 };
|
||||||
|
return scrapeMonths([current, nextMonth]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeFullYear(force = false): Promise<ScrapeResult> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const months = Array.from({ length: 12 }, (_, i) => ({ year, month: i + 1 }));
|
||||||
|
return scrapeMonths(months, force);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "..",
|
||||||
|
"baseUrl": "..",
|
||||||
|
"paths": {
|
||||||
|
"@lib/*": ["lib/*"]
|
||||||
|
},
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "../lib/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user