A meal-planning PWA that suggests recipes based on real, currently-in-stock
grocery products and prices at K-Supermarket Hyvätuuli, Kotka, Finland
(K-Ruoka / Kesko). No framework, no build step — a single index.html with
inline CSS/JS, a manifest.json, and a service worker (sw.js).
index.html— the entire app (recipes, Grab & Go healthy-snack picker, meal logging, barcode scanner via ZXing + Open Food Facts, admin-free product catalogP, ~2000 recipes in amealsarray,GRABGO/SAUCESarrays). Large file (~2.7MB) because all data is inlined, not fetched.admin.html— internal dashboard readingadmin_status.json, for monitoring the scraper pipeline's health. Not linked from the app itself.scraper/— Python + Playwright pipeline that scrapes k-ruoka.fi for stock/price/nutrition data and patches it intoindex.htmlin place (see "Data pipeline" below).data/— large source datasets used to originally build the recipe pool (Food.com dumps, interaction CSVs) — not touched by day-to-day jobs..github/workflows/— see "Automation" below.
This (tuore-app) is the private source of truth. There's a second,
public repo, tuore-app-public, which mirrors just the deployable files
(index.html, manifest.json, sw.js, icons/) and is served live via
GitHub Pages at https://simonkundrik.github.io/tuore-app-public/ — that's
the actual installable PWA end users get. It has no scraper, no admin panel,
no datasets.
tuore-app-public's Pages is configured as build type "GitHub Actions"
(not "deploy from a branch"), driven by a custom .github/workflows/pages.yml
in that repo that retries the deploy up to 3 spaced-out times per run. This
was added because GitHub's Pages backend intermittently returned
"Deployment failed, try again later" (~1 in 4 deploys, always self-recovering
on the next sync) — the retry absorbs those transients so they stop surfacing
as failed runs. So there is intentionally no pages-build-deployment run
on the public repo anymore; deploys show up as the "Deploy to GitHub Pages"
workflow instead.
Never enable GitHub Pages on this (private) repo without deliberately picking a branch. It was accidentally left publishing the whole repo (admin panel included) from the default branch earlier and had to be unpublished — see commit history around July 2026 if this needs revisiting.
Product prices/stock/nutrition are scraped from k-ruoka.fi by Python scripts
in scraper/ running on a cron-scheduled Oracle Cloud VM (Oracle Linux,
user opc). Key jobs (see crontab -l on the VM for the live schedule):
daily_refresh.py— re-scrapes stock/price for the ~109 ingredients in thePdict, patchesindex.html, validates, commits+pushes.hourly_photo_refresh.py— recipe photo refresh.weekly_refresh.py/monthly_refresh.py— recipe + sauces pool refresh.monthly_catalog_refresh.py— full store catalog re-crawl, rebuilds Grab & Go recommendations, then runs the category-confidence audit (below).grabgo_stock_refresh.py— daily availability sweep for Grab & Go items specifically (added 2026-07-03; only walks the 5 category listings Grab & Go draws from, not the full catalog).write_admin_status.py— feedsadmin.html, runs every 10 min.
All of these share one Chrome remote-debugging session on the VM, so new cron entries must not overlap with the existing time slots.
Budget filter thresholds (budget/verybudget tags) are computed live in
index.html from actual per-serving price (Math.round(t.per*100)/100 < 2),
not hardcoded per-recipe — this was a real bug once (stale tags after a
price changed) and the fix is now the pattern to follow if this logic is
ever touched again.
Grab & Go grouping lives in scraper/build_grabgo_from_catalog.py —
classify_with_basis(item) returns both the group and why: a basis of
path (K-Ruoka's real subcategory path — the authoritative signal),
override/produce (an explicit special-case, e.g. jerky, dips, whole
fruit/veg), or guess/default (fell back to a name-keyword or a category
default because no rule handles that subcategory yet — the classifications
most likely to be wrong; see the audit below). classify_group is a thin
wrapper that just discards the basis. There are 23 sections today, including
ready_pasta ("Fresh pasta" — fettuccine/tortellini/tortelloni/ravioli/etc.,
filed by what it physically is rather than lumped into "Ready meals", since
you still cook it yourself rather than just reheating it) and dips ("Dips
& spreads" — hummus/guacamole/tzatziki/etc., which K-Ruoka's own category
page had bundled in with Salads/Fresh fruit). insert_grabgo.py regenerates
both the GRABGO data block and the GRABGO_SECTIONS front-end list
from that data, so the sections can never drift from the groups the
pipeline produces. LOW_CAL_THRESHOLD there controls how strict the
"healthy-ish" filter is per section (raise a threshold to fill a thin
section out with lighter-end options).
Because keyword-guessing is a fallback, not the primary signal,
build_grabgo_from_catalog.py also writes grabgo_low_confidence.json —
every item whose basis came back guess or default rather than a real
K-Ruoka subcategory path. audit_grabgo_categories.py diffs that against
grabgo_category_baseline.json (the previously-seen set) to find items that
are genuinely new since last run, and — with --file-issue (what
monthly_catalog_refresh.py runs automatically after every catalog refresh)
— files a GitHub issue listing them for claude-auto-triage.yml to review,
then refreshes the baseline so each item only surfaces once.
This is how two real miscategorizations got caught, both worth remembering as failure modes if this classifier is extended further:
- Tofu slugged into "Yogurts" — its real path was
liha-ja-kasviproteiinit(meat & plant protein), but a keyword match fired anyway. Fixed by short-circuiting toNone/uncategorized whenever the path's top-level segment isn't a recognized category, instead of falling through to keyword guessing. - A generic
'snack'/'snacks'keyword hijacked chocolate bars and cookies into "Crisps" (e.g. Fazer/Puhdas snack bars). Fixed by removing those two words fromCRISPS_KEYWORDSand making the K-Ruoka category path win before any name-keyword check runs, not after.
Every job above validates index.html before committing (validate_index.py)
and reverts instead of pushing anything broken. On top of that, a failed
validation now also files a GitHub issue via scraper/report_failure.py
(deduplicated — one open issue per job, not a fresh one every day it keeps
failing), which claude-auto-triage.yml then picks up automatically: it
comments a triage and opens a fix PR for anything clear and low-risk. So a
broken scrape gets diagnosed and often fixed without anyone needing to
notice the log file first. report_failure.py exposes this as two entry
points — file_failure_issue(job, errors) for a validation failure, and the
lower-level file_issue(title, body) used directly by the category-confidence
audit above.
One-time setup this needs on the VM: create a fine-grained GitHub PAT
scoped to tuore-app only, with Issues: Read and write and nothing else,
and save it to scraper/keys/gh_issue_token.txt on the VM (already gitignored
via the existing scraper/keys/ rule, same pattern as the Unsplash key).
Without this file present, report_failure.py just logs a skip message and
the job's normal revert+exit(1) behavior is unaffected — it's an additive,
fail-safe layer, not a required dependency. (Done 2026-07-05.)
-
sync-public.yml— on every push tomainthat touches the app build files, copies them totuore-app-publicand pushes. Needs thePUBLIC_REPO_TOKENrepo secret (fine-grained PAT, scoped only totuore-app-public, Contents read/write). The push then triggers the public repo's ownpages.yml(see "Two-repo setup" above). -
claude-auto-triage.yml— runs Claude automatically on every newly opened issue (including onesreport_failure.pyfiles, see above): comments with a triage (summary/root cause/priority), and opens a fix PR for clear, low-risk bugs. NeedsCLAUDE_CODE_OAUTH_TOKEN(a Claude subscription token, not a separate API key/bill) and theClaudeGitHub App (github.com/apps/claude) installed on this repo.If this workflow ever stops posting comments, check these in order (each was a real, separate bug hit while first setting this up):
- Is
CLAUDE_CODE_OAUTH_TOKENactually valid? (401 "Invalid bearer token" in the Action logs = regenerate viaclaude setup-tokenand re-save the secret — a bad copy-paste is the usual cause.) - Is
github_token: ${{ secrets.GITHUB_TOKEN }}still explicitly passed as an input? Without it, the agent has no GitHub API access at all in "agent mode" (bareissues: opened, no triggering comment). - Is the
claude_args: --allowedTools "..."allowlist still present? The action's Bash tool is disabled by default; without an explicit allowlist everygh/gitcall gets silently auto-denied (no interactive approver exists in headless CI). - Is
${{ github.event.issue.number }}still interpolated into theprompt:text? Without it, the agent has no way to know which issue triggered the run and may act on the wrong one entirely.
show_full_output: truecan be temporarily re-added to see the real error text from a failing run (hidden by default "for security").
- Is
-
cloudflare-test.yml— unrelated reachability smoke test, manually triggered or on changes toscraper/cf_test.py.
- Oracle Cloud Shell ≠ the actual VM. Cloud Shell is a separate,
ephemeral sandbox GitHub/Oracle spins up in the browser — commands run
there (
crontab,sudo, etc.) do nothing to the real scraper VM. SSH to the VM directly instead (check the instance's current public IP in the OCI console first — it's not a reserved/static IP and has changed before, which looks identical to a firewall problem if you assume it hasn't). - GitHub Actions secrets are write-only. Clicking "Update" on an existing secret always shows an empty box — that's normal, not a sign the secret is missing. There's no way to read a secret's value back via the UI, ever.
.github/workflows/needs a separate token scope. A fine-grained PAT with "Contents: Read and write" still gets403on paths under.github/workflows/unless "Workflows: Read and write" is also granted.- Transient Pages "try again later" failures on the public repo are
handled by the retry in its
pages.yml— if deploys start failing hard (all 3 attempts), that's a real/sustained Pages problem, not the usual transient. - Grab & Go keyword matching is a last resort, not a primary signal —
see "Category-confidence audit" above. Any new keyword list added to
build_grabgo_from_catalog.pyshould be checked after the K-Ruoka category path, not before, or it can silently steal items from an unrelated section.