Switch menu source from Nutrislice to Claude-read PDF, weekly to monthly pull
All checks were successful
Deploy / deploy (push) Successful in 35s
All checks were successful
Deploy / deploy (push) Successful in 35s
The district discontinued Nutrislice in favor of a monthly PDF calendar per grade band on kyrene.org. That PDF is a graphic calendar grid, not reliably text-extractable, so scraper.py now sends it to Claude (vision + structured JSON output) instead of hitting a JSON API. Fetches through a stable Finalsite resource-manager permalink that always redirects to whichever month is currently published, so no code change is needed each month. Also moves the pull schedule from weekly (day-of-week list) to monthly (day-of-month), since the source itself is now monthly — defaults to the 25th, since the district tends to post next month's PDF a few days early. Requires a new ANTHROPIC_API_KEY in the server's .env. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RbBoYm23muVyMZ18Ldqa6
This commit is contained in:
parent
1bc07a7ad0
commit
a809aa5a5a
5 changed files with 263 additions and 163 deletions
143
CLAUDE.md
143
CLAUDE.md
|
|
@ -1,9 +1,8 @@
|
|||
# Lunch Notifier
|
||||
|
||||
Single-file Flask app that scrapes the weekly Mariposa (Kyrene school
|
||||
district) lunch menu and pushes each day to a Home Assistant calendar as an
|
||||
all-day event. Runs as one Docker container on the user's Unraid home
|
||||
server.
|
||||
Single-file Flask app that scrapes the Mariposa (Kyrene school district)
|
||||
lunch menu and pushes each day to a Home Assistant calendar as an all-day
|
||||
event. Runs as one Docker container on the user's Unraid home server.
|
||||
|
||||
**2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone
|
||||
numbers via email-to-SMS carrier gateways; that whole path (Gmail SMTP,
|
||||
|
|
@ -12,40 +11,70 @@ user's request in favor of Home Assistant calendar events exclusively. If
|
|||
you find references to texting/SMS/phone numbers anywhere (docs, old
|
||||
commits), they describe the pre-2026-09-03 app, not the current one.
|
||||
|
||||
**2026-09-08: pivoted from Nutrislice to a Claude-read PDF, and from
|
||||
weekly to monthly pulls.** The district discontinued Nutrislice (the old
|
||||
scraper hit `kyrene.api.nutrislice.com`, a clean per-day JSON API) in
|
||||
favor of publishing one monthly menu PDF per grade band on
|
||||
`kyrene.org/family-student-resources/food-services/school-menus`. See
|
||||
"Menu source" and "Monthly pull scheduler" below for how that's handled
|
||||
now. Anything describing a weekly Nutrislice pull describes the
|
||||
pre-2026-09-08 app.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`app.py`** — everything: Flask web UI/API, `schedule`-library job
|
||||
scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend
|
||||
is embedded as a Python string constant (`HTML`) rather than a separate
|
||||
template file — no build step, no template engine.
|
||||
- **`scraper.py`** — fetches and parses the school's published lunch menu
|
||||
from Nutrislice's API. `get_week_menu(date_str)` returns every day in the
|
||||
API's week-granular response as `[{"date": ..., "items": [...]}, ...]`
|
||||
(the API returns a full week regardless of which single date you ask
|
||||
for). Days with no posted menu (weekends, breaks, holidays) come back
|
||||
with an empty `items` list rather than being omitted.
|
||||
- **`scraper.py`** — fetches the currently-published K-5 lunch menu PDF and
|
||||
asks Claude (Anthropic API, `claude-opus-5`, structured JSON output via
|
||||
`output_config.format`) to read it into `[{"date": ..., "items": [...]}, ...]`
|
||||
for every day of whatever month is published. Days with no posted menu
|
||||
(weekends, breaks, holidays) come back with an empty `items` list rather
|
||||
than being omitted — same contract the old Nutrislice-based scraper used,
|
||||
so `app.py`'s push logic didn't need to change.
|
||||
- **Menu source (Nutrislice → PDF, 2026-09-08):** the district's page links
|
||||
a *stable* Finalsite "resource manager" permalink
|
||||
(`kyrene.org/fs/resource-manager/view/32bb3f8b-5a50-4b38-ad68-a5d3108de571`
|
||||
for the K-5/"Standard" menu — there's a different UUID per grade band/
|
||||
campus on that page) that 302-redirects to whichever monthly PDF is
|
||||
currently live (`scraper.MENU_PAGE_URL`). **Don't hardcode the PDF URL
|
||||
itself** — it's a Finalsite CDN path that changes every month (verified:
|
||||
the September 2026 PDF and August's page both linked through the same
|
||||
permalink); only the permalink UUID is stable, and only changes if the
|
||||
district restructures that page entirely. The PDF itself is a single-page
|
||||
graphic calendar grid (mascots, colored cells, day numbers laid out
|
||||
visually) — not text-extractable in any reliable way (raw text extraction
|
||||
jumbles day numbers away from their menu items), which is why this reads
|
||||
it via Claude vision/document understanding rather than `pdfplumber`/
|
||||
regex. Cost is negligible: one ~370KB single-page PDF per month, well
|
||||
under a cent of Opus 5 tokens.
|
||||
- **`ANTHROPIC_API_KEY`** is now required (server `.env`, alongside
|
||||
`HA_WEBHOOK_URL`) — `scraper.get_month_menu()` fails without it, same
|
||||
fail-soft-into-`_status`-error behavior as a Home Assistant push failure.
|
||||
`GET /api/integrations` reports both keys' configured/not-configured
|
||||
state.
|
||||
- Settings (calendar pull schedule) persist to `/data/settings.json`, not
|
||||
environment variables — env vars (`CALENDAR_RUN_TIME`, `CALENDAR_RUN_DAYS`)
|
||||
are defaults used only the very first time, before a settings file exists.
|
||||
`load_settings()` merges the file's contents *over* `_DEFAULTS` rather
|
||||
than returning the file verbatim — this matters because the server's
|
||||
settings.json predates this schema (it's from the SMS era) and lacks
|
||||
`calendar_run_time`/`calendar_run_days` entirely; without the merge, the
|
||||
scheduler would silently come up with zero jobs after every deploy until
|
||||
someone happened to re-save the Schedule card in the UI. Verified via a
|
||||
migration test (old-schema settings.json + fresh container → correct
|
||||
defaults filled in, `next_calendar_run` populated).
|
||||
environment variables — env vars (`CALENDAR_RUN_TIME`,
|
||||
`CALENDAR_RUN_DAY_OF_MONTH`) are defaults used only the very first time,
|
||||
before a settings file exists. `load_settings()` merges the file's
|
||||
contents *over* `_DEFAULTS` rather than returning the file verbatim —
|
||||
this matters because the server's settings.json predates several schema
|
||||
changes (SMS era, then the weekly-schedule era) and is missing whatever
|
||||
keys the current schema expects; without the merge, the scheduler would
|
||||
silently come up with zero jobs after every deploy until someone happened
|
||||
to re-save the Schedule card in the UI.
|
||||
- **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).**
|
||||
A day with no posted menu already produces an empty `items` list from
|
||||
`get_week_menu()`, and `_push_calendar()` already skips pushing when
|
||||
`get_month_menu()`, and `_push_calendar()` already skips pushing when
|
||||
`items` is empty — that's sufficient for "don't create an event for a day
|
||||
with nothing happening." A separate vacation-range setting to *additionally*
|
||||
suppress days the school did post a menu for was judged unnecessary
|
||||
complexity and deleted at the user's request; don't re-add it without
|
||||
being asked.
|
||||
- **Home Assistant integration (built 2026-09-02/03, calendar-only pivot
|
||||
2026-09-03):** `HA_WEBHOOK_URL` is now required, not optional — it's the
|
||||
only way this app does anything externally-visible. On the HA side
|
||||
2026-09-03):** `HA_WEBHOOK_URL` is required — it's the only way this app
|
||||
does anything externally-visible. On the HA side
|
||||
(`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`),
|
||||
a `local_only` webhook trigger runs `calendar.create_event` against
|
||||
`calendar.family_2` (the "Family" Google Calendar entity — there are two
|
||||
|
|
@ -55,38 +84,47 @@ commits), they describe the pre-2026-09-03 app, not the current one.
|
|||
`delete_event` — a bad push can't be cleaned up via the HA API, only
|
||||
manually in Google Calendar. No duplicate-prevention: re-pushing the
|
||||
same date's menu creates a second event — this is why the default
|
||||
schedule is a single day/week, not daily (see below).
|
||||
schedule is a single day/month, not daily (see below).
|
||||
**`mode: parallel`, not the default `single` (fixed 2026-09-03 after a
|
||||
real bug):** a week push fires one webhook POST per school day
|
||||
back-to-back with no delay. Under `mode: single`, HA rejects any
|
||||
webhook trigger that arrives while a previous run is still executing
|
||||
(`calendar.create_event` itself takes ~600-700ms) — 4 of every 5 days
|
||||
were silently dropped as `failed_single`, so only one day's event ever
|
||||
actually got created per week push. Found via `ha_get_automation_traces`
|
||||
after the user reported a manual test push not showing up on the
|
||||
calendar. Each invocation is independent (one `calendar.create_event`
|
||||
call, no shared state), so `parallel` is the correct mode per HA's own
|
||||
guidance ("independent per-entity actions → parallel"), not just a
|
||||
workaround. The webhook id is
|
||||
real bug):** a push fires one webhook POST per school day back-to-back
|
||||
with no delay. Under `mode: single`, HA rejects any webhook trigger that
|
||||
arrives while a previous run is still executing (`calendar.create_event`
|
||||
itself takes ~600-700ms) — most days in a batch were silently dropped as
|
||||
`failed_single`, so only one day's event ever actually got created per
|
||||
push. Found via `ha_get_automation_traces` after the user reported a
|
||||
manual test push not showing up on the calendar. Each invocation is
|
||||
independent (one `calendar.create_event` call, no shared state), so
|
||||
`parallel` is the correct mode per HA's own guidance ("independent
|
||||
per-entity actions → parallel"), not just a workaround. The webhook id is
|
||||
a random token that lives only in the server's `.env` (`HA_WEBHOOK_URL`)
|
||||
and in the automation's trigger config — never committed here. If the
|
||||
calendar push stops working, check both ends: `GET /api/integrations`
|
||||
on this app reports whether `HA_WEBHOOK_URL` is set at all, and
|
||||
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
|
||||
shows whether HA is even receiving the webhook.
|
||||
- **Independent weekly-pull scheduler (2026-09-03):** calendar pulls run on
|
||||
their own schedule (`calendar_run_time`/`calendar_run_days` in settings,
|
||||
`schedule` jobs tagged `"calendar"`), completely decoupled from any other
|
||||
job — there's nothing else in this app anymore, but the tag/isolation
|
||||
pattern (`schedule.clear("calendar")` instead of a bare `schedule.clear()`)
|
||||
is deliberate so a future second schedule wouldn't stomp this one.
|
||||
`push_week_to_calendar()` fetches the whole current school week and
|
||||
pushes every day with a posted menu. Default is Sunday 18:00 (one
|
||||
firing/week) — the UI warns that picking more than one day/week creates
|
||||
duplicate events for days already covered, since there's no dedup.
|
||||
- **Manual actions:** "Push This Week to Calendar" (`POST /api/push-calendar`
|
||||
→ `push_week_to_calendar()`, same function the schedule calls) and
|
||||
"Preview This Week's Menu" (`GET /api/preview` → `get_week_menu()`
|
||||
- **Monthly pull scheduler (weekly → monthly, 2026-09-08):** calendar pulls
|
||||
run on their own schedule (`calendar_run_time`/`calendar_run_day_of_month`
|
||||
in settings, `schedule` jobs tagged `"calendar"`), completely decoupled
|
||||
from anything else — the tag/isolation pattern
|
||||
(`schedule.clear("calendar")` instead of a bare `schedule.clear()`) is
|
||||
deliberate so a future second schedule wouldn't stomp this one. The
|
||||
`schedule` library has no native day-of-month trigger, so
|
||||
`apply_calendar_schedule()` registers a *daily* job
|
||||
(`_maybe_push_month`) that no-ops unless `datetime.now().day` matches
|
||||
the configured day — `push_month_to_calendar()` only actually runs on
|
||||
that one day per month. `next_calendar_run()` computes the real next
|
||||
firing directly from settings (handling month/year rollover and clamping
|
||||
the configured day to short months, e.g. day 30 in February → the 28th)
|
||||
for the UI's "Next Scheduled" field, since the underlying `schedule` job's
|
||||
own `next_run` is meaningless here (it's always just "tomorrow"). Default
|
||||
is day 25 at 18:00 — picked because the district tends to post next
|
||||
month's PDF a few days before month-end, so pulling then usually catches
|
||||
the *upcoming* month rather than the one ending. Whatever month is live
|
||||
at the permalink when the job fires is what gets pushed — there's no
|
||||
logic forcing "next month specifically."
|
||||
- **Manual actions:** "Push This Month to Calendar" (`POST /api/push-calendar`
|
||||
→ `push_month_to_calendar()`, same function the schedule calls) and
|
||||
"Preview This Month's Menu" (`GET /api/preview` → `get_month_menu()`
|
||||
directly, pure dry run, no Home Assistant push, no side effects at all —
|
||||
use this to check the scraper before trusting a scheduled push).
|
||||
|
||||
|
|
@ -97,8 +135,10 @@ pip install -r requirements.txt
|
|||
python3 app.py
|
||||
```
|
||||
|
||||
Needs `HA_WEBHOOK_URL` set to actually push anywhere; without it the app
|
||||
still runs and previews fine, `GET /api/integrations` just reports
|
||||
Needs `ANTHROPIC_API_KEY` set for anything menu-related to work at all
|
||||
(including Preview, since that calls the same Claude-backed scraper) and
|
||||
`HA_WEBHOOK_URL` set to actually push anywhere — without the latter the
|
||||
app still runs and previews fine, `GET /api/integrations` just reports
|
||||
`ha_webhook_configured: false` and pushes resolve to `"not configured"`.
|
||||
Visit `http://localhost:2323`.
|
||||
|
||||
|
|
@ -124,7 +164,8 @@ manually-edited `.env` on its own.
|
|||
**Persistent state, never touched by deploys:** `data/settings.json` (the
|
||||
volume-mounted `/data` inside the container) holds the calendar pull
|
||||
schedule — this is live user data, not deploy-managed. `.env` holds
|
||||
`HA_WEBHOOK_URL` and is server-only, gitignored, never committed.
|
||||
`HA_WEBHOOK_URL` and `ANTHROPIC_API_KEY` and is server-only, gitignored,
|
||||
never committed.
|
||||
|
||||
**Container timezone (fixed 2026-09-08):** the container previously had no
|
||||
timezone configured — `python:3.12-slim` defaults to UTC and the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue