groceries: Vomar importer (public product gateway, EANs; regular prices only) + store row
All checks were successful
Deploy production / deploy (push) Successful in 1m21s
All checks were successful
Deploy production / deploy (push) Successful in 1m21s
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- Vomar (vomar.nl).
|
||||
INSERT INTO "groceries"."stores" ("slug", "name", "website", "color", "sort_order") VALUES
|
||||
('vomar', 'Vomar', 'https://www.vomar.nl', '#e30613', 90)
|
||||
ON CONFLICT ("slug") DO NOTHING;
|
||||
29492
drizzle/20261025185715_groceries_store_vomar/snapshot.json
Normal file
29492
drizzle/20261025185715_groceries_store_vomar/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
};
|
||||
|
||||
174
src/lib/server/groceries/vomar.ts
Normal file
174
src/lib/server/groceries/vomar.ts
Normal 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()];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user