Files
apex/server/services/leubaHiagCrawler.js
T
lejayandCursor 30921aade6 v2.1
Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
2026-10-07 17:13:03 +02:00

534 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}
}