Horizon

Horizon

What's On

Cincinnati has a lot going on. History lectures at the Cincinnati Museum Center, archaeological tours through Fort Ancient, craft workshops at local makerspaces, film screenings, nature programs, WWII aviation days at the National Museum of the Air Force, earthworks tours at Hopewell Culture. The problem isn't a shortage of events. It's that they're scattered across dozens of individual websites, each with its own calendar format, update cadence, and degree of crawlability.

No single place aggregates them. The city's general event sites tend toward concerts and festivals. The niche stuff — the history talks, the guided walks, the lecture series at small museums — lives on individual institution websites and dies there.

Horizon is my attempt to fix that for myself.

How It Works

Every Sunday morning a GitHub Actions cron job kicks off the scraper. It works through a growing list of sources: history museums, nature centers, archaeological sites, art venues, community theaters, and local institutions across Cincinnati, Northern Kentucky, and the Dayton area.

Sources

The single biggest challenge is that every organization presents events differently. One site uses a full calendar widget. Another lists them as plain bullet points. Another publishes a separate page per event with no index. There's no standard. So rather than one generic extractor, the scraper has a shared framework with a custom module for each source — each one written specifically for how that site structures its data. Almost every module is meaningfully different from the last.

For each source, the appropriate module fetches the HTML. For sites that require JavaScript to render — which is most of them — it uses Playwright to get the fully rendered page first. That HTML goes to Gemini Flash, which extracts structured event data: title, date, time, location, categories, cost, and a short description. Gemini Flash was a deliberate choice: its large context window handles dense calendar pages cleanly, and the cost per extraction is low enough to run the full source list weekly without it adding up.

Extracted events are written to the database. A separate enrichment workflow runs afterward to fill in missing fields — coordinates, venue details, category tags — where the initial extraction left gaps.

Database Storage

Events are stored in a database and consolidated into a static manifest at build time. Each record carries a version tag from the scrape batch that created it.

Ask or Browse

The interface has two modes. Ask is a conversational prompt: type what you're in the mood for and Gemini Flash surfaces matching events from the current dataset. Browse is a dense, filterable list with date range, category, cost, and distance tier controls.

Ask what you want to do, or browse the full filtered event list

Architecture

The project is split into two packages: a scraper and a web frontend.

Scraper

The scraper is a TypeScript CLI (tsx) that runs on a weekly GitHub Actions schedule. Each source has a dedicated module that handles its specific page structure — some sources use a calendar widget, others a flat list, others individual event pages, and so on. Almost every module is written differently because almost every site works differently. A shared framework handles scheduling, deduplication, and database writes; the per-source modules handle extraction logic.

  • Static sites: fetch HTML directly with node-fetch
  • JS-rendered sites: launch a headless Chromium browser with Playwright, wait for the calendar to render, then extract the HTML
  • LLM extraction: the rendered HTML goes to Gemini Flash with a structured prompt and a Zod schema for the expected output. Gemini Flash's large context window handles dense calendar pages well, and the per-call cost is low enough to run the full source list weekly.
  • Deduplication: match on source_slug + date + title-slug; update changed fields, set status: "cancelled" for events that disappear
  • Enrichment: a separate post-scrape workflow fills in missing fields — coordinates, venue details, category tags — where the initial extraction left gaps

Each event is stored in the database following a strict schema: status, categories (controlled vocabulary), distance tier, cost, and coordinate data where available.

Frontend

The Next.js frontend reads events from the database at build time via a prebuild script, which consolidates them into a single static manifest. The app ships as a fully static site with no server queries at runtime.

The Ask tab uses the Google Gemini API (via /api/ask) to answer natural language questions against the current event dataset. The Browse tab is client-side filtering over the pre-built manifest — no API calls needed.

Data Freshness

The weekly GitHub Actions run tags each batch with a version (v2026-03-08). Events are written to the database; Vercel pulls the latest at the next build. The live site reflects the most recent scrape within minutes of the cron completing.