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',
|
'albert heijn': 'house',
|
||||||
jumbo: 'house',
|
jumbo: 'house',
|
||||||
plus: 'house',
|
plus: 'house',
|
||||||
|
picnic: 'house',
|
||||||
'ah biologisch': 'house-bio',
|
'ah biologisch': 'house-bio',
|
||||||
'jumbo biologisch': 'house-bio',
|
'jumbo biologisch': 'house-bio',
|
||||||
'plus 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',
|
'hoogvliet',
|
||||||
'dirk',
|
'dirk',
|
||||||
'aldi',
|
'aldi',
|
||||||
|
'picnic',
|
||||||
'milsani',
|
'milsani',
|
||||||
'lidl',
|
'lidl',
|
||||||
'boni',
|
'boni',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Store catalogue sources. Each returns the full assortment as RawItem[].
|
// Store catalogue sources. Each returns the full assortment as RawItem[].
|
||||||
// - Albert Heijn: the anonymous mobile-app API (api.ah.nl), crawled per top category.
|
// - 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.
|
// - 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 type { GroceryUnit } from '../db/schema/groceries';
|
||||||
import { HttpError, PoliteFetcher, USER_AGENT } from './http';
|
import { HttpError, PoliteFetcher, USER_AGENT } from './http';
|
||||||
import { normaliseGtin, parseAhUnitPrice, storeUnit } from './normalize';
|
import { normaliseGtin, parseAhUnitPrice, storeUnit } from './normalize';
|
||||||
@@ -10,6 +10,7 @@ import { aldiSource } from './aldi';
|
|||||||
import { dirkSource } from './dirk';
|
import { dirkSource } from './dirk';
|
||||||
import { hoogvlietSource } from './hoogvliet';
|
import { hoogvlietSource } from './hoogvliet';
|
||||||
import { lidlSource } from './lidl';
|
import { lidlSource } from './lidl';
|
||||||
|
import { picnicSource } from './picnic';
|
||||||
import { vomarSource } from './vomar';
|
import { vomarSource } from './vomar';
|
||||||
import { orientalSource } from './oriental';
|
import { orientalSource } from './oriental';
|
||||||
import { plusSource } from './plus';
|
import { plusSource } from './plus';
|
||||||
@@ -395,5 +396,6 @@ export const SOURCES: Record<string, Source> = {
|
|||||||
hoogvliet: hoogvlietSource,
|
hoogvliet: hoogvlietSource,
|
||||||
vomar: vomarSource,
|
vomar: vomarSource,
|
||||||
aldi: aldiSource,
|
aldi: aldiSource,
|
||||||
|
picnic: picnicSource,
|
||||||
action: actionSource
|
action: actionSource
|
||||||
};
|
};
|
||||||
@@ -59,6 +59,7 @@ export const OWN_BRANDS: { slug: string; rx: string; unbranded: boolean }[] = [
|
|||||||
{ slug: 'hoogvliet', rx: `^(hoogvliet|${SUPERUNIE})`, unbranded: true },
|
{ slug: 'hoogvliet', rx: `^(hoogvliet|${SUPERUNIE})`, unbranded: true },
|
||||||
{ slug: 'vomar', rx: `^(vomar|${SUPERUNIE})`, unbranded: true },
|
{ slug: 'vomar', rx: `^(vomar|${SUPERUNIE})`, unbranded: true },
|
||||||
{ slug: 'aldi', rx: `^(aldi|${ALDI})`, unbranded: true },
|
{ slug: 'aldi', rx: `^(aldi|${ALDI})`, unbranded: true },
|
||||||
|
{ slug: 'picnic', rx: '^picnic($|\\s)', unbranded: true },
|
||||||
{
|
{
|
||||||
slug: 'lidl',
|
slug: 'lidl',
|
||||||
rx: '^(lidl|milbona|pilos|vitasia|chef select|deluxe|solevita|freeway)',
|
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.
|
// 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. */
|
/** Staples basket ("Gouda shopping route"). rx/nrx match lower(name); qty in kg, l or pieces. */
|
||||||
const STAPLES: (Staple & {
|
const STAPLES: (Staple & {
|
||||||
|
|||||||
Reference in new issue
Block a user