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