openvibers/openvibe.food Commits

Food: HelloFresh's price is not listed, and the page says why (#175)

OP OpenVibers committed 12 days ago · 83842b9746 · 4 files, +36 −9

HelloFresh shows its plan prices only after a postcode and its terms let nobody
copy its pages, so the price and the delivery cannot be read under them. The box
page now says that in one line instead of "not checked yet", and no price is
invented.

Changes

ddiff --git a/README.md b/README.md
iindex 590bcaa..8c0d4cb 100644
---- a/README.md
++++ b/README.md
@@@ -28,7 +28,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it. Checked recipes are OpenVibe.Search documents (owner `food`, type `recipe`) through Search's owner API ([server/food/search-index.js](server/food/search-index.js), `FOOD_SEARCH_URL`, `search.document.write`) |
| Food recalls | [/recalls](server/http/recall-pages.js), [server/food/recalls.js](server/food/recalls.js) | The FDA's food enforcement reports, read from OpenVibe.Sources (source `openfda-food-enforcement`, category `food`, `sources.item.read`): Food follows Sources' change feed hourly from its cursor (`openvibe-publishing/ingest`) and keeps each recall as the report states it (product, reason, company, class, status, where it was sold, dates). `/recalls` filters by words, class and the company's state; `/recalls/:number` is one recall (noindex, as the source's registry entry asks). Every page carries openFDA's note: unvalidated, not for medical decisions, no FDA endorsement. Food never calls the FDA; `OV_SOURCES_INTERNAL_URL` (default `http://127.0.0.1:4720`, empty turns it off) |
--| Food boxes | [/boxes, /boxes/:slug, /boxes/compare](server/http/box-pages.js), [server/food/boxes.js](server/food/boxes.js), [server/food/box-links.js](server/food/box-links.js) | Every meal kit, ready-meal service and food box (produce, meat and fish, snacks, coffee, baby, pets), weekly or monthly, side by side: 38 of them in 8 kinds, a tab per kind, diets as chips, a search row, a box's plans, what its stated price comes to for your plan size, and boxes side by side. Each fact was read on the service's own public pages by a person and carries that page and the day (2026-10-28); nothing is fetched from the services (the 2026-09-28 rule), and a fact nobody checked says so. A box shows its codes from OpenVibe.Coupons and its score from OpenVibe.Reviews (read from their public APIs on the loopback, 10 minutes, 1.5 s at most; off unless `FOOD_COUPONS_URL`/`FOOD_REVIEWS_URL` are set outside production), and links to OpenVibe.Watch for its prices. The same data is `GET /api/v1/boxes`, `/boxes/:slug` and `/boxes/:slug/cost`. |
++| Food boxes | [/boxes, /boxes/:slug, /boxes/compare](server/http/box-pages.js), [server/food/boxes.js](server/food/boxes.js), [server/food/box-links.js](server/food/box-links.js) | Every meal kit, ready-meal service and food box (produce, meat and fish, snacks, coffee, baby, pets), weekly or monthly, side by side: 38 of them in 8 kinds, a tab per kind, diets as chips, a search row, a box's plans, what its stated price comes to for your plan size, and boxes side by side. Each fact was read on the service's own public pages by a person and carries that page and the day (2026-10-28); nothing is fetched from the services (the 2026-09-28 rule), and a fact nobody checked says so. A fact the service's own terms do not allow us to read says why instead, in one line (HelloFresh: its plans page shows a price only after a postcode, and its terms let nobody copy its pages). A box shows its codes from OpenVibe.Coupons and its score from OpenVibe.Reviews (read from their public APIs on the loopback, 10 minutes, 1.5 s at most; off unless `FOOD_COUPONS_URL`/`FOOD_REVIEWS_URL` are set outside production), and links to OpenVibe.Watch for its prices. The same data is `GET /api/v1/boxes`, `/boxes/:slug` and `/boxes/:slug/cost`. |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| Household chat | [/plan/p/:id](server/http/pages.js), [server/food/household-chat.js](server/food/household-chat.js) | The owner of a shared plan starts a private room on OpenVibe.Chat for the household, made through Chat's room API as the owner, with every member added by account subject (and anyone who joins later, when the owner opens the plan) and **Pantry**, an AI member linked to the plan. Writing `@pantry` there asks it: Chat reads the household from `GET /internal/households/:id/chat-context` (`food.household.read`, Chat's service token) — the plan, what is still to buy and every member's cupboard — and OpenVibe.AI answers. The plan's page links the room for everyone |
ddiff --git a/server/food/boxes.js b/server/food/boxes.js
iindex c0e68fe..9d387bf 100644
---- a/server/food/boxes.js
++++ b/server/food/boxes.js
@@@ -8,7 +8,8 @@
* How it is filled, the permitted way (2026-09-28, Q8: official links, a person's own session, licensed data; never
* scraping): each fact below was read on the service's own public page, by a person, and carries that page and the
* day it was read. Nothing here is fetched from the services. A fact nobody checked is null and the page says "not
-- * checked yet" with the link to their site; a price that changes is the service's to show, and OpenVibe.Watch can
++ * checked yet" with the link to their site; a fact the service's own terms do not allow us to read is null too, and
++ * the page says so in one line instead; a price that changes is the service's to show, and OpenVibe.Watch can
* tell you when it does. Codes come from OpenVibe.Coupons and scores from OpenVibe.Reviews at request time
* (./box-links.js); a box never claims either.
*
@@@ -24,6 +25,8 @@
* cancel, notes[], sources[], checked }
* price { from, to, unit: 'serving'|'meal'|'box'|'month'|'delivery'|'day'|'membership', text } or null
* shipping { amount, to, text } or null (amount 0: free)
++ * unreadable why a price is not listed, when the service's own terms do not allow it to be read (the page says it
++ * in one line instead of "not checked yet"); null for every box whose facts were read
*/
const CHECKED = '2026-10-28';
@@@ -45,7 +48,7 @@ const DIETS = {
'calorie-smart': 'Calorie-smart', 'high-protein': 'High-protein', family: 'Family', quick: 'Quick', 'plant-based': 'Plant-based',
};
--const box = (o) => ({ cadence: [], people: [], meals: [], diets: [], price: null, shipping: null, ships_to: null, cancel: null, notes: [], sources: [], checked: CHECKED, ...o });
++const box = (o) => ({ cadence: [], people: [], meals: [], diets: [], price: null, shipping: null, ships_to: null, cancel: null, notes: [], sources: [], checked: CHECKED, unreadable: null, ...o });
const BOXES = [
// ── Meal kits ──
@@@ -58,6 +61,10 @@ const BOXES = [
cadence: ['weekly'], people: [2, 4], meals: [3, 4, 5], diets: ['vegetarian', 'pescatarian', 'calorie-smart', 'gluten-free', 'keto', 'family'],
ships_to: 'The whole US except Alaska and Hawaii', cancel: 'No commitment: pause or cancel by 11:59 pm PT five days before the next delivery',
notes: ['5 recipes a week are for the two-person plan only.', 'Gluten-free and keto are options within the plans, not plans of their own.'],
++ // Its plans page (hellofresh.com/plans) asks for a postcode and shows no price without one, and its terms
++ // (hellofresh.com/about/termsandconditions, 16.8/16.9/18/19(9)) allow nobody to copy its pages: the price and
++ // the delivery cannot be read under them, so the page says so instead of "not checked yet".
++ unreadable: 'HelloFresh shows its plan prices only after a postcode is entered, and its terms (hellofresh.com/about/termsandconditions) let nobody copy its pages, so no price or delivery figure is listed here. Its plans page was read on 2026-11-01.',
sources: ['https://www.hellofresh.com/', 'https://www.hellofresh.com/eat/kid-friendly-meal-delivery', 'https://www.hellofresh.com/eat/healthy-meal-delivery'] }),
box({ slug: 'home-chef', name: 'Home Chef', kind: 'cook', host: 'homechef.com', url: 'https://www.homechef.com/', what: 'Kits plus oven-ready and fast-prep meals; meals can be customized.',
cadence: ['weekly'], price: { from: 6.99, to: null, unit: 'serving', text: 'Meals from $6.99 a serving' },
@@@ -204,6 +211,8 @@ function toWire(b) {
slug: b.slug, name: b.name, kind: b.kind, kind_name: KIND[b.kind].name, url: b.url, host: b.host, what: b.what,
cadence: b.cadence, people: b.people, meals_per_week: b.meals, diets: b.diets,
price: b.price ? { ...b.price, line: priceLine(b.price) } : null, shipping: b.shipping, ships_to: b.ships_to, cancel: b.cancel, notes: b.notes,
++ // Why a price is missing, when the service's own terms do not allow it to be read (null for every other box).
++ unreadable: b.unreadable || null,
sources: b.sources, checked: b.sources.length ? b.checked : null,
links: { page: `https://openvibe.food/boxes/${b.slug}`, coupons: `https://openvibe.coupons/?q=${encodeURIComponent(b.host)}`, reviews: `https://openvibe.reviews/search?q=${encodeURIComponent(b.name)}` },
};
ddiff --git a/server/http/box-pages.js b/server/http/box-pages.js
iindex 788d427..55a2b39 100644
---- a/server/http/box-pages.js
++++ b/server/http/box-pages.js
@@@ -75,8 +75,8 @@ ${list.length ? k(kit.list(list.map(row))) : k(kit.empty({ icon: 'search', title
}
const rows = [
['Kind', (b) => boxes.KIND[b.kind].name],
-- ['Price, as stated', (b) => (b.price ? b.price.text : 'Not checked yet')],
-- ['Shipping', (b) => (b.shipping ? b.shipping.text : 'Not checked yet')],
++ ['Price, as stated', (b) => (b.price ? b.price.text : (b.unreadable ? 'Not read' : 'Not checked yet'))],
++ ['Shipping', (b) => (b.shipping ? b.shipping.text : (b.unreadable ? 'Not read' : 'Not checked yet'))],
['People', (b) => span(b.people) || '—'],
['Meals a week', (b) => span(b.meals) || '—'],
['How often', (b) => b.cadence.join(', ') || '—'],
@@@ -107,7 +107,8 @@ ${picked.length ? html`<div class="bx-compare" role="region" aria-label="The box
const links = boxLinks ? await boxLinks.forBox(b) : { coupons: null, reviews: null };
const head = kit.head({ title: b.name, subHtml: String(html`${kind.name} · <a href="${b.url}" rel="noopener nofollow">${b.host}</a>`), back: { href: `/boxes?kind=${b.kind}`, label: kind.name },
actions: [kit.iconButton({ href: watchHref(b), icon: 'bell', label: `Watch ${b.name}'s prices on OpenVibe.Watch` }), kit.iconButton({ href: b.url, icon: 'open', label: `${b.name}'s site`, attrs: { rel: 'noopener nofollow' } })] });
-- const fact = (label, value) => html`<div class="ovk-set-row bx-fact"><span class="ovk-set-text">${label}</span><span class="bx-val">${value || html`<span class="muted">Not checked yet</span>`}</span></div>`;
++ // A fact nobody read says so; a fact the service's own terms do not allow us to read says that instead.
++ const fact = (label, value, unreadable) => html`<div class="ovk-set-row bx-fact"><span class="ovk-set-text">${label}</span><span class="bx-val">${value || (unreadable ? html`<span class="muted">Not read</span>` : html`<span class="muted">Not checked yet</span>`)}</span></div>`;
const opt = (list, cur) => list.map((n) => html`<option value="${n}"${n === cur ? raw(' selected') : ''}>${n}</option>`);
const sizes = (b.people.length ? b.people : [1, 2, 3, 4, 5, 6]);
const weekly = (b.meals.length ? b.meals : [2, 3, 4, 5, 6, 7]);
@@@ -125,20 +126,21 @@ ${c ? html`<p class="bx-cost"><strong>${range(c.total) || range(c.food)}</strong
const others = boxes.list({ kind: b.kind }).filter((x) => x !== b).slice(0, 4);
page(req, res, {
title: `${b.name}: ${kind.name.toLowerCase()} compared`,
-- description: `${b.name}: ${b.what} ${b.price ? `${b.price.text}. ` : ''}Plan sizes, shipping, diets and how to cancel, from its own pages, with codes and reviews.`.slice(0, 300),
++ description: `${b.name}: ${b.what} ${b.price ? `${b.price.text}. ` : (b.unreadable ? 'No price is listed: it cannot be read under the service\'s terms. ' : '')}Plan sizes, shipping, diets and how to cancel, from its own pages, with codes and reviews.`.slice(0, 300),
index: !req.query.people && !req.query.meals, cache: signedIn(req) ? null : cache.htmlHeaders({ maxAge: 300 }), crumbs: [], styles: ['kit.css'], localStyles: ['css/boxes.css'],
body: html`${k(head)}
<p class="bx-what">${b.what}</p>
${b.price ? html`<p class="bx-price"><strong>${boxes.priceLine(b.price)}</strong>${b.shipping && b.shipping.amount === 0 ? ' · free shipping' : ''}</p>` : ''}
<section class="ovk-section" aria-labelledby="bx-facts-h"><h2 id="bx-facts-h">The plans</h2>
--${fact('Price', b.price ? b.price.text : null)}
--${fact('Shipping', b.shipping ? b.shipping.text : null)}
++${fact('Price', b.price ? b.price.text : null, b.unreadable)}
++${fact('Shipping', b.shipping ? b.shipping.text : null, b.unreadable)}
${fact('People', span(b.people))}
${fact('Meals a week', span(b.meals))}
${fact('How often', b.cadence.join(', '))}
${fact('Diets', b.diets.length ? b.diets.map((d) => boxes.DIETS[d]).join(', ') : null)}
${fact('Delivers to', b.ships_to)}
${fact('Skip and cancel', b.cancel)}
++${b.unreadable ? html`<p class="ovk-note bx-unreadable">${b.unreadable}</p>` : ''}
${b.notes.length ? html`<ul class="ovk-note bx-notes">${b.notes.map((n) => html`<li>${n}</li>`)}</ul>` : ''}
<p class="ovk-note">${b.sources.length ? html`From ${b.name}'s own pages, read ${b.checked}: ${b.sources.map((s, i) => html`${i ? ', ' : ''}<a href="${s}" rel="noopener nofollow">${s.replace(/^https?:\/\/(www\.)?/, '').slice(0, 60)}</a>`)}.` : html`Nothing checked yet beyond what it is; its plans are on <a href="${b.url}" rel="noopener nofollow">its site</a>.`}</p></section>
${calc}
ddiff --git a/test/boxes-pages.test.js b/test/boxes-pages.test.js
iindex 7567baf..ff239e9 100644
---- a/test/boxes-pages.test.js
++++ b/test/boxes-pages.test.js
@@@ -43,6 +43,22 @@ const { boot } = require('./helpers/boot');
assert.doesNotMatch(r.text, /What it comes to/, 'and nothing worked out');
assert.strictEqual((await t.get('/boxes/nope')).status, 404);
++ // ── A price the service's own terms do not allow us to read (HelloFresh): the page says why, in one line,
++ // instead of "not checked yet" ──
++ r = await t.get('/boxes/hellofresh');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /HelloFresh shows its plan prices only after a postcode is entered/, 'the page says why there is no price');
++ assert.match(r.text, /hellofresh\.com\/about\/termsandconditions/, 'and names the terms it may not read under');
++ assert.match(r.text, /Its plans page was read on 2026-11-01/, 'and the day that page was read');
++ assert.doesNotMatch(r.text, /Not checked yet/, 'no bare "not checked yet" where a reason is known');
++ assert.strictEqual((r.text.match(/>Not read</g) || []).length, 2, 'the price and the shipping both say they were not read');
++ r = await t.get('/boxes/compare?b=everyplate,hellofresh');
++ assert.match(r.text, />Not read</, 'the comparison says it too');
++ assert.match(r.text, /Plans from \$5\.99 a serving/, 'and the boxes that do state a price keep it');
++ const hf = (await t.get('/api/v1/boxes/hellofresh')).json().box;
++ assert.strictEqual(hf.price, null, 'no price is invented');
++ assert.match(hf.unreadable, /postcode/, 'the API says why too');
++
// ── Side by side ──
r = await t.get('/boxes/compare?b=everyplate,hellofresh,nope');
assert.match(r.text, /<a href="\/boxes\/everyplate">EveryPlate<\/a>/);