groceries: add PLUS, Xenos and Amazing Oriental stores, is_food flag, PLUS EAN backfill
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
This commit is contained in:
12
drizzle/20261025171621_groceries_more_stores/migration.sql
Normal file
12
drizzle/20261025171621_groceries_more_stores/migration.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE "groceries"."groups" ADD COLUMN "is_food" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."items" ADD COLUMN "is_food" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
-- New stores (Plus already exists): Xenos and Amazing Oriental (orientalwebshop.nl).
|
||||||
|
INSERT INTO "groceries"."stores" ("slug", "name", "website", "color", "sort_order") VALUES
|
||||||
|
('xenos', 'Xenos', 'https://www.xenos.nl', '#e6007e', 50),
|
||||||
|
('amazing-oriental', 'Amazing Oriental', 'https://www.orientalwebshop.nl/nl/', '#2e7d32', 60)
|
||||||
|
ON CONFLICT ("slug") DO NOTHING;--> statement-breakpoint
|
||||||
|
-- Existing AH/Jumbo items: drugstore and household are not food (the importer keeps this current).
|
||||||
|
UPDATE "groceries"."items" SET "is_food" = false WHERE "category" IN ('Drogisterij', 'Huishouden & dieren');--> statement-breakpoint
|
||||||
|
UPDATE "groceries"."groups" g SET "is_food" = false
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM "groceries"."items" i WHERE i.group_id = g.id AND i.available AND i.is_food)
|
||||||
|
AND EXISTS (SELECT 1 FROM "groceries"."items" i WHERE i.group_id = g.id AND i.available);
|
||||||
29492
drizzle/20261025171621_groceries_more_stores/snapshot.json
Normal file
29492
drizzle/20261025171621_groceries_more_stores/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,28 @@
|
|||||||
// Nightly supermarket catalogue import (Albert Heijn + Jumbo) and product regrouping.
|
// Supermarket catalogue import and product regrouping.
|
||||||
//
|
//
|
||||||
// bun --env-file=$HOME/.config/groceries-import.env scripts/groceries-import.ts [options]
|
// bun scripts/groceries-import.ts [options]
|
||||||
// --store ah|jumbo only this store (repeatable; default: all)
|
// --store SLUG only this store (repeatable). Default: the nightly stores
|
||||||
|
// (ah, jumbo, plus, xenos); amazing-oriental runs weekly on its own timer.
|
||||||
// --skip-fetch no store requests; only regroup what is in the DB
|
// --skip-fetch no store requests; only regroup what is in the DB
|
||||||
// --gtin-limit N AH product details to fetch for EANs this run (default 1500, 0 = off)
|
// --gtin-limit N AH and PLUS product details to fetch for EANs this run (default 1500, 0 = off)
|
||||||
// --delay-ms N minimum delay between requests (default 2000)
|
// --gtin-only skip the catalogue crawl; only the EAN backfill (+ regroup)
|
||||||
|
// --gtin-parallel N parallel PLUS detail requests for the backfill (default 1, max 4)
|
||||||
|
// --delay-ms N minimum delay between requests (default: per store, else 2000)
|
||||||
//
|
//
|
||||||
// Politeness: one request at a time, >= 2 s apart, backoff on 429/5xx, identifying User-Agent,
|
// Politeness: one request at a time per store (Amazing Oriental: 25 s, its robots.txt
|
||||||
// and a same-day cache in $GROCERIES_CACHE_DIR (default ~/.cache/groceries, kept 7 days).
|
// Crawl-delay), backoff on 429/5xx, and a same-day cache in $GROCERIES_CACHE_DIR
|
||||||
// Plus is not imported: plus.nl blocks this server's IP range (Incapsula), and we do not evade it.
|
// (default ~/.cache/groceries, kept 7 days). The systemd wrapper holds a flock so two imports
|
||||||
|
// never run at once.
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { PoliteFetcher, pruneCache } from '../src/lib/server/groceries/http';
|
import { PoliteFetcher, pruneCache } from '../src/lib/server/groceries/http';
|
||||||
import { enrichAhGtins, importStore, regroup } from '../src/lib/server/groceries/import';
|
import {
|
||||||
|
enrichAhGtins,
|
||||||
|
enrichPlusGtins,
|
||||||
|
importStore,
|
||||||
|
regroup
|
||||||
|
} from '../src/lib/server/groceries/import';
|
||||||
import { SOURCES } from '../src/lib/server/groceries/sources';
|
import { SOURCES } from '../src/lib/server/groceries/sources';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -26,10 +35,14 @@ const num = (name: string, d: number) => {
|
|||||||
return n;
|
return n;
|
||||||
};
|
};
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is not set');
|
if (!process.env.DB_URL) throw new Error('DB_URL is not set');
|
||||||
const stores = values('--store').length ? values('--store') : Object.keys(SOURCES);
|
const stores = values('--store').length
|
||||||
|
? values('--store')
|
||||||
|
: Object.keys(SOURCES).filter((s) => SOURCES[s].nightly !== false);
|
||||||
for (const s of stores) if (!SOURCES[s]) throw new Error(`unknown store ${s}`);
|
for (const s of stores) if (!SOURCES[s]) throw new Error(`unknown store ${s}`);
|
||||||
const delay = num('--delay-ms', 2000);
|
const delayArg = values('--delay-ms').length ? num('--delay-ms', 2000) : null;
|
||||||
const gtinLimit = num('--gtin-limit', 1500);
|
const gtinLimit = num('--gtin-limit', 1500);
|
||||||
|
const gtinParallel = Math.min(4, Math.max(1, num('--gtin-parallel', 1)));
|
||||||
|
const gtinOnly = args.includes('--gtin-only');
|
||||||
const cacheDir = process.env.GROCERIES_CACHE_DIR || join(homedir(), '.cache', 'groceries');
|
const cacheDir = process.env.GROCERIES_CACHE_DIR || join(homedir(), '.cache', 'groceries');
|
||||||
const log = (m: string) => console.log(`[${new Date().toISOString().slice(11, 19)}Z] ${m}`);
|
const log = (m: string) => console.log(`[${new Date().toISOString().slice(11, 19)}Z] ${m}`);
|
||||||
|
|
||||||
@@ -39,17 +52,33 @@ try {
|
|||||||
pruneCache(cacheDir);
|
pruneCache(cacheDir);
|
||||||
if (!args.includes('--skip-fetch')) {
|
if (!args.includes('--skip-fetch')) {
|
||||||
for (const slug of stores) {
|
for (const slug of stores) {
|
||||||
const f = new PoliteFetcher({ store: slug, minDelayMs: delay, cacheDir, log });
|
const src = SOURCES[slug];
|
||||||
|
const fetcher = () =>
|
||||||
|
new PoliteFetcher({
|
||||||
|
store: slug,
|
||||||
|
minDelayMs: delayArg ?? src.minDelayMs ?? 2000,
|
||||||
|
cacheDir,
|
||||||
|
log,
|
||||||
|
...src.fetcher
|
||||||
|
});
|
||||||
|
const f = fetcher();
|
||||||
try {
|
try {
|
||||||
log(`${slug}: importing`);
|
if (!gtinOnly) {
|
||||||
const r = await importStore(sql, SOURCES[slug], f, log);
|
log(`${slug}: importing`);
|
||||||
log(
|
const r = await importStore(sql, src, f, log);
|
||||||
`${slug}: ${r.fetched} products (${r.inserted} new, ${r.priceChanges} price changes, ${r.delisted} delisted); ${r.requests} requests, ${r.cacheHits} from cache`
|
log(
|
||||||
);
|
`${slug}: ${r.fetched} products (${r.inserted} new, ${r.priceChanges} price changes, ${r.delisted} delisted); ${r.requests} requests, ${r.cacheHits} from cache`
|
||||||
|
);
|
||||||
|
}
|
||||||
if (slug === 'ah' && gtinLimit > 0) {
|
if (slug === 'ah' && gtinLimit > 0) {
|
||||||
const g = await enrichAhGtins(sql, f, gtinLimit, log);
|
const g = await enrichAhGtins(sql, f, gtinLimit, log);
|
||||||
log(`ah: EAN lookup ${g.checked} checked, ${g.found} found`);
|
log(`ah: EAN lookup ${g.checked} checked, ${g.found} found`);
|
||||||
}
|
}
|
||||||
|
if (slug === 'plus' && gtinLimit > 0) {
|
||||||
|
const fs = [f, ...Array.from({ length: gtinParallel - 1 }, fetcher)];
|
||||||
|
const g = await enrichPlusGtins(sql, fs, gtinLimit, log);
|
||||||
|
log(`plus: EAN lookup ${g.checked} checked, ${g.found} found`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
failed = true;
|
failed = true;
|
||||||
log(`${slug}: FAILED ${e instanceof Error ? e.message : e}`);
|
log(`${slug}: FAILED ${e instanceof Error ? e.message : e}`);
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export const groceryGroups = groceriesSchema.table(
|
|||||||
minPriceCents: integer('min_price_cents'),
|
minPriceCents: integer('min_price_cents'),
|
||||||
promoCount: integer('promo_count').notNull().default(0),
|
promoCount: integer('promo_count').notNull().default(0),
|
||||||
imageUrl: text('image_url'),
|
imageUrl: text('image_url'),
|
||||||
|
/** True when any item is edible (refreshGroupStats); homeware-only groups are false. */
|
||||||
|
isFood: boolean('is_food').notNull().default(true),
|
||||||
searchText: text('search_text').notNull().default(''),
|
searchText: text('search_text').notNull().default(''),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
@@ -102,7 +104,7 @@ export const groceryItems = groceriesSchema.table(
|
|||||||
storeId: integer('store_id')
|
storeId: integer('store_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => groceryStores.id, { onDelete: 'cascade' }),
|
.references(() => groceryStores.id, { onDelete: 'cascade' }),
|
||||||
storeProductId: text('store_product_id').notNull(), // AH webshopId, Jumbo SKU
|
storeProductId: text('store_product_id').notNull(), // AH webshopId, Jumbo/Plus/Xenos/AO SKU
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
brand: text('brand'),
|
brand: text('brand'),
|
||||||
sizeText: text('size_text'), // as the store prints it, e.g. "6 x 330 ml"
|
sizeText: text('size_text'), // as the store prints it, e.g. "6 x 330 ml"
|
||||||
@@ -122,6 +124,8 @@ export const groceryItems = groceriesSchema.table(
|
|||||||
imageUrl: text('image_url'),
|
imageUrl: text('image_url'),
|
||||||
url: text('url'),
|
url: text('url'),
|
||||||
available: boolean('available').notNull().default(true),
|
available: boolean('available').notNull().default(true),
|
||||||
|
/** Edible (food/drink). False for drugstore, household and homeware (Xenos décor, woks…). */
|
||||||
|
isFood: boolean('is_food').notNull().default(true),
|
||||||
firstSeenAt: timestamptz('first_seen_at').notNull().defaultNow(),
|
firstSeenAt: timestamptz('first_seen_at').notNull().defaultNow(),
|
||||||
lastSeenAt: timestamptz('last_seen_at').notNull().defaultNow(),
|
lastSeenAt: timestamptz('last_seen_at').notNull().defaultNow(),
|
||||||
groupId: integer('group_id').references(() => groceryGroups.id, { onDelete: 'set null' }),
|
groupId: integer('group_id').references(() => groceryGroups.id, { onDelete: 'set null' }),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type FetcherOptions = {
|
|||||||
store: string;
|
store: string;
|
||||||
minDelayMs?: number;
|
minDelayMs?: number;
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
|
/** First retry backoff; doubles each try (default 5 s). */
|
||||||
|
retryBaseMs?: number;
|
||||||
cacheDir?: string | null;
|
cacheDir?: string | null;
|
||||||
log?: (msg: string) => void;
|
log?: (msg: string) => void;
|
||||||
};
|
};
|
||||||
@@ -25,12 +27,14 @@ export class PoliteFetcher {
|
|||||||
private last = 0;
|
private last = 0;
|
||||||
private readonly minDelayMs: number;
|
private readonly minDelayMs: number;
|
||||||
private readonly maxRetries: number;
|
private readonly maxRetries: number;
|
||||||
|
private readonly retryBaseMs: number;
|
||||||
private readonly dir: string | null;
|
private readonly dir: string | null;
|
||||||
private readonly log: (msg: string) => void;
|
private readonly log: (msg: string) => void;
|
||||||
|
|
||||||
constructor(opts: FetcherOptions) {
|
constructor(opts: FetcherOptions) {
|
||||||
this.minDelayMs = opts.minDelayMs ?? 2000;
|
this.minDelayMs = opts.minDelayMs ?? 2000;
|
||||||
this.maxRetries = opts.maxRetries ?? 5;
|
this.maxRetries = opts.maxRetries ?? 5;
|
||||||
|
this.retryBaseMs = opts.retryBaseMs ?? 5000;
|
||||||
this.log = opts.log ?? (() => {});
|
this.log = opts.log ?? (() => {});
|
||||||
this.dir = opts.cacheDir ? join(opts.cacheDir, today(), opts.store) : null;
|
this.dir = opts.cacheDir ? join(opts.cacheDir, today(), opts.store) : null;
|
||||||
if (this.dir) mkdirSync(this.dir, { recursive: true });
|
if (this.dir) mkdirSync(this.dir, { recursive: true });
|
||||||
@@ -38,10 +42,25 @@ export class PoliteFetcher {
|
|||||||
|
|
||||||
/** Fetch JSON. `cacheKey` (a file-safe string) enables the same-day cache. */
|
/** Fetch JSON. `cacheKey` (a file-safe string) enables the same-day cache. */
|
||||||
async json<T>(url: string, init: RequestInit = {}, cacheKey?: string): Promise<T> {
|
async json<T>(url: string, init: RequestInit = {}, cacheKey?: string): Promise<T> {
|
||||||
const file = this.dir && cacheKey ? join(this.dir, `${cacheKey}.json.gz`) : null;
|
return this.request(url, init, cacheKey, 'json', (text) => JSON.parse(text) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch text (HTML). Same politeness, retries and same-day cache as json(). */
|
||||||
|
async text(url: string, init: RequestInit = {}, cacheKey?: string): Promise<string> {
|
||||||
|
return this.request(url, init, cacheKey, 'html', (text) => text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
cacheKey: string | undefined,
|
||||||
|
ext: string,
|
||||||
|
parse: (text: string) => T
|
||||||
|
): Promise<T> {
|
||||||
|
const file = this.dir && cacheKey ? join(this.dir, `${cacheKey}.${ext}.gz`) : null;
|
||||||
if (file && existsSync(file)) {
|
if (file && existsSync(file)) {
|
||||||
this.cacheHits++;
|
this.cacheHits++;
|
||||||
return JSON.parse(gunzipSync(readFileSync(file)).toString('utf8')) as T;
|
return parse(gunzipSync(readFileSync(file)).toString('utf8'));
|
||||||
}
|
}
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
const wait = this.last + this.minDelayMs - Date.now();
|
const wait = this.last + this.minDelayMs - Date.now();
|
||||||
@@ -54,7 +73,7 @@ export class PoliteFetcher {
|
|||||||
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(45_000) });
|
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(45_000) });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const data = JSON.parse(text) as T;
|
const data = parse(text);
|
||||||
if (file) writeFileSync(file, gzipSync(text));
|
if (file) writeFileSync(file, gzipSync(text));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -67,18 +86,21 @@ export class PoliteFetcher {
|
|||||||
}
|
}
|
||||||
retryAfter = Number(res.headers.get('retry-after')) * 1000 || 0;
|
retryAfter = Number(res.headers.get('retry-after')) * 1000 || 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof HttpError) throw e;
|
if (e instanceof HttpError || e instanceof FatalFetchError) throw e;
|
||||||
problem = e instanceof Error ? e.message : String(e);
|
problem = e instanceof Error ? e.message : String(e);
|
||||||
}
|
}
|
||||||
if (attempt >= this.maxRetries)
|
if (attempt >= this.maxRetries)
|
||||||
throw new Error(`${problem} for ${url} after ${attempt + 1} tries`);
|
throw new Error(`${problem} for ${url} after ${attempt + 1} tries`);
|
||||||
const backoff = Math.max(retryAfter, 5000 * 2 ** attempt);
|
const backoff = Math.max(retryAfter, this.retryBaseMs * 2 ** attempt);
|
||||||
this.log(` ${problem}; retrying in ${Math.round(backoff / 1000)}s`);
|
this.log(` ${problem}; retrying in ${Math.round(backoff / 1000)}s`);
|
||||||
await sleep(backoff);
|
await sleep(backoff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Thrown from a parse callback to stop without retrying (e.g. a bot wall instead of data). */
|
||||||
|
export class FatalFetchError extends Error {}
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly status: number,
|
readonly status: number,
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
brandClass,
|
brandClass,
|
||||||
brandLabel,
|
brandLabel,
|
||||||
displayName,
|
displayName,
|
||||||
|
fold,
|
||||||
matchKey,
|
matchKey,
|
||||||
nameTokens,
|
nameTokens,
|
||||||
normaliseUnit
|
normaliseUnit
|
||||||
} from './normalize';
|
} from './normalize';
|
||||||
import { AhClient, type RawItem, type Source } from './sources';
|
import { PlusClient } from './plus';
|
||||||
|
import { AhClient, itemIsFood, type RawItem, type Source } from './sources';
|
||||||
|
|
||||||
type Log = (m: string) => void;
|
type Log = (m: string) => void;
|
||||||
|
|
||||||
@@ -34,6 +36,10 @@ export async function importStore(sql: Sql, source: Source, f: PoliteFetcher, lo
|
|||||||
insert into groceries.import_runs (store_id) values (${sid}) returning id, started_at`;
|
insert into groceries.import_runs (store_id) values (${sid}) returning id, started_at`;
|
||||||
try {
|
try {
|
||||||
const raw = await source.fetchAll(f, log);
|
const raw = await source.fetchAll(f, log);
|
||||||
|
if (BRAND_FROM_NAME.has(source.slug)) {
|
||||||
|
const n = await inferBrands(sql, sid, raw);
|
||||||
|
log(` ${source.slug}: brand inferred from the name for ${n} products`);
|
||||||
|
}
|
||||||
const [prev] = await sql<{ n: number }[]>`
|
const [prev] = await sql<{ n: number }[]>`
|
||||||
select count(*)::int as n from groceries.items where store_id = ${sid} and available`;
|
select count(*)::int as n from groceries.items where store_id = ${sid} and available`;
|
||||||
const res = await upsertItems(sql, sid, raw, run.started_at);
|
const res = await upsertItems(sql, sid, raw, run.started_at);
|
||||||
@@ -91,6 +97,7 @@ async function upsertItems(sql: Sql, sid: number, raw: RawItem[], runStart: Date
|
|||||||
image_url: r.imageUrl,
|
image_url: r.imageUrl,
|
||||||
url: r.url,
|
url: r.url,
|
||||||
available: r.available,
|
available: r.available,
|
||||||
|
is_food: itemIsFood(r),
|
||||||
match_key: keyFor(r.name, r.brand, n.unit).key
|
match_key: keyFor(r.name, r.brand, n.unit).key
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -104,7 +111,7 @@ async function upsertItems(sql: Sql, sid: number, raw: RawItem[], runStart: Date
|
|||||||
promo_unit_price_cents = excluded.promo_unit_price_cents, category = excluded.category,
|
promo_unit_price_cents = excluded.promo_unit_price_cents, category = excluded.category,
|
||||||
category_path = excluded.category_path, gtin = coalesce(excluded.gtin, items.gtin),
|
category_path = excluded.category_path, gtin = coalesce(excluded.gtin, items.gtin),
|
||||||
image_url = excluded.image_url, url = excluded.url, available = excluded.available,
|
image_url = excluded.image_url, url = excluded.url, available = excluded.available,
|
||||||
match_key = excluded.match_key, last_seen_at = now()
|
is_food = excluded.is_food, match_key = excluded.match_key, last_seen_at = now()
|
||||||
returning (xmax = 0) as inserted`;
|
returning (xmax = 0) as inserted`;
|
||||||
inserted += res.filter((r) => r.inserted).length;
|
inserted += res.filter((r) => r.inserted).length;
|
||||||
}
|
}
|
||||||
@@ -132,6 +139,92 @@ async function upsertItems(sql: Sql, sid: number, raw: RawItem[], runStart: Date
|
|||||||
return { inserted, priceChanges: hist.filter((h) => h.changed).length };
|
return { inserted, priceChanges: hist.filter((h) => h.changed).length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stores whose listings carry no (or little) brand: take it from the start of the name. */
|
||||||
|
const BRAND_FROM_NAME = new Set(['xenos', 'amazing-oriental']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill in missing brands when the name starts with a known brand ("Lotus Biscoff …"). Known
|
||||||
|
* brands: those on >= 5 products at other stores, plus the store's own brands in this batch.
|
||||||
|
* House brands are never inferred. Returns how many items got a brand.
|
||||||
|
*/
|
||||||
|
export async function inferBrands(sql: Sql, sid: number, raw: RawItem[]): Promise<number> {
|
||||||
|
const rows = await sql<{ brand: string }[]>`
|
||||||
|
select brand from groceries.items
|
||||||
|
where brand is not null and store_id <> ${sid} and length(brand) >= 3
|
||||||
|
group by brand having count(*) >= 5`;
|
||||||
|
const known = new Map<string, string>();
|
||||||
|
for (const b of [...rows.map((r) => r.brand), ...raw.map((r) => r.brand)]) {
|
||||||
|
if (!b || b.trim().length < 3 || isHouseBrand(b)) continue;
|
||||||
|
const k = fold(b).replace(/\s+/g, ' ').trim();
|
||||||
|
if (!known.has(k)) known.set(k, b.trim());
|
||||||
|
}
|
||||||
|
const byFirstWord = new Map<string, string[]>();
|
||||||
|
for (const k of known.keys()) {
|
||||||
|
const w = k.split(' ')[0];
|
||||||
|
byFirstWord.set(w, [...(byFirstWord.get(w) ?? []), k]);
|
||||||
|
}
|
||||||
|
for (const list of byFirstWord.values()) list.sort((a, b) => b.length - a.length);
|
||||||
|
let n = 0;
|
||||||
|
for (const r of raw) {
|
||||||
|
if (r.brand) continue;
|
||||||
|
const name = fold(r.name).replace(/\s+/g, ' ').trim();
|
||||||
|
const first = name.split(' ')[0];
|
||||||
|
for (const k of byFirstWord.get(first) ?? []) {
|
||||||
|
// The brand must be followed by more words, so "Mango" alone never becomes a brand.
|
||||||
|
if (name.startsWith(k + ' ') && name.length > k.length + 2) {
|
||||||
|
r.brand = known.get(k)!;
|
||||||
|
n++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
const isHouseBrand = (b: string) => brandClass(b).startsWith('house');
|
||||||
|
|
||||||
|
/** Fill in PLUS EANs from the product detail action, `limit` products per run, once per SKU. */
|
||||||
|
export async function enrichPlusGtins(
|
||||||
|
sql: Sql,
|
||||||
|
fetchers: PoliteFetcher[],
|
||||||
|
limit: number,
|
||||||
|
log: Log
|
||||||
|
) {
|
||||||
|
if (limit <= 0 || !fetchers.length) return { checked: 0, found: 0 };
|
||||||
|
const sid = await storeId(sql, 'plus');
|
||||||
|
const todo = await sql<{ id: number; store_product_id: string; url: string | null }[]>`
|
||||||
|
select id, store_product_id, url from groceries.items
|
||||||
|
where store_id = ${sid} and gtin is null and available
|
||||||
|
and (gtin_checked_at is null or gtin_checked_at < now() - interval '180 days')
|
||||||
|
order by gtin_checked_at asc nulls first, id
|
||||||
|
limit ${limit}`;
|
||||||
|
const clients = fetchers.map((f) => new PlusClient(f));
|
||||||
|
let found = 0;
|
||||||
|
let errors = 0;
|
||||||
|
let next = 0;
|
||||||
|
let done = 0;
|
||||||
|
await Promise.all(
|
||||||
|
clients.map(async (plus) => {
|
||||||
|
while (next < todo.length) {
|
||||||
|
const t = todo[next++];
|
||||||
|
const slug = t.url?.split('/product/')[1] ?? '';
|
||||||
|
let gtin: string | null;
|
||||||
|
try {
|
||||||
|
gtin = await plus.gtin(t.store_product_id, slug);
|
||||||
|
} catch (e) {
|
||||||
|
errors++;
|
||||||
|
log(` plus gtin ${t.store_product_id}: ${e instanceof Error ? e.message : e}`);
|
||||||
|
if (errors > 25 && found === 0) throw e; // something is wrong; stop hammering
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (gtin) found++;
|
||||||
|
await sql`update groceries.items set gtin = ${gtin}, gtin_checked_at = now() where id = ${t.id}`;
|
||||||
|
if (++done % 500 === 0) log(` plus gtin: ${done}/${todo.length} checked, ${found} found`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { checked: done, found };
|
||||||
|
}
|
||||||
|
|
||||||
/** Fill in AH EANs from the product detail endpoint, `limit` products per run (A-brands first). */
|
/** Fill in AH EANs from the product detail endpoint, `limit` products per run (A-brands first). */
|
||||||
export async function enrichAhGtins(sql: Sql, f: PoliteFetcher, limit: number, log: Log) {
|
export async function enrichAhGtins(sql: Sql, f: PoliteFetcher, limit: number, log: Log) {
|
||||||
if (limit <= 0) return { checked: 0, found: 0 };
|
if (limit <= 0) return { checked: 0, found: 0 };
|
||||||
@@ -374,7 +467,7 @@ export async function refreshGroupStats(sql: Sql, ids?: number[]) {
|
|||||||
update groceries.groups g set
|
update groceries.groups g set
|
||||||
item_count = s.item_count, store_count = s.store_count, store_ids = s.store_ids,
|
item_count = s.item_count, store_count = s.store_count, store_ids = s.store_ids,
|
||||||
min_unit_price_cents = s.min_unit, min_price_cents = s.min_price, promo_count = s.promo_count,
|
min_unit_price_cents = s.min_unit, min_price_cents = s.min_price, promo_count = s.promo_count,
|
||||||
image_url = s.image_url, search_text = s.search_text
|
image_url = s.image_url, search_text = s.search_text, is_food = s.is_food
|
||||||
from (
|
from (
|
||||||
select g2.id,
|
select g2.id,
|
||||||
count(i.id)::int as item_count,
|
count(i.id)::int as item_count,
|
||||||
@@ -384,6 +477,7 @@ export async function refreshGroupStats(sql: Sql, ids?: number[]) {
|
|||||||
min(coalesce(i.promo_price_cents, i.price_cents)) as min_price,
|
min(coalesce(i.promo_price_cents, i.price_cents)) as min_price,
|
||||||
(count(*) filter (where i.promo_price_cents is not null or i.promo_label is not null))::int as promo_count,
|
(count(*) filter (where i.promo_price_cents is not null or i.promo_label is not null))::int as promo_count,
|
||||||
(array_agg(i.image_url order by i.store_id, i.id) filter (where i.image_url is not null))[1] as image_url,
|
(array_agg(i.image_url order by i.store_id, i.id) filter (where i.image_url is not null))[1] as image_url,
|
||||||
|
coalesce(bool_or(i.is_food), true) as is_food,
|
||||||
translate(lower(concat_ws(' ', g2.name, g2.brand, g2.category,
|
translate(lower(concat_ws(' ', g2.name, g2.brand, g2.category,
|
||||||
string_agg(distinct concat_ws(' ', i.brand, i.name), ' '))), ${ACCENTS_FROM}, ${ACCENTS_TO}) as search_text
|
string_agg(distinct concat_ws(' ', i.brand, i.name), ' '))), ${ACCENTS_FROM}, ${ACCENTS_TO}) as search_text
|
||||||
from groceries.groups g2
|
from groceries.groups g2
|
||||||
@@ -392,6 +486,6 @@ export async function refreshGroupStats(sql: Sql, ids?: number[]) {
|
|||||||
group by g2.id
|
group by g2.id
|
||||||
) s
|
) s
|
||||||
where g.id = s.id and (g.item_count, g.store_count, g.store_ids, g.min_unit_price_cents, g.min_price_cents,
|
where g.id = s.id and (g.item_count, g.store_count, g.store_ids, g.min_unit_price_cents, g.min_price_cents,
|
||||||
g.promo_count, g.image_url, g.search_text) is distinct from
|
g.promo_count, g.image_url, g.search_text, g.is_food) is distinct from
|
||||||
(s.item_count, s.store_count, s.store_ids, s.min_unit, s.min_price, s.promo_count, s.image_url, s.search_text)`;
|
(s.item_count, s.store_count, s.store_ids, s.min_unit, s.min_price, s.promo_count, s.image_url, s.search_text, s.is_food)`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,10 @@ export function nameTokens(title: string, brand?: string | null): string[] {
|
|||||||
.replace(/\bdurum\s*-?\s*(?:tarwe)?\b/g, ' '); // "Penne durum tarwe" is plain penne
|
.replace(/\bdurum\s*-?\s*(?:tarwe)?\b/g, ' '); // "Penne durum tarwe" is plain penne
|
||||||
const drop = new Set<string>();
|
const drop = new Set<string>();
|
||||||
for (const w of [brand ?? '', ...STORE_WORDS])
|
for (const w of [brand ?? '', ...STORE_WORDS])
|
||||||
for (const t of fold(w).replace(/['’]/g, '').split(/[^a-z0-9]+/)) if (t) drop.add(t);
|
for (const t of fold(w)
|
||||||
|
.replace(/['’]/g, '')
|
||||||
|
.split(/[^a-z0-9]+/))
|
||||||
|
if (t) drop.add(t);
|
||||||
s = s.replace(/['’]/g, '').replace(/[^a-z0-9%+]+/g, ' ');
|
s = s.replace(/['’]/g, '').replace(/[^a-z0-9%+]+/g, ' ');
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const raw of s.split(' ')) {
|
for (const raw of s.split(' ')) {
|
||||||
|
|||||||
171
src/lib/server/groceries/oriental.ts
Normal file
171
src/lib/server/groceries/oriental.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Amazing Oriental webshop (orientalwebshop.nl, Magento 2) catalogue source. There is no JSON
|
||||||
|
// API (graphql/rest are closed), so we parse the category listing HTML. The site's bot wall
|
||||||
|
// (Anubis) lets honest non-browser User-Agents through and expects them to follow robots.txt,
|
||||||
|
// which asks for Crawl-delay: 25, so this source makes ONE request per 25 s (~45 min a crawl)
|
||||||
|
// and runs weekly on its own timer. A challenge page stops the crawl; we never work around it.
|
||||||
|
import { FatalFetchError, HttpError, type PoliteFetcher } from './http';
|
||||||
|
import type { RawItem, Source } from './sources';
|
||||||
|
|
||||||
|
const BASE = 'https://www.orientalwebshop.nl/nl';
|
||||||
|
const UA = 'stan-grocery-tracker/0.1 (personal price tracker; +https://stanrunge.dev)';
|
||||||
|
export const AO_CRAWL_DELAY_MS = 25_000; // robots.txt Crawl-delay: 25
|
||||||
|
const LIMIT = 95; // the largest product_list_limit the shop offers
|
||||||
|
|
||||||
|
/** Top category url key → shared category. Crawled in this order; outlet last. */
|
||||||
|
const TOPS: [string, string][] = [
|
||||||
|
['verse-producten', 'Groente, fruit & aardappelen'],
|
||||||
|
['diepvriesproducten', 'Diepvries'],
|
||||||
|
['dranken-alcohol', 'Frisdrank & sappen'],
|
||||||
|
['rijst', 'Pasta, rijst & wereldkeuken'],
|
||||||
|
['noedels', 'Pasta, rijst & wereldkeuken'],
|
||||||
|
['sushi-producten', 'Pasta, rijst & wereldkeuken'],
|
||||||
|
['hot-pot-fondue', 'Pasta, rijst & wereldkeuken'],
|
||||||
|
['sauzen', 'Soepen, sauzen & conserven'],
|
||||||
|
['kruiden', 'Soepen, sauzen & conserven'],
|
||||||
|
['geconserveerd', 'Soepen, sauzen & conserven'],
|
||||||
|
['bakproducten', 'Ontbijt & beleg'],
|
||||||
|
['aziatische-snacks', 'Snoep, koek & chips'],
|
||||||
|
['mooncakes', 'Snoep, koek & chips'], // seasonal; 404 out of season
|
||||||
|
['huis-beauty-welzijn', 'Overig'],
|
||||||
|
['outlet', 'Overig'] // short-dated cross-cut; items keep their real category when seen first
|
||||||
|
];
|
||||||
|
const TOP_MAP = new Map(TOPS);
|
||||||
|
/** Level-2 (data-category segment 2, slugified) overrides. */
|
||||||
|
const SUB: Record<string, string> = {
|
||||||
|
'zeevruchten-vlees-eieren': 'Vlees, vis & vega',
|
||||||
|
'tofu-tempeh': 'Vlees, vis & vega',
|
||||||
|
'cake-dessert': 'Brood & bakkerij',
|
||||||
|
thee: 'Koffie & thee',
|
||||||
|
'instant-drank': 'Koffie & thee',
|
||||||
|
koffie: 'Koffie & thee',
|
||||||
|
alcohol: 'Bier & wijn',
|
||||||
|
bier: 'Bier & wijn',
|
||||||
|
wijn: 'Bier & wijn'
|
||||||
|
};
|
||||||
|
|
||||||
|
const decode = (s: string) =>
|
||||||
|
s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/'|�?39;/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const attr = (blk: string, name: string) => {
|
||||||
|
const m = blk.match(new RegExp(`${name}="([^"]*)"`));
|
||||||
|
return m ? decode(m[1]) : null;
|
||||||
|
};
|
||||||
|
const slug = (s: string) =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/&/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
const cents = (v: string | undefined) => {
|
||||||
|
const n = v ? parseFloat(v) : NaN;
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parse one listing page into items (exported for tests). */
|
||||||
|
export function parseOrientalListing(html: string, top: string): RawItem[] {
|
||||||
|
const arts = html.match(/<article class="item product product-item"[\s\S]*?<\/article>/g) ?? [];
|
||||||
|
const out: RawItem[] = [];
|
||||||
|
for (const a of arts) {
|
||||||
|
const id = attr(a, 'data-sku');
|
||||||
|
const link =
|
||||||
|
a.match(/class="product-item-link"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/) ??
|
||||||
|
a.match(/href="([^"]+)"[^>]*class="product-item-link"[^>]*>([\s\S]*?)<\/a>/);
|
||||||
|
const name = decode(link?.[2]?.replace(/<[^>]+>/g, '') ?? attr(a, 'data-name') ?? '');
|
||||||
|
if (!id || !name) continue;
|
||||||
|
const fin = cents(a.match(/data-price-amount="([\d.]+)" data-price-type="finalPrice"/)?.[1]);
|
||||||
|
const old = cents(a.match(/data-price-amount="([\d.]+)" data-price-type="oldPrice"/)?.[1]);
|
||||||
|
const promo = old && fin && fin < old ? fin : null;
|
||||||
|
const labels = [
|
||||||
|
...a.matchAll(/class="product-label (label-[a-z_-]+)"[^>]*><span>([^<]*)</g)
|
||||||
|
].map((m) => [m[1], decode(m[2])] as const);
|
||||||
|
const sale = labels.find(([c]) => c === 'label-sale')?.[1] || null;
|
||||||
|
const exp = labels.find(([c]) => c.startsWith('label-expiration'))?.[1] || null;
|
||||||
|
const catAttr = attr(a, 'data-category'); // "Verse Producten/Tofu & Tempeh" or "OUTLET"
|
||||||
|
const real = catAttr && catAttr.toUpperCase() !== 'OUTLET' ? catAttr : null;
|
||||||
|
const category = (real && SUB[slug(real.split('/')[1] ?? '')]) ?? TOP_MAP.get(top) ?? 'Overig';
|
||||||
|
out.push({
|
||||||
|
storeProductId: id,
|
||||||
|
name,
|
||||||
|
brand: attr(a, 'data-brand') || null,
|
||||||
|
sizeText:
|
||||||
|
name.match(
|
||||||
|
/(\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|gr|ml|cl|l|ltr|liter|st|stuks)\b/i
|
||||||
|
)?.[0] ?? null,
|
||||||
|
priceCents: old ?? fin,
|
||||||
|
promoPriceCents: promo,
|
||||||
|
promoLabel: promo
|
||||||
|
? [sale ?? 'Aanbieding', exp ? `THT ${exp.replace(/^THT\s*/i, '')}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
: null,
|
||||||
|
storeUnitPrice: null,
|
||||||
|
storeUnitBasisCents: null,
|
||||||
|
category,
|
||||||
|
categoryPath: real ? real.replace(/\//g, ' > ') : top === 'outlet' ? 'Outlet' : top,
|
||||||
|
gtin: null,
|
||||||
|
imageUrl: a.match(/class="product-image-photo"[^>]*src="([^"]+)"/)?.[1] ?? null,
|
||||||
|
url: link?.[1] ?? null,
|
||||||
|
available: attr(a, 'data-dimension10') !== 'Niet op voorraad',
|
||||||
|
isFood: top !== 'huis-beauty-welzijn'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orientalSource: Source = {
|
||||||
|
slug: 'amazing-oriental',
|
||||||
|
minDelayMs: AO_CRAWL_DELAY_MS,
|
||||||
|
nightly: false, // weekly, own timer (groceries-import-oriental.timer)
|
||||||
|
// On 429 back off hard (minutes), never hammer.
|
||||||
|
fetcher: { maxRetries: 3, retryBaseMs: 120_000 },
|
||||||
|
async fetchAll(f: PoliteFetcher, log) {
|
||||||
|
const items = new Map<string, RawItem>();
|
||||||
|
const get = (top: string, p: number) =>
|
||||||
|
f.text(
|
||||||
|
`${BASE}/${top}?product_list_limit=${LIMIT}&p=${p}`,
|
||||||
|
{ headers: { 'User-Agent': UA, Accept: 'text/html' } },
|
||||||
|
`${top}-p${p}`
|
||||||
|
);
|
||||||
|
for (const [top] of TOPS) {
|
||||||
|
const before = items.size;
|
||||||
|
let prevFirst = '';
|
||||||
|
for (let p = 1; p <= 200; p++) {
|
||||||
|
let html: string;
|
||||||
|
try {
|
||||||
|
html = await get(top, p);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError && e.status === 404) {
|
||||||
|
log(` ao ${top}: 404, skipped (seasonal?)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (html.includes('anubis_challenge'))
|
||||||
|
throw new FatalFetchError('amazing-oriental: bot challenge page (policy changed)');
|
||||||
|
const page = parseOrientalListing(html, top);
|
||||||
|
if (!page.length || page[0].storeProductId === prevFirst) break; // Magento clamps p
|
||||||
|
prevFirst = page[0].storeProductId;
|
||||||
|
for (const it of page) {
|
||||||
|
const prev = items.get(it.storeProductId);
|
||||||
|
if (!prev) items.set(it.storeProductId, it);
|
||||||
|
else if (top === 'outlet' && it.promoPriceCents && !prev.promoPriceCents)
|
||||||
|
items.set(it.storeProductId, {
|
||||||
|
...prev,
|
||||||
|
promoPriceCents: it.promoPriceCents,
|
||||||
|
promoLabel: it.promoLabel,
|
||||||
|
priceCents: it.priceCents
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (page.length < LIMIT) break;
|
||||||
|
}
|
||||||
|
log(` ao ${top}: +${items.size - before} (${items.size} total, ${f.requests} requests)`);
|
||||||
|
}
|
||||||
|
return [...items.values()];
|
||||||
|
}
|
||||||
|
};
|
||||||
317
src/lib/server/groceries/plus.ts
Normal file
317
src/lib/server/groceries/plus.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
// PLUS (plus.nl) catalogue source: the OutSystems "screenservices" data actions the web shop
|
||||||
|
// itself calls (product listing + product detail). Anonymous, no login. Prices are the national
|
||||||
|
// webshop prices (StoreNumber 0). Listing pages are fixed at 12 products, so a full crawl is
|
||||||
|
// ~1,450 requests; the product detail action is only used to backfill EANs once per SKU.
|
||||||
|
import type { PoliteFetcher } from './http';
|
||||||
|
import { normaliseGtin } from './normalize';
|
||||||
|
import type { RawItem, Source } from './sources';
|
||||||
|
|
||||||
|
const BASE = 'https://www.plus.nl';
|
||||||
|
// plus.nl serves browsers only (Imperva/Incapsula); a desktop UA, one request at a time.
|
||||||
|
const UA =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130 Safari/537.36';
|
||||||
|
const CSRF = 'T6C+9iB49TLra4jEsMeSckDMNhQ='; // OutSystems' fixed anonymous-user CSRF token
|
||||||
|
const PLP_ACTION =
|
||||||
|
'screenservices/ECP_Composition_CW/ProductLists/PLP_Content/DataActionGetProductListAndCategoryInfo';
|
||||||
|
const PLP_JS = '/ECOP/scripts/ECP_Composition_CW.ProductLists.PLP_Content.mvc.js';
|
||||||
|
const PDP_ACTION =
|
||||||
|
'screenservices/ECP_Product_CW/ProductDetails/PDPContent/DataActionGetProductDetailsAndAgeInfo';
|
||||||
|
const PDP_JS = '/ECOP/scripts/ECP_Product_CW.ProductDetails.PDPContent.mvc.js';
|
||||||
|
|
||||||
|
/** Top category → shared category. */
|
||||||
|
const TOP: Record<string, string> = {
|
||||||
|
'Aardappelen, groente, fruit': 'Groente, fruit & aardappelen',
|
||||||
|
'Vlees, kip, vis, vega': 'Vlees, vis & vega',
|
||||||
|
'Kaas, vleeswaren, tapas': 'Vleeswaren, kaas & tapas',
|
||||||
|
'Zuivel, eieren, boter': 'Zuivel & eieren',
|
||||||
|
'Brood, gebak': 'Brood & bakkerij',
|
||||||
|
'Brood, gebak, bakproducten': 'Brood & bakkerij',
|
||||||
|
'Ontbijtgranen, broodbeleg, tussendoor': 'Ontbijt & beleg',
|
||||||
|
'Verse kant-en-klaarmaaltijden': 'Maaltijden & gemak',
|
||||||
|
'Pasta, rijst, internationale keuken': 'Pasta, rijst & wereldkeuken',
|
||||||
|
'Soepen, conserven, sauzen, smaakmakers': 'Soepen, sauzen & conserven',
|
||||||
|
'Snoep, koek, chocolade, chips, noten': 'Snoep, koek & chips',
|
||||||
|
'Snoep, koek, chips, zoutjes, noten': 'Snoep, koek & chips',
|
||||||
|
Diepvries: 'Diepvries',
|
||||||
|
'Frisdrank, sappen, koffie, thee': 'Frisdrank & sappen',
|
||||||
|
'Wijn, bier, sterke drank': 'Bier & wijn',
|
||||||
|
'Baby, drogisterij': 'Drogisterij',
|
||||||
|
Huishouden: 'Huishouden & dieren',
|
||||||
|
Huisdier: 'Huishouden & dieren',
|
||||||
|
'Bewuste voeding': 'Overig', // like AH "Glutenvrij" and Jumbo "Bewuste voeding"
|
||||||
|
'Sinterklaas assortiment': 'Snoep, koek & chips',
|
||||||
|
Kerstassortiment: 'Overig',
|
||||||
|
Paasassortiment: 'Overig',
|
||||||
|
'BBQ assortiment': 'Overig',
|
||||||
|
'Wonen, bloemen, service': 'Overig'
|
||||||
|
};
|
||||||
|
/** Level-2 overrides. */
|
||||||
|
const SUB: Record<string, string> = {
|
||||||
|
'Koffie, koffiemelk, suiker': 'Koffie & thee',
|
||||||
|
Koffie: 'Koffie & thee',
|
||||||
|
Thee: 'Koffie & thee',
|
||||||
|
Babyvoeding: 'Baby & kind',
|
||||||
|
'Luiers, doekjes': 'Baby & kind',
|
||||||
|
'BBQ sauzen, smaakmakers': 'Soepen, sauzen & conserven',
|
||||||
|
'BBQ dranken': 'Frisdrank & sappen',
|
||||||
|
'Kerst wijn, bier': 'Bier & wijn',
|
||||||
|
'Sint bakproducten': 'Ontbijt & beleg'
|
||||||
|
};
|
||||||
|
const NON_FOOD_TOP = new Set(['Wonen, bloemen, service', 'Huishouden', 'Huisdier']);
|
||||||
|
const NON_FOOD_SUB = new Set(['Kerst Adventkalenders']);
|
||||||
|
|
||||||
|
/** Delivery offers are not product promotions. */
|
||||||
|
const NOT_PROMO = /gratis\s+bezorg|bezorging/i;
|
||||||
|
|
||||||
|
type Versions = { moduleVersion: string; apiVersion: string };
|
||||||
|
type PlpProduct = {
|
||||||
|
SKU: string | number;
|
||||||
|
Name: string;
|
||||||
|
Brand?: string;
|
||||||
|
Slug?: string;
|
||||||
|
Product_Subtitle?: string;
|
||||||
|
OriginalPrice?: string;
|
||||||
|
NewPrice?: string;
|
||||||
|
PromotionLabel?: string;
|
||||||
|
PromotionBasedLabel?: string;
|
||||||
|
ImageURL?: string;
|
||||||
|
IsAvailable?: boolean;
|
||||||
|
IsOfflineSaleOnly?: boolean;
|
||||||
|
EAN?: string;
|
||||||
|
Categories?: { List?: { Name: string }[] };
|
||||||
|
};
|
||||||
|
type PlpData = {
|
||||||
|
TotalNumberItems: number;
|
||||||
|
TotalPages: number;
|
||||||
|
ProductList?: { List?: { PLP_Str: PlpProduct }[] };
|
||||||
|
FilterList?: {
|
||||||
|
List?: {
|
||||||
|
Filter_Str: { Name: string; FilterItem: { List: { Name: string; URL: string }[] } };
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const euroCents = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || v === '') return null;
|
||||||
|
const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.'));
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Effective unit price for common Dutch multibuy labels ("1+1 GRATIS", "3 VOOR 6.00", …). */
|
||||||
|
export function promoFromLabel(label: string | null, priceCents: number | null): number | null {
|
||||||
|
if (!label || !priceCents) return null;
|
||||||
|
const l = label.toLowerCase();
|
||||||
|
let m;
|
||||||
|
if ((m = l.match(/(\d+)\s*\+\s*(\d+)\s*gratis/)))
|
||||||
|
return Math.round((priceCents * +m[1]) / (+m[1] + +m[2]));
|
||||||
|
if (/2e\s*halve\s*prijs/.test(l)) return Math.round(priceCents * 0.75);
|
||||||
|
if ((m = l.match(/(\d+)\s*%\s*korting/))) return Math.round(priceCents * (1 - +m[1] / 100));
|
||||||
|
if ((m = l.match(/^(\d+)\s*voor\s*€?\s*(\d+(?:[.,]\d+)?)/))) {
|
||||||
|
const each = (parseFloat(m[2].replace(',', '.')) * 100) / +m[1];
|
||||||
|
return each < priceCents ? Math.round(each) : null;
|
||||||
|
}
|
||||||
|
if ((m = l.match(/^(\d+(?:[.,]\d+)?)\s*korting/))) {
|
||||||
|
const off = Math.round(parseFloat(m[1].replace(',', '.')) * 100);
|
||||||
|
return off < priceCents ? priceCents - off : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function plusItem(p: PlpProduct): RawItem {
|
||||||
|
const price = euroCents(p.OriginalPrice);
|
||||||
|
const now = euroCents(p.NewPrice);
|
||||||
|
const labels = [p.PromotionLabel, p.PromotionBasedLabel]
|
||||||
|
.map((l) => l?.trim())
|
||||||
|
.filter((l): l is string => !!l && !NOT_PROMO.test(l));
|
||||||
|
const label = labels.length ? [...new Set(labels)].join(' · ') : null;
|
||||||
|
const promo = now && price && now < price ? now : promoFromLabel(label, price);
|
||||||
|
const cats = p.Categories?.List?.map((c) => c.Name) ?? [];
|
||||||
|
const category = SUB[cats[1]] ?? TOP[cats[0]] ?? 'Overig';
|
||||||
|
return {
|
||||||
|
storeProductId: String(p.SKU),
|
||||||
|
name: p.Name.trim(),
|
||||||
|
brand: p.Brand?.trim() || null,
|
||||||
|
sizeText: p.Product_Subtitle?.replace(/^Per\s+/i, '').trim() || null,
|
||||||
|
priceCents: price ?? now,
|
||||||
|
promoPriceCents: promo,
|
||||||
|
promoLabel: label,
|
||||||
|
storeUnitPrice: null,
|
||||||
|
storeUnitBasisCents: null,
|
||||||
|
category,
|
||||||
|
categoryPath: cats.join(' > ') || null,
|
||||||
|
gtin: normaliseGtin(p.EAN),
|
||||||
|
imageUrl: p.ImageURL || null,
|
||||||
|
url: p.Slug ? `${BASE}/product/${p.Slug}` : null,
|
||||||
|
available: !!p.IsAvailable && !p.IsOfflineSaleOnly,
|
||||||
|
isFood: !(
|
||||||
|
NON_FOOD_TOP.has(cats[0]) ||
|
||||||
|
NON_FOOD_SUB.has(cats[1]) ||
|
||||||
|
(cats[0] === 'Baby, drogisterij' && cats[1] !== 'Babyvoeding')
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlusClient {
|
||||||
|
private cookie = '';
|
||||||
|
private versions: { plp?: Versions; pdp?: Versions } = {};
|
||||||
|
constructor(private f: PoliteFetcher) {}
|
||||||
|
|
||||||
|
private headers(json = false): Record<string, string> {
|
||||||
|
const h: Record<string, string> = { 'User-Agent': UA, Accept: 'application/json' };
|
||||||
|
if (this.cookie) h.Cookie = this.cookie;
|
||||||
|
if (json) {
|
||||||
|
h['Content-Type'] = 'application/json; charset=UTF-8';
|
||||||
|
h['X-CSRFToken'] = CSRF;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incapsula session cookies from the home page (plain fetch: no JSON, never cached). */
|
||||||
|
private async session() {
|
||||||
|
const res = await fetch(BASE + '/', {
|
||||||
|
headers: { 'User-Agent': UA },
|
||||||
|
signal: AbortSignal.timeout(45_000)
|
||||||
|
});
|
||||||
|
const jar = res.headers
|
||||||
|
.getSetCookie()
|
||||||
|
.map((c) => c.split(';')[0])
|
||||||
|
.filter((c) => c.includes('='));
|
||||||
|
this.cookie = jar.join('; ');
|
||||||
|
const html = await res.text();
|
||||||
|
if (!res.ok || /_Incapsula_Resource|Request unsuccessful/i.test(html))
|
||||||
|
throw new Error(`plus.nl blocked this IP (Incapsula, HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** moduleVersion + the action's apiVersion; both change on every PLUS deploy. */
|
||||||
|
private async discover(kind: 'plp' | 'pdp'): Promise<Versions> {
|
||||||
|
if (!this.cookie) await this.session();
|
||||||
|
const { versionToken } = await this.f.json<{ versionToken: string }>(
|
||||||
|
`${BASE}/moduleservices/moduleversioninfo`,
|
||||||
|
{ headers: this.headers() }
|
||||||
|
);
|
||||||
|
const info = await this.f.json<{ manifest: { urlVersions: Record<string, string> } }>(
|
||||||
|
`${BASE}/moduleservices/moduleinfo`,
|
||||||
|
{ headers: this.headers() }
|
||||||
|
);
|
||||||
|
const path = kind === 'plp' ? PLP_JS : PDP_JS;
|
||||||
|
const q = info.manifest.urlVersions[path];
|
||||||
|
if (q === undefined) throw new Error(`plus: ${path} not in module manifest`);
|
||||||
|
const js = await this.f.text(`${BASE}${path.replace('/ECOP', '')}${q}`, {
|
||||||
|
headers: { 'User-Agent': UA, Cookie: this.cookie }
|
||||||
|
});
|
||||||
|
const action =
|
||||||
|
kind === 'plp'
|
||||||
|
? 'DataActionGetProductListAndCategoryInfo'
|
||||||
|
: 'DataActionGetProductDetailsAndAgeInfo';
|
||||||
|
const m = js.match(new RegExp(`"${action}",\\s*"[^"]+",\\s*"([^"]+)"`));
|
||||||
|
if (!m) throw new Error(`plus: apiVersion for ${action} not found`);
|
||||||
|
const v = { moduleVersion: versionToken, apiVersion: m[1] };
|
||||||
|
this.versions[kind] = v;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async action<T>(
|
||||||
|
kind: 'plp' | 'pdp',
|
||||||
|
url: string,
|
||||||
|
viewName: string,
|
||||||
|
variables: Record<string, unknown>,
|
||||||
|
cacheKey?: string
|
||||||
|
): Promise<T> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const versionInfo = this.versions[kind] ?? (await this.discover(kind));
|
||||||
|
const r = await this.f.json<{ versionInfo?: { hasApiVersionChanged?: boolean }; data: T }>(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(true),
|
||||||
|
body: JSON.stringify({ versionInfo, viewName, screenData: { variables } })
|
||||||
|
},
|
||||||
|
attempt === 0 ? cacheKey : undefined
|
||||||
|
);
|
||||||
|
if (!r.versionInfo?.hasApiVersionChanged) return r.data;
|
||||||
|
if (attempt >= 1) throw new Error('plus: apiVersion keeps changing; PLUS is deploying?');
|
||||||
|
delete this.versions[kind];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listing(filter: string, page: number): Promise<PlpData> {
|
||||||
|
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
|
||||||
|
return this.action<PlpData>(
|
||||||
|
'plp',
|
||||||
|
`${BASE}/${PLP_ACTION}`,
|
||||||
|
'MainFlow.ProductListPage',
|
||||||
|
{
|
||||||
|
PageNumber: page,
|
||||||
|
CategorySlug: '',
|
||||||
|
SearchKeyword: '',
|
||||||
|
IsSearch: false,
|
||||||
|
IsDesktop: true,
|
||||||
|
IsMobile: false,
|
||||||
|
IsTablet: false,
|
||||||
|
URLPageNumber: 0,
|
||||||
|
FilterQueryURL: filter,
|
||||||
|
SelectedSort: '',
|
||||||
|
StoreNumber: 0,
|
||||||
|
StoreChannel: '',
|
||||||
|
OneWelcomeUserId: '',
|
||||||
|
Monitoring_FlowTypeId: 0,
|
||||||
|
IsCustomerUnderAge: false,
|
||||||
|
AppliedFiltersList: { List: [] },
|
||||||
|
Period: { FromDate: today, ToDate: today }
|
||||||
|
},
|
||||||
|
`plp-${filter.replace(/[^a-z0-9]+/gi, '_').slice(0, 60) || 'all'}-p${page}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EAN from the product detail action (the listing has none). */
|
||||||
|
async gtin(sku: string, slug: string): Promise<string | null> {
|
||||||
|
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
|
||||||
|
const d = await this.action<unknown>(
|
||||||
|
'pdp',
|
||||||
|
`${BASE}/${PDP_ACTION}`,
|
||||||
|
'MainFlow.ProductDetailsPage',
|
||||||
|
{ SKU: sku, ProductSlug: slug, IsDesktop: true, Period: { FromDate: today, ToDate: today } }
|
||||||
|
);
|
||||||
|
const m = JSON.stringify(d).match(/"EAN":"(\d{8,14})"/);
|
||||||
|
return normaliseGtin(m?.[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const plusSource: Source = {
|
||||||
|
slug: 'plus',
|
||||||
|
minDelayMs: 300,
|
||||||
|
async fetchAll(f, log) {
|
||||||
|
const plus = new PlusClient(f);
|
||||||
|
const items = new Map<string, RawItem>();
|
||||||
|
const add = (d: PlpData) => {
|
||||||
|
for (const row of d.ProductList?.List ?? []) {
|
||||||
|
const p = row.PLP_Str;
|
||||||
|
if (!p?.SKU || !p.Name) continue;
|
||||||
|
const key = String(p.SKU);
|
||||||
|
if (!items.has(key)) items.set(key, plusItem(p));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Sweep the unfiltered listing (all SKUs, 12 per page) ...
|
||||||
|
const first = await plus.listing('', 1);
|
||||||
|
add(first);
|
||||||
|
for (let page = 2; page <= first.TotalPages; page++) {
|
||||||
|
add(await plus.listing('', page));
|
||||||
|
if (page % 200 === 0) log(` plus sweep: page ${page}/${first.TotalPages}, ${items.size}`);
|
||||||
|
}
|
||||||
|
log(` plus sweep: ${items.size} of ${first.TotalNumberItems}`);
|
||||||
|
// ... then, if ordering shifted during the sweep, fill the gaps per category facet.
|
||||||
|
if (items.size < first.TotalNumberItems * 0.995) {
|
||||||
|
const facet = first.FilterList?.List?.map((f) => f.Filter_Str).find(
|
||||||
|
(f) => f.Name === 'Categorie'
|
||||||
|
);
|
||||||
|
for (const fv of facet?.FilterItem.List ?? []) {
|
||||||
|
if (items.size >= first.TotalNumberItems) break;
|
||||||
|
const p1 = await plus.listing(fv.URL, 1);
|
||||||
|
add(p1);
|
||||||
|
for (let page = 2; page <= p1.TotalPages; page++) add(await plus.listing(fv.URL, page));
|
||||||
|
log(` plus ${fv.Name}: ${items.size} total`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...items.values()];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
// 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: not available from the server (plus.nl blocks datacenter IPs via Incapsula).
|
// - Plus, Xenos, Amazing Oriental: see plus.ts, xenos.ts, oriental.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';
|
||||||
|
import { orientalSource } from './oriental';
|
||||||
|
import { plusSource } from './plus';
|
||||||
|
import { xenosSource } from './xenos';
|
||||||
|
|
||||||
export type RawItem = {
|
export type RawItem = {
|
||||||
storeProductId: string;
|
storeProductId: string;
|
||||||
@@ -22,13 +25,26 @@ export type RawItem = {
|
|||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
/** Edible (food/drink). Default: from the category (drugstore/household = false). */
|
||||||
|
isFood?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Source = {
|
export type Source = {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
/** Minimum delay between requests for this store (default: the importer's --delay-ms). */
|
||||||
|
minDelayMs?: number;
|
||||||
|
/** Part of the default nightly run (default true); false = only with --store. */
|
||||||
|
nightly?: boolean;
|
||||||
|
/** PoliteFetcher overrides (retries/backoff). */
|
||||||
|
fetcher?: { maxRetries?: number; retryBaseMs?: number };
|
||||||
fetchAll(f: PoliteFetcher, log: (m: string) => void): Promise<RawItem[]>;
|
fetchAll(f: PoliteFetcher, log: (m: string) => void): Promise<RawItem[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NON_FOOD_CATEGORIES = new Set(['Drogisterij', 'Huishouden & dieren']);
|
||||||
|
/** isFood for an item: the source's flag, else derived from its shared category. */
|
||||||
|
export const itemIsFood = (r: Pick<RawItem, 'isFood' | 'category'>) =>
|
||||||
|
r.isFood ?? !NON_FOOD_CATEGORIES.has(r.category);
|
||||||
|
|
||||||
const cents = (euros: unknown) =>
|
const cents = (euros: unknown) =>
|
||||||
typeof euros === 'number' && Number.isFinite(euros) ? Math.round(euros * 100) : null;
|
typeof euros === 'number' && Number.isFinite(euros) ? Math.round(euros * 100) : null;
|
||||||
|
|
||||||
@@ -140,7 +156,9 @@ function findKey(o: unknown, key: string, depth = 0): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ahItem(p: AhProduct, category: string): RawItem {
|
const AH_NON_FOOD_IDS = new Set([44025]); // Bloemenshop
|
||||||
|
|
||||||
|
function ahItem(p: AhProduct, category: string, isFood?: boolean): RawItem {
|
||||||
const before = cents(p.priceBeforeBonus);
|
const before = cents(p.priceBeforeBonus);
|
||||||
const now = cents(p.currentPrice);
|
const now = cents(p.currentPrice);
|
||||||
const promoPrice = p.isBonus && now != null && before != null && now < before ? now : null;
|
const promoPrice = p.isBonus && now != null && before != null && now < before ? now : null;
|
||||||
@@ -163,7 +181,8 @@ function ahItem(p: AhProduct, category: string): RawItem {
|
|||||||
gtin: null,
|
gtin: null,
|
||||||
imageUrl: img?.url ?? null,
|
imageUrl: img?.url ?? null,
|
||||||
url: `https://www.ah.nl/producten/product/wi${p.webshopId}`,
|
url: `https://www.ah.nl/producten/product/wi${p.webshopId}`,
|
||||||
available: p.orderAvailabilityStatus ? p.orderAvailabilityStatus === 'IN_ASSORTMENT' : true
|
available: p.orderAvailabilityStatus ? p.orderAvailabilityStatus === 'IN_ASSORTMENT' : true,
|
||||||
|
isFood
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +217,8 @@ async function crawlAhCategory(
|
|||||||
for (const p of r.products ?? []) {
|
for (const p of r.products ?? []) {
|
||||||
if (!p.webshopId || !p.title) continue;
|
if (!p.webshopId || !p.title) continue;
|
||||||
const key = String(p.webshopId);
|
const key = String(p.webshopId);
|
||||||
if (!items.has(key)) items.set(key, ahItem(p, category));
|
if (!items.has(key))
|
||||||
|
items.set(key, ahItem(p, category, AH_NON_FOOD_IDS.has(id) ? false : undefined));
|
||||||
}
|
}
|
||||||
if (!r.products?.length) break;
|
if (!r.products?.length) break;
|
||||||
}
|
}
|
||||||
@@ -282,7 +302,7 @@ type JumboProduct = {
|
|||||||
|
|
||||||
const JUMBO_NOT_PROMO = /gratis bezorging|^vanaf\b|bezorg/i;
|
const JUMBO_NOT_PROMO = /gratis bezorging|^vanaf\b|bezorg/i;
|
||||||
|
|
||||||
function jumboItem(p: JumboProduct, category: string): RawItem {
|
function jumboItem(p: JumboProduct, category: string, isFood?: boolean): RawItem {
|
||||||
const price = p.price?.price ?? null;
|
const price = p.price?.price ?? null;
|
||||||
const promo = p.price?.promoPrice ?? null;
|
const promo = p.price?.promoPrice ?? null;
|
||||||
const ppu = p.price?.pricePerUnit;
|
const ppu = p.price?.pricePerUnit;
|
||||||
@@ -306,7 +326,8 @@ function jumboItem(p: JumboProduct, category: string): RawItem {
|
|||||||
gtin: normaliseGtin(p.ean),
|
gtin: normaliseGtin(p.ean),
|
||||||
imageUrl: p.image || null,
|
imageUrl: p.image || null,
|
||||||
url: p.link ? new URL(p.link, 'https://www.jumbo.com').toString() : null,
|
url: p.link ? new URL(p.link, 'https://www.jumbo.com').toString() : null,
|
||||||
available: (p.inAssortment ?? true) && (p.availability?.isAvailable ?? true)
|
available: (p.inAssortment ?? true) && (p.availability?.isAvailable ?? true),
|
||||||
|
isFood
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +360,11 @@ export const jumboSource: Source = {
|
|||||||
const sp = r.data?.searchProducts;
|
const sp = r.data?.searchProducts;
|
||||||
for (const p of sp?.products ?? []) {
|
for (const p of sp?.products ?? []) {
|
||||||
if (!p.id || !p.title) continue;
|
if (!p.id || !p.title) continue;
|
||||||
if (!items.has(p.id)) items.set(p.id, jumboItem(p, category));
|
if (!items.has(p.id))
|
||||||
|
items.set(
|
||||||
|
p.id,
|
||||||
|
jumboItem(p, category, root === 'nonfood-en-servicebalie' ? false : undefined)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!sp?.products?.length || offSet + JUMBO_PAGE >= sp.count) break;
|
if (!sp?.products?.length || offSet + JUMBO_PAGE >= sp.count) break;
|
||||||
}
|
}
|
||||||
@@ -349,4 +374,10 @@ export const jumboSource: Source = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SOURCES: Record<string, Source> = { ah: ahSource, jumbo: jumboSource };
|
export const SOURCES: Record<string, Source> = {
|
||||||
|
ah: ahSource,
|
||||||
|
jumbo: jumboSource,
|
||||||
|
plus: plusSource,
|
||||||
|
xenos: xenosSource,
|
||||||
|
'amazing-oriental': orientalSource
|
||||||
|
};
|
||||||
|
|||||||
35
src/lib/server/groceries/stores.test.ts
Normal file
35
src/lib/server/groceries/stores.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { promoFromLabel } from './plus';
|
||||||
|
import { xenosCategory } from './xenos';
|
||||||
|
import { parseOrientalListing } from './oriental';
|
||||||
|
|
||||||
|
describe('plus promoFromLabel', () => {
|
||||||
|
it('handles common multibuy labels', () => {
|
||||||
|
expect(promoFromLabel('1+1 GRATIS', 200)).toBe(100);
|
||||||
|
expect(promoFromLabel('2e halve prijs', 200)).toBe(150);
|
||||||
|
expect(promoFromLabel('25% korting', 400)).toBe(300);
|
||||||
|
expect(promoFromLabel('3 voor 5.00', 200)).toBe(167);
|
||||||
|
expect(promoFromLabel('0.50 KORTING', 200)).toBe(150);
|
||||||
|
expect(promoFromLabel('Gratis bezorging', 200)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('xenosCategory', () => {
|
||||||
|
it('maps food and non-food', () => {
|
||||||
|
expect(xenosCategory(['Eten en drinken', 'Drinken', 'Koffie'])).toEqual({
|
||||||
|
category: 'Koffie & thee',
|
||||||
|
isFood: true
|
||||||
|
});
|
||||||
|
expect(xenosCategory(['Eten en drinken', 'Bakken', 'Bakvormen'])).toEqual({
|
||||||
|
category: 'Overig',
|
||||||
|
isFood: false
|
||||||
|
});
|
||||||
|
expect(xenosCategory(['Wonen', 'Kaarsen'])).toEqual({ category: 'Overig', isFood: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseOrientalListing', () => {
|
||||||
|
it('returns nothing for a page without products', () => {
|
||||||
|
expect(parseOrientalListing('<html><body>Geen producten</body></html>', 'rijst')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
182
src/lib/server/groceries/xenos.ts
Normal file
182
src/lib/server/groceries/xenos.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Xenos (xenos.nl) catalogue source. Top categories come from Xenos' public Tweakwise navigation
|
||||||
|
// gateway; products from the storefront's own JSON API (needs the session + XSRF cookies of a
|
||||||
|
// plain home page visit). Xenos is mostly homeware: only "Eten en drinken" is food, so every
|
||||||
|
// item gets an isFood flag and non-food lands in 'Overig' (hidden by default in the tracker).
|
||||||
|
import type { PoliteFetcher } from './http';
|
||||||
|
import type { RawItem, Source } from './sources';
|
||||||
|
|
||||||
|
const BASE = 'https://www.xenos.nl';
|
||||||
|
const TWEAKWISE = 'https://gateway.tweakwisenavigator.net/navigation/829e5ad2'; // public key
|
||||||
|
const NL_ROOT = '100012'; // "Xenos NL - Master catalogus"
|
||||||
|
const UA = 'Mozilla/5.0 (compatible; stan-grocery-tracker/0.1; +https://stanrunge.dev)';
|
||||||
|
|
||||||
|
/** "Eten en drinken" level 2 → shared category. */
|
||||||
|
const FOOD_L2: Record<string, string> = {
|
||||||
|
Drinken: 'Frisdrank & sappen',
|
||||||
|
Eten: 'Snoep, koek & chips',
|
||||||
|
Smaakmakers: 'Soepen, sauzen & conserven',
|
||||||
|
Wereldkeukens: 'Pasta, rijst & wereldkeuken'
|
||||||
|
};
|
||||||
|
/** Level 3 overrides. */
|
||||||
|
const FOOD_L3: Record<string, string> = {
|
||||||
|
Koffie: 'Koffie & thee',
|
||||||
|
Thee: 'Koffie & thee',
|
||||||
|
Chocolademelk: 'Koffie & thee',
|
||||||
|
'Pasta, rijst en wraps': 'Pasta, rijst & wereldkeuken',
|
||||||
|
Noodles: 'Pasta, rijst & wereldkeuken',
|
||||||
|
Sushi: 'Pasta, rijst & wereldkeuken',
|
||||||
|
Panettone: 'Brood & bakkerij'
|
||||||
|
};
|
||||||
|
/** "Bakken" is mostly bakeware; these sub-categories are ingredients. */
|
||||||
|
const BAKING_FOOD = new Set([
|
||||||
|
'Bakmixen',
|
||||||
|
'Fondant',
|
||||||
|
'Fondant en marsepein',
|
||||||
|
'Kleurstoffen',
|
||||||
|
'Taartdecoratie'
|
||||||
|
]);
|
||||||
|
|
||||||
|
type XenosProduct = {
|
||||||
|
sku: string | number;
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
url_key?: string;
|
||||||
|
price_incl_tax?: number | string;
|
||||||
|
final_price_incl_tax?: number | string;
|
||||||
|
discount_percentage?: number;
|
||||||
|
is_salable?: boolean;
|
||||||
|
in_stock?: boolean;
|
||||||
|
default_image?: { url?: string };
|
||||||
|
category_paths?: { label: string }[][];
|
||||||
|
};
|
||||||
|
type XenosPage = { items?: XenosProduct[]; total_items?: number; last_page?: number };
|
||||||
|
|
||||||
|
const euroCents = (v: unknown): number | null => {
|
||||||
|
const n = typeof v === 'number' ? v : parseFloat(String(v ?? ''));
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function xenosCategory(labels: string[]): { category: string; isFood: boolean } {
|
||||||
|
if (labels[0] === 'Eten en drinken') {
|
||||||
|
if (labels[1] === 'Bakken')
|
||||||
|
return BAKING_FOOD.has(labels[2])
|
||||||
|
? { category: 'Ontbijt & beleg', isFood: true }
|
||||||
|
: { category: 'Overig', isFood: false };
|
||||||
|
return {
|
||||||
|
category: FOOD_L3[labels[2]] ?? FOOD_L2[labels[1]] ?? 'Overig',
|
||||||
|
isFood: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (labels[0] === 'Huishouden') return { category: 'Huishouden & dieren', isFood: false };
|
||||||
|
return { category: 'Overig', isFood: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xenosItem(p: XenosProduct): RawItem {
|
||||||
|
const paths = p.category_paths ?? [];
|
||||||
|
const path =
|
||||||
|
paths.find((x) => x[0]?.label === 'Eten en drinken') ??
|
||||||
|
paths.find((x) => x[0]?.label === 'Huishouden') ??
|
||||||
|
paths[0] ??
|
||||||
|
[];
|
||||||
|
const labels = path.map((x) => x.label);
|
||||||
|
const { category, isFood } = xenosCategory(labels);
|
||||||
|
const price = euroCents(p.price_incl_tax);
|
||||||
|
const final = euroCents(p.final_price_incl_tax);
|
||||||
|
const promo = final && price && final < price ? final : null;
|
||||||
|
const name = String(p.name).trim();
|
||||||
|
// Names end in the pack size: "Tamarinde - 360 gram".
|
||||||
|
const size = name.match(
|
||||||
|
/-\s*((?:\d+\s*x\s*)?[\d.,]+\s*(?:g|gr|gram|kg|ml|cl|l|liter|stuks?))\s*$/i
|
||||||
|
)?.[1];
|
||||||
|
return {
|
||||||
|
storeProductId: String(p.sku),
|
||||||
|
name,
|
||||||
|
brand: null, // not in the listing; inferred from the name at import
|
||||||
|
sizeText: size ?? null,
|
||||||
|
priceCents: price ?? final,
|
||||||
|
promoPriceCents: promo,
|
||||||
|
promoLabel: promo
|
||||||
|
? p.discount_percentage
|
||||||
|
? `${Math.round(p.discount_percentage)}% korting`
|
||||||
|
: 'Actie'
|
||||||
|
: null,
|
||||||
|
storeUnitPrice: null,
|
||||||
|
storeUnitBasisCents: null,
|
||||||
|
category,
|
||||||
|
categoryPath: labels.join(' > ') || null,
|
||||||
|
gtin: null,
|
||||||
|
imageUrl: p.default_image?.url?.replace('{size}', '500x500') ?? null,
|
||||||
|
url: p.url ? new URL(p.url, BASE).toString() : p.url_key ? BASE + p.url_key : null,
|
||||||
|
available: !!p.is_salable && !!p.in_stock,
|
||||||
|
isFood
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function session(): Promise<Record<string, string>> {
|
||||||
|
const res = await fetch(BASE + '/', {
|
||||||
|
headers: { 'User-Agent': UA },
|
||||||
|
signal: AbortSignal.timeout(45_000)
|
||||||
|
});
|
||||||
|
const set = res.headers.getSetCookie();
|
||||||
|
await res.arrayBuffer();
|
||||||
|
if (!res.ok) throw new Error(`xenos home -> HTTP ${res.status}`);
|
||||||
|
const xsrf = set.find((c) => c.startsWith('XSRF-TOKEN='));
|
||||||
|
if (!xsrf) throw new Error('xenos: no XSRF-TOKEN cookie');
|
||||||
|
return {
|
||||||
|
'User-Agent': UA,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-XSRF-TOKEN': decodeURIComponent(xsrf.split(';')[0].slice('XSRF-TOKEN='.length)),
|
||||||
|
'X-STORE-ID': '1',
|
||||||
|
Cookie: set.map((c) => c.split(';')[0]).join('; ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const xenosSource: Source = {
|
||||||
|
slug: 'xenos',
|
||||||
|
minDelayMs: 300,
|
||||||
|
async fetchAll(f: PoliteFetcher, log) {
|
||||||
|
const headers = await session();
|
||||||
|
const nav = await f.json<{
|
||||||
|
facets: {
|
||||||
|
facetsettings: { urlkey: string };
|
||||||
|
attributes: { title: string; url: string }[];
|
||||||
|
}[];
|
||||||
|
}>(`${TWEAKWISE}?tn_cid=${NL_ROOT}&tn_ps=1&format=json`, { headers: { 'User-Agent': UA } });
|
||||||
|
const facet = nav.facets.find((x) => x.facetsettings.urlkey === 'categorie');
|
||||||
|
if (!facet) throw new Error('xenos: no categorie facet');
|
||||||
|
// Tweakwise id = "1000" + store id (1) + Magento entity id, e.g. 100011259 → 1259.
|
||||||
|
const cats = facet.attributes.map((a) => ({
|
||||||
|
id: String(a.url.split('-').pop()).slice(5),
|
||||||
|
title: a.title
|
||||||
|
}));
|
||||||
|
// Food first so an item listed in food and a cross-cut (Acties, Cadeaus) keeps food data.
|
||||||
|
cats.sort(
|
||||||
|
(a, b) => Number(b.title === 'Eten en drinken') - Number(a.title === 'Eten en drinken')
|
||||||
|
);
|
||||||
|
const items = new Map<string, RawItem>();
|
||||||
|
for (const c of cats) {
|
||||||
|
const before = items.size;
|
||||||
|
const list = (page: number) =>
|
||||||
|
f.json<XenosPage>(
|
||||||
|
`${BASE}/api/catalog/category/${c.id}/product/list?page=${page}`,
|
||||||
|
{
|
||||||
|
headers
|
||||||
|
},
|
||||||
|
`cat-${c.id}-p${page}`
|
||||||
|
);
|
||||||
|
const first = await list(1);
|
||||||
|
const last = first.last_page ?? 1;
|
||||||
|
for (let page = 1; page <= last; page++) {
|
||||||
|
const r = page === 1 ? first : await list(page);
|
||||||
|
for (const p of r.items ?? []) {
|
||||||
|
if (!p.sku || !p.name) continue;
|
||||||
|
const key = String(p.sku);
|
||||||
|
if (!items.has(key)) items.set(key, xenosItem(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(` xenos ${c.title}: +${items.size - before} (${items.size} total)`);
|
||||||
|
}
|
||||||
|
return [...items.values()];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -73,6 +73,8 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
const favorites = p.get('fav') === '1';
|
const favorites = p.get('fav') === '1';
|
||||||
const promo = p.get('promo') === '1';
|
const promo = p.get('promo') === '1';
|
||||||
const multi = p.get('multi') === '1';
|
const multi = p.get('multi') === '1';
|
||||||
|
// Homeware (Xenos décor, woks, …) is hidden unless asked for; drugstore/household stay.
|
||||||
|
const nonfood = p.get('nonfood') === '1';
|
||||||
const page = Math.max(1, Math.min(10_000, Number(p.get('page')) || 1));
|
const page = Math.max(1, Math.min(10_000, Number(p.get('page')) || 1));
|
||||||
const detailId = Number(p.get('g')) || null;
|
const detailId = Number(p.get('g')) || null;
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
if (favorites) conds.push(s`g.favorite`);
|
if (favorites) conds.push(s`g.favorite`);
|
||||||
if (promo) conds.push(s`g.promo_count > 0`);
|
if (promo) conds.push(s`g.promo_count > 0`);
|
||||||
if (multi) conds.push(s`g.store_count >= 2`);
|
if (multi) conds.push(s`g.store_count >= 2`);
|
||||||
|
if (!nonfood) conds.push(s`(g.is_food or g.category is distinct from 'Overig')`);
|
||||||
const where = conds.reduce((a, c) => s`${a} and ${c}`);
|
const where = conds.reduce((a, c) => s`${a} and ${c}`);
|
||||||
const order = q
|
const order = q
|
||||||
? s`g.favorite desc, (lower(g.name) = ${q}) desc, public.word_similarity(${q}::text, lower(g.name)) desc, g.store_count desc, g.item_count desc, g.name`
|
? s`g.favorite desc, (lower(g.name) = ${q}) desc, public.word_similarity(${q}::text, lower(g.name)) desc, g.store_count desc, g.item_count desc, g.name`
|
||||||
@@ -187,7 +190,15 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
filters: { q: p.get('q') ?? '', store: store?.slug ?? '', category, favorites, promo, multi },
|
filters: {
|
||||||
|
q: p.get('q') ?? '',
|
||||||
|
store: store?.slug ?? '',
|
||||||
|
category,
|
||||||
|
favorites,
|
||||||
|
promo,
|
||||||
|
multi,
|
||||||
|
nonfood
|
||||||
|
},
|
||||||
categories: catList,
|
categories: catList,
|
||||||
stats,
|
stats,
|
||||||
runs,
|
runs,
|
||||||
|
|||||||
@@ -272,6 +272,9 @@
|
|||||||
<label class="check"
|
<label class="check"
|
||||||
><input type="checkbox" name="multi" value="1" checked={data.filters.multi} /> In 2+ stores</label
|
><input type="checkbox" name="multi" value="1" checked={data.filters.multi} /> In 2+ stores</label
|
||||||
>
|
>
|
||||||
|
<label class="check" title="Also show homeware and other non-food (mostly Xenos)"
|
||||||
|
><input type="checkbox" name="nonfood" value="1" checked={data.filters.nonfood} /> Non-food</label
|
||||||
|
>
|
||||||
<Button type="submit" variant="outline">Search</Button>
|
<Button type="submit" variant="outline">Search</Button>
|
||||||
<div class="segmented ml-auto" role="group" aria-label="Compare by">
|
<div class="segmented ml-auto" role="group" aria-label="Compare by">
|
||||||
<button type="button" aria-pressed={mode === 'unit'} onclick={() => (mode = 'unit')}
|
<button type="button" aria-pressed={mode === 'unit'} onclick={() => (mode = 'unit')}
|
||||||
|
|||||||
Reference in New Issue
Block a user