Files
felhom.eu/documentation/audits/website-refresh-2026-10-08/shots/tools/mealie.js
T
admin 67b3fd23d1
gates / gates (push) Failing after 13m1s
website refresh: true numbers (56 apps), claims checked against the capability map, ten missing apps, an English twin of every public page (public, choice B), 404 page; site gates 13-18 + 11 decoys; R-902 opened
Part A: claim table documentation/audits/website-refresh-2026-10-08/claims.md; "100% open source" corrected
from the licence read; unbacked claims cut (firewall, RAID, snapshots, new-machine restore, self-managed
mode, household VPN, "never lost"). Part B: dawarich, docmost, grimmory, homebox, karakeep, mealie, metube,
radicale, recipe-importer, sparkyfitness cards; plant-it and wger cut (not offered). Part C: /en/ twins,
nav language switch, hreflang both ways, sitemap with xhtml:link, og-image-en.png. Part D: site_gates.py
twins/lang/same-apps/no-Hungarian/FAQ-JSON-LD/tail. Operator ruling 9 + choice B in 10-localisation.md §11,
§10.7. Register: R-902 (contact mailer source in no repo); R-813, R-784, R-559 annotated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-08 08:29:17 +02:00

121 lines
8.0 KiB
JavaScript

// Mealie screenshots for the website card: on scratch guest 9202, headless Chrome through the box's own traefik,
// passing the setup gate as the household. The first account (changeme@example.com, the password this install
// generated) adds a few INVENTED recipes and a week's meal plan through Mealie's own API, then its own UI is shot.
// No recipe site is contacted.
const puppeteer = require('puppeteer');
const fs = require('fs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const S = JSON.parse(fs.readFileSync('/out/secrets.json'));
const D = process.env.DOMAIN, URL = `https://${process.env.SUB}.${D}`;
const RECIPES = [
['Rakott krumpli', 'Réteges, tejfölös, kolbászos egytálétel a hétvégére.', '6 adag', '1 óra 30 perc',
['1,5 kg burgonya', '8 tojás', '300 g parasztkolbász', '500 g tejföl', 'só, bors, vaj'],
['A burgonyát és a tojásokat héjában megfőzzük, majd felkarikázzuk.', 'Kivajazott tálba rétegezzük a burgonyát, a tojást és a kolbászt.',
'Minden réteget megkenünk tejföllel, a tetejére is tejföl kerül.', '180 fokon kb. 40 percig sütjük.']],
['Gulyásleves', 'Bográcsban vagy fazékban, ahogy a nagymama főzte.', '8 adag', '2 óra',
['800 g marhalábszár', '2 fej vöröshagyma', '2 sárgarépa', '1 fehérrépa', '600 g burgonya', 'pirospaprika, kömény'],
['A hagymát zsíron üvegesre pároljuk, rászórjuk a pirospaprikát.', 'Hozzáadjuk a felkockázott húst, és fehéredésig pirítjuk.',
'Felöntjük vízzel, és fedő alatt puhára főzzük.', 'Hozzáadjuk a zöldségeket és a burgonyát, készre főzzük.']],
['Túrós csusza', 'Gyors vacsora tepertővel.', '4 adag', '30 perc',
['500 g széles metélt', '500 g tehéntúró', '200 g tejföl', '150 g tepertő'],
['A tésztát sós vízben kifőzzük.', 'A tepertőt kicsit megpirítjuk.', 'A tésztára túrót, tejfölt és tepertőt szórunk.']],
['Meggyes pite', 'Linzeres tészta, savanykás meggytöltelék.', '12 szelet', '1 óra 15 perc',
['500 g liszt', '250 g vaj', '150 g porcukor', '2 tojás', '800 g magozott meggy', '3 ek darált keksz'],
['A tészta hozzávalóit összegyúrjuk, és fél órára hűtőbe tesszük.', 'A tészta felét tepsibe nyújtjuk, megszórjuk keksszel.',
'Ráterítjük a meggyet, befedjük a másik tésztalappal.', '180 fokon aranybarnára sütjük.']],
['Lecsó', 'Nyári klasszikus, tojással vagy kolbásszal.', '4 adag', '40 perc',
['1 kg TV paprika', '500 g paradicsom', '2 fej vöröshagyma', 'só, pirospaprika'],
['A hagymát megpároljuk.', 'Hozzáadjuk a felcsíkozott paprikát, majd a paradicsomot.', 'Sűrűre főzzük.']],
['Palacsinta', 'Lekvárral, kakaóval vagy túróval.', '15 darab', '45 perc',
['250 g liszt', '3 tojás', '5 dl tej', '2 dl szódavíz', 'csipet só', 'olaj a sütéshez'],
['A hozzávalókból csomómentes tésztát keverünk.', 'Fél órát pihentetjük.', 'Forró serpenyőben vékonyra kisütjük.']],
];
(async () => {
const b = await puppeteer.launch({args: ['--no-sandbox', '--ignore-certificate-errors', '--lang=hu-HU',
'--host-resolver-rules=MAP * ' + process.env.TRAEFIK]});
const p = await b.newPage();
await p.setCookie({name: 'felhom_session', value: S.session, domain: `felhom.${D}`, path: '/', secure: true});
await p.setViewport({width: 1280, height: 800});
await p.goto(URL + '/login', {waitUntil: 'networkidle2'});
await sleep(1500);
console.log('landed', p.url().replace(/\?.*/, ''));
if (!S.skipseed) {
const r = await p.evaluate(async (S, RECIPES) => {
const out = [];
const fd = new URLSearchParams({username: 'changeme@example.com', password: S.gen.ADMIN_PASSWORD});
const t = await fetch('/api/auth/token', {method: 'POST', body: fd});
out.push(['auth', t.status]);
if (t.status !== 200) return out;
const tok = 'Bearer ' + (await t.json()).access_token;
const J = async (m, u, body) => { const x = await fetch(u, {method: m, headers: {'Content-Type': 'application/json', Authorization: tok}, body: body ? JSON.stringify(body) : undefined}); return [x.status, await x.text()]; };
const ids = [];
for (const [name, desc, yld, total, ing, steps] of RECIPES) {
const [c, slugTxt] = await J('POST', '/api/recipes', {name});
const slug = JSON.parse(slugTxt);
const [gc, full] = await J('GET', '/api/recipes/' + slug);
const o = JSON.parse(full);
o.description = desc; o.recipeYield = yld; o.totalTime = total;
o.recipeIngredient = ing.map(n => ({note: n, display: n, quantity: 0, disableAmount: true}));
o.recipeInstructions = steps.map(s => ({text: s, title: '', ingredientReferences: []}));
const [pc, pt] = await J('PUT', '/api/recipes/' + slug, o);
out.push([name, c, gc, pc, pc >= 300 ? pt.slice(0, 160) : '']);
ids.push(o.id);
}
// a week's meal plan from today
const d0 = new Date();
for (let i = 0; i < 6; i++) {
const d = new Date(d0.getTime() + i * 86400000).toISOString().slice(0, 10);
const [mc, mt] = await J('POST', '/api/households/mealplans', {date: d, entryType: 'dinner', recipeId: ids[i % ids.length]});
out.push(['plan ' + d, mc, mc >= 300 ? mt.slice(0, 120) : '']);
}
// the account's display name and language
const [uc, ut] = await J('GET', '/api/users/self');
const u = JSON.parse(ut);
return out.concat([['self', uc, u.fullName, u.locale || u.language || '']]);
}, S, RECIPES);
console.log(JSON.stringify(r));
}
// sign in: Mealie's /api/auth/token answer sets its own session cookie in this browser (no form typing: the login
// page pre-fills the demo defaults over typed text, and five wrong passwords lock the account for hours)
const st = await p.evaluate(async (pw) => {
const fd = new URLSearchParams({username: 'changeme@example.com', password: pw, remember_me: 'true'});
return (await fetch('/api/auth/token', {method: 'POST', body: fd, credentials: 'same-origin'})).status;
}, S.gen.ADMIN_PASSWORD);
console.log('sign-in status', st);
await p.goto(URL + '/', {waitUntil: 'networkidle2'});
await sleep(2000);
if (p.url().includes('/admin/setup')) { // Mealie's first-login wizard: its own "already set up" button
await p.click("::-p-text(I'm already set up)").catch(e => console.log('no skip button', e.message));
await sleep(3000);
console.log('after setup skip', p.url().replace(URL, ''));
}
if (S.rename) { // the first account's display name: an invented household name instead of "Change Me"
console.log('rename', await p.evaluate(async () => {
const u = await (await fetch('/api/users/self')).json();
u.fullName = 'Demo Család';
return (await fetch('/api/users/' + u.id, {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(u)})).status;
}));
}
// Mealie's own language switch keeps the choice in this cookie: Hungarian
await p.setCookie({name: 'i18n_redirected', value: 'hu-HU', domain: `${process.env.SUB}.${D}`, path: '/', secure: true});
await p.goto(URL + '/g/home', {waitUntil: 'networkidle2'});
await sleep(4000);
console.log('after login', p.url().replace(URL, ''));
await p.screenshot({path: '/out/1.png'});
const home = p.url();
await p.click('::-p-text(Rakott krumpli)').catch(e => console.log('no recipe card', e.message));
await sleep(4000);
console.log('shot 2 at', p.url().replace(URL, ''));
await p.screenshot({path: '/out/2.png'});
const recipeUrl = p.url();
await p.goto(URL + '/household/mealplan/planner/view', {waitUntil: 'networkidle2'}).catch(() => {});
await sleep(3000);
console.log('shot 3 at', p.url().replace(URL, ''));
await p.screenshot({path: '/out/3.png'});
await p.setViewport({width: 390, height: 844, deviceScaleFactor: 2});
await p.goto(recipeUrl, {waitUntil: 'networkidle2'}).catch(() => {});
await sleep(3000);
await p.screenshot({path: '/out/4.png'});
await b.close();
})().catch(e => { console.error(e); process.exit(1); });