/** * Parser kart produktu Bobochic Paris (PrestaShop, statyczny HTML). * Port `vilmax-cockpit/src/adapters/bobochicFR.js` — tylko ekstrakcja * z utrwalonego snapshotu. Bez pobierania sieciowego (to robi warstwa * source_imports) i BEZ slug_fallback: brak danych = jawny błąd parsowania, * nigdy dane poglądowe podane jako produkt. * * Ekstrahuje wyłącznie parametry techniczne i metadane — zero opisów * marketingowych i zdjęć do galerii (imageUrl zostaje referencją). */ export class BobochicParseError extends Error { constructor( message: string, public readonly code: "unsupported_host" | "unsupported_category" | "no_product_data" | "not_html", ) { super(message); } } const UPHOLSTERED_CATEGORY_SLUGS = new Set([ "canapes", "canapes-d-angle", "canapes-d-angle-convertibles", "canapes-droits", "canapes-convertibles", "canapes-lits", "canapes-convertibles-electriques", "canapes-avance-recule", "canapes-panoramiques", "canapes-d-angle-l", "canapes-modulables", "canapes-capitonnes", "canapes-reversibles", "canapes-petit-espace", "canapes-en-velours", "canapes-bouclette", "canapes-convertibles-express", "ensemble-de-canape", "fauteuils-poufs", "fauteuils", "poufs", "lits", "lits-coffre", "lits-bas", "lits-electriques", "tetes-de-lit", "bouts-de-lit", "ensemble-lit-et-chevet", ]); const NON_UPHOLSTERED_TOKENS = [ "matelas", "sommier", "couette", "oreiller", "linge-de-lit", "table-a-manger", "table-basse", "meuble-tv", "buffet", "bibliotheque", "meuble-vitrine", "console", "bureau", "chaise", "commode", "table-de-chevet", "armoire", "panier-pour-chien", "deco", "tapis", "jardin", ]; const UPHOLSTERED_TOKENS = [ "canape", "fauteuil", "pouf", "lit-coffre", "lit-coffres", "tete-de-lit", "tetes-de-lit", "bout-de-lit", "bouts-de-lit", ]; const COLLECTION_NAMES = [ "BOBOCHIC COUTURE", "MONT-BLANC", "MONTAIGNE", "LAURA", "EVEREST", "FUJI", "VOLTAIRE", "DENISE", "ROXELANE", "MONACO", "ERNEST", "IBIZA", "BRADLEY", "LUCIEN", "MARA", "JADE", "KUMO", "BOBBY", "JADEN", "CASSIOPEE", "ELYA", "OSLO", ]; export interface BobochicVariant { couleurAttrId: number; couleurSlug: string; angleAttrId: number; angleSlug: string; /** Wszystkie ID atrybutów z fragmentu URL tej kombinacji, w kolejności * wystąpienia (kolor, kąt, i ewentualne dodatkowe — np. liczba poduszek). * Używane do precyzyjnego dopasowania do `var combinations` przy * produktach z więcej niż dwoma atrybutami wariantu. */ attrIds: number[]; imageUrl: string; gtin: string; availability: string; color: string; typeAngle: string; priceEur: number; } export interface BobochicPackage { index: number; lengthCm: number; widthCm: number; heightCm: number; weightKg: number; } export interface BobochicProduct { requestedUrl: string; categorySlug: string; productSlug: string; productRef: string; name: string; collection: string; gtin: string; priceEur: number; imageUrl: string; color: string; typeAngle: string; requestedVariant: { couleur: string; type_d_angle: string }; resolvedVariant: (BobochicVariant & { guessed?: boolean }) | null; variants: BobochicVariant[]; sides: Array<"L" | "P">; features: Record; dimensionsItems: Record; packages: BobochicPackage[]; params: { widthCm: number; depthCm: number; depthValuesCm: number[]; depthBodyCm: number; depthChaiseCm: number; heightCm: number; heightWithoutCushionsCm: number; sleepWidthCm: number; sleepLengthCm: number; convertibleType: string; couchingType: string; coating: string; fabricComposition: string; seatFilling: string; backFilling: string; cushionFilling: string; armrestFilling: string; seatDensity: string; backDensity: string; armrestDensity: string; seatSuspension: string; backSuspension: string; martindale: number; antiBouloches: boolean; resistantAccrocs: boolean; deperlant: boolean; places: number; structure: string; typeBois: string; feetIncluded: boolean; feetCount: number; feetMaterial: string; feetHeightCm: number; seatHeightCm: number; backHeightCm: number; seatDepthCm: number; seatDepthMeridienneCm: number; armrestHeightCm: number; armrestLengthCm: number; armrestWidthCm: number; storageVolumeLiters: number; reversible: boolean; removableCover: boolean; coffreDimensions: string; cushionDimsCm: string; cushionDimsMediumCm: string; cushionDimsLargeCm: string; maxLoadKg: number; weightKg: number; garantieAns: number; fabrication: string; style: string; systemeAccroche: string; typeAccroche: string; nombreCoussins: number; nombreMeridienne: number; convertibleOui: boolean; coffre: boolean; comfortSeat: string; backSupport: string; selfAssembly: string; decoCushionsIncluded: boolean; seatWidthCm: number; centerSeatWidthCm: number; meridianSeatWidthCm: number; meridianSeatDepthWithCushionsCm: number; meridianSeatDepthWithoutCushionsCm: number; seatDepthWithoutCushionsCm: number; }; } function stripHtml(html: string): string { return String(html ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); } function decodeEntities(text: string): string { return String(text ?? "") .replace(/�?39;/g, "'").replace(/'/g, "'").replace(/"/g, '"') .replace(/&/g, "&").replace(/é/g, "é").replace(/è/g, "è") .replace(/à/g, "à").replace(/ç/g, "ç").replace(/ê/g, "ê") .replace(/ /g, " ") .trim(); } function toNumberOrZero(value: unknown): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; } function toPositiveNumber(value: unknown): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } function toDecimalNumber(text: unknown): number { const cleaned = String(text ?? "").replace(/[^\d.,]/g, "").replace(",", "."); return toNumberOrZero(cleaned); } function parseCmValues(text: unknown): number[] { const cleaned = String(text ?? "").replace(/cm/gi, "").split("x")[0]!; return cleaned.split("/").map((part) => toDecimalNumber(part)).filter((v) => v > 0); } export function parseBobochicPath(url: string): { categorySlug: string; productSlug: string; productRef: string } { try { const parsed = new URL(url); const segments = parsed.pathname.split("/").filter(Boolean); const categorySlug = segments[1] ?? ""; const rawProduct = (segments[2] ?? "").replace(/\.html$/i, ""); const refMatch = rawProduct.match(/^(\d+)-/); return { categorySlug, productSlug: rawProduct.replace(/^\d+-/, ""), productRef: refMatch?.[1] ?? "" }; } catch { return { categorySlug: "", productSlug: "", productRef: "" }; } } function assertUpholsteredCategory(categorySlug: string, productSlug: string): void { const cat = categorySlug.toLowerCase().trim(); const prod = productSlug.toLowerCase().trim(); if (cat && UPHOLSTERED_CATEGORY_SLUGS.has(cat)) return; const combined = `${cat} ${prod}`; if (NON_UPHOLSTERED_TOKENS.some((t) => combined.includes(t))) { throw new BobochicParseError(`Kategoria nietapicerowana (odrzucono): ${cat || "(brak)"}.`, "unsupported_category"); } if (!UPHOLSTERED_TOKENS.some((t) => prod.includes(t) || cat.includes(t))) { throw new BobochicParseError(`Kategoria nietapicerowana (odrzucono): ${cat || "(brak)"}.`, "unsupported_category"); } } function parseDimensionsBlock(html: string) { const out = { widthCm: 0, depthCm: 0, heightCm: 0, depthValuesCm: [] as number[] }; const re = /
\s*(Longueur|Largeur|Hauteur|Profondeur)\s*
\s*\s*([^<]+?)\s*<\/span>/g; let m; while ((m = re.exec(html))) { const label = decodeEntities(m[1]!).toLowerCase(); const values = parseCmValues(m[2]); if (!values.length) continue; if (label === "longueur") out.widthCm = Math.max(...values); else if (label === "largeur" || label === "profondeur") { out.depthValuesCm = values; out.depthCm = Math.max(...values); } else if (label === "hauteur") out.heightCm = Math.max(...values); } return out; } function parseDimensionsDescription(html: string) { const out = { items: {} as Record, packages: [] as BobochicPackage[] }; const section = html.match(/DIMENSIONS DU CANAP[^<]*[\s\S]*?DIMENSIONS DES COLIS[\s\S]*?(?=<\/div>|mention_colis)/i); if (!section) return out; const liRe = /
  • \s*([^<:]+?)\s*:\s*([^<]+?)\s*<\/li>/g; let m; while ((m = liRe.exec(section[0]))) { const label = decodeEntities(m[1]!).trim(); const value = decodeEntities(m[2]!).trim(); const colisMatch = label.match(/^Colis\s+(\d+)/i); if (colisMatch) { const dims = value.match(/L\.\s*([\d,]+)\s*x\s*l\.\s*([\d,]+)\s*x\s*H\.\s*([\d,]+)\s*cm\s*\/\s*([\d,]+)\s*kg/i); if (dims) { out.packages.push({ index: Number(colisMatch[1]), lengthCm: toDecimalNumber(dims[1]), widthCm: toDecimalNumber(dims[2]), heightCm: toDecimalNumber(dims[3]), weightKg: toDecimalNumber(dims[4]), }); } continue; } out.items[label] = value; } return out; } /** Segmenty fragmentu `#/N-key-value/N-key-value/...` → id atrybutu + jego klucz/wartość. */ function parseFragmentSegments(fragment: string): Array<{ key: string; id: number; value: string }> { const out: Array<{ key: string; id: number; value: string }> = []; for (const part of fragment.split("/")) { const m = part.match(/^(\d+)-([a-z_]+)-(.+)$/); if (m) out.push({ id: Number(m[1]), key: m[2]!, value: m[3]! }); } return out; } /** * Parsuje warianty (kombinacje) produktu. Nagłówek każdej kombinacji * (fragment URL + image_url + gtin + availability) jest sztywny, ale * zawartość `custom_fields` — Couleur/Type d'angle/prix_ttc — parsowana * jest niezależnie w wyciętym fragmencie tekstu, tolerując dodatkowe * atrybuty pomiędzy nimi (np. „Lot”/liczba poduszek na produktach * 3-atrybutowych, jak Everest). Sztywna kolejność pól złamałaby się na * takich produktach i dawała pustą listę wariantów (patrz .devin/STAN.md). */ function parseVariantCombinations(html: string): BobochicVariant[] { // Nagłówek jednej kombinacji: fragment #/... zaraz po nim . // To odróżnia go od powtórzonego w środku custom_fields // (ten drugi nie jest bezpośrednio poprzedzony image_url). Regex lokalny do // wywołania — globalny `lastIndex` na współdzielonym literale psuje kolejne wywołania. const headerRe = /]*>[^<]*#\/([^"<]+?)<\/span>\s*([^<]+)<\/span>\s*(\d{8,14})<\/span>\s*([^<]*)<\/span>\s*/g; const headers: Array<{ start: number; end: number; fragment: string; imageUrl: string; gtin: string; availability: string }> = []; let hm; while ((hm = headerRe.exec(html))) { headers.push({ start: hm.index, end: hm.index + hm[0].length, fragment: hm[1]!, imageUrl: hm[2]!, gtin: hm[3]!, availability: hm[4]!, }); } const combos: BobochicVariant[] = []; for (let i = 0; i < headers.length; i++) { const h = headers[i]!; const segs = parseFragmentSegments(h.fragment); const couleur = segs.find((s) => s.key === "couleur"); const angle = segs.find((s) => s.key === "type_d_angle"); // type_d_angle występuje tylko na narożnikach — produkty z samym // atrybutem koloru (sofy proste, fotele, łóżka) mają fragment // bez segmentu kąta i wcześniej były odrzucane w całości. if (!couleur) continue; const chunkEnd = headers[i + 1]?.start ?? Math.min(html.length, h.end + 4000); const chunk = html.slice(h.end, chunkEnd); const color = chunk.match(/([^<]*)<\/span>/)?.[1] ?? ""; const typeAngle = chunk.match(/([^<]*)<\/span>/)?.[1] ?? ""; const priceRaw = chunk.match(/([^<]*)<\/span>/)?.[1] ?? "0"; combos.push({ couleurAttrId: couleur.id, couleurSlug: couleur.value, angleAttrId: angle?.id ?? 0, angleSlug: angle?.value ?? "", attrIds: segs.map((s) => s.id), imageUrl: decodeEntities(h.imageUrl).trim(), gtin: h.gtin, availability: decodeEntities(h.availability).trim(), color: decodeEntities(color).trim(), typeAngle: decodeEntities(typeAngle).trim(), priceEur: toDecimalNumber(priceRaw), }); } return combos; } export function parseUrlFragmentAttrs(url: string): { couleur: string; type_d_angle: string } { const hash = String(url ?? "").split("#")[1] ?? ""; const out = { couleur: "", type_d_angle: "" } as Record; for (const part of hash.split("/")) { const m = part.match(/^\d+-([a-z_]+)-(.+)$/); if (m) out[m[1]!] = m[2]!; } return out as { couleur: string; type_d_angle: string }; } function parseFeaturesBlock(html: string): Map { const features = new Map(); const re = /
  • ([^<]+)<\/p>

    \s*([^<]*?)\s*<\/p>/g; let m; while ((m = re.exec(html))) { const label = decodeEntities(m[1]!); const value = decodeEntities(stripHtml(m[2]!)); if (label && value) features.set(label, value); } return features; } function parseJsonLdProduct(html: string): { name: string; manufacturer: string; url: string; images: string[] } | null { const blocks = html.matchAll(/]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/g); for (const block of blocks) { try { const data = JSON.parse(block[1]!) as { "@type"?: string; name?: string; manufacturer?: string; url?: string; image?: unknown }; if (data && data["@type"] === "Product") { const images = Array.isArray(data.image) ? data.image.filter((u): u is string => typeof u === "string") : typeof data.image === "string" ? [data.image] : []; return { name: decodeEntities(data.name || ""), manufacturer: decodeEntities(data.manufacturer || ""), url: data.url || "", images, }; } } catch { /* uszkodzony blok — próbujemy kolejny */ } } return null; } function detectCollection(text: string): string { const hay = String(text ?? "").toUpperCase(); for (const name of COLLECTION_NAMES) if (hay.includes(name)) return name; return ""; } function angleSlugToSide(slug: string): "L" | "P" | null { if (slug === "angle_gauche") return "L"; if (slug === "angle_droit") return "P"; return null; } /** * Parsuje snapshot HTML karty produktu Bobochic. * Rzuca BobochicParseError: nietapicerowana kategoria, obcy host, * albo HTML bez żadnych danych produktu (brak fallbacku na slug). */ export function parseBobochicProduct(html: string, requestedUrl: string): BobochicProduct { if (typeof html !== "string" || !html.trim()) { throw new BobochicParseError("Pusty snapshot HTML.", "not_html"); } const { categorySlug, productSlug, productRef } = parseBobochicPath(requestedUrl); assertUpholsteredCategory(categorySlug, productSlug); const dims = parseDimensionsBlock(html); const features = parseFeaturesBlock(html); const descDims = parseDimensionsDescription(html); const combos = parseVariantCombinations(html); const jsonLd = parseJsonLdProduct(html); const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); const title = titleMatch ? decodeEntities(stripHtml(titleMatch[1]!)) : ""; const gtin = html.match(/(\d{8,14})<\/span>/)?.[1] ?? ""; const imageUrl = decodeEntities(html.match(/([^<]+)<\/span>/)?.[1] ?? "").trim(); const priceMatch = html.match(/id="our_price_display"[^>]*content="([^"]+)"/); const priceEur = priceMatch ? toPositiveNumber(decodeEntities(priceMatch[1]!).replace(/[^\d.,]/g, "").replace(",", ".")) : 0; const color = decodeEntities(html.match(/([^<]+)<\/span>/)?.[1] ?? "").trim(); const typeAngle = decodeEntities(html.match(/([^<]+)<\/span>/)?.[1] ?? "").trim(); const hasAny = dims.widthCm > 0 || dims.depthCm > 0 || dims.heightCm > 0 || features.size > 0 || Boolean(gtin) || Boolean(jsonLd?.name); if (!hasAny) { throw new BobochicParseError( "Snapshot nie zawiera danych produktu Bobochic (brak wymiarów, cech i GTIN). Import przerwany — żadne dane nie zostały zapisane jako fakty.", "no_product_data", ); } const requested = parseUrlFragmentAttrs(requestedUrl); let resolvedVariant: (BobochicVariant & { guessed?: boolean }) | null = null; if (combos.length) { resolvedVariant = combos.find((c) => c.couleurSlug === requested.couleur && c.angleSlug === requested.type_d_angle) ?? null; if (!resolvedVariant) resolvedVariant = { ...combos[0]!, guessed: true }; } const get = (key: string) => features.get(key) ?? ""; const getNum = (key: string) => toDecimalNumber(get(key)); const getBoolOui = (key: string) => /^oui$/i.test(get(key).trim()); const desc = (label: string) => descDims.items[label] ?? ""; const descNum = (label: string) => { const values = parseCmValues(desc(label)); return values.length ? Math.max(...values) : 0; }; const name = jsonLd?.name || title || ""; const collection = detectCollection(name) || detectCollection(title); const totalWidthValues = parseCmValues(get("Largeur totale (cm)")); const depthValuesCm = dims.depthValuesCm.length ? dims.depthValuesCm : totalWidthValues; const depthCm = dims.depthCm || (depthValuesCm.length ? Math.max(...depthValuesCm) : 0); const sides = [...new Set(combos.map((c) => angleSlugToSide(c.angleSlug)).filter((s): s is "L" | "P" => s !== null))].sort() as Array<"L" | "P">; const params: BobochicProduct["params"] = { widthCm: dims.widthCm || getNum("Longueur totale (cm)"), depthCm, depthValuesCm, depthBodyCm: depthValuesCm.length === 2 ? depthValuesCm[0]! : 0, depthChaiseCm: depthValuesCm.length === 2 ? depthValuesCm[1]! : 0, heightCm: dims.heightCm || getNum("Hauteur totale (cm)") || descNum("Hauteur avec coussins"), heightWithoutCushionsCm: descNum("Hauteur sans coussins"), sleepWidthCm: getNum("Largeur de couchage"), sleepLengthCm: getNum("Longueur de couchage"), convertibleType: get("Type de convertible"), couchingType: get("Type de couchage"), coating: get("Revêtement"), fabricComposition: get("Composition du tissu"), seatFilling: get("Garnissage assise"), backFilling: get("Garnissage dossier"), cushionFilling: get("Garnissage des coussins"), armrestFilling: get("Garnissage des accoudoirs"), seatDensity: get("Densité assise (kg/m3)"), backDensity: get("Densité dossier (kg/m3)"), armrestDensity: get("Densité accoudoir (kg/m3)"), seatSuspension: get("Type de suspension assise"), backSuspension: get("Type de suspension dossier"), martindale: getNum("Test Martindale (cycles)"), antiBouloches: getBoolOui("Tissu anti bouloches"), resistantAccrocs: getBoolOui("Tissu résistant aux accrocs"), deperlant: getBoolOui("Tissu déperlant"), places: toDecimalNumber(get("Nombre de places")), structure: get("Structure"), typeBois: get("Type de bois"), feetIncluded: getBoolOui("Pieds inclus"), feetCount: toNumberOrZero(get("Nombre de pieds").replace(/[^\d]/g, "")), feetMaterial: get("Matière Pieds"), feetHeightCm: getNum("Hauteur des pieds (cm)") || descNum("Hauteur des pieds"), seatHeightCm: getNum("Hauteur d'assise (cm)") || descNum("Hauteur d'assise"), backHeightCm: getNum("Hauteur dossier"), seatDepthCm: getNum("Profondeur d'assise de la partie centrale"), seatDepthMeridienneCm: getNum("Profondeur d'assise de la méridienne"), armrestHeightCm: getNum("Hauteur de l'accoudoir (cm)"), armrestLengthCm: getNum("Longueur de l'accoudoir (cm)"), armrestWidthCm: getNum("Largeur de l'accoudoir (cm)"), storageVolumeLiters: getNum("Capacité de rangement (en litre)"), reversible: getBoolOui("Réversible"), removableCover: getBoolOui("Déhoussable"), coffreDimensions: get("Dimensions du coffre (cm)"), cushionDimsCm: get("Dimensions des coussins (cm)"), cushionDimsMediumCm: get("Dimensions moyen coussin (cm)"), cushionDimsLargeCm: get("Dimensions grand coussin (cm)"), maxLoadKg: getNum("Charge maximum (Kg)"), weightKg: getNum("Poids (Kg)"), garantieAns: toDecimalNumber(get("Garantie")), fabrication: get("Fabrication"), style: get("Style"), systemeAccroche: get("Système d'accroche"), typeAccroche: get("Type d'accroche"), nombreCoussins: toNumberOrZero(get("Nombre de coussins").replace(/[^\d]/g, "")), nombreMeridienne: toNumberOrZero(get("Nombre de méridienne").replace(/[^\d]/g, "")), convertibleOui: getBoolOui("Convertible"), coffre: getBoolOui("Coffre"), comfortSeat: get("Type de confort assise"), backSupport: get("Soutien du dossier"), selfAssembly: get("A monter soi-même"), decoCushionsIncluded: getBoolOui("Coussin(s) déco inclus"), seatWidthCm: descNum("Largeur d'assise"), centerSeatWidthCm: descNum("Largeur d'assise de la partie centrale"), meridianSeatWidthCm: descNum("Largeur d'assise de la méridienne"), meridianSeatDepthWithCushionsCm: descNum("Profondeur d'assise de la méridienne avec coussins"), meridianSeatDepthWithoutCushionsCm: descNum("Profondeur d'assise de la méridienne sans coussins"), seatDepthWithoutCushionsCm: descNum("Profondeur d'assise de la partie centrale sans coussins"), }; return { requestedUrl, categorySlug, productSlug, productRef, name, collection, gtin: resolvedVariant?.gtin || gtin, priceEur: resolvedVariant?.priceEur || priceEur, imageUrl: resolvedVariant?.imageUrl || imageUrl, color: resolvedVariant?.color || color || get("Couleur"), typeAngle: resolvedVariant?.typeAngle || typeAngle, requestedVariant: requested, resolvedVariant, variants: combos, sides, features: Object.fromEntries(features), dimensionsItems: descDims.items, packages: descDims.packages, params, }; } // --- galeria zdjęć vendora ---------------------------------------------------- export interface BobochicGalleryImage { /** id_image PrestaShop (segment liczbowy URL CDN). */ imageId: string; /** 1-based — kolejność w galerii rozstrzygniętego wariantu. */ position: number; /** URL oryginału w pełnej rozdzielczości (//.jpg). */ url: string; /** Zapasowy URL (thickbox — niższa jakość), gdy oryginał nie odpowie. */ fallbackUrl: string | null; legend: string; } export interface BobochicGallery { /** id kombinacji PrestaShop (id_product_attribute) rozstrzygniętego wariantu. */ combinationId: string | null; /** * "combination" = galeria rozstrzygniętego wariantu; "page" = fallback na * całą stronę (uzasadniony tylko gdy produkt ma jeden wariant albo dane JS * galerii są niedostępne); "ambiguous" = adres nie wskazuje jednoznacznie * koloru/kąta a produkt ma więcej niż jeden wariant — zdjęcia NIE są * zwracane, żeby nie zapisać złego/wymieszanego koloru (patrz availableVariants). */ scope: "combination" | "page" | "ambiguous"; images: BobochicGalleryImage[]; /** Wypełnione tylko gdy scope="ambiguous" — do wyboru przez operatora. */ availableVariants?: Array<{ color: string; typeAngle: string; url: string; thumbnailUrl: string }>; } /** Wyciąga obiekt JSON przypisany do zmiennej JS (np. `var combinations={…}`). */ function parseJsVar(html: string, varName: string): unknown | null { const m = new RegExp(`(?:^|[\\s;{}])${varName.replace(/[$]/g, "\\$&")}\\s*=`).exec(html); if (!m) return null; const start = html.indexOf("{", m.index + m[0].length); if (start < 0) return null; let depth = 0; let inStr = false; let esc = false; for (let i = start; i < html.length; i++) { const ch = html[i]!; if (inStr) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') inStr = false; continue; } if (ch === '"') inStr = true; else if (ch === "{") depth++; else if (ch === "}") { depth--; if (!depth) { try { return JSON.parse(html.slice(start, i + 1)); } catch { return null; } } } } return null; } const IMAGE_ID_RE = /\/(\d+)(?:-[a-zA-Z0-9_]+)?(?:\/|\.(?:jpe?g|png|webp)(?:\?|$))/i; function imageIdFromUrl(url: string): string { return String(url ?? "").match(IMAGE_ID_RE)?.[1] ?? ""; } /** * Wyciąga URL-e zdjęć produktu w pełnej rozdzielczości z snapshotu karty * Bobochic. Zakres = galeria rozstrzygniętego wariantu: `var combinations` mapuje * atrybuty na id_product_attribute, `var combinationImages` daje uporządkowaną * listę id_image. URL oryginału (//.jpg — pełna rozdzielczość) bierze * się ze spanów alternate_image_url; thickbox z miniatury galerii to fallback. * Brak mapowania kombinacji → scope "page": okładki wariantów + JSON-LD image. */ export function extractBobochicImages(html: string, product: BobochicProduct): BobochicGallery { // mapy id_image → URL const origMap = new Map(); for (const m of html.matchAll(/\s*(https:\/\/[^<]+?)\s*<\/span>/g)) { const url = decodeEntities(m[1]!); const id = imageIdFromUrl(url); if (id) origMap.set(id, url); } const thumbMap = new Map(); const thumbMarks = [...html.matchAll(/id="thumbnail_new_(\d+)"/g)]; for (let i = 0; i < thumbMarks.length; i++) { const seg = html.slice(thumbMarks[i]!.index!, thumbMarks[i + 1]?.index ?? undefined); const src = seg.match(/data-src="([^"]+)"/)?.[1]; if (src) thumbMap.set(thumbMarks[i]![1]!, decodeEntities(src)); } const fileSlug = [...origMap.values()][0]?.match(/\/([^/]+)\.(?:jpe?g|png|webp)$/i)?.[1] ?? [...thumbMap.values()][0]?.match(/\/([^/]+)\.(?:jpe?g|png|webp)$/i)?.[1] ?? ""; const combinations = (parseJsVar(html, "combinations") ?? parseJsVar(html, "combinationsFromController")) as Record | null; const combinationImages = parseJsVar(html, "combinationImages") as Record> | null; const rv = product.resolvedVariant; // Wariant nie został jednoznacznie rozstrzygnięty (brak fragmentu URL albo // niepasujący do żadnej kombinacji — rv.guessed) I na stronie istnieje // więcej niż jeden kolor/kąt: NIE zgadujemy i nie mieszamy okładek różnych // wariantów w jedną galerię (to była przyczyna zdjęć „w innym kolorze”). // Sprawdzane PRZED próbą dopasowania combinationId — zgadnięty wariant // czasem trafia w istniejącą kombinację, ale to wciąż zgadywanie, nie fakt. const distinctVariants = new Map(); for (const v of product.variants) { const key = `${v.couleurSlug}/${v.angleSlug}`; if (!distinctVariants.has(key)) distinctVariants.set(key, v); } if (distinctVariants.size > 1 && (!rv || rv.guessed)) { const baseUrl = product.requestedUrl.split("#")[0]!; return { combinationId: null, scope: "ambiguous", images: [], availableVariants: [...distinctVariants.values()].map((v) => ({ color: v.color, typeAngle: v.typeAngle, url: `${baseUrl}#/${v.couleurAttrId}-couleur-${v.couleurSlug}${v.angleAttrId ? `/${v.angleAttrId}-type_d_angle-${v.angleSlug}` : ""}`, thumbnailUrl: v.imageUrl, })), }; } let combinationId: string | null = null; if (rv && combinations) { // Dopasowanie po WSZYSTKICH ID atrybutów fragmentu (nie tylko kolor+kąt) — // na produktach 3+ atrybutowych (np. dodatkowy atrybut liczby poduszek) // samo kolor+kąt bywa niejednoznaczne wśród kombinacji. const rvIds = rv.attrIds && rv.attrIds.length ? rv.attrIds : [rv.couleurAttrId, rv.angleAttrId].filter((n) => n > 0); for (const [id, val] of Object.entries(combinations)) { const attrs = val?.attributes; if (Array.isArray(attrs) && rvIds.every((a) => attrs.includes(a))) { combinationId = id; break; } } // fallback: okładka wariantu wskazuje jego kombinację if (!combinationId && combinationImages) { const coverId = imageIdFromUrl(rv.imageUrl); if (coverId) { for (const [id, list] of Object.entries(combinationImages)) { if (Array.isArray(list) && list.some((e) => String(e?.id_image ?? "") === coverId)) { combinationId = id; break; } } } } } const build = (id: string, position: number, legend: string): BobochicGalleryImage | null => { const orig = origMap.get(id); const thumb = thumbMap.get(id); const url = orig ?? (fileSlug ? `https://cdn.bobochicparis.com/${id}/${fileSlug}` : undefined) ?? thumb; if (!url) return null; return { imageId: id, position, url, fallbackUrl: thumb && thumb !== url ? thumb : null, legend }; }; const list = combinationId && combinationImages ? combinationImages[combinationId] : null; if (Array.isArray(list) && list.length) { const images = list .map((e, i) => build(String(e?.id_image ?? ""), i + 1, decodeEntities(String(e?.legend ?? "")))) .filter((x): x is BobochicGalleryImage => x !== null && Boolean(x.imageId)); if (images.length) return { combinationId, scope: "combination", images }; } // fallback stronowy: galeria JSON-LD + okładki wariantów (deduplikacja po id) const seen = new Set(); const urls = [...(parseJsonLdProduct(html)?.images ?? []), product.imageUrl, ...product.variants.map((v) => v.imageUrl)] .map((u) => decodeEntities(String(u ?? "")).trim()) .filter((u) => u && /^https:\/\//.test(u)); const images: BobochicGalleryImage[] = []; for (const url of urls) { const id = imageIdFromUrl(url) || url; if (seen.has(id)) continue; seen.add(id); const orig = origMap.get(imageIdFromUrl(url)); const thumb = thumbMap.get(imageIdFromUrl(url)); images.push({ imageId: imageIdFromUrl(url), position: images.length + 1, url: orig ?? url, fallbackUrl: thumb && thumb !== (orig ?? url) ? thumb : null, legend: product.name }); } return { combinationId: null, scope: "page", images }; }