/** * Adapter Lech Fabrics (lechfabrics.com) — parser strony kolekcji tkaniny. * Wejście: utrwalony snapshot HTML strony /pl/kolekcja/{slug}. * Struktura strony (Bootstrap + fancybox, stan 2026-09): * meta[property="og:title"] — nazwa kolekcji ("LUMO") * .offer-description-name2 + .offer-description — parametry techniczne (pary etykieta/wartość) * a[data-fancybox][data-src][data-caption] — odcienie; data-src = pełnowymiarowa * próbka JPG (upload/import/colors/...), data-caption = "LUMO 30" * .conservation-svg[data-bs-title] — ikony konserwacji (tooltipy) * a[href$=".pdf"] — karta produktu PDF * Wyjście: wspólny kontrakt ParsedFabricCollection (fabricParser.ts). * Bez sieci, bez zapisu do DB. */ import { load } from "cheerio"; import { FabricParseError, type ParsedFabricCollection, type ParsedFabricShade } from "./fabricParser.js"; const num = (s: string) => Number(s.replace(/\s/g, "").replace(",", ".")); /** "300 g/m² ± 10%" → {value:300, tolerance:"±10%"} */ function parseGrammage(v: string): unknown { const g = v.match(/(\d[\d\s]*)\s*g\s*\/\s*m/i); if (!g) return v; const tol = v.match(/\+\s*\/\s*-\s*(\d+)\s*%|±\s*(\d+)\s*%/); return { value: num(g[1]!), ...(tol ? { tolerance: `±${tol[1] ?? tol[2]}%` } : {}) }; } /** "143 cm ± 2 cm" → {value:143, tolerance:"±2 cm"}; "140 cm" → {value:140} */ function parseWidth(v: string): unknown { const w = v.match(/(\d[\d\s]*(?:[.,]\d+)?)\s*cm/i); if (!w) return v; const tol = v.match(/±\s*(\d[\d\s]*(?:[.,]\d+)?)\s*cm/i); return { value: num(w[1]!), ...(tol ? { tolerance: `±${num(tol[1]!)} cm` } : {}) }; } /** ">5" → {min:5}; "< 30000 cykli" → {max:30000}; "4-5" → {min:4,max:5}; "A" → "A" */ function parseRange(v: string): unknown { const t = v.trim(); const nums = [...t.matchAll(/(\d[\d\s]*(?:[.,]\d+)?)/g)].map((m) => num(m[1]!)); if (!nums.length) return t; if (/^[<>]/.test(t)) return t.startsWith(">") ? { min: nums[0] } : { max: nums[0] }; if (nums.length >= 2) return { min: nums[0], max: nums[1] }; return nums[0]; } const DETAIL_LABELS: Array<[RegExp, string, (v: string) => unknown]> = [ [/^skład/i, "composition", (v) => v.replace(/\s+/g, " ").trim()], [/^gramatura/i, "grammage_g_m2", parseGrammage], [/^szerokość/i, "usable_width_cm", parseWidth], [/odporność na ścieranie|martindale/i, "martindale_cycles", parseRange], [/odporność na światło/i, "light_fastness", parseRange], [/trudnopalność/i, "flammability", (v) => v], [/^kolory/i, "colors_count", (v) => Number(v.replace(/\D/g, "")) || v], ]; const labelKey = (label: string) => label.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase() .replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "detail"; export function parseLechCollectionPage(html: string, pageUrl: string): ParsedFabricCollection { let host: string; try { host = new URL(pageUrl).hostname; } catch { throw new FabricParseError("unsupported_host", "Nieprawidłowy URL kolekcji Lech."); } if (!/(^|\.)lechfabrics\.com$/i.test(host)) { throw new FabricParseError("unsupported_host", "Adapter Lech obsługuje wyłącznie lechfabrics.com."); } const $ = load(html); const name = ( $("meta[property='og:title']").attr("content") ?? $("h1").first().text() ?? "" ).replace(/\s+/g, " ").trim(); if (!name) throw new FabricParseError("no_title", "Strona kolekcji Lech nie zawiera nazwy (og:title)."); const slug = pageUrl.match(/\/kolekcja\/([\w-]+)/i)?.[1]; const code = slug ?? name.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase() .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40); // --- parametry: pary .offer-description-name2 / .offer-description --- const params: Record = {}; $(".offer-description-name2").each((_, el) => { const label = $(el).text().replace(/\s+/g, " ").replace(/:$/, "").trim(); const value = $(el).parent().find(".offer-description").first().text().replace(/\s+/g, " ").trim(); if (!label || !value) return; const known = DETAIL_LABELS.find(([re]) => re.test(label)); params[known ? known[1] : labelKey(label)] = known ? known[2](value) : value; }); // --- rodzaj tkaniny z tekstu opisu --- const bodyText = $("body").text(); if (/szenil/i.test(bodyText)) params.fabric_type = "szenil"; else if (/chenille/i.test(bodyText)) params.fabric_type = "chenille"; // --- ikony konserwacji: tooltips bootstrap --- const care = $(".conservation-svg[data-bs-title]") .map((_, el) => ($(el).attr("data-bs-title") ?? "").trim()) .get() .filter(Boolean); if (care.length) params.care = [...new Set(care)]; // --- odcienie: a[data-fancybox][data-src][data-caption] --- const shades = new Map(); $("a[data-fancybox][data-src]").each((_, el) => { const src = ($(el).attr("data-src") ?? "").trim(); if (!/\/colors\//i.test(src) || !/\.(?:jpe?g|png|webp)(?:[?#]|$)/i.test(src)) return; const caption = ($(el).attr("data-caption") ?? "").replace(/\s+/g, " ").trim(); const fileStem = src.match(/\/([\w-]+)\.(?:jpe?g|png|webp)(?:[?#]|$)/i)?.[1] ?? ""; // kod: sufiks podpisu ("LUMO 30" → "30") albo sufiks pliku ("LUMO-30" → "30"); // wpis bez kodu w podpisie ("LUMO") to zdjęcie kolekcji, nie odcień const code = caption.match(/(\w+)$/)?.[1] ?? fileStem.match(/-(\w+)$/)?.[1] ?? ""; if (!code || !/^\w{1,10}$/.test(code) || shades.has(code)) return; if (caption && caption.toUpperCase() === name.toUpperCase() && !caption.match(/\d/)) return; const imageUrl = src.startsWith("//") ? `https:${src}` : new URL(src, pageUrl).href; shades.set(code, { code, vendorCode: fileStem || code, name: null, imageUrl, }); }); if (!shades.size) { throw new FabricParseError("no_shades", "Strona kolekcji Lech nie zawiera odcieni (a[data-fancybox][data-src])."); } // --- karta PDF produktu --- const cardHref = $("a[href$='.pdf']").first().attr("href")?.trim() ?? null; const cardUrl = cardHref ? new URL(cardHref, pageUrl).href : null; return { code, name, producer: "Lech Fabrics", params, cardUrl, shades: [...shades.values()].sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true })), }; }