groceries: add Picnic store (picnic.app storefront API, full range via category pages)
Deploy production / deploy (push) Successful in 1m24s

- picnic.ts: crawls the category tree, subcategory and overview pages (RSC), maps
  Picnic categories to the shared list; no EANs, so matching is by name/brand/size
- picnic-auth.ts + scripts/picnic-login.ts: one-shot login from
  /etc/grocery-tracker/picnic.env, token kept in ~/.config/groceries/picnic.env;
  2FA is never requested unattended (run picnic-login.ts by hand)
- migration: store row picnic; Picnic as own brand in stats
This commit is contained in:
stan committed 2026-10-27 09:04:26 +00:00
1 parent 079399635d
commit c094754c5b
11 files changed
+46305 -2

No files matched your search

@@ -0,0 +1,4 @@
-- Picnic (picnic.app, app-only delivery supermarket).
INSERT INTO "groceries"."stores" ("slug", "name", "website", "color", "sort_order") VALUES
('picnic', 'Picnic', 'https://picnic.app/nl/', '#e1171e', 120)
ON CONFLICT ("slug") DO NOTHING;
File diff suppressed because it is too large. Load diff
+58
View File
@@ -0,0 +1,58 @@
// Picnic session setup for the grocery importer (see src/lib/server/groceries/picnic-auth.ts).
//
// bun scripts/picnic-login.ts check the saved session; if missing/expired, log in once
// and, when Picnic asks for 2FA, have it text the code
// bun scripts/picnic-login.ts verify N finish that login with the texted code N
// bun scripts/picnic-login.ts resend C send the pending login's code again via C (SMS or EMAIL)
// bun scripts/picnic-login.ts status only check the saved session
//
// Never prints credentials or the auth key. One login attempt per run.
import {
PicnicAuthError,
checkAuth,
loadState,
login,
requestSmsCode,
saveState,
stateFile,
verifySmsCode
} from '../src/lib/server/groceries/picnic-auth';
const [cmd, arg] = process.argv.slice(2);
const s = loadState();
try {
if (cmd === 'verify') {
if (!arg || !/^\d{4,8}$/.test(arg)) throw new PicnicAuthError('usage: verify <code>');
await verifySmsCode(s, arg);
console.log(`picnic: 2FA verified; session saved to ${stateFile()}`);
console.log(`picnic: session ${(await checkAuth(s)) ? 'works' : 'REJECTED'}`);
} else if (cmd === 'resend') {
const channel = (arg ?? 'SMS').toUpperCase();
if (channel !== 'SMS' && channel !== 'EMAIL')
throw new PicnicAuthError('usage: resend SMS|EMAIL');
await requestSmsCode(s, channel);
console.log(
`picnic: 2FA code requested via ${channel}. Run: bun scripts/picnic-login.ts verify <code>`
);
} else if (cmd === 'status') {
console.log(`picnic: session ${(await checkAuth(s)) ? 'works' : 'missing or expired'}`);
} else if (!cmd) {
if (await checkAuth(s)) {
console.log('picnic: saved session still works; nothing to do');
} else {
saveState(s); // persist the device id before logging in with it
const r = await login(s);
if (r === 'ok') console.log(`picnic: logged in; session saved to ${stateFile()}`);
else {
await requestSmsCode(s);
console.log(
'picnic: 2FA required; Picnic is texting a code. Run: bun scripts/picnic-login.ts verify <code>'
);
process.exitCode = 2;
}
}
} else throw new PicnicAuthError(`unknown command ${cmd}`);
} catch (e) {
console.error(e instanceof Error ? e.message : String(e));
process.exitCode = 1;
}
+1
View File
@@ -192,6 +192,7 @@ const HOUSE: Record<string, string> = {
'albert heijn': 'house',
jumbo: 'house',
plus: 'house',
picnic: 'house',
'ah biologisch': 'house-bio',
'jumbo biologisch': 'house-bio',
'plus biologisch': 'house-bio',
+190
View File
@@ -0,0 +1,190 @@
// Picnic (picnic.app) login for the grocery importer. Picnic has no public catalogue: every
// page needs an account's auth key (the app's unofficial storefront API, as used by
// python-picnic-api / picnic-api). Secrets handling:
// - credentials are read from a 0600 env file (default /etc/grocery-tracker/picnic.env with
// PICNIC_EMAIL / PICNIC_PASSWORD), only when a (re)login is needed, and never logged;
// - the resulting auth key and this installation's device id are kept in a 0600 state file
// (default ~/.config/groceries/picnic.env), outside the repo, and reused until they expire;
// - a login that needs two-factor auth (first login from a new device) is never completed
// unattended: `bun scripts/picnic-login.ts` requests the SMS and verifies the code.
import { createHash, randomBytes } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
export const PICNIC_API = 'https://storefront-prod.nl.picnicinternational.com/api/15';
/** App identity sent with page requests (client 30100 = the Android app). */
export const PICNIC_AGENT = '30100;1.251.0-15611;';
export const credentialsFile = () =>
process.env.PICNIC_CREDENTIALS_FILE || '/etc/grocery-tracker/picnic.env';
export const stateFile = () =>
process.env.PICNIC_STATE_FILE || join(homedir(), '.config', 'groceries', 'picnic.env');
export class PicnicAuthError extends Error {}
/** Parse KEY=value lines (optionally quoted); comments and blank lines ignored. */
export function parseEnv(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const raw of text.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const i = line.indexOf('=');
if (i < 1) continue;
let v = line.slice(i + 1).trim();
if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v.at(-1) === v[0]) v = v.slice(1, -1);
out[
line
.slice(0, i)
.replace(/^export\s+/, '')
.trim()
] = v;
}
return out;
}
export type PicnicState = {
/** Device id this installation presents to Picnic (stable, so 2FA is needed once). */
did: string;
/** Working auth key. */
auth?: string;
/** Auth key of a login still waiting for its 2FA code. */
pending?: string;
};
export function loadState(file = stateFile()): PicnicState {
const e = existsSync(file) ? parseEnv(readFileSync(file, 'utf8')) : {};
return {
did: e.PICNIC_DID || randomBytes(8).toString('hex').toUpperCase(),
auth: e.PICNIC_AUTH || undefined,
pending: e.PICNIC_AUTH_PENDING || undefined
};
}
export function saveState(s: PicnicState, file = stateFile()) {
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
const lines = [
'# Picnic session for the grocery importer (written by picnic-auth.ts). Secret: keep 0600.',
`PICNIC_DID=${s.did}`,
...(s.auth ? [`PICNIC_AUTH=${s.auth}`] : []),
...(s.pending ? [`PICNIC_AUTH_PENDING=${s.pending}`] : [])
];
const tmp = `${file}.tmp`;
writeFileSync(tmp, lines.join('\n') + '\n', { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, file);
}
export const picnicHeaders = (s: PicnicState, auth = s.auth): Record<string, string> => ({
'User-Agent': 'okhttp/4.9.0',
'Content-Type': 'application/json; charset=UTF-8',
'Accept-Language': 'nl',
'x-picnic-agent': PICNIC_AGENT,
'x-picnic-did': s.did,
...(auth ? { 'x-picnic-auth': auth } : {})
});
/** Picnic's error code from an error body ({ error: { code } }), without echoing the body. */
async function errorCode(res: Response): Promise<string> {
try {
const j = (await res.json()) as { error?: { code?: string } };
return j?.error?.code ?? `HTTP ${res.status}`;
} catch {
return `HTTP ${res.status}`;
}
}
function readCredentials(): { email: string; password: string } {
const file = credentialsFile();
let text: string;
try {
text = readFileSync(file, 'utf8');
} catch {
throw new PicnicAuthError(`picnic: cannot read credentials file ${file}`);
}
const e = parseEnv(text);
if (!e.PICNIC_EMAIL || !e.PICNIC_PASSWORD)
throw new PicnicAuthError(`picnic: ${file} lacks PICNIC_EMAIL / PICNIC_PASSWORD`);
return { email: e.PICNIC_EMAIL, password: e.PICNIC_PASSWORD };
}
export type LoginResult = 'ok' | '2fa-required';
/**
* One password login (never retried, so a wrong password cannot lock the account). On success
* the auth key is saved; when Picnic wants a second factor the half-authenticated key is saved
* as pending and '2fa-required' is returned (nothing is sent: see requestSmsCode).
*/
export async function login(s: PicnicState): Promise<LoginResult> {
const { email, password } = readCredentials();
const res = await fetch(`${PICNIC_API}/user/login`, {
method: 'POST',
headers: picnicHeaders(s, undefined),
body: JSON.stringify({
key: email,
secret: createHash('md5').update(password, 'utf8').digest('hex'),
client_id: 30100
}),
signal: AbortSignal.timeout(30_000)
});
if (!res.ok) throw new PicnicAuthError(`picnic: login failed (${await errorCode(res)})`);
const auth = res.headers.get('x-picnic-auth');
const body = (await res.json().catch(() => ({}))) as {
second_factor_authentication_required?: boolean;
};
if (!auth) throw new PicnicAuthError('picnic: login returned no auth key');
if (body.second_factor_authentication_required) {
s.auth = undefined;
s.pending = auth;
saveState(s);
return '2fa-required';
}
s.auth = auth;
s.pending = undefined;
saveState(s);
return 'ok';
}
/** Ask Picnic to send the 2FA code for the pending login ('SMS' or 'EMAIL'). */
export async function requestSmsCode(s: PicnicState, channel: 'SMS' | 'EMAIL' = 'SMS') {
if (!s.pending) throw new PicnicAuthError('picnic: no login waiting for a 2FA code');
const res = await fetch(`${PICNIC_API}/user/2fa/generate`, {
method: 'POST',
headers: picnicHeaders(s, s.pending),
body: JSON.stringify({ channel }),
signal: AbortSignal.timeout(30_000)
});
if (!res.ok)
throw new PicnicAuthError(`picnic: 2FA code request failed (${await errorCode(res)})`);
}
/** Verify the texted code; Picnic answers with the full auth key. */
export async function verifySmsCode(s: PicnicState, otp: string) {
if (!s.pending) throw new PicnicAuthError('picnic: no login waiting for a 2FA code');
const res = await fetch(`${PICNIC_API}/user/2fa/verify`, {
method: 'POST',
headers: picnicHeaders(s, s.pending),
body: JSON.stringify({ otp }),
signal: AbortSignal.timeout(30_000)
});
if (!res.ok)
throw new PicnicAuthError(`picnic: 2FA verification failed (${await errorCode(res)})`);
const auth = res.headers.get('x-picnic-auth');
if (!auth) throw new PicnicAuthError('picnic: 2FA verification returned no auth key');
s.auth = auth;
s.pending = undefined;
saveState(s);
}
/** Whether the saved auth key still works (GET /user; reads nothing else). */
export async function checkAuth(s: PicnicState): Promise<boolean> {
if (!s.auth) return false;
const res = await fetch(`${PICNIC_API}/user`, {
headers: picnicHeaders(s),
signal: AbortSignal.timeout(30_000)
});
await res.body?.cancel();
if (res.status === 401 || res.status === 403) return false;
if (!res.ok) throw new Error(`picnic: auth check HTTP ${res.status}`);
return true;
}
+95
View File
@@ -0,0 +1,95 @@
// Minimal reader for the React Server Components ("flight") payloads Picnic serves its app pages
// in (text/x-component). Ported from picnic-api (MRVDH/picnic-api, MIT): rows are
// `<hex id>:<json>`; values refer to other rows as "$7"/"$L7" or into them as "$4:props:items:0".
export type RscPage = { rows: Record<string, unknown>; modules: Record<string, unknown> };
const ROW = /^([0-9a-f]+):(I?)(.*)$/;
const ROW_REF = /^\$L?([0-9a-f]+)$/;
const PATH_REF = /^\$([0-9a-f]+):(.+)$/;
type Element = ['$', string, unknown, Record<string, unknown> | undefined];
const isElement = (v: unknown): v is Element =>
Array.isArray(v) && v[0] === '$' && typeof v[1] === 'string';
export function parseRscPayload(payload: string): RscPage {
const page: RscPage = { rows: {}, modules: {} };
for (const line of payload.split('\n')) {
const m = ROW.exec(line);
if (!m) continue;
try {
const v: unknown = JSON.parse(m[3]);
if (m[2]) page.modules[m[1]] = v;
else page.rows[m[1]] = v;
} catch {
// hint / text rows carry no page data
}
}
return page;
}
function resolvePath(page: RscPage, rowId: string, path: string): unknown {
let cur: unknown = page.rows[rowId];
for (const seg of path.split(':')) {
const ref = typeof cur === 'string' ? ROW_REF.exec(cur)?.[1] : undefined;
if (ref !== undefined && ref in page.rows) cur = page.rows[ref];
if (isElement(cur) && seg === 'props') cur = cur[3];
else if (Array.isArray(cur)) cur = cur[Number(seg)];
else if (typeof cur === 'object' && cur !== null) cur = (cur as Record<string, unknown>)[seg];
else return undefined;
}
return cur;
}
/** Row 0 (the page) with every reference resolved; React elements stay ["$", type, key, props]. */
export function decodeRscPage(page: RscPage, value: unknown = page.rows['0']): unknown {
const done = new Map<string, unknown>();
const busy = new Set<string>();
const ref = (key: string, raw: () => unknown) => {
if (done.has(key)) return done.get(key);
if (busy.has(key)) throw new Error(`RSC reference cycle at ${key}`);
busy.add(key);
const v = decode(raw());
busy.delete(key);
done.set(key, v);
return v;
};
const decode = (item: unknown): unknown => {
if (typeof item === 'string') {
if (item === '$undefined') return undefined;
if (item.startsWith('$$')) return item.slice(1);
const id = ROW_REF.exec(item)?.[1];
if (id !== undefined && id in page.rows) return ref(`$${id}`, () => page.rows[id]);
const p = PATH_REF.exec(item);
if (p && p[1] in page.rows) return ref(item, () => resolvePath(page, p[1], p[2]));
return item;
}
if (Array.isArray(item)) {
if (isElement(item)) return ['$', item[1], item[2], decode(item[3] ?? {})];
return item.map(decode).filter((e) => e !== undefined);
}
if (typeof item === 'object' && item !== null) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(item)) {
const d = decode(v);
if (d !== undefined) out[k] = d;
}
return out;
}
return item;
};
return decode(value);
}
/** Every plain object in a decoded value, depth-first, each object once. */
export function* walkObjects(
v: unknown,
seen = new Set<object>()
): Generator<Record<string, unknown>> {
if (typeof v !== 'object' || v === null || seen.has(v)) return;
seen.add(v);
if (Array.isArray(v)) {
for (const x of v) yield* walkObjects(x, seen);
return;
}
yield v as Record<string, unknown>;
for (const x of Object.values(v)) yield* walkObjects(x, seen);
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { parseEnv } from './picnic-auth';
import { decodeRscPage, parseRscPayload } from './picnic-rsc';
import { pageLinks, pageTiles, picnicCategory, picnicItem, TOP } from './picnic';
describe('picnic rsc', () => {
it('resolves row and path references', () => {
const page = parseRscPayload(
[
'0:{"_value":"$L1"}',
'2:I["./x/vertical-list.tsx",["c1"],"X"]',
'1:["$","$L2",null,{"items":"$3","first":"$3:0","gone":"$undefined"}]',
'3:[{"type":"row-item","title":"Zuivel & eieren","deeplink":"app.picnic://store/page;id=subcategory-page,category_id=21730"}]',
':HL["x"]'
].join('\n')
);
const d = decodeRscPage(page) as { _value: [string, string, null, Record<string, unknown>] };
expect(d._value[3].first).toEqual(d._value[3].items && (d._value[3].items as unknown[])[0]);
expect('gone' in d._value[3]).toBe(false);
expect(pageLinks(d)).toEqual([
{ title: 'Zuivel & eieren', page: 'subcategory-page', categoryId: '21730' }
]);
});
});
describe('picnicItem', () => {
const base = {
type: 'sellable',
id: 's1188901',
subtitle: { text: 'Picnic' },
images: { primaryImage: { imageId: 'abc' } },
labels: { bottomLeft: [{ type: 'PROMO', labelText: '15% korting' }] },
price: {
priceValue: 1.77,
referencePriceValue: 2.09,
priceRanges: [{ fromQuantity: 1, price: 177 }]
},
suffixes: [
{ type: 'SIZE', text: 'XL' },
{ type: 'TEXT', text: '1,5 liter' },
{ type: 'TEXT', text: 'In prijs verlaagd' }
],
title: { title: 'volle melk', prefix: 'Bio ' },
unavailability: null
};
it('maps a promoted tile', () => {
expect(picnicItem(base, TOP['21730'], 'Zuivel & eieren / Melk')).toMatchObject({
storeProductId: 's1188901',
name: 'Bio volle melk',
brand: 'Picnic',
sizeText: '1,5 liter',
priceCents: 209,
promoPriceCents: 177,
promoLabel: '15% korting',
category: 'Zuivel & eieren',
available: true,
imageUrl: 'https://storefront-prod.nl.picnicinternational.com/static/images/abc/medium.png'
});
});
it('maps a regular and an unavailable tile', () => {
const regular = { ...base, labels: {}, price: { priceValue: 0.84 } };
expect(picnicItem(regular, TOP['21730'], 'x')).toMatchObject({
priceCents: 84,
promoPriceCents: null,
promoLabel: null
});
expect(picnicItem(regular, TOP['21730'], 'x', true)?.available).toBe(false);
expect(picnicItem({ ...base, title: {} }, TOP['21730'], 'x')).toBeNull();
});
it('flags non-food categories', () => {
expect(picnicItem(base, picnicCategory('21741', 'Wasmiddel'), 'x')?.isFood).toBe(false);
expect(picnicCategory('21734', 'Ontbijtgranen').category).toBe('Ontbijt & beleg');
expect(picnicCategory('99999', 'x').category).toBe('Overig');
});
it('collects tiles and unavailable ids from facets', () => {
const d = {
items: [base, { ...base, id: 's2' }, base],
ctx: {
entityIdsByFacetId: {
'relevancy.relevant': ['s1188901', 's2'],
'availability.SHORT_TERM_UNAVAILABLE': ['s2']
}
}
};
const r = pageTiles(d);
expect(r.tiles.map((t) => t.id)).toEqual(['s1188901', 's2']);
expect([...r.unavailable]).toEqual(['s2']);
expect(r.relevant).toBe(2);
});
});
describe('parseEnv', () => {
it('reads plain and quoted values', () => {
expect(parseEnv('# c\nA=1\nexport B="x=y"\n\nC=\'z\'')).toEqual({ A: '1', B: 'x=y', C: 'z' });
});
});
+262
View File
@@ -0,0 +1,262 @@
// Picnic (picnic.app) catalogue source. Picnic has no public web shop: the app's storefront API
// needs an account session (see picnic-auth.ts; read-only use, nothing touches the cart). The app's
// category pages are React Server Components payloads (picnic-rsc.ts):
// category-tree-root → the top categories (subcategory-page links)
// subcategory-page?category_id=… → the sub-categories (category-product-overview links)
// category-product-overview?… → every product tile of the sub-category, in one response
// One request at a time (~1/s); "Custom…" theme categories (glutenvrij, lactosevrij, …) are
// crawled last so a product keeps its regular category. Tiles carry no EAN (nor does the product
// page), so Picnic is matched on name/brand/size.
import { HttpError, type PoliteFetcher } from './http';
import {
PICNIC_API,
PicnicAuthError,
checkAuth,
loadState,
login,
picnicHeaders,
type PicnicState
} from './picnic-auth';
import { decodeRscPage, parseRscPayload, walkObjects } from './picnic-rsc';
import type { RawItem, Source } from './sources';
const IMAGES = 'https://storefront-prod.nl.picnicinternational.com/static/images';
const PRODUCT_LINK = 'https://picnic.app/nl/link/store/page;id=product-details-page-root,id=';
type Cat = { category: string; isFood?: boolean };
const c = (category: string, isFood?: boolean): Cat => ({ category, isFood });
/** Picnic top category id → shared category (names as of 2026-10). */
export const TOP: Record<string, Cat> = {
'21724': c('Groente, fruit & aardappelen'), // Fruit
'21725': c('Groente, fruit & aardappelen'), // Aardappelen & groente
'21726': c('Vlees, vis & vega'), // Vlees & vis
'32811': c('Vlees, vis & vega'), // Vega & plantaardig
'21727': c('Maaltijden & gemak'),
'21728': c('Brood & bakkerij'), // Bakkerij
'21730': c('Zuivel & eieren'),
'26714': c('Vleeswaren, kaas & tapas'), // Kaas
'21731': c('Vleeswaren, kaas & tapas'), // Vleeswaren & broodsalades
'36362': c('Vleeswaren, kaas & tapas'), // Borrel, tapas & spreads
'21732': c('Ontbijt & beleg'), // Ontbijt & zoet beleg
'21733': c('Pasta, rijst & wereldkeuken'),
'21734': c('Soepen, sauzen & conserven'), // Voorraadkast
'21735': c('Snoep, koek & chips'), // Koek, snoep & snacks
'21740': c('Diepvries'),
'21738': c('Koffie & thee'),
'21736': c('Frisdrank & sappen'), // Drinken
'33825': c('Bier & wijn'), // Bier & aperitieven
'33826': c('Bier & wijn'), // Wijn & bubbels
'38115': c('Baby & kind'),
'37727': c('Drogisterij', false), // Gezichtsverzorging
'38177': c('Drogisterij', false), // Gezondheid & vitamines
'37655': c('Drogisterij', false), // Haarverzorging
'37784': c('Drogisterij', false), // Lichaamsverzorging
'37627': c('Drogisterij', false), // Mondverzorging
'37611': c('Drogisterij', false), // Zonbescherming
'21741': c('Huishouden & dieren', false), // Huishouden
'21744': c('Huishouden & dieren', false), // Dier
'34134': c('Overig', false), // Koken, tafelen & vrije tijd
'38263': c('Overig') // Sport (sports nutrition)
};
/** Sub-category name overrides inside a top category ("top id/regex"). */
const SUB: [string, RegExp, Cat][] = [
['21734', /ontbijt|beleg|broodsmeer/i, c('Ontbijt & beleg')],
['21734', /bak(ken|producten)|meel|suiker/i, c('Ontbijt & beleg')],
['21734', /pasta|rijst|noedel|wereld/i, c('Pasta, rijst & wereldkeuken')],
['21738', /sap|frisdrank/i, c('Frisdrank & sappen')],
['21736', /koffie|thee/i, c('Koffie & thee')],
['21736', /bier|wijn/i, c('Bier & wijn')],
['38115', /verzorging|luiers|billendoek/i, c('Baby & kind', false)]
];
export function picnicCategory(topId: string, subName: string): Cat {
for (const [t, re, cat] of SUB) if (t === topId && re.test(subName)) return cat;
return TOP[topId] ?? c('Overig');
}
export type PicnicTile = {
id?: string;
type?: string;
title?: { title?: string; prefix?: string };
subtitle?: { text?: string };
images?: { primaryImage?: { imageId?: string }; sellingUnitImage?: { imageId?: string } };
price?: {
priceValue?: number;
referencePriceValue?: number;
basePrice?: number;
priceRanges?: { fromQuantity?: number; price?: number }[];
};
discountInfo?: { label?: string } | null;
labels?: Record<string, { type?: string; labelText?: string }[]>;
suffixes?: { type?: string; text?: string }[];
unavailability?: unknown;
};
const cents = (euros: unknown) =>
typeof euros === 'number' && Number.isFinite(euros) && euros > 0 ? Math.round(euros * 100) : null;
export function picnicItem(
t: PicnicTile,
cat: Cat,
path: string,
unavailable = false
): RawItem | null {
const title = t.title?.title?.trim();
if (!t.id || !title) return null;
const name = `${t.title?.prefix?.trim() ? `${t.title.prefix.trim()} ` : ''}${title}`;
const tier1 = t.price?.priceRanges?.find((r) => r.fromQuantity === 1)?.price;
const now =
cents(t.price?.priceValue) ??
(typeof tier1 === 'number' && tier1 > 0 ? Math.round(tier1) : null);
const ref = cents(t.price?.referencePriceValue);
const promo = now !== null && ref !== null && now < ref ? now : null;
const promoText =
Object.values(t.labels ?? {})
.flat()
.find((l) => l?.type === 'PROMO' && l.labelText)?.labelText ?? t.discountInfo?.label;
const texts = (t.suffixes ?? []).filter((s) => s.type === 'TEXT' && s.text).map((s) => s.text!);
const imageId = t.images?.primaryImage?.imageId ?? t.images?.sellingUnitImage?.imageId;
return {
storeProductId: t.id,
name,
brand: t.subtitle?.text?.trim() || null,
sizeText: texts[0]?.trim() || null,
priceCents: promo ? ref : now,
promoPriceCents: promo,
promoLabel: promo ? promoText?.trim() || 'Actie' : null,
storeUnitPrice: null,
storeUnitBasisCents: null,
category: cat.category,
categoryPath: path,
gtin: null,
imageUrl: imageId ? `${IMAGES}/${imageId}/medium.png` : null,
url: `${PRODUCT_LINK}${t.id}`,
available: !unavailable && !t.unavailability && now !== null,
...(cat.isFood !== undefined ? { isFood: cat.isFood } : {})
};
}
type Link = { title: string; page: string; categoryId: string };
/** Category links (row items) of a decoded page: deeplinks like `…;id=subcategory-page,category_id=21724`. */
export function pageLinks(decoded: unknown): Link[] {
const out = new Map<string, Link>();
for (const o of walkObjects(decoded)) {
if (o.type !== 'row-item' || typeof o.deeplink !== 'string' || typeof o.title !== 'string')
continue;
const [page, ...params] = (o.deeplink.split(';id=')[1] ?? '').split(',');
const id = params.find((p) => p.startsWith('category_id='))?.slice('category_id='.length);
if (!id) continue;
const categoryId = decodeURIComponent(id);
out.set(`${page}:${categoryId}`, { title: o.title.trim(), page, categoryId });
}
return [...out.values()];
}
/** Product tiles of a decoded overview page, with the ids Picnic lists as not available. */
export function pageTiles(decoded: unknown): {
tiles: PicnicTile[];
unavailable: Set<string>;
relevant: number | null;
} {
const tiles = new Map<string, PicnicTile>();
const unavailable = new Set<string>();
let relevant: number | null = null;
for (const o of walkObjects(decoded)) {
if (o.type === 'sellable' && typeof o.id === 'string' && !tiles.has(o.id))
tiles.set(o.id, o as PicnicTile);
const facets = o.entityIdsByFacetId as Record<string, unknown> | undefined;
if (facets && typeof facets === 'object') {
for (const [k, ids] of Object.entries(facets)) {
if (!Array.isArray(ids)) continue;
if (k === 'relevancy.relevant') relevant = Math.max(relevant ?? 0, ids.length);
if (k.startsWith('availability.') && k !== 'availability.AVAILABLE')
for (const id of ids) if (typeof id === 'string') unavailable.add(id);
}
}
}
return { tiles: [...tiles.values()], unavailable, relevant };
}
const isTheme = (id: string) => /^Custom/i.test(id);
/** A logged-in session: the saved one, else one password login (never a 2FA prompt unattended). */
async function session(log: (m: string) => void): Promise<PicnicState> {
const s = loadState();
if (await checkAuth(s)) return s;
log(' picnic: saved session missing or expired; logging in');
if ((await login(s)) === '2fa-required')
throw new PicnicAuthError(
'picnic: login needs a 2FA code; run `bun scripts/picnic-login.ts` on stan-ovh (then `verify <code>`)'
);
return s;
}
export const picnicSource: Source = {
slug: 'picnic',
minDelayMs: 1200,
async fetchAll(f: PoliteFetcher, log) {
const s = await session(log);
const only = process.env.PICNIC_ONLY_CATEGORY; // test runs: one top category id
const get = async (page: string, key: string) => {
try {
const text = await f.text(
`${PICNIC_API}/pages/${page}`,
{ headers: picnicHeaders(s) },
key.replace(/[^\w.-]+/g, '_')
);
return decodeRscPage(parseRscPayload(text));
} catch (e) {
if (e instanceof HttpError && (e.status === 401 || e.status === 403))
throw new PicnicAuthError(`picnic: session rejected (HTTP ${e.status})`);
throw e;
}
};
const tops = pageLinks(await get('category-tree-root', 'tree')).filter(
(l) => l.page === 'subcategory-page' && (!only || l.categoryId === only)
);
if (!only && tops.length < 20) throw new Error(`picnic: only ${tops.length} top categories`);
for (const t of tops)
if (!TOP[t.categoryId] && !isTheme(t.categoryId))
log(` picnic: unmapped top category ${t.categoryId} "${t.title}" → Overig`);
tops.sort((a, b) => Number(isTheme(a.categoryId)) - Number(isTheme(b.categoryId)));
// Sub-categories of every top category; theme ones (Custom…) go last overall.
const subs: (Link & { top: Link })[] = [];
for (const top of tops) {
const links = pageLinks(
await get(
`subcategory-page?category_id=${encodeURIComponent(top.categoryId)}`,
`sub-${top.categoryId}`
)
);
for (const l of links) if (l.page === 'category-product-overview') subs.push({ ...l, top });
}
subs.sort(
(a, b) =>
Number(isTheme(a.top.categoryId) || isTheme(a.categoryId)) -
Number(isTheme(b.top.categoryId) || isTheme(b.categoryId))
);
log(` picnic: ${tops.length} categories, ${subs.length} sub-categories`);
const items = new Map<string, RawItem>();
const seen = new Set<string>();
for (const sub of subs) {
if (seen.has(sub.categoryId)) continue;
seen.add(sub.categoryId);
const d = await get(
`category-product-overview?category_id=${encodeURIComponent(sub.categoryId)}`,
`ov-${sub.categoryId}`
);
const { tiles, unavailable, relevant } = pageTiles(d);
if (relevant !== null && relevant > tiles.length)
log(` picnic ${sub.top.title} / ${sub.title}: ${relevant} listed, ${tiles.length} tiles`);
const cat = picnicCategory(sub.top.categoryId, sub.title);
for (const t of tiles) {
const it = picnicItem(t, cat, `${sub.top.title} / ${sub.title}`, unavailable.has(t.id!));
if (it && !items.has(it.storeProductId)) items.set(it.storeProductId, it);
}
}
log(` picnic: ${items.size} products from ${seen.size} sub-categories`);
return [...items.values()];
}
};
+1
View File
@@ -191,6 +191,7 @@ export const STORE_BRANDS = [
'hoogvliet',
'dirk',
'aldi',
'picnic',
'milsani',
'lidl',
'boni',
+3 -1
View File
@@ -1,7 +1,7 @@
// Store catalogue sources. Each returns the full assortment as RawItem[].
// - Albert Heijn: the anonymous mobile-app API (api.ah.nl), crawled per top category.
// - Jumbo: the public GraphQL search endpoint used by jumbo.com, crawled per top category.
// - Plus, Xenos, Amazing Oriental, Lidl, …: see plus.ts, xenos.ts, oriental.ts, lidl.ts, ….
// - Plus, Xenos, Amazing Oriental, Lidl, Picnic, …: see plus.ts, xenos.ts, oriental.ts, lidl.ts, picnic.ts, ….
import type { GroceryUnit } from '../db/schema/groceries';
import { HttpError, PoliteFetcher, USER_AGENT } from './http';
import { normaliseGtin, parseAhUnitPrice, storeUnit } from './normalize';
@@ -10,6 +10,7 @@ import { aldiSource } from './aldi';
import { dirkSource } from './dirk';
import { hoogvlietSource } from './hoogvliet';
import { lidlSource } from './lidl';
import { picnicSource } from './picnic';
import { vomarSource } from './vomar';
import { orientalSource } from './oriental';
import { plusSource } from './plus';
@@ -395,5 +396,6 @@ export const SOURCES: Record<string, Source> = {
hoogvliet: hoogvlietSource,
vomar: vomarSource,
aldi: aldiSource,
picnic: picnicSource,
action: actionSource
};
+2 -1
View File
@@ -59,6 +59,7 @@ export const OWN_BRANDS: { slug: string; rx: string; unbranded: boolean }[] = [
{ slug: 'hoogvliet', rx: `^(hoogvliet|${SUPERUNIE})`, unbranded: true },
{ slug: 'vomar', rx: `^(vomar|${SUPERUNIE})`, unbranded: true },
{ slug: 'aldi', rx: `^(aldi|${ALDI})`, unbranded: true },
{ slug: 'picnic', rx: '^picnic($|\\s)', unbranded: true },
{
slug: 'lidl',
rx: '^(lidl|milbona|pilos|vitasia|chef select|deluxe|solevita|freeway)',
@@ -66,7 +67,7 @@ export const OWN_BRANDS: { slug: string; rx: string; unbranded: boolean }[] = [
}
];
// A group counts as own brand when its display brand is a house class or any store's own label.
const GROUP_HOUSE_RX = `^(huismerk|ah|albert heijn|jumbo|plus|dirk|hoogvliet|vomar|aldi|${SUPERUNIE}|${ALDI})`;
const GROUP_HOUSE_RX = `^(huismerk|ah|albert heijn|jumbo|plus|dirk|hoogvliet|vomar|aldi|picnic|${SUPERUNIE}|${ALDI})`;
/** Staples basket ("Gouda shopping route"). rx/nrx match lower(name); qty in kg, l or pieces. */
const STAPLES: (Staple & {