SF Pools:用 LLM 解析旧金山公共泳池时刻表的开源项目
fwextensions/sf-pools
开源项目 SF Pools 抓取旧金山 SF Rec & Park 的泳池时刻表 PDF,用 LLM 提取结构化数据,并提供可按项目、日期、时间和泳池浏览的界面。
SF Pools Schedule Viewer
Centralized, searchable schedules for San Francisco public swimming pools. This app scrapes official pool schedule PDFs from SF Rec & Park, uses an LLM to extract structured schedules, and provides a clean UI to browse by program, day, time, and pool.
Tech
- Next.js (App Router), React 19, TypeScript
- Tailwind CSS v4 (via
@tailwindcss/postcssand@import "tailwindcss") - Vercel AI SDK (
ai) with Google Generative AI provider (@ai-sdk/google) - Zod for strict schema validation
- GitHub Actions for automated weekly schedule updates
Prerequisites
- Node.js 22+
- npm
- A Google Generative AI API key
Setup
-
Install dependencies:
npm install -
Create
.env.localin the project root and add:GOOGLE_GENERATIVE_AI_API_KEY=your_key_here -
Run the dev server:
npm run dev -
Generate schedules:
npm run build-schedules
This will scrape PDF URLs, download PDFs, extract schedules, and write public/data/all_schedules.json. View at /schedules.
Architecture
Data Files
data/pools.json— Source of truth for static pool metadata (id, name, shortName, address, pageUrl). Does not change frequently.public/data/discovered_pool_schedules.json— Scraped PDF URLs (poolId → pdfUrl mapping). Regenerated on each scrape.data/pdf-manifest.json— Tracks downloaded PDFs by hash to detect changes.data/extracted/<poolId>.json— Cached LLM extractions per PDF, keyed by PDF hash indata/extracted/_manifest.json. Committed, so the weekly run only sends a PDF to the model when it has actually changed.public/data/all_schedules.json— Aggregated schedule data for the UI.data/changelog/— Change history between schedule updates.
Pipeline Flow
scrape → download-pdfs → process-all-pdfs
↓ ↓ ↓
validates checks hash preserves data
pool count downloads if fails on large
& URLs content changed changes
- Scrape: Discovers pool pages and PDF URLs from SF Rec & Park. Validates against
pools.json— fails if pool count or page URLs change unexpectedly. - Download: Fetches PDFs, checks content hash against manifest. Only downloads if content actually changed (handles URL changes gracefully).
- Process: Extracts schedules via LLM, preserves unchanged pool schedules, detects large changes.
Change Detection
The pipeline computes a changelog comparing old vs new schedules:
- none/minor: Normal updates, build succeeds
- major/wholesale: Large changes detected, build fails in CI (requires manual review)
Set FAIL_ON_LARGE_CHANGES=false locally to bypass this check during development.
Scripts
npm run dev— start Next.js dev servernpm run build— build for productionnpm run start— start production buildnpm run lint— run ESLintnpm run test— run testsnpm run scrape— scrape pool pages, validate against pools.json, discover PDF URLsnpm run download-pdfs— download changed PDFs intodata/pdfs/npm run process-all-pdfs— extract schedules from PDFs, preserve unchanged, write changelognpm run build-schedules— full pipeline: scrape → download → processnpm run scrape-alerts— scrape pool alerts from SF Rec & Parknpm run analyze-programs— analyze raw vs canonical program names
How Extraction Works
- For each PDF, send content to the LLM with a strict Zod schema
- The model extracts:
- Pool metadata (name, season, date range)
- Program entries with day, time, lanes, notes
- Pipeline normalizes program names to canonical labels (e.g., "LAP SWIM" → "Lap Swim")
- Enriches with static metadata from
pools.json(address, URLs)
Automated Updates
GitHub Actions runs weekly to:
- Scrape and download new PDFs
- Extract schedules from changed PDFs
- Commit changes to
public/data/anddata/changelog/ - Send push notifications via Pushover
If large changes are detected, the build fails and a notification is sent for manual review.
Environment Variables
# Required: Google AI API key for schedule extraction GOOGLE_GENERATIVE_AI_API_KEY=your_key_here # Optional: Disable build failure on large changes (for local dev) FAIL_ON_LARGE_CHANGES=false # Optional: Force re-extraction even if cache exists REFRESH_EXTRACT=1 # For CI notifications (GitHub Actions secrets) PUSHOVER_USER_KEY=... PUSHOVER_API_TOKEN=...
Header Water Animation
The animated pool header is a two-pass WebGL pipeline run by p5.js in src/components/HeaderAnimation.tsx.
How it works
Pass 1 — simulation (src/components/water-sim.frag). A real water heightfield is simulated with the discrete 2D wave equation over a small float texture:
next = (2·h − hPrev + WAVE_SPEED · laplacian(h)) · DAMPING
Each texel stores the current height in the red channel and the previous frame's height in green; two framebuffers ping-pong so one frame's output is the next frame's input. Everything watery — rings expanding, waves interfering, reflections off the pool edges (clamped texture sampling acts as walls) — emerges from this one equation. Nothing else animates the ripples.
Disturbances are injected into the field each frame:
- Pointer — a Gaussian dent pressed into the surface, swept along the segment the pointer traveled since the last frame so fast swipes carve a continuous trough. Injection pulls the surface toward a target depth (
mix) rather than adding, so holding the pointer in place can't stack the dent infinitely deep. - Scroll — scrolling shoves the pool, and the water's inertia piles it against the leading edge as a full-width line swell (bottom edge when accelerating downward, top when decelerating or scrolling up). It responds to scroll acceleration, not velocity — a steady scroll glides silently — and injections are rate-limited (
SCROLL_COOLDOWN_MS) so scrubbing the scrollbar can't pump the pool into chaos. Per-column hash jitter roughens the line so it doesn't read as a ruler-straight artifact.
The texture is sized in CSS pixels (SIM_TEXEL_CSS_PX, ~3px per texel, square on screen) so ripples have the same on-screen wavelength and speed on every device, from phone to ultrawide.
Pass 2 — display (src/components/header-shader.frag). Renders the tiles, "SF POOLS" text, and lighting. It samples the heightfield (height + gradient via central differences) and combines it with a cheap analytic ambient swell (four drifting sines). The combined wave gradient drives everything, which is what makes the effects feel physically coherent:
- refraction — tile UVs are displaced along the wave slope
- caustics — a procedural light pattern, its sampling position bent by the same slope, with per-channel falloff exponents faking chromatic dispersion at the fringes
- crest/trough lighting — raised water brightens, troughs darken, through saturating
x/(1+x)curves so extreme waves can't blow out to white or black - specular glints — a
pow(dot(N, L), 64)sparkle on slopes tilted toward the light, scaled by local ripple energy so glints fade with the wave rather than snapping off
Tuning guide
| Knob | Where | Effect |
|---|---|---|
WAVE_SPEED |
water-sim.frag | Ring travel speed. Must stay < 0.5 (CFL stability limit) |
DAMPING |
water-sim.frag | How long waves live; toward 1.0 = longer sloshing |
SIM_SUBSTEPS |
HeaderAnimation.tsx | Sim steps per frame; overall simulation speed |
SIM_TEXEL_CSS_PX |
HeaderAnimation.tsx | Master scale: bigger = chunkier, softer water everywhere |
SIM_IMPULSE_RADIUS |
HeaderAnimation.tsx | Size of the pointer's dent, in sim texels |
impulseAmp formula |
HeaderAnimation.tsx | How hard moves/clicks press into the water |
SCROLL_AMP_PER_PX, SCROLL_AMP_MAX |
HeaderAnimation.tsx | Scroll slosh strength per unit of scroll acceleration, and its cap |
SCROLL_COOLDOWN_MS |
HeaderAnimation.tsx | Min gap between scroll sloshes; higher = calmer under scrubbing |
SIM_GRAD_SCALE |
header-shader.frag | How strongly ripples refract tiles / bend caustics / glint |
SIM_HEIGHT_SCALE |
header-shader.frag | How strongly crests brighten and troughs darken |
refraction 0.035 |
header-shader.frag main() | Overall "looking through water" distortion |
caustic coupling 0.6 |
header-shader.frag main() | How much waves warp the caustic pattern |
dispersion exponents 1.25 / 0.8 |
header-shader.frag main() | Chromatic fringing around caustic highlights |
| glint constants | header-shader.frag main() | Sparkle tightness (64), threshold (0.05), brightness (0.4) |
Two structural constraints worth knowing before tweaking: sim texels must stay square on screen (the JS derives texture height from the clamped width — don't size the two axes independently) or waves propagate anisotropically; and windowResized deliberately ignores height-only resizes because iOS fires them as browser chrome collapses during scrolling — recreating the framebuffers there would blank the water mid-slosh.
License
MIT
来源:GitHub · github.com