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
15
.env.example
15
.env.example
|
|
@ -6,12 +6,17 @@
|
||||||
# e.g. http://192.168.86.X:8123/api/webhook/<random-token>
|
# e.g. http://192.168.86.X:8123/api/webhook/<random-token>
|
||||||
HA_WEBHOOK_URL=
|
HA_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# Anthropic API key. The district's lunch menu is now a graphic PDF
|
||||||
|
# calendar (Nutrislice was discontinued), so scraper.py sends it to Claude
|
||||||
|
# to read into structured data — see console.anthropic.com/settings/keys.
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
# --- Optional ---
|
# --- Optional ---
|
||||||
# Day(s) of week to pull the upcoming school week and push it to the
|
# Day of the month (1-28) to pull whichever month is currently published
|
||||||
# calendar (comma-separated: mon,tue,wed,thu,fri,sat,sun). Picking more
|
# and push it to the calendar. Re-pushing the same month creates duplicate
|
||||||
# than one day creates duplicate events for days already pushed — most
|
# events, so pick one day near month-end (the district tends to post next
|
||||||
# people want just one.
|
# month's menu a few days early).
|
||||||
CALENDAR_RUN_DAYS=sun
|
CALENDAR_RUN_DAY_OF_MONTH=25
|
||||||
|
|
||||||
# Time to run the pull (24-hour HH:MM, container timezone)
|
# Time to run the pull (24-hour HH:MM, container timezone)
|
||||||
CALENDAR_RUN_TIME=18:00
|
CALENDAR_RUN_TIME=18:00
|
||||||
|
|
|
||||||
143
CLAUDE.md
143
CLAUDE.md
|
|
@ -1,9 +1,8 @@
|
||||||
# Lunch Notifier
|
# Lunch Notifier
|
||||||
|
|
||||||
Single-file Flask app that scrapes the weekly Mariposa (Kyrene school
|
Single-file Flask app that scrapes the Mariposa (Kyrene school district)
|
||||||
district) lunch menu and pushes each day to a Home Assistant calendar as an
|
lunch menu and pushes each day to a Home Assistant calendar as an all-day
|
||||||
all-day event. Runs as one Docker container on the user's Unraid home
|
event. Runs as one Docker container on the user's Unraid home server.
|
||||||
server.
|
|
||||||
|
|
||||||
**2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone
|
**2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone
|
||||||
numbers via email-to-SMS carrier gateways; that whole path (Gmail SMTP,
|
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
|
you find references to texting/SMS/phone numbers anywhere (docs, old
|
||||||
commits), they describe the pre-2026-09-03 app, not the current one.
|
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
|
## Architecture
|
||||||
|
|
||||||
- **`app.py`** — everything: Flask web UI/API, `schedule`-library job
|
- **`app.py`** — everything: Flask web UI/API, `schedule`-library job
|
||||||
scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend
|
scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend
|
||||||
is embedded as a Python string constant (`HTML`) rather than a separate
|
is embedded as a Python string constant (`HTML`) rather than a separate
|
||||||
template file — no build step, no template engine.
|
template file — no build step, no template engine.
|
||||||
- **`scraper.py`** — fetches and parses the school's published lunch menu
|
- **`scraper.py`** — fetches the currently-published K-5 lunch menu PDF and
|
||||||
from Nutrislice's API. `get_week_menu(date_str)` returns every day in the
|
asks Claude (Anthropic API, `claude-opus-5`, structured JSON output via
|
||||||
API's week-granular response as `[{"date": ..., "items": [...]}, ...]`
|
`output_config.format`) to read it into `[{"date": ..., "items": [...]}, ...]`
|
||||||
(the API returns a full week regardless of which single date you ask
|
for every day of whatever month is published. Days with no posted menu
|
||||||
for). Days with no posted menu (weekends, breaks, holidays) come back
|
(weekends, breaks, holidays) come back with an empty `items` list rather
|
||||||
with an empty `items` list rather than being omitted.
|
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
|
- Settings (calendar pull schedule) persist to `/data/settings.json`, not
|
||||||
environment variables — env vars (`CALENDAR_RUN_TIME`, `CALENDAR_RUN_DAYS`)
|
environment variables — env vars (`CALENDAR_RUN_TIME`,
|
||||||
are defaults used only the very first time, before a settings file exists.
|
`CALENDAR_RUN_DAY_OF_MONTH`) are defaults used only the very first time,
|
||||||
`load_settings()` merges the file's contents *over* `_DEFAULTS` rather
|
before a settings file exists. `load_settings()` merges the file's
|
||||||
than returning the file verbatim — this matters because the server's
|
contents *over* `_DEFAULTS` rather than returning the file verbatim —
|
||||||
settings.json predates this schema (it's from the SMS era) and lacks
|
this matters because the server's settings.json predates several schema
|
||||||
`calendar_run_time`/`calendar_run_days` entirely; without the merge, the
|
changes (SMS era, then the weekly-schedule era) and is missing whatever
|
||||||
scheduler would silently come up with zero jobs after every deploy until
|
keys the current schema expects; without the merge, the scheduler would
|
||||||
someone happened to re-save the Schedule card in the UI. Verified via a
|
silently come up with zero jobs after every deploy until someone happened
|
||||||
migration test (old-schema settings.json + fresh container → correct
|
to re-save the Schedule card in the UI.
|
||||||
defaults filled in, `next_calendar_run` populated).
|
|
||||||
- **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).**
|
- **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
|
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
|
`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*
|
with nothing happening." A separate vacation-range setting to *additionally*
|
||||||
suppress days the school did post a menu for was judged unnecessary
|
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
|
complexity and deleted at the user's request; don't re-add it without
|
||||||
being asked.
|
being asked.
|
||||||
- **Home Assistant integration (built 2026-09-02/03, calendar-only pivot
|
- **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
|
2026-09-03):** `HA_WEBHOOK_URL` is required — it's the only way this app
|
||||||
only way this app does anything externally-visible. On the HA side
|
does anything externally-visible. On the HA side
|
||||||
(`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`),
|
(`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`),
|
||||||
a `local_only` webhook trigger runs `calendar.create_event` against
|
a `local_only` webhook trigger runs `calendar.create_event` against
|
||||||
`calendar.family_2` (the "Family" Google Calendar entity — there are two
|
`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
|
`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
|
manually in Google Calendar. No duplicate-prevention: re-pushing the
|
||||||
same date's menu creates a second event — this is why the default
|
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
|
**`mode: parallel`, not the default `single` (fixed 2026-09-03 after a
|
||||||
real bug):** a week push fires one webhook POST per school day
|
real bug):** a push fires one webhook POST per school day back-to-back
|
||||||
back-to-back with no delay. Under `mode: single`, HA rejects any
|
with no delay. Under `mode: single`, HA rejects any webhook trigger that
|
||||||
webhook trigger that arrives while a previous run is still executing
|
arrives while a previous run is still executing (`calendar.create_event`
|
||||||
(`calendar.create_event` itself takes ~600-700ms) — 4 of every 5 days
|
itself takes ~600-700ms) — most days in a batch were silently dropped as
|
||||||
were silently dropped as `failed_single`, so only one day's event ever
|
`failed_single`, so only one day's event ever actually got created per
|
||||||
actually got created per week push. Found via `ha_get_automation_traces`
|
push. Found via `ha_get_automation_traces` after the user reported a
|
||||||
after the user reported a manual test push not showing up on the
|
manual test push not showing up on the calendar. Each invocation is
|
||||||
calendar. Each invocation is independent (one `calendar.create_event`
|
independent (one `calendar.create_event` call, no shared state), so
|
||||||
call, no shared state), so `parallel` is the correct mode per HA's own
|
`parallel` is the correct mode per HA's own guidance ("independent
|
||||||
guidance ("independent per-entity actions → parallel"), not just a
|
per-entity actions → parallel"), not just a workaround. The webhook id is
|
||||||
workaround. The webhook id is
|
|
||||||
a random token that lives only in the server's `.env` (`HA_WEBHOOK_URL`)
|
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
|
and in the automation's trigger config — never committed here. If the
|
||||||
calendar push stops working, check both ends: `GET /api/integrations`
|
calendar push stops working, check both ends: `GET /api/integrations`
|
||||||
on this app reports whether `HA_WEBHOOK_URL` is set at all, and
|
on this app reports whether `HA_WEBHOOK_URL` is set at all, and
|
||||||
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
|
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
|
||||||
shows whether HA is even receiving the webhook.
|
shows whether HA is even receiving the webhook.
|
||||||
- **Independent weekly-pull scheduler (2026-09-03):** calendar pulls run on
|
- **Monthly pull scheduler (weekly → monthly, 2026-09-08):** calendar pulls
|
||||||
their own schedule (`calendar_run_time`/`calendar_run_days` in settings,
|
run on their own schedule (`calendar_run_time`/`calendar_run_day_of_month`
|
||||||
`schedule` jobs tagged `"calendar"`), completely decoupled from any other
|
in settings, `schedule` jobs tagged `"calendar"`), completely decoupled
|
||||||
job — there's nothing else in this app anymore, but the tag/isolation
|
from anything else — the tag/isolation pattern
|
||||||
pattern (`schedule.clear("calendar")` instead of a bare `schedule.clear()`)
|
(`schedule.clear("calendar")` instead of a bare `schedule.clear()`) is
|
||||||
is deliberate so a future second schedule wouldn't stomp this one.
|
deliberate so a future second schedule wouldn't stomp this one. The
|
||||||
`push_week_to_calendar()` fetches the whole current school week and
|
`schedule` library has no native day-of-month trigger, so
|
||||||
pushes every day with a posted menu. Default is Sunday 18:00 (one
|
`apply_calendar_schedule()` registers a *daily* job
|
||||||
firing/week) — the UI warns that picking more than one day/week creates
|
(`_maybe_push_month`) that no-ops unless `datetime.now().day` matches
|
||||||
duplicate events for days already covered, since there's no dedup.
|
the configured day — `push_month_to_calendar()` only actually runs on
|
||||||
- **Manual actions:** "Push This Week to Calendar" (`POST /api/push-calendar`
|
that one day per month. `next_calendar_run()` computes the real next
|
||||||
→ `push_week_to_calendar()`, same function the schedule calls) and
|
firing directly from settings (handling month/year rollover and clamping
|
||||||
"Preview This Week's Menu" (`GET /api/preview` → `get_week_menu()`
|
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 —
|
directly, pure dry run, no Home Assistant push, no side effects at all —
|
||||||
use this to check the scraper before trusting a scheduled push).
|
use this to check the scraper before trusting a scheduled push).
|
||||||
|
|
||||||
|
|
@ -97,8 +135,10 @@ pip install -r requirements.txt
|
||||||
python3 app.py
|
python3 app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Needs `HA_WEBHOOK_URL` set to actually push anywhere; without it the app
|
Needs `ANTHROPIC_API_KEY` set for anything menu-related to work at all
|
||||||
still runs and previews fine, `GET /api/integrations` just reports
|
(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"`.
|
`ha_webhook_configured: false` and pushes resolve to `"not configured"`.
|
||||||
Visit `http://localhost:2323`.
|
Visit `http://localhost:2323`.
|
||||||
|
|
||||||
|
|
@ -124,7 +164,8 @@ manually-edited `.env` on its own.
|
||||||
**Persistent state, never touched by deploys:** `data/settings.json` (the
|
**Persistent state, never touched by deploys:** `data/settings.json` (the
|
||||||
volume-mounted `/data` inside the container) holds the calendar pull
|
volume-mounted `/data` inside the container) holds the calendar pull
|
||||||
schedule — this is live user data, not deploy-managed. `.env` holds
|
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
|
**Container timezone (fixed 2026-09-08):** the container previously had no
|
||||||
timezone configured — `python:3.12-slim` defaults to UTC and the
|
timezone configured — `python:3.12-slim` defaults to UTC and the
|
||||||
|
|
|
||||||
151
app.py
151
app.py
|
|
@ -2,6 +2,7 @@
|
||||||
Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home
|
Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home
|
||||||
Assistant calendar. Visit http://localhost:2323 to manage settings.
|
Assistant calendar. Visit http://localhost:2323 to manage settings.
|
||||||
"""
|
"""
|
||||||
|
import calendar
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
@ -14,7 +15,7 @@ import requests
|
||||||
import schedule
|
import schedule
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
from scraper import get_week_menu
|
from scraper import get_month_menu
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -27,7 +28,7 @@ PORT = int(os.environ.get("PORT", "2323"))
|
||||||
# Env-var defaults (used only when no settings file exists yet)
|
# Env-var defaults (used only when no settings file exists yet)
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
"calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
|
"calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
|
||||||
"calendar_run_days": [d.strip() for d in os.environ.get("CALENDAR_RUN_DAYS", "sun").split(",") if d.strip()],
|
"calendar_run_day_of_month": int(os.environ.get("CALENDAR_RUN_DAY_OF_MONTH", "25")),
|
||||||
}
|
}
|
||||||
|
|
||||||
_status = {"last_calendar_run": None, "calendar_result": None}
|
_status = {"last_calendar_run": None, "calendar_result": None}
|
||||||
|
|
@ -41,8 +42,9 @@ def load_settings() -> dict:
|
||||||
try:
|
try:
|
||||||
with open(SETTINGS_FILE) as f:
|
with open(SETTINGS_FILE) as f:
|
||||||
# Merge over _DEFAULTS so a settings.json from before a schema
|
# Merge over _DEFAULTS so a settings.json from before a schema
|
||||||
# change (e.g. the pre-calendar SMS-era file) still gets
|
# change (e.g. the pre-calendar SMS-era file, or the
|
||||||
# calendar_run_time/calendar_run_days rather than silently
|
# weekly-schedule era before calendar_run_day_of_month
|
||||||
|
# existed) still gets a valid schedule rather than silently
|
||||||
# scheduling nothing.
|
# scheduling nothing.
|
||||||
return {**_DEFAULTS, **json.load(f)}
|
return {**_DEFAULTS, **json.load(f)}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -80,23 +82,23 @@ def _push_calendar(items: list[str], date_str: str | None) -> str:
|
||||||
return f"error: {e}"
|
return f"error: {e}"
|
||||||
|
|
||||||
|
|
||||||
def push_week_to_calendar() -> None:
|
def push_month_to_calendar() -> None:
|
||||||
"""Fetch the current school week and push every day that has a posted
|
"""Fetch whichever month is currently published and push every day that
|
||||||
menu — days with nothing posted (weekends, breaks, holidays) are
|
has a posted menu — days with nothing posted (weekends, breaks,
|
||||||
skipped automatically since there's nothing to create an event from.
|
holidays) are skipped automatically since there's nothing to create an
|
||||||
Runs on its own schedule (calendar_run_time/calendar_run_days),
|
event from. Runs on its own schedule (calendar_run_time/
|
||||||
independent of anything else."""
|
calendar_run_day_of_month), independent of anything else."""
|
||||||
global _status
|
global _status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
week = get_week_menu()
|
month = get_month_menu()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Week fetch failed")
|
log.exception("Month fetch failed")
|
||||||
_status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"}
|
_status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"}
|
||||||
return
|
return
|
||||||
|
|
||||||
pushed = errors = no_items = 0
|
pushed = errors = no_items = 0
|
||||||
for day in week:
|
for day in month:
|
||||||
result = _push_calendar(day["items"], day["date"])
|
result = _push_calendar(day["items"], day["date"])
|
||||||
if result == "ok":
|
if result == "ok":
|
||||||
pushed += 1
|
pushed += 1
|
||||||
|
|
@ -105,7 +107,7 @@ def push_week_to_calendar() -> None:
|
||||||
else:
|
else:
|
||||||
errors += 1
|
errors += 1
|
||||||
|
|
||||||
log.info(f"Week push: {pushed} pushed, {errors} errors, {no_items} no menu")
|
log.info(f"Month push: {pushed} pushed, {errors} errors, {no_items} no menu")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "")
|
summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "")
|
||||||
|
|
@ -123,26 +125,43 @@ def push_week_to_calendar() -> None:
|
||||||
|
|
||||||
# ── Scheduler ─────────────────────────────────────────────────────────────────
|
# ── Scheduler ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DAY_MAP_FNS = {
|
def _maybe_push_month(day_of_month: int) -> None:
|
||||||
"mon": lambda: schedule.every().monday,
|
"""schedule fires this daily at run_time; only actually pushes on the
|
||||||
"tue": lambda: schedule.every().tuesday,
|
configured day of the month (the `schedule` library has no native
|
||||||
"wed": lambda: schedule.every().wednesday,
|
day-of-month trigger)."""
|
||||||
"thu": lambda: schedule.every().thursday,
|
if datetime.now().day == day_of_month:
|
||||||
"fri": lambda: schedule.every().friday,
|
push_month_to_calendar()
|
||||||
"sat": lambda: schedule.every().saturday,
|
|
||||||
"sun": lambda: schedule.every().sunday,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def apply_calendar_schedule(settings: dict) -> None:
|
def apply_calendar_schedule(settings: dict) -> None:
|
||||||
with _lock:
|
with _lock:
|
||||||
schedule.clear("calendar")
|
schedule.clear("calendar")
|
||||||
run_time = settings.get("calendar_run_time", "18:00")
|
run_time = settings.get("calendar_run_time", "18:00")
|
||||||
for day in settings.get("calendar_run_days", []):
|
day_of_month = settings.get("calendar_run_day_of_month", 25)
|
||||||
day = day.strip().lower()
|
schedule.every().day.at(run_time).do(_maybe_push_month, day_of_month).tag("calendar")
|
||||||
if day in DAY_MAP_FNS:
|
log.info(f"Scheduled calendar pull: day {day_of_month} of each month at {run_time}")
|
||||||
DAY_MAP_FNS[day]().at(run_time).do(push_week_to_calendar).tag("calendar")
|
|
||||||
log.info(f"Scheduled calendar pull: {day} at {run_time}")
|
|
||||||
|
def next_calendar_run(settings: dict) -> datetime:
|
||||||
|
"""The `schedule` job above fires daily (it has no native day-of-month
|
||||||
|
trigger — see _maybe_push_month), so its own next_run is just
|
||||||
|
"tomorrow", not the actual next push. Compute the real one directly
|
||||||
|
from settings for display in the UI."""
|
||||||
|
run_time = settings.get("calendar_run_time", "18:00")
|
||||||
|
hour, minute = (int(p) for p in run_time.split(":"))
|
||||||
|
day_of_month = settings.get("calendar_run_day_of_month", 25)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
candidate = now.replace(
|
||||||
|
day=min(day_of_month, calendar.monthrange(now.year, now.month)[1]),
|
||||||
|
hour=hour, minute=minute, second=0, microsecond=0,
|
||||||
|
)
|
||||||
|
if candidate <= now:
|
||||||
|
y, m = (now.year, now.month + 1) if now.month < 12 else (now.year + 1, 1)
|
||||||
|
candidate = candidate.replace(
|
||||||
|
year=y, month=m, day=min(day_of_month, calendar.monthrange(y, m)[1]),
|
||||||
|
)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
def _schedule_loop() -> None:
|
def _schedule_loop() -> None:
|
||||||
|
|
@ -176,7 +195,7 @@ def api_get_settings():
|
||||||
def api_post_settings():
|
def api_post_settings():
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
s = load_settings()
|
s = load_settings()
|
||||||
for key in ("calendar_run_time", "calendar_run_days"):
|
for key in ("calendar_run_time", "calendar_run_day_of_month"):
|
||||||
if key in data:
|
if key in data:
|
||||||
s[key] = data[key]
|
s[key] = data[key]
|
||||||
save_settings(s)
|
save_settings(s)
|
||||||
|
|
@ -186,32 +205,33 @@ def api_post_settings():
|
||||||
|
|
||||||
@app.route("/api/push-calendar", methods=["POST"])
|
@app.route("/api/push-calendar", methods=["POST"])
|
||||||
def api_push_calendar():
|
def api_push_calendar():
|
||||||
threading.Thread(target=push_week_to_calendar, daemon=True).start()
|
threading.Thread(target=push_month_to_calendar, daemon=True).start()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/preview")
|
@app.route("/api/preview")
|
||||||
def api_preview():
|
def api_preview():
|
||||||
"""Dry-run week scrape: no Home Assistant push, no side effects."""
|
"""Dry-run month scrape: no Home Assistant push, no side effects."""
|
||||||
try:
|
try:
|
||||||
week = get_week_menu()
|
month = get_month_menu()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 502
|
return jsonify({"error": str(e)}), 502
|
||||||
return jsonify({"days": week})
|
return jsonify({"days": month})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/integrations")
|
@app.route("/api/integrations")
|
||||||
def api_integrations():
|
def api_integrations():
|
||||||
return jsonify({"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL"))})
|
return jsonify({
|
||||||
|
"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL")),
|
||||||
|
"anthropic_api_key_configured": bool(os.environ.get("ANTHROPIC_API_KEY")),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/status")
|
@app.route("/api/status")
|
||||||
def api_status():
|
def api_status():
|
||||||
with _lock:
|
|
||||||
next_runs = sorted([j.next_run for j in schedule.get_jobs("calendar") if j.next_run])
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
**_status,
|
**_status,
|
||||||
"next_calendar_run": next_runs[0].isoformat() if next_runs else None,
|
"next_calendar_run": next_calendar_run(load_settings()).isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -277,17 +297,11 @@ header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
|
||||||
.field:last-child { margin-bottom: 0; }
|
.field:last-child { margin-bottom: 0; }
|
||||||
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
|
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
|
||||||
margin-bottom: 6px; }
|
margin-bottom: 6px; }
|
||||||
input[type=text], input[type=time] {
|
input[type=text], input[type=time], input[type=number] {
|
||||||
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
|
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
|
||||||
font-size: .875rem; color: var(--text); background: var(--card);
|
font-size: .875rem; color: var(--text); background: var(--card);
|
||||||
}
|
}
|
||||||
input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; }
|
input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; }
|
||||||
/* Days toggle */
|
|
||||||
.days-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
||||||
.day-chip { padding: 6px 13px; border: 1.5px solid var(--border); border-radius: 99px;
|
|
||||||
font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card);
|
|
||||||
transition: all .15s; user-select: none; }
|
|
||||||
.day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); }
|
|
||||||
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
|
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
|
||||||
/* Divider */
|
/* Divider */
|
||||||
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
||||||
|
|
@ -297,11 +311,11 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
|
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
|
||||||
pointer-events: none; white-space: nowrap; z-index: 100; }
|
pointer-events: none; white-space: nowrap; z-index: 100; }
|
||||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
.week-day { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
.day-row { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||||
.week-day:last-child { border-bottom: none; }
|
.day-row:last-child { border-bottom: none; }
|
||||||
.week-day-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; }
|
.day-row-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; }
|
||||||
.week-day-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; }
|
.day-row-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; }
|
||||||
.week-day-empty { font-size: .8rem; color: var(--muted); font-style: italic; }
|
.day-row-empty { font-size: .8rem; color: var(--muted); font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -330,10 +344,10 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Manual Actions</div>
|
<div class="card-label">Manual Actions</div>
|
||||||
<button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()">
|
<button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()">
|
||||||
Push This Week to Calendar
|
Push This Month to Calendar
|
||||||
</button>
|
</button>
|
||||||
<button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()">
|
<button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()">
|
||||||
Preview This Week's Menu
|
Preview This Month's Menu
|
||||||
</button>
|
</button>
|
||||||
<div id="preview-box" style="display:none;margin-top:14px;"></div>
|
<div id="preview-box" style="display:none;margin-top:14px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -343,8 +357,9 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
<div class="card-label">Integrations</div>
|
<div class="card-label">Integrations</div>
|
||||||
<dl class="stat-grid">
|
<dl class="stat-grid">
|
||||||
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
|
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
|
||||||
|
<div class="stat"><dt>Anthropic API</dt><dd id="i-anthropic">—</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>) on the server, not here.</div>
|
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>, <code>ANTHROPIC_API_KEY</code>) on the server, not here.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schedule -->
|
<!-- Schedule -->
|
||||||
|
|
@ -355,9 +370,9 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
<input type="time" id="f-time" style="max-width:160px">
|
<input type="time" id="f-time" style="max-width:160px">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">Pull Days</label>
|
<label class="field-label">Pull Day of Month</label>
|
||||||
<div class="days-row" id="days-row"></div>
|
<input type="number" id="f-day" min="1" max="28" style="max-width:100px">
|
||||||
<div class="item-sub" style="margin-top:8px;">Each day fires a full week pull. Picking more than one day will create duplicate calendar events for days already pushed — most people want just one (e.g. Sunday evening).</div>
|
<div class="item-sub" style="margin-top:8px;">Fires once a month and pushes whichever month is currently published — e.g. day 25 catches next month's menu once the district posts it near month-end. There's no dedup, so re-pushing the same month creates duplicate calendar events.</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="divider">
|
<hr class="divider">
|
||||||
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
|
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
|
||||||
|
|
@ -367,8 +382,6 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const DAYS = ['mon','tue','wed','thu','fri','sat','sun'];
|
|
||||||
const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat',sun:'Sun'};
|
|
||||||
let cfg = {};
|
let cfg = {};
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -391,18 +404,16 @@ function fmt(iso) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyIntegrations(ig) {
|
function applyIntegrations(ig) {
|
||||||
document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured
|
const badge = ok => ok
|
||||||
? '<span class="badge badge-green">Configured</span>'
|
? '<span class="badge badge-green">Configured</span>'
|
||||||
: '<span class="badge badge-yellow">Not configured</span>';
|
: '<span class="badge badge-yellow">Not configured</span>';
|
||||||
|
document.getElementById('i-ha').innerHTML = badge(ig.ha_webhook_configured);
|
||||||
|
document.getElementById('i-anthropic').innerHTML = badge(ig.anthropic_api_key_configured);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySettings(s) {
|
function applySettings(s) {
|
||||||
document.getElementById('f-time').value = s.calendar_run_time || '18:00';
|
document.getElementById('f-time').value = s.calendar_run_time || '18:00';
|
||||||
const row = document.getElementById('days-row');
|
document.getElementById('f-day').value = s.calendar_run_day_of_month || 25;
|
||||||
row.innerHTML = DAYS.map(d =>
|
|
||||||
`<span class="day-chip ${(s.calendar_run_days||[]).includes(d)?'on':''}" data-day="${d}"
|
|
||||||
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
|
|
||||||
).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStatus(st) {
|
function applyStatus(st) {
|
||||||
|
|
@ -417,8 +428,8 @@ function applyStatus(st) {
|
||||||
|
|
||||||
async function saveSchedule() {
|
async function saveSchedule() {
|
||||||
const calendar_run_time = document.getElementById('f-time').value;
|
const calendar_run_time = document.getElementById('f-time').value;
|
||||||
const calendar_run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
|
const calendar_run_day_of_month = parseInt(document.getElementById('f-day').value, 10);
|
||||||
await post('/api/settings', {calendar_run_time, calendar_run_days});
|
await post('/api/settings', {calendar_run_time, calendar_run_day_of_month});
|
||||||
toast('Schedule saved');
|
toast('Schedule saved');
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
@ -441,14 +452,14 @@ async function previewMenu() {
|
||||||
const r = await fetch('/api/preview');
|
const r = await fetch('/api/preview');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
box.innerHTML = `<div class="week-day-empty">Error: ${data.error}</div>`;
|
box.innerHTML = `<div class="day-row-empty">Error: ${data.error}</div>`;
|
||||||
} else {
|
} else {
|
||||||
box.innerHTML = (data.days||[]).map(d => `
|
box.innerHTML = (data.days||[]).map(d => `
|
||||||
<div class="week-day">
|
<div class="day-row">
|
||||||
<div class="week-day-date">${d.date}</div>
|
<div class="day-row-date">${d.date}</div>
|
||||||
${d.items.length
|
${d.items.length
|
||||||
? `<div class="week-day-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
|
? `<div class="day-row-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
|
||||||
: '<div class="week-day-empty">No menu posted</div>'}
|
: '<div class="day-row-empty">No menu posted</div>'}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
box.style.display = '';
|
box.style.display = '';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
anthropic==0.125.0
|
||||||
flask==3.1.0
|
flask==3.1.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
schedule==1.2.2
|
schedule==1.2.2
|
||||||
|
|
|
||||||
116
scraper.py
116
scraper.py
|
|
@ -1,51 +1,93 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
import anthropic
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCHOOL_SLUG = "kyrene-de-la-mariposa"
|
# Stable Finalsite "resource manager" permalink for the K-5 lunch menu, found
|
||||||
MENU_TYPE = "lunch"
|
# on https://www.kyrene.org/family-student-resources/food-services/school-menus.
|
||||||
API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school"
|
# This URL itself never changes — it 302-redirects to whichever monthly PDF
|
||||||
|
# is currently published (verified 2026-09-08: it pointed straight at that
|
||||||
|
# month's file). Nutrislice, the previous data source, was discontinued by
|
||||||
|
# the district.
|
||||||
|
MENU_PAGE_URL = "https://www.kyrene.org/fs/resource-manager/view/32bb3f8b-5a50-4b38-ad68-a5d3108de571"
|
||||||
|
|
||||||
|
MODEL = "claude-opus-5"
|
||||||
|
|
||||||
|
_MENU_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"days": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": {"type": "string", "description": "ISO date, YYYY-MM-DD"},
|
||||||
|
"items": {"type": "array", "items": {"type": "string"}},
|
||||||
|
},
|
||||||
|
"required": ["date", "items"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["days"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
_PROMPT = """This PDF is a school cafeteria's monthly lunch calendar (grades K-5). It's a
|
||||||
|
calendar-grid layout with one cell per school day, each listing a Breakfast
|
||||||
|
section and a Lunch section.
|
||||||
|
|
||||||
|
Extract ONLY the Lunch items (ignore Breakfast) for every day of the month
|
||||||
|
shown on the calendar. For each day 1 through the last day of the month:
|
||||||
|
- If the calendar shows lunch items for that day, list them.
|
||||||
|
- If the day has no cafeteria menu (weekend, holiday, or a no-school day
|
||||||
|
like a break), return an empty items list for that date.
|
||||||
|
|
||||||
|
Read the month and year printed on the calendar itself to build the ISO
|
||||||
|
dates. Return every calendar day of the month, in date order."""
|
||||||
|
|
||||||
|
|
||||||
def _fetch_week(date_str: str) -> dict:
|
def _fetch_current_pdf() -> bytes:
|
||||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
resp = requests.get(MENU_PAGE_URL, timeout=20)
|
||||||
url = f"{API_BASE}/{SCHOOL_SLUG}/menu-type/{MENU_TYPE}/{dt.year}/{dt.month:02d}/{dt.day:02d}/"
|
|
||||||
log.info(f"Fetching {url}")
|
|
||||||
|
|
||||||
resp = requests.get(
|
|
||||||
url,
|
|
||||||
headers={"Accept": "application/json", "User-Agent": "Mozilla/5.0"},
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
def _extract_items(day: dict) -> list[str]:
|
def get_month_menu() -> list[dict]:
|
||||||
items = []
|
"""Fetches whichever monthly K-5 lunch menu PDF is currently published
|
||||||
for entry in day.get("menu_items", []):
|
and asks Claude to read it into structured per-day data. Returns
|
||||||
if entry.get("is_section_title") or entry.get("is_station_header"):
|
[{"date": "YYYY-MM-DD", "items": [...]}, ...] for every day of that
|
||||||
continue
|
month — days with no posted menu (weekends, breaks, holidays) come back
|
||||||
food = entry.get("food")
|
with an empty items list, same contract the old Nutrislice-based scraper
|
||||||
if food and food.get("name"):
|
used."""
|
||||||
items.append(food["name"].strip())
|
log.info(f"Fetching {MENU_PAGE_URL}")
|
||||||
return items
|
pdf_b64 = base64.standard_b64encode(_fetch_current_pdf()).decode("utf-8")
|
||||||
|
|
||||||
|
client = anthropic.Anthropic()
|
||||||
|
response = client.messages.create(
|
||||||
|
model=MODEL,
|
||||||
|
max_tokens=8000,
|
||||||
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "document",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "application/pdf",
|
||||||
|
"data": pdf_b64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"type": "text", "text": _PROMPT},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
output_config={"format": {"type": "json_schema", "schema": _MENU_SCHEMA}},
|
||||||
|
)
|
||||||
|
|
||||||
def get_week_menu(date_str: str | None = None) -> list[dict]:
|
text = next(b.text for b in response.content if b.type == "text")
|
||||||
"""Returns every day in the school-published week containing date_str,
|
return json.loads(text)["days"]
|
||||||
as [{"date": "YYYY-MM-DD", "items": [...]}, ...] in the API's own order.
|
|
||||||
Days with no posted menu (weekends, breaks, holidays) have an empty items list."""
|
|
||||||
if date_str is None:
|
|
||||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
data = _fetch_week(date_str)
|
|
||||||
return [
|
|
||||||
{"date": day.get("date"), "items": _extract_items(day)}
|
|
||||||
for day in data.get("days", [])
|
|
||||||
if day.get("date")
|
|
||||||
]
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue