Skip to main content
Glama
pmaxhogan

stlouis-pollen

by pmaxhogan
README.md
# stlouis-pollen

JSON API, Prometheus metrics, and **MCP server** for the [St. Louis County
pollen & mold count](https://pollenandmold.stlouisco.com/), with historical
data back to **March 2000**. Runs on Cloudflare Workers.

**Base URLs:**

- https://stlouis-pollen.maxhogan.dev
- https://stlouis-pollen.pmaxhogan.workers.dev

No auth, no tokens. Please be gentle: responses are highly cacheable and
rate-limited to 60 requests/minute/IP.

## HTTP API

| Endpoint | Description |
|---|---|
| `GET /health` | `200 OK` |
| `GET /today` | Today's headline levels (original, stable shape) |
| `GET /latest` | Latest full daily report with per-species counts |
| `GET /day/YYYY-MM-DD` | Full daily report for a date |
| `GET /history?start=YYYY-MM-DD&end=YYYY-MM-DD[&species=true]` | Day-by-day summaries, max **31 days** per request |
| `GET /metrics` | Prometheus metrics for today's report |

### Examples

```bash
curl https://stlouis-pollen.maxhogan.dev/today
# {"reportDate":"2026-07-31T05:00:00.000Z","tree":"","treeLevel":null,
#  "grass":"Low","grassLevel":1,"weed":"Moderate","weedLevel":2,
#  "mold":"Moderate","moldLevel":2}

curl https://stlouis-pollen.maxhogan.dev/day/2026-07-30
# {"date":"2026-07-30","available":true,"targetId":13634,
#  "tree":{"count":0,"level":"Absent","levelValue":0}, ...,
#  "species":{"tree":[{"name":"Ash","count":0,"level":"Absent"}, ...]}}

curl "https://stlouis-pollen.maxhogan.dev/history?start=2025-04-01&end=2025-04-30"
# {"start":"2025-04-01","end":"2025-04-30","days":[
#   {"date":"2025-04-01","available":true,"tree":{"count":905,"level":"High",...}, ...},
#   {"date":"2025-04-05","available":false,"reason":"no-report"}, ...]}
```

Days with no published report return `available: false` with a `reason`:

- `no-report` — proven report-free (weekends, holidays, skipped days)
- `not-yet-published` — the date is newer than the newest published report
- `unknown` — couldn't be resolved this request (rare; retry)

## MCP server

Model Context Protocol server over Streamable HTTP (plus legacy SSE at
`/sse`). No auth required.

```
https://stlouis-pollen.maxhogan.dev/mcp
```

Claude Code:

```bash
claude mcp add --transport http stlouis-pollen https://stlouis-pollen.maxhogan.dev/mcp
```

Claude.ai / Claude Desktop: add a custom connector with the URL above.

### Tools

| Tool | Args | Description |
|---|---|---|
| `get_current_report` | — | Most recent published report (per-species counts + category levels), with `is_today` flag |
| `get_day_report` | `date` | Full report for one date |
| `get_history` | `start_date`, `end_date`, `include_species?` | Day-by-day history, max 31 days per call |

## Data notes

- **Source:** scraped from the county's
  [Pollen and Mold Center](https://pollenandmold.stlouisco.com/). All data
  belongs to the Saint Louis County Department of Public Health.
- **Cadence:** reports are published on **weekdays only**, usually by ~noon
  US Central, and some days are skipped entirely. Dates are US Central.
- **Coverage:** daily reports back to 2000-03-03.
- **Levels:** the headline tree/grass/weed/mold levels are the summed
  category counts mapped through the standard NAB scale (`Absent=0, Low=1,
  Moderate=2, High=3, Very High=4`), which matches how the county site
  derives its home-page levels. Per-species levels are reported as printed.
- **Immutability & caching:** past days are never revised upstream, so they
  are cached permanently (Workers KV, bundled per month) and served with
  long/immutable cache headers. Cold historical queries scrape the county
  site day-by-day (`Pollen_Day.aspx?TargetID=N`); warm ones never touch it.

## How the historical scrape works

The county site keys daily reports by a sequential `TargetID` (one per
published report, monotonically increasing with date). There is no direct
date→ID endpoint, so this worker:

1. ships a static table of ~80 `(TargetID, date)` anchor pairs,
2. resolves a date to an ID with an interpolation search between anchors
   (each probe is a full day page, so probes double as cache fills),
3. walks IDs forward to cover a range, proving skipped dates report-free
   when consecutive IDs bracket them,
4. memoizes every discovered pair and day to KV — searches get cheaper
   forever.

Free-tier KV budget: ~317 month-bundle keys exist in total; each is written
only while it fills and then never again, so lifetime writes are bounded by
the amount of history (≪ the 1,000 writes/day free limit) and reads are ≤3
per request.

## Development

```bash
npm install
npm test          # parser unit tests against saved fixtures
npm run typecheck
npm run dev       # wrangler dev
```

Deploys happen automatically on push to `main` via the Cloudflare Workers
native GitHub integration (Workers Builds). `wrangler.jsonc` is the source of
truth for the custom domain, the `workers.dev` route, KV, Durable Object, and
rate-limit bindings.

## License

[MIT](LICENSE). Not affiliated with St. Louis County — data is theirs;
please attribute it to the
[Saint Louis County Pollen and Mold Center](https://pollenandmold.stlouisco.com/).