v1.0
This commit is contained in:
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* Formex.ch web crawler service
|
||||
* Scrapes panel/material data from the Formex catalog
|
||||
*/
|
||||
import * as cheerio from 'cheerio';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASE_URL = 'https://www.formex.ch';
|
||||
const ASSORTIMENT_URL = `${BASE_URL}/fr/assortiment`;
|
||||
const CRAWL_DELAY = parseInt(process.env.FORMEX_CRAWL_DELAY_MS || '500', 10);
|
||||
|
||||
// Directory for storing downloaded material images
|
||||
const IMAGES_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'materials');
|
||||
|
||||
// Ensure images directory exists
|
||||
if (!fs.existsSync(IMAGES_DIR)) {
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper for rate limiting
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL with error handling
|
||||
*/
|
||||
async function fetchPage(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'fr-CH,fr;q=0.9'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image and save it locally
|
||||
* @param {string} url - Image URL to download
|
||||
* @param {string} filename - Local filename (e.g., "12345.jpg")
|
||||
* @returns {string|null} - Local path or null if failed
|
||||
*/
|
||||
async function downloadImage(url, filename) {
|
||||
try {
|
||||
if (!url) return null;
|
||||
|
||||
// Ensure absolute URL
|
||||
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
|
||||
'Accept': 'image/*'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to download image ${fullUrl}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let ext = '.jpg';
|
||||
if (contentType.includes('png')) ext = '.png';
|
||||
else if (contentType.includes('webp')) ext = '.webp';
|
||||
|
||||
// Ensure filename has correct extension
|
||||
const finalFilename = filename.replace(/\.[^.]+$/, '') + ext;
|
||||
const filepath = path.join(IMAGES_DIR, finalFilename);
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
|
||||
// Return the public path (relative to client/public)
|
||||
return `/materials/${finalFilename}`;
|
||||
} catch (err) {
|
||||
console.warn(`Error downloading image ${url}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract collection URLs from the assortiment page
|
||||
*/
|
||||
async function getCollections() {
|
||||
const html = await fetchPage(ASSORTIMENT_URL);
|
||||
const $ = cheerio.load(html);
|
||||
const collections = [];
|
||||
|
||||
// Find collection links in the "Collections" section
|
||||
$('a[href*="/fr/shop/"]').each((_, el) => {
|
||||
const href = $(el).attr('href');
|
||||
const name = $(el).text().trim();
|
||||
|
||||
// Only include category pages (contain -c followed by numbers)
|
||||
if (href && href.match(/-c\d+$/) && name) {
|
||||
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
|
||||
// Avoid duplicates
|
||||
if (!collections.find(c => c.url === fullUrl)) {
|
||||
collections.push({ name, url: fullUrl });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dimensions from a string like "2800mm x 2070mm x 19mm"
|
||||
*/
|
||||
function parseDimensions(dimStr) {
|
||||
if (!dimStr) return { width: null, height: null, thickness: null };
|
||||
|
||||
const matches = dimStr.match(/(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm/i);
|
||||
if (matches) {
|
||||
return {
|
||||
width: parseInt(matches[1], 10),
|
||||
height: parseInt(matches[2], 10),
|
||||
thickness: parseInt(matches[3], 10)
|
||||
};
|
||||
}
|
||||
return { width: null, height: null, thickness: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract decor code from product name or page content
|
||||
* e.g., "Panneaux mélaminés Duplex Plus 10000 Tilia" -> "10000"
|
||||
*/
|
||||
function extractDecorCode(text) {
|
||||
// Look for a 4-5 digit code
|
||||
const match = text.match(/\b(\d{4,5})\b/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of common color keywords found in Formex product names to representative hex colors
|
||||
*/
|
||||
const COLOR_KEYWORDS = {
|
||||
// Whites and off-whites
|
||||
'blanc': '#f5f5f5', 'white': '#f5f5f5', 'weiss': '#f5f5f5', 'bianco': '#f5f5f5',
|
||||
'crème': '#fffdd0', 'cream': '#fffdd0', 'ivoire': '#fffff0', 'ivory': '#fffff0',
|
||||
'magnolia': '#f8f4f0', 'perle': '#eae0c8',
|
||||
// Greys
|
||||
'gris': '#909090', 'grey': '#909090', 'gray': '#909090', 'grau': '#909090',
|
||||
'anthracite': '#383838', 'anthrazit': '#383838', 'graphite': '#4a4a4a',
|
||||
'platine': '#e5e4e2', 'argent': '#c0c0c0', 'silver': '#c0c0c0',
|
||||
'titane': '#878681',
|
||||
// Blacks
|
||||
'noir': '#1a1a1a', 'black': '#1a1a1a', 'schwarz': '#1a1a1a',
|
||||
// Browns and woods
|
||||
'chêne': '#b5895b', 'oak': '#b5895b', 'eiche': '#b5895b',
|
||||
'noyer': '#6f4e37', 'walnut': '#6f4e37', 'nussbaum': '#6f4e37',
|
||||
'hêtre': '#c9a46c', 'beech': '#c9a46c', 'buche': '#c9a46c',
|
||||
'érable': '#d4b572', 'maple': '#d4b572', 'ahorn': '#d4b572',
|
||||
'cerisier': '#9c3b17', 'cherry': '#9c3b17', 'kirsch': '#9c3b17',
|
||||
'teck': '#b08444', 'teak': '#b08444',
|
||||
'pin': '#dcc8a0', 'pine': '#dcc8a0',
|
||||
'orme': '#a07850', 'elm': '#a07850',
|
||||
'bouleau': '#e0c99c', 'birch': '#e0c99c',
|
||||
'frêne': '#c8b88a', 'ash': '#c8b88a',
|
||||
'acacia': '#b89244',
|
||||
'wenge': '#4a3728', 'wengé': '#4a3728',
|
||||
'mélèze': '#c4a06c', 'larch': '#c4a06c',
|
||||
'aulne': '#b07c4c', 'alder': '#b07c4c',
|
||||
'brun': '#8b4513', 'brown': '#8b4513', 'braun': '#8b4513',
|
||||
'marron': '#6d3e1c', 'chocolat': '#4a2c14',
|
||||
'cognac': '#9f381d', 'caramel': '#a67040',
|
||||
'noisette': '#ab7746',
|
||||
// Beiges and sandstones
|
||||
'beige': '#d9c8a9', 'sable': '#c2b280', 'sand': '#c2b280',
|
||||
'sahara': '#c4a661', 'champagne': '#f7e7ce',
|
||||
'vanille': '#f3e5ab', 'vanilla': '#f3e5ab',
|
||||
'cappuccino': '#a0785a', 'moka': '#6f4e37',
|
||||
// Reds
|
||||
'rouge': '#cc3333', 'red': '#cc3333', 'rot': '#cc3333',
|
||||
'bordeaux': '#722f37', 'burgundy': '#722f37',
|
||||
'terracotta': '#c65d3e', 'rouille': '#b7410e',
|
||||
// Blues
|
||||
'bleu': '#4169e1', 'blue': '#4169e1', 'blau': '#4169e1',
|
||||
'marine': '#000080', 'navy': '#000080',
|
||||
'ciel': '#87ceeb', 'azur': '#007fff',
|
||||
'cobalt': '#0047ab', 'indigo': '#4b0082',
|
||||
// Greens
|
||||
'vert': '#4caf50', 'green': '#4caf50', 'grün': '#4caf50',
|
||||
'olive': '#808000', 'sauge': '#9caf88', 'sage': '#9caf88',
|
||||
'emeraude': '#50c878', 'mousse': '#738276',
|
||||
// Yellows and golds
|
||||
'jaune': '#f0c040', 'yellow': '#f0c040',
|
||||
'or': '#d4af37', 'gold': '#d4af37',
|
||||
'miel': '#cd8032', 'honey': '#cd8032',
|
||||
// Stone and concrete
|
||||
'béton': '#a0a0a0', 'beton': '#a0a0a0', 'concrete': '#a0a0a0',
|
||||
'pierre': '#b0a898', 'stone': '#b0a898', 'stein': '#b0a898',
|
||||
'ardoise': '#5a5a5a', 'slate': '#5a5a5a',
|
||||
'marbre': '#e0d8d0', 'marble': '#e0d8d0',
|
||||
'granit': '#808080', 'granite': '#808080',
|
||||
'travertin': '#d1c6b0',
|
||||
// Others
|
||||
'rouille': '#b7410e', 'rust': '#b7410e',
|
||||
'cuivre': '#b87333', 'copper': '#b87333',
|
||||
'linen': '#e0d5c0', 'lin': '#e0d5c0',
|
||||
'taupe': '#8b7d6b',
|
||||
'mauve': '#e0b0ff', 'lilas': '#c8a2c8',
|
||||
'rose': '#ffc0cb', 'pink': '#ffc0cb', 'rosa': '#ffc0cb'
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract dominant color from product name by matching color keywords
|
||||
* @param {string} name - Product name
|
||||
* @returns {string|null} - Hex color code or null
|
||||
*/
|
||||
function extractDominantColor(name) {
|
||||
if (!name) return null;
|
||||
const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
// Try exact keyword matching (longest match first for compound words)
|
||||
const sortedKeywords = Object.entries(COLOR_KEYWORDS)
|
||||
.sort((a, b) => b[0].length - a[0].length);
|
||||
|
||||
for (const [keyword, hex] of sortedKeywords) {
|
||||
// Check the raw name (preserving accents) and the normalized name
|
||||
if (nameLower.includes(keyword) || lower.includes(keyword.normalize('NFD').replace(/[\u0300-\u036f]/g, ''))) {
|
||||
return hex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product details from a product detail page
|
||||
* Enhanced to capture category, images, dimension variants, and color variants
|
||||
*/
|
||||
async function getProductDetails(productUrl) {
|
||||
try {
|
||||
const html = await fetchPage(productUrl);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Get product name from h1
|
||||
const name = $('h1').first().text().trim();
|
||||
|
||||
// Get decor code - look for "N° du décor:" or extract from name
|
||||
let decorCode = null;
|
||||
$('*').each((_, el) => {
|
||||
const text = $(el).text();
|
||||
const match = text.match(/N°\s*du\s*décor\s*:\s*(\d+)/i);
|
||||
if (match) {
|
||||
decorCode = match[1];
|
||||
return false; // break
|
||||
}
|
||||
});
|
||||
|
||||
if (!decorCode) {
|
||||
decorCode = extractDecorCode(name);
|
||||
}
|
||||
|
||||
// Get category from breadcrumb (second-to-last item usually)
|
||||
let category = null;
|
||||
const breadcrumbLinks = $('a[href*="/fr/shop/"]').filter((_, el) => {
|
||||
const href = $(el).attr('href') || '';
|
||||
return href.match(/-c\d+$/); // Collection links
|
||||
});
|
||||
if (breadcrumbLinks.length > 0) {
|
||||
category = $(breadcrumbLinks.last()).text().trim();
|
||||
}
|
||||
|
||||
// Get main product image
|
||||
let imageUrl = null;
|
||||
// Look for product image in various selectors
|
||||
$('img').each((_, el) => {
|
||||
const src = $(el).attr('src') || '';
|
||||
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
|
||||
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// Try data-src for lazy-loaded images
|
||||
if (!imageUrl) {
|
||||
$('img[data-src]').each((_, el) => {
|
||||
const src = $(el).attr('data-src') || '';
|
||||
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
|
||||
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get ALL dimension variants (multiple formats like "3650mm x 600mm x 29mm")
|
||||
const dimensionVariants = [];
|
||||
const seenDimensions = new Set();
|
||||
$('a, button, span, div').each((_, el) => {
|
||||
const text = $(el).text().trim();
|
||||
const match = text.match(/^(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm$/i);
|
||||
if (match) {
|
||||
const key = `${match[1]}-${match[2]}-${match[3]}`;
|
||||
if (!seenDimensions.has(key)) {
|
||||
seenDimensions.add(key);
|
||||
dimensionVariants.push({
|
||||
width: parseInt(match[1], 10),
|
||||
height: parseInt(match[2], 10),
|
||||
thickness: parseInt(match[3], 10)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Default dimensions (first variant or from legacy parsing)
|
||||
let dimensions = dimensionVariants[0] || { width: null, height: null, thickness: null };
|
||||
if (!dimensions.width) {
|
||||
$('a, span, div').each((_, el) => {
|
||||
const text = $(el).text();
|
||||
if (text.match(/\d+\s*mm\s*x\s*\d+\s*mm\s*x\s*\d+\s*mm/i)) {
|
||||
dimensions = parseDimensions(text);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get color/decor variants (from "Choisir la couleur" section)
|
||||
const colorVariants = [];
|
||||
$('a[href*="/fr/shop/"]').each((_, el) => {
|
||||
const href = $(el).attr('href') || '';
|
||||
// Look for product links that are variants (not the current product)
|
||||
if (href.match(/-p\d+$/) && !href.includes(productUrl.split('/').pop())) {
|
||||
const variantUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
|
||||
const variantName = $(el).attr('title') || $(el).text().trim();
|
||||
const variantCode = extractDecorCode(variantName) || extractDecorCode(href);
|
||||
|
||||
// Look for variant thumbnail image
|
||||
let variantImage = null;
|
||||
const img = $(el).find('img').first();
|
||||
if (img.length) {
|
||||
variantImage = img.attr('src') || img.attr('data-src');
|
||||
if (variantImage && !variantImage.startsWith('http')) {
|
||||
variantImage = `${BASE_URL}${variantImage}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (variantCode && variantName && !colorVariants.find(v => v.code === variantCode)) {
|
||||
colorVariants.push({
|
||||
code: variantCode,
|
||||
name: variantName,
|
||||
url: variantUrl,
|
||||
imageUrl: variantImage
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Download main product image locally
|
||||
let localImageUrl = null;
|
||||
if (imageUrl && decorCode) {
|
||||
localImageUrl = await downloadImage(imageUrl, `${decorCode}.jpg`);
|
||||
}
|
||||
|
||||
// Download variant thumbnails
|
||||
for (const variant of colorVariants) {
|
||||
if (variant.imageUrl && variant.code) {
|
||||
const localPath = await downloadImage(variant.imageUrl, `${variant.code}_thumb.jpg`);
|
||||
if (localPath) {
|
||||
variant.imageUrl = localPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract a representative dominant color from the product name
|
||||
const dominantColor = extractDominantColor(name);
|
||||
|
||||
return {
|
||||
name,
|
||||
decorCode,
|
||||
dimensions,
|
||||
dimensionVariants,
|
||||
colorVariants,
|
||||
category,
|
||||
imageUrl: localImageUrl || imageUrl, // Prefer local path, fallback to URL
|
||||
dominantColor
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Error fetching product details from ${productUrl}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products from a collection page
|
||||
* @param {string} collectionUrl
|
||||
* @param {string} collectionName
|
||||
* @param {Function} emit - Optional emitter for progress events
|
||||
*/
|
||||
async function getProductsFromCollection(collectionUrl, collectionName, emit = () => {}) {
|
||||
const products = [];
|
||||
const html = await fetchPage(collectionUrl);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Find product links
|
||||
const productLinks = new Set();
|
||||
$('a[href*="/fr/shop/"]').each((_, el) => {
|
||||
const href = $(el).attr('href');
|
||||
// Product pages contain -p followed by numbers
|
||||
if (href && href.match(/-p\d+$/)) {
|
||||
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
|
||||
productLinks.add(fullUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const productCount = productLinks.size;
|
||||
console.log(` Found ${productCount} products in ${collectionName}`);
|
||||
emit({ type: 'log', message: ` ${productCount} produits trouvés dans ${collectionName}` });
|
||||
|
||||
// Fetch each product's details
|
||||
let productIndex = 0;
|
||||
for (const productUrl of productLinks) {
|
||||
await sleep(CRAWL_DELAY);
|
||||
const details = await getProductDetails(productUrl);
|
||||
productIndex++;
|
||||
|
||||
if (details && details.decorCode) {
|
||||
products.push({
|
||||
formex_code: details.decorCode,
|
||||
name: details.name,
|
||||
collection: collectionName,
|
||||
category: details.category || collectionName,
|
||||
width_mm: details.dimensions.width,
|
||||
height_mm: details.dimensions.height,
|
||||
thickness_mm: details.dimensions.thickness,
|
||||
image_url: details.imageUrl,
|
||||
formex_url: productUrl,
|
||||
dominant_color: details.dominantColor,
|
||||
dimensionVariants: details.dimensionVariants || [],
|
||||
colorVariants: details.colorVariants || []
|
||||
});
|
||||
const dimCount = details.dimensionVariants?.length || 0;
|
||||
const varCount = details.colorVariants?.length || 0;
|
||||
emit({ type: 'log', message: ` [${productIndex}/${productCount}] ${details.name} (${dimCount} dims, ${varCount} variants)` });
|
||||
}
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a material into the database with enhanced data
|
||||
*/
|
||||
async function upsertMaterial(material) {
|
||||
// Try to update first
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||
[material.formex_code]
|
||||
);
|
||||
|
||||
let materialId;
|
||||
|
||||
if (rows.length > 0) {
|
||||
materialId = rows[0].id;
|
||||
// Update existing
|
||||
await run(
|
||||
`UPDATE formex_materials
|
||||
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
|
||||
image_url = ?, formex_url = ?, dominant_color = COALESCE(?, dominant_color), last_synced_at = datetime('now')
|
||||
WHERE formex_code = ?`,
|
||||
[material.name, material.collection, material.category || null,
|
||||
material.width_mm, material.height_mm, material.thickness_mm,
|
||||
material.image_url || null, material.formex_url, material.dominant_color || null, material.formex_code]
|
||||
);
|
||||
} else {
|
||||
// Insert new
|
||||
await run(
|
||||
`INSERT INTO formex_materials
|
||||
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, dominant_color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[material.formex_code, material.name, material.collection, material.category || null,
|
||||
material.width_mm, material.height_mm, material.thickness_mm,
|
||||
material.image_url || null, material.formex_url, material.dominant_color || null]
|
||||
);
|
||||
|
||||
// Get the inserted ID
|
||||
const inserted = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||
[material.formex_code]
|
||||
);
|
||||
materialId = inserted.rows[0]?.id;
|
||||
}
|
||||
|
||||
// Update dimension variants if we have a materialId
|
||||
if (materialId && material.dimensionVariants && material.dimensionVariants.length > 0) {
|
||||
// Delete existing dimensions
|
||||
await run('DELETE FROM formex_dimensions WHERE material_id = ?', [materialId]);
|
||||
|
||||
// Insert new dimensions
|
||||
for (let i = 0; i < material.dimensionVariants.length; i++) {
|
||||
const dim = material.dimensionVariants[i];
|
||||
await run(
|
||||
`INSERT INTO formex_dimensions (material_id, width_mm, height_mm, thickness_mm, is_default)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[materialId, dim.width, dim.height, dim.thickness, i === 0 ? 1 : 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update color variants if we have a materialId
|
||||
if (materialId && material.colorVariants && material.colorVariants.length > 0) {
|
||||
// Delete existing variants
|
||||
await run('DELETE FROM formex_variants WHERE material_id = ?', [materialId]);
|
||||
|
||||
// Insert new variants
|
||||
for (const variant of material.colorVariants) {
|
||||
await run(
|
||||
`INSERT INTO formex_variants (material_id, variant_code, variant_name, variant_url, image_url)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[materialId, variant.code, variant.name, variant.url || null, variant.imageUrl || null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return materialId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log sync result to database
|
||||
*/
|
||||
async function logSync(source, status, itemsCount, error = null) {
|
||||
await run(
|
||||
`INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)`,
|
||||
[source, status, itemsCount, error]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main sync function - crawls Formex and updates database
|
||||
* @param {Function} emit - Optional emitter callback for SSE progress events
|
||||
* Called with { type: 'log'|'progress'|'complete'|'error', ... }
|
||||
*/
|
||||
export async function sync(emit = () => {}) {
|
||||
console.log('Starting Formex sync...');
|
||||
emit({ type: 'log', message: 'Démarrage de la synchronisation Formex...' });
|
||||
|
||||
const startTime = Date.now();
|
||||
let totalProducts = 0;
|
||||
|
||||
try {
|
||||
// Get all collections
|
||||
console.log('Fetching collections...');
|
||||
emit({ type: 'log', message: 'Récupération des collections...' });
|
||||
const collections = await getCollections();
|
||||
const totalCollections = collections.length;
|
||||
console.log(`Found ${totalCollections} collections`);
|
||||
emit({ type: 'log', message: `${totalCollections} collections trouvées` });
|
||||
emit({ type: 'progress', current: 0, total: totalCollections, collection: '' });
|
||||
|
||||
// Process each collection
|
||||
for (let i = 0; i < collections.length; i++) {
|
||||
const collection = collections[i];
|
||||
console.log(`Processing: ${collection.name}`);
|
||||
emit({ type: 'log', message: `Traitement: ${collection.name}` });
|
||||
emit({ type: 'progress', current: i, total: totalCollections, collection: collection.name });
|
||||
|
||||
await sleep(CRAWL_DELAY);
|
||||
|
||||
try {
|
||||
const products = await getProductsFromCollection(collection.url, collection.name, emit);
|
||||
|
||||
// Upsert each product
|
||||
for (const product of products) {
|
||||
await upsertMaterial(product);
|
||||
totalProducts++;
|
||||
}
|
||||
|
||||
emit({ type: 'log', message: ` ${products.length} produits enregistrés pour ${collection.name}` });
|
||||
} catch (err) {
|
||||
console.error(` Error processing ${collection.name}:`, err.message);
|
||||
emit({ type: 'log', message: ` Erreur: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`Sync complete: ${totalProducts} products in ${elapsed}s`);
|
||||
|
||||
await logSync('formex', 'success', totalProducts);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
itemsCount: totalProducts,
|
||||
duration: elapsed
|
||||
};
|
||||
|
||||
emit({ type: 'progress', current: totalCollections, total: totalCollections, collection: '' });
|
||||
emit({ type: 'complete', result });
|
||||
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err.message);
|
||||
await logSync('formex', 'error', totalProducts, err.message);
|
||||
|
||||
const result = {
|
||||
success: false,
|
||||
error: err.message,
|
||||
itemsCount: totalProducts
|
||||
};
|
||||
|
||||
emit({ type: 'error', error: err.message });
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync logs from database
|
||||
*/
|
||||
export async function getSyncLogs(limit = 20) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM sync_logs ORDER BY created_at DESC LIMIT ?',
|
||||
[limit]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scraped materials
|
||||
*/
|
||||
export async function getMaterials() {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials ORDER BY collection, name'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
Reference in New Issue
Block a user