Skip to content

Repository files navigation

Tuore

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).

Repo layout

  • index.html — the entire app (recipes, Grab & Go healthy-snack picker, meal logging, barcode scanner via ZXing + Open Food Facts, admin-free product catalog P, ~2000 recipes in a meals array, GRABGO/SAUCES arrays). Large file (~2.7MB) because all data is inlined, not fetched.
  • admin.html — internal dashboard reading admin_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 into index.html in 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.

Two-repo setup

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.

Data pipeline

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 the P dict, patches index.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 — feeds admin.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.pyclassify_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).

Category-confidence audit

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 to None/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 from CRISPS_KEYWORDS and making the K-Ruoka category path win before any name-keyword check runs, not after.

Unattended self-diagnosis

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.)

Automation (.github/workflows/)

  • sync-public.yml — on every push to main that touches the app build files, copies them to tuore-app-public and pushes. Needs the PUBLIC_REPO_TOKEN repo secret (fine-grained PAT, scoped only to tuore-app-public, Contents read/write). The push then triggers the public repo's own pages.yml (see "Two-repo setup" above).

  • claude-auto-triage.yml — runs Claude automatically on every newly opened issue (including ones report_failure.py files, see above): comments with a triage (summary/root cause/priority), and opens a fix PR for clear, low-risk bugs. Needs CLAUDE_CODE_OAUTH_TOKEN (a Claude subscription token, not a separate API key/bill) and the Claude GitHub 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):

    1. Is CLAUDE_CODE_OAUTH_TOKEN actually valid? (401 "Invalid bearer token" in the Action logs = regenerate via claude setup-token and re-save the secret — a bad copy-paste is the usual cause.)
    2. 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" (bare issues: opened, no triggering comment).
    3. Is the claude_args: --allowedTools "..." allowlist still present? The action's Bash tool is disabled by default; without an explicit allowlist every gh/git call gets silently auto-denied (no interactive approver exists in headless CI).
    4. Is ${{ github.event.issue.number }} still interpolated into the prompt: 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: true can be temporarily re-added to see the real error text from a failing run (hidden by default "for security").
  • cloudflare-test.yml — unrelated reachability smoke test, manually triggered or on changes to scraper/cf_test.py.

Gotchas / things that cost real debugging time once

  • 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 gets 403 on 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.py should be checked after the K-Ruoka category path, not before, or it can silently steal items from an unrelated section.

Live app

https://simonkundrik.github.io/tuore-app-public/

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages