groceries: Vomar importer (public product gateway, EANs; regular prices only) + store row
All checks were successful
Deploy production / deploy (push) Successful in 1m21s

This commit is contained in:
2026-10-25 18:57:21 +00:00
parent 1bab96c20d
commit cce6ec3f2b
4 changed files with 29673 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import { normaliseGtin, parseAhUnitPrice, storeUnit } from './normalize';
import { dirkSource } from './dirk';
import { hoogvlietSource } from './hoogvliet';
import { lidlSource } from './lidl';
import { vomarSource } from './vomar';
import { orientalSource } from './oriental';
import { plusSource } from './plus';
import { xenosSource } from './xenos';
@@ -389,5 +390,6 @@ export const SOURCES: Record<string, Source> = {
'amazing-oriental': orientalSource,
lidl: lidlSource,
dirk: dirkSource,
hoogvliet: hoogvlietSource
hoogvliet: hoogvlietSource,
vomar: vomarSource
};

View File

@@ -0,0 +1,174 @@
// Vomar (vomar.nl) catalogue source: the site's public product gateway (gateway.vomar.nl):
// /departments → department/main-group tree, /products?departmentNumber&mainGroupNumber → the
// whole group in one page, with shelf price, brand and EAN. Weekly offers live on a separate
// site (acties.vomar.nl) and are not in the gateway, so Vomar has regular prices only.
import type { PoliteFetcher } from './http';
import { normaliseGtin } from './normalize';
import type { RawItem, Source } from './sources';
const BASE = 'https://www.vomar.nl';
const GATEWAY = 'https://gateway.vomar.nl';
const FILES = 'https://d3vricquk1sjgf.cloudfront.net/';
const UA = 'Mozilla/5.0 (compatible; stan-grocery-tracker/0.1; +https://stanrunge.dev)';
const HEADERS = { 'User-Agent': UA, Accept: 'application/json' };
type Cat = { category: string; isFood?: boolean };
const DEPT: Record<number, Cat> = {
10: { category: 'Baby & kind' },
15: { category: 'Bier & wijn' },
20: { category: 'Diepvries' },
25: { category: 'Drogisterij', isFood: false },
30: { category: 'Frisdrank & sappen' },
35: { category: 'Huishouden & dieren', isFood: false },
40: { category: 'Huishouden & dieren', isFood: false },
45: { category: 'Overig', isFood: false },
50: { category: 'Groente, fruit & aardappelen' },
55: { category: 'Pasta, rijst & wereldkeuken' }
};
const c = (category: string, isFood?: boolean): Cat => ({ category, isFood });
const GROUP: Record<number, Cat> = {
100: c('Baby & kind', true),
101: c('Baby & kind', false),
102: c('Baby & kind', false),
301: c('Zuivel & eieren'),
303: c('Koffie & thee'),
350: c('Huishouden & dieren', false),
501: c('Brood & bakkerij'),
502: c('Brood & bakkerij'),
504: c('Vlees, vis & vega'),
506: c('Vleeswaren, kaas & tapas'),
507: c('Vlees, vis & vega'),
508: c('Ontbijt & beleg'),
509: c('Vlees, vis & vega'),
510: c('Brood & bakkerij'),
511: c('Ontbijt & beleg'),
513: c('Maaltijden & gemak'),
514: c('Snoep, koek & chips'),
515: c('Frisdrank & sappen'),
517: c('Vleeswaren, kaas & tapas'),
518: c('Vlees, vis & vega'),
519: c('Vlees, vis & vega'),
520: c('Vleeswaren, kaas & tapas'),
521: c('Zuivel & eieren'),
550: c('Ontbijt & beleg'),
551: c('Overig'),
552: c('Ontbijt & beleg'),
553: c('Snoep, koek & chips'),
554: c('Snoep, koek & chips'),
556: c('Ontbijt & beleg'),
558: c('Soepen, sauzen & conserven'),
559: c('Snoep, koek & chips'),
560: c('Koffie & thee')
};
const UNITS: Record<string, string> = {
gram: 'g',
kilogram: 'kg',
kg: 'kg',
ml: 'ml',
milliliter: 'ml',
liter: 'l',
cl: 'cl',
stuks: 'stuks',
stuk: 'stuks'
};
export type VomarProduct = {
articleNumber: number;
description: string;
contents?: string | null;
unit?: string | null;
price?: number | null;
inWebshop?: boolean;
departmentWebShopNumber?: number;
mainGroupWebShopNumber?: number;
primaryEan?: string | null;
brand?: string | null;
images?: { fileName: string; imageType?: string }[] | null;
};
/** The site's own slug function (formatLink in its bundle). */
export function vomarSlug(s: string): string {
let e = s
.replace(/,/g, '-')
.replace(/\./g, '-')
.replace(' & ', '-')
.replace(/ /g, '-')
.replace(/'/g, '-')
.replace(/\//g, '-')
.replace(/%/g, '-')
.replace('+', '')
.replace(/---/g, '-')
.replace(/--/g, '-');
if (e.endsWith('-')) e = e.slice(0, -1);
return e.toLowerCase();
}
export function vomarItem(p: VomarProduct, dept: string, group: string): RawItem | null {
const name = p.description?.trim();
if (!p.articleNumber || !name) return null;
const price = typeof p.price === 'number' && p.price > 0 ? Math.round(p.price * 100) : null;
const cat = GROUP[p.mainGroupWebShopNumber ?? -1] ??
DEPT[p.departmentWebShopNumber ?? -1] ?? { category: 'Overig' };
const unit = UNITS[(p.unit ?? '').toLowerCase()];
const qty = Number(p.contents);
const img = p.images?.find((i) => i.imageType === 'PackShot') ?? p.images?.[0];
return {
storeProductId: String(p.articleNumber),
name,
brand: p.brand?.trim() || null,
sizeText: unit && qty > 0 ? `${qty} ${unit}` : null,
priceCents: price,
promoPriceCents: null,
promoLabel: null,
storeUnitPrice: null,
storeUnitBasisCents: null,
category: cat.category,
categoryPath: `${dept} > ${group}`,
gtin: normaliseGtin(p.primaryEan),
imageUrl: img ? FILES + img.fileName : null,
url: `${BASE}/producten/${vomarSlug(dept)}/${vomarSlug(group)}/${vomarSlug(name)}/${p.articleNumber}`,
available: price !== null && p.inWebshop !== false,
...(cat.isFood !== undefined ? { isFood: cat.isFood } : {})
};
}
export const vomarSource: Source = {
slug: 'vomar',
minDelayMs: 1500,
async fetchAll(f: PoliteFetcher, log) {
const { departments } = await f.json<{
departments: {
departmentNumber: number;
departmentDescription: string;
mainGroups: { mainGroupNumber: number; mainGroupDescription: string }[];
}[];
}>(`${GATEWAY}/departments`, { headers: HEADERS }, 'departments');
const items = new Map<string, RawItem>();
for (const d of departments) {
const before = items.size;
for (const g of d.mainGroups) {
const q = new URLSearchParams({
departmentNumber: String(d.departmentNumber),
mainGroupNumber: String(g.mainGroupNumber),
pageSize: '1000'
});
for (let page = 1; page <= 20; page++) {
if (page > 1) q.set('page', String(page));
const r = await f.json<{ products: VomarProduct[] | null; totalPages?: number }>(
`${GATEWAY}/products?${q}`,
{ headers: HEADERS },
`g${g.mainGroupNumber}-p${page}`
);
for (const p of r.products ?? []) {
const it = vomarItem(p, d.departmentDescription, g.mainGroupDescription);
if (it && !items.has(it.storeProductId)) items.set(it.storeProductId, it);
}
if (!r.totalPages || page >= r.totalPages) break;
}
}
log(` vomar ${d.departmentDescription}: +${items.size - before} (${items.size} total)`);
}
return [...items.values()];
}
};