/** * Adapter Davis (davis.pl) — parser strony kolekcji tkaniny. * Wejście: utrwalony snapshot HTML strony /kolekcja/{slug}/. * Struktura strony (WordPress + ACF, stan 2026-09): * h1.product__title — nazwa kolekcji * dl.product__details > dt/dd — parametry techniczne * .product__technologies — badge'e technologii (może być pusty) * a.product__colors__element[href][title] — odcienie; href = pełnowymiarowa * próbka ~2000px, span.product__colors__title = kod ("01", "83", "100") * a[href*="generate-card"] — karta PDF z PIM (pim.davis.pl) * 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(",", ".")); /** "420g/m² +/-5%" → {value:420, tolerance:"±5%"}; "500g/m² +/-10%" analogicznie */ 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]}%` } : {}) }; } /** "90000-100000" → {min,max}; ">100000" → {min}; "4" → 4; "min. 140cm" → {min:140} */ function parseRange(v: string): unknown { const nums = [...v.matchAll(/(\d[\d\s]*(?:[.,]\d+)?)/g)].map((m) => num(m[1]!)); if (!nums.length) return v; if (/^>/.test(v.trim()) || /^min/i.test(v.trim())) return { min: nums[0] }; if (nums.length >= 2) return { min: nums[0], max: nums[1] }; return nums[0]; } /** "1,9-2,1 mm WARP / 1,5-1,7 mm WEFT" → {warp:{min,max}, weft:{min,max}} */ function parseSlippage(v: string): unknown { const warp = v.match(/([\d.,]+)\s*-\s*([\d.,]+)\s*mm\s*WARP/i); const weft = v.match(/([\d.,]+)\s*-\s*([\d.,]+)\s*mm\s*WEFT/i); if (!warp && !weft) return v; const range = (m: RegExpMatchArray | null) => (m ? { min: num(m[1]!), max: num(m[2]!) } : null); return { ...(range(warp) ? { warp: range(warp) } : {}), ...(range(weft) ? { weft: range(weft) } : {}) }; } /** "light-4-5 / medium-5 / dark-4-5" → {light:"4-5", medium:"5", dark:"4-5"}; "4-5" → {min:4,max:5} */ function parseRubbing(v: string): unknown { const tones = [...v.matchAll(/(light|medium|dark)\s*-\s*(\d(?:-\d)?)/gi)]; if (tones.length) { const out: Record = {}; for (const t of tones) out[t[1]!.toLowerCase()] = t[2]!; return out; } return parseRange(v); } /** "100% Polyester" → "100% poliester" (kanon jak w params_json TILII) */ function parseComposition(v: string): string { return v.replace(/\s+/g, " ").trim().replace(/polyester/i, "poliester"); } const DETAIL_LABELS: Array<[RegExp, string, (v: string) => unknown]> = [ [/^gramatura/i, "grammage_g_m2", parseGrammage], [/^skład/i, "composition", parseComposition], [/^szerokość/i, "usable_width_cm", parseRange], [/martindale/i, "martindale_cycles", parseRange], [/przesuwalność/i, "seam_slippage_mm", parseSlippage], [/mechacenia|pillingu/i, "pilling_grade", parseRange], [/tarcie\s+suche/i, "rubbing_fastness_dry", parseRubbing], [/tarcie\s+mokre/i, "rubbing_fastness_wet", parseRubbing], ]; const TECH_FLAGS: Array<[RegExp, string]> = [ [/easy[\s-]*clean/i, "easy_clean"], [/water[\s-]*repellent|hydrofob/i, "water_repellent"], [/pet[\s-]*friendly/i, "pet_friendly"], [/recycled/i, "recycled"], ]; const labelKey = (label: string) => label.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase() .replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "detail"; export function parseDavisCollectionPage(html: string, pageUrl: string): ParsedFabricCollection { let host: string; try { host = new URL(pageUrl).hostname; } catch { throw new FabricParseError("unsupported_host", "Nieprawidłowy URL kolekcji Davis."); } if (!/(^|\.)davis\.pl$/i.test(host)) { throw new FabricParseError("unsupported_host", "Adapter Davis obsługuje wyłącznie davis.pl."); } const $ = load(html); const name = $("h1.product__title").first().text().replace(/\s+/g, " ").trim(); if (!name) throw new FabricParseError("no_title", "Strona kolekcji Davis nie zawiera nazwy (h1.product__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: dl.product__details > dt/dd --- const params: Record = {}; $("dl.product__details > dt").each((_, el) => { const label = $(el).text().replace(/\s+/g, " ").trim(); const dd = $(el).next("dd"); const value = dd.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; }); // --- technologie: tekst badge'ów + alt ikon --- const techText = `${$(".product__technologies").text()} ${$(".product__technologies img").map((_, i) => $(i).attr("alt") ?? "").get().join(" ")}`; for (const [re, key] of TECH_FLAGS) if (re.test(techText)) params[key] = true; // --- odcienie: a.product__colors__element --- const shades = new Map(); $("a.product__colors__element").each((_, el) => { const href = ($(el).attr("href") ?? "").trim(); const code = $(el).find(".product__colors__title").first().text().replace(/\s+/g, "").trim(); if (!href || !code || shades.has(code)) return; const imageUrl = href.startsWith("//") ? `https:${href}` : new URL(href, pageUrl).href; const title = ($(el).attr("title") ?? "").trim(); // title zwykle "Tilia 01" → nazwa handlowa po odjęciu kolekcji i kodu const shadeName = title.replace(new RegExp(`^${name}`, "i"), "").replace(code, "").trim() || null; const vendorCode = imageUrl.match(/\/([\w-]+)\.(?:jpe?g|png|webp)(?:[?#]|$)/i)?.[1] ?? code; shades.set(code, { code, vendorCode, name: shadeName, imageUrl }); }); if (!shades.size) { throw new FabricParseError("no_shades", "Strona kolekcji Davis nie zawiera odcieni (a.product__colors__element)."); } // --- karta PDF: link PIM (protokół-względny //pim.davis.pl/...) --- const cardHref = $("a[href*='generate-card']").first().attr("href")?.trim() ?? null; const cardUrl = cardHref ? (cardHref.startsWith("//") ? `https:${cardHref}` : new URL(cardHref, pageUrl).href) : null; return { code, name: name.toUpperCase(), producer: "Davis", params, cardUrl, shades: [...shades.values()].sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true })), }; }