Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+533
View File
@@ -0,0 +1,533 @@
/**
* Leuba Hiag catalog crawler.
*
* The Leuba Hiag webshop (https://www.leubahiag.ch) is a Laravel/Bagisto shop whose
* product catalog is served by Algolia. The public catalog search page embeds a
* short-lived "secured" Algolia API key (guest scope) inside the page markup. We read
* that key live on every run (it expires), then page through the Algolia index and
* upsert each product into the shared `formex_materials` table under the `leubahiag`
* supplier.
*
* Notes:
* - Algolia exposes the product catalog but NOT prices. Customer-specific prices come
* from the authenticated `/fr/sales/prices-and-availability` endpoint: we log in with
* the account credentials (LEUBAHIAG_EMAIL/PASSWORD) and, after importing each line,
* fetch the per-SKU panel price and store it in price_chf.
* - Imported items are inactive by default so the catalog rows don't flood the
* configurator finish picker. Activate the wanted decors from the Matériaux admin page.
*/
import pool, { run } from '../db/index.js';
const BASE_URL = 'https://www.leubahiag.ch';
const SEARCH_URL = `${BASE_URL}/fr/catalog/search`;
const CRAWL_DELAY = parseInt(process.env.LEUBAHIAG_CRAWL_DELAY_MS || '300', 10);
const ALGOLIA_HITS_PER_PAGE = 1000;
/**
* Curated catalog lines to import (per the supplier's agreed assortment).
* Each target maps to an Algolia query and/or brand facet filter. The `label` is used
* as the material `collection` so the configurator groups items by recognisable line.
*/
export const LEUBA_HIAG_TARGETS = [
{ key: 'pfleiderer-xpsm', label: 'Pfleiderer XPSM (Antifingerprint)', tier: 'Entrée de gamme', query: 'XPSM', brands: ['Pfleiderer'] },
{ key: 'swiss-krono', label: 'Swiss Krono', tier: 'Entrée de gamme', query: '', brands: ['Swiss Krono'] },
{ key: 'unilin-master-oak', label: 'Unilin Master Oak', tier: 'Milieu de gamme', query: 'Master Oak', brands: ['UNILIN'] },
{ key: 'shinnoki', label: 'Shinnoki', tier: 'Milieu de gamme', query: 'Shinnoki', brands: [] },
{ key: 'cleaf', label: 'Cleaf', tier: 'Haut de gamme', query: '', brands: ['CLEAF'] },
{ key: 'valchromat', label: 'Valchromat', tier: 'Haut de gamme', query: '', brands: ['Valchromat'] },
];
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function browserHeaders(extra = {}) {
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
...extra,
};
}
function absoluteUrl(url) {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
}
/** Parse a "CHF 1'234.50" string into a number, or null. */
function parseCHF(text) {
if (typeof text !== 'string') return null;
const clean = text.replace(/CHF/gi, '').replace(/[\u00a0\u202f'’\s]/g, '').replace(',', '.');
const n = parseFloat(clean);
return Number.isFinite(n) ? n : null;
}
/**
* Authenticated Leuba Hiag (Laravel) session. Used to read customer-specific prices
* from the `prices-and-availability` endpoint (axios sends the XSRF-TOKEN cookie back
* as an X-XSRF-TOKEN header — we replicate that here).
*/
class LeubaHiagSession {
constructor() {
this.cookies = new Map();
this.loggedIn = false;
}
captureCookies(response) {
let raw = [];
if (typeof response.headers.getSetCookie === 'function') {
raw = response.headers.getSetCookie();
} else {
const header = response.headers.get('set-cookie');
if (header) raw = header.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
}
for (const entry of raw) {
const [pair] = entry.split(';');
const eq = pair.indexOf('=');
if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
cookieHeader() {
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
xsrfToken() {
const raw = this.cookies.get('XSRF-TOKEN');
return raw ? decodeURIComponent(raw) : '';
}
async login(emit = () => {}, credentials = {}) {
const email = credentials.username || credentials.email
|| process.env.LEUBAHIAG_EMAIL || process.env.LEUBAHIAG_USER;
const password = credentials.password || process.env.LEUBAHIAG_PASSWORD;
if (!email || !password) {
emit({ type: 'log', message: 'Identifiants Leuba Hiag absents : import sans prix.' });
return false;
}
const loginUrl = `${BASE_URL}/fr/customer/auth/login`;
try {
const getRes = await fetch(loginUrl, { headers: browserHeaders(), redirect: 'follow' });
this.captureCookies(getRes);
const html = await getRes.text();
const token = (html.match(/name="_token"\s+value="([^"]+)"/) || [])[1];
if (!token) {
emit({ type: 'log', message: 'Jeton CSRF Leuba Hiag introuvable : import sans prix.' });
return false;
}
const body = new URLSearchParams();
body.set('_token', token);
body.set('email', email);
body.set('password', password);
// redirect:'manual' so we capture Set-Cookie from the post-login 302 (the
// LoggedIn/session cookies live on the redirect response, not the final page).
const postRes = await fetch(loginUrl, {
method: 'POST',
headers: browserHeaders({
'Content-Type': 'application/x-www-form-urlencoded',
Referer: loginUrl,
Origin: BASE_URL,
Cookie: this.cookieHeader(),
}),
body: body.toString(),
redirect: 'manual',
});
this.captureCookies(postRes);
// Confirm the session and refresh the XSRF-TOKEN from the authenticated catalog page.
const verifyRes = await fetch(`${BASE_URL}/fr/catalog/search`, {
headers: browserHeaders({ Cookie: this.cookieHeader() }),
redirect: 'follow',
});
this.captureCookies(verifyRes);
const verifyHtml = await verifyRes.text();
const isLoggedIn = /\\u0022isLoggedIn\\u0022:true/.test(verifyHtml)
|| this.cookies.get('LoggedIn') === '1';
this.loggedIn = isLoggedIn;
emit({
type: 'log',
message: this.loggedIn
? `Connecté à Leuba Hiag (${email}).`
: 'Connexion Leuba Hiag non confirmée : import sans prix.',
});
return this.loggedIn;
} catch (err) {
emit({ type: 'log', message: `Erreur de connexion Leuba Hiag : ${err.message}` });
return false;
}
}
/** POST {skus} to the prices endpoint; returns the per-sku price map (or {}). */
async fetchPrices(skus) {
if (!this.loggedIn || skus.length === 0) return {};
const res = await fetch(`${BASE_URL}/fr/sales/prices-and-availability`, {
method: 'POST',
headers: browserHeaders({
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': this.xsrfToken(),
Referer: `${BASE_URL}/fr/catalog/search`,
Origin: BASE_URL,
Cookie: this.cookieHeader(),
}),
body: JSON.stringify({ skus }),
});
this.captureCookies(res);
if (!res.ok) {
throw new Error(`prices-and-availability HTTP ${res.status}`);
}
return res.json();
}
}
/**
* Read the live Algolia connection config (appId, index, secured apiKey) from the
* catalog search page. The key is embedded as `\u0022apiKey\u0022:\u0022...\u0022`
* inside the `window.globalSearchConfigBase` JSON blob.
*/
async function fetchAlgoliaConfig(emit = () => {}) {
const res = await fetch(SEARCH_URL, { headers: browserHeaders(), redirect: 'follow' });
if (!res.ok) {
throw new Error(`Impossible de charger la page catalogue Leuba Hiag (HTTP ${res.status}).`);
}
const html = await res.text();
const appId = (html.match(/\\u0022appId\\u0022:\\u0022([A-Za-z0-9]+)\\u0022/) || [])[1]
|| process.env.LEUBAHIAG_ALGOLIA_APP_ID;
const indexName = (html.match(/\\u0022indexName\\u0022:\\u0022([A-Za-z0-9_]+)\\u0022/) || [])[1]
|| process.env.LEUBAHIAG_ALGOLIA_INDEX;
const apiKey = (html.match(/\\u0022apiKey\\u0022:\\u0022([A-Za-z0-9+/=]+)\\u0022/) || [])[1];
if (!appId || !indexName || !apiKey) {
throw new Error('Clé Algolia introuvable sur la page catalogue Leuba Hiag (structure du site modifiée ?).');
}
emit({ type: 'log', message: `Catalogue Algolia détecté: index ${indexName} (app ${appId}).` });
return { appId, indexName, apiKey };
}
/**
* Run one Algolia query page. `facetFilters` is an array of arrays per Algolia syntax.
*/
async function queryAlgolia({ appId, indexName, apiKey }, { query = '', page = 0, hitsPerPage = ALGOLIA_HITS_PER_PAGE, facetFilters = [] } = {}) {
const url = `https://${appId}-dsn.algolia.net/1/indexes/${indexName}/query`;
const params = new URLSearchParams();
params.set('query', query);
params.set('hitsPerPage', String(hitsPerPage));
params.set('page', String(page));
if (facetFilters.length > 0) {
params.set('facetFilters', JSON.stringify(facetFilters));
}
const res = await fetch(url, {
method: 'POST',
headers: {
'X-Algolia-Application-Id': appId,
'X-Algolia-API-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ params: params.toString() }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Requête Algolia échouée (HTTP ${res.status})${body ? `: ${body.slice(0, 160)}` : ''}`);
}
return res.json();
}
function firstString(value) {
if (Array.isArray(value)) return value.find((v) => typeof v === 'string') || null;
return typeof value === 'string' ? value : null;
}
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* Map an Algolia product record to a `formex_materials` row.
* Dimension fields from the index: thickness (mm), length (mm), width (mm).
* @param {string} [collectionOverride] Force the `collection` (curated line label).
*/
function mapHit(hit, collectionOverride) {
const sku = String(hit.sku || hit.objectID || '').trim();
if (!sku) return null;
const categoryPath = Array.isArray(hit.categories_with_path) ? hit.categories_with_path : [];
const category = categoryPath.length > 0
? categoryPath[categoryPath.length - 1]
: (firstString(hit.categories?.level0) || null);
const collection = collectionOverride
|| firstString(hit.brand)
|| firstString(hit.categories?.level0)
|| 'Leuba Hiag';
return {
sku,
materialCode: `LH-${sku}`,
name: hit.name || hit.group_name || sku,
collection,
category,
width_mm: toNumber(hit.width),
height_mm: toNumber(hit.length),
thickness_mm: toNumber(hit.thickness),
image_url: absoluteUrl(hit.image_url),
formex_url: absoluteUrl(hit.url),
price_chf: null,
price_unit: null,
};
}
async function ensureSupplier() {
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
if (existing.rows.length > 0) return existing.rows[0].id;
await run(
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
['leubahiag', 'Leuba Hiag', '[email protected]', 'materials', 1]
);
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
return created.rows[0]?.id || null;
}
/**
* Upsert a material. Existing rows keep their `active` flag (so admin activations are
* preserved across re-syncs); new rows are inserted with the provided `active` value.
*/
async function upsertMaterial(material, supplierId, activeForNew) {
const existing = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.materialCode]
);
if (existing.rows.length > 0) {
await run(
`UPDATE formex_materials
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?,
supplier_id = COALESCE(?, supplier_id), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.image_url,
material.formex_url,
supplierId,
material.materialCode,
]
);
return { id: existing.rows[0].id, created: false };
}
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
image_url, formex_url, price_chf, price_unit, supplier_id, active, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
[
material.materialCode,
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
activeForNew ? 1 : 0,
]
);
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
return { id: created.rows[0]?.id || null, created: true };
}
/** Update the price of an already-imported material by its SKU. */
async function updateMaterialPrice(sku, priceChf, priceUnit) {
await run(
`UPDATE formex_materials
SET price_chf = ?, price_unit = COALESCE(?, price_unit), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[priceChf, priceUnit, `LH-${sku}`]
);
}
async function logSync(status, itemsCount, error = null) {
await run(
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
['leubahiag', status, itemsCount, error]
);
}
/**
* Import all products matching one curated target (a single catalog line).
*/
async function importTarget(config, supplierId, target, activate, emit) {
const facetFilters = Array.isArray(target.brands) && target.brands.length > 0
? [target.brands.map((b) => `brand:${b}`)]
: [];
let synced = 0;
let created = 0;
let page = 0;
let nbPages = 1;
let nbHits = 0;
const skus = [];
do {
if (page > 0) await sleep(CRAWL_DELAY);
const data = await queryAlgolia(config, { query: target.query, page, facetFilters });
nbPages = data.nbPages || 1;
nbHits = data.nbHits || 0;
if (page === 0) {
emit({ type: 'log', message: `→ ${target.label} (${target.tier}) : ${nbHits} produit(s).` });
}
const hits = Array.isArray(data.hits) ? data.hits : [];
for (const hit of hits) {
const material = mapHit(hit, target.label);
if (!material) continue;
try {
const result = await upsertMaterial(material, supplierId, activate);
synced++;
if (result.created) created++;
skus.push(material.sku);
} catch (err) {
emit({ type: 'log', message: ` ✗ ${material.materialCode}: ${err.message}` });
}
}
page++;
} while (page < nbPages);
emit({ type: 'log', message: ` ✓ ${target.label} : ${synced} importé(s) (${created} nouveau(x)).` });
return { synced, created, skus };
}
/**
* Fetch and store customer prices for a list of SKUs (in batches).
* Returns the number of items priced.
*/
async function importPrices(session, skus, emit) {
const BATCH = 50;
let priced = 0;
for (let i = 0; i < skus.length; i += BATCH) {
const batch = skus.slice(i, i + BATCH);
try {
const data = await session.fetchPrices(batch);
for (const sku of batch) {
const info = data?.[sku];
if (!info) continue;
const tier = Array.isArray(info.tierprices) ? info.tierprices[0] : null;
const priceChf = parseCHF(tier?.price);
if (priceChf == null) continue;
await updateMaterialPrice(sku, priceChf, 'pièce');
priced++;
}
} catch (err) {
emit({ type: 'log', message: ` ✗ Prix lot ${i / BATCH + 1}: ${err.message}` });
}
emit({ type: 'log', message: ` Prix: ${priced}/${skus.length} récupérés…` });
if (i + BATCH < skus.length) await sleep(CRAWL_DELAY);
}
return priced;
}
/**
* Synchronise the curated Leuba Hiag catalog lines via Algolia.
*
* @param {object} options
* @param {string[]} [options.targets] Subset of target keys to import (default: all).
* @param {boolean} [options.activate] Mark newly imported items active (default false).
* @param {boolean} [options.fetchPrices] Log in and import customer prices (default true).
* @param {object} [options.credentials] { username/email, password } (else env vars).
* @param {function} emit SSE-style event emitter.
*/
export async function syncLeubaHiag(options = {}, emit = () => {}) {
const { targets = null, activate = false, fetchPrices = true, credentials = {} } = options;
const selected = Array.isArray(targets) && targets.length > 0
? LEUBA_HIAG_TARGETS.filter((t) => targets.includes(t.key))
: LEUBA_HIAG_TARGETS;
const start = Date.now();
let synced = 0;
let created = 0;
let priced = 0;
try {
if (selected.length === 0) {
throw new Error('Aucune gamme Leuba Hiag sélectionnée.');
}
const config = await fetchAlgoliaConfig(emit);
const supplierId = await ensureSupplier();
// Log in up-front so prices can be fetched after each line is imported.
const session = new LeubaHiagSession();
if (fetchPrices) {
await session.login(emit, credentials);
}
emit({ type: 'log', message: `${selected.length} gamme(s) à importer.` });
emit({ type: 'progress', current: 0, total: selected.length, collection: 'Leuba Hiag' });
const allSkus = [];
for (let i = 0; i < selected.length; i++) {
const target = selected[i];
emit({ type: 'progress', current: i, total: selected.length, collection: target.label });
try {
const res = await importTarget(config, supplierId, target, activate, emit);
synced += res.synced;
created += res.created;
allSkus.push(...res.skus);
} catch (err) {
emit({ type: 'log', message: ` ✗ ${target.label}: ${err.message}` });
}
if (i < selected.length - 1) await sleep(CRAWL_DELAY);
}
if (session.loggedIn && allSkus.length > 0) {
emit({ type: 'log', message: `Récupération des prix client pour ${allSkus.length} article(s)…` });
priced = await importPrices(session, allSkus, emit);
emit({ type: 'log', message: `✓ ${priced} prix client enregistrés.` });
}
const duration = ((Date.now() - start) / 1000).toFixed(1);
await logSync('success', synced, null);
const result = {
success: true,
itemsCount: synced,
createdCount: created,
updatedCount: synced - created,
pricedCount: priced,
duration,
error: null,
};
emit({ type: 'progress', current: selected.length, total: selected.length, collection: 'Leuba Hiag' });
emit({ type: 'complete', result });
return result;
} catch (err) {
await logSync('error', synced, err.message);
emit({ type: 'error', error: err.message });
return { success: false, itemsCount: synced, error: err.message };
}
}