groceries: add Picnic store (picnic.app storefront API, full range via category pages)
Deploy production / deploy (push) Successful in 1m24s
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:
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
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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()];
|
||||
}
|
||||
};
|
||||
@@ -191,6 +191,7 @@ export const STORE_BRANDS = [
|
||||
'hoogvliet',
|
||||
'dirk',
|
||||
'aldi',
|
||||
'picnic',
|
||||
'milsani',
|
||||
'lidl',
|
||||
'boni',
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 & {
|
||||
|
||||
Reference in new issue
Block a user