(() => { const isNil = (value) => value === null || typeof value === 'undefined'; const pruneEmpty = (obj) => Object.fromEntries( Object.entries(obj).filter( ([, value]) => !isNil(value) && value !== '' && (!Array.isArray(value) || value.length) ) ); const toAbsoluteUrl = (src) => (src?.startsWith('//') ? `${location.protocol}${src}` : src); const normalizeImageUrls = (product, baseInfo) => (product.images?.map(({ src }) => src) ?? baseInfo.imageUrls ?? []).filter(Boolean).map(toAbsoluteUrl); const extractCategory = (categories) => (categories?.find(({ representativeYn }) => representativeYn === 'Y') ?? categories?.[0])?.fullCategoryLabel ?? ''; // 가격 정보가 있을 때만 Offer 반환 — offerPrice가 nil이면 schema.org 유효성 위반이므로 제외 const buildOffers = ({ baseInfo, offerPrice, isSoldOut, sellerName }) => { if (isNil(offerPrice)) { return null; } return pruneEmpty({ '@type': 'Offer', url: document.querySelector('link[rel="canonical"]')?.href || location.href, priceCurrency: 'KRW', price: String(offerPrice), // 판매 종료일이 없으면 빈 문자열이 되어 pruneEmpty에서 제거됨 priceValidUntil: String(baseInfo.saleEndYmdt ?? '').split(' ')[0], availability: `https://schema.org/${isSoldOut ? 'OutOfStock' : 'InStock'}`, itemCondition: 'https://schema.org/NewCondition', seller: sellerName ? { '@type': 'Organization', name: sellerName } : null, }); }; // ratingValue 없는 AggregateRating은 schema.org 유효성 위반이므로 평점·리뷰 수 모두 유효할 때만 반환 const buildAggregateRating = ({ reviewRate, reviewCnt }) => { if (!(Number(reviewRate) > 0 && Number(reviewCnt) > 0)) { return null; } return { '@type': 'AggregateRating', ratingValue: Number(reviewRate), reviewCount: Number(reviewCnt), bestRating: 5, worstRating: 1, }; }; // cspell:ignore soldout const resolveOfferPrice = ({ summary, price }) => summary.discountedPrice ?? summary.originalPrice ?? price.salePrice; const resolveIsSoldOut = ({ status, product }) => typeof status.soldout === 'boolean' ? status.soldout : product.isSoldOut; const buildProductSchema = (product) => { const { summary = {}, price = {}, baseInfo = {}, brand = {}, partner = {}, status = {}, counter = {} } = product; const offerPrice = resolveOfferPrice({ summary, price }); const isSoldOut = resolveIsSoldOut({ status, product }); const sellerName = partner.companyName || partner.partnerName; return pruneEmpty({ '@context': 'https://schema.org', '@type': 'Product', name: product.unescapedProductName || product.productName, image: normalizeImageUrls(product, baseInfo), description: product.unescapedProductName || product.productName, sku: String(product.productNo), mpn: baseInfo.productManagementCd ?? '', category: extractCategory(product.categories), brand: brand?.name ? { '@type': 'Brand', name: brand.name } : null, offers: buildOffers({ baseInfo, offerPrice, isSoldOut, sellerName }), aggregateRating: buildAggregateRating({ reviewRate: product.reviewRate, reviewCnt: counter.reviewCnt }), }); }; const injectJsonLd = (schema, id) => { document.getElementById(id)?.remove(); document.head.appendChild( Object.assign(document.createElement('script'), { type: 'application/ld+json', id, text: JSON.stringify(schema), }) ); }; /** * 상품 구조화 데이터(JSON-LD)를 에 삽입한다. * schema.org Product 스펙(https://schema.org/Product)에 맞춰 생성한다. * * 구조화 데이터가 필요한 페이지에서 아래와 같이 직접 호출한다. * ShopbySkin.structuredData.injectProductStructuredData(product) * * @param {object} product - 상품 상세조회 API(GET /products/{productNo}) 응답 객체 */ const injectProductStructuredData = (product) => { if (!product || isNil(product.productNo)) { return; } try { injectJsonLd(buildProductSchema(product), 'shopby-product-jsonld'); } catch (error) { console.error('[structured-data] 구조화 데이터(JSON-LD) 생성 실패:', error); } }; const resolveLogoImageUrl = (bannerConfig) => toAbsoluteUrl(bannerConfig?.bannerMap?.get?.('LOGO')?.banners?.[0]?.bannerImages?.[0]?.imageUrl); const buildOrganizationSchema = ({ mallName, siteUrl, logoUrl }) => pruneEmpty({ '@type': 'Organization', name: mallName, url: siteUrl, logo: logoUrl, }); // 검색 결과 페이지(product-list.html?keywords=)로 연결되는 사이트링크 검색창 액션 — ShopbyHeader의 검색 이동 경로와 동일하게 맞춘다 const buildSearchAction = (siteUrl) => ({ '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${siteUrl}/pages/product/product-list.html?keywords={search_term_string}`, }, 'query-input': 'required name=search_term_string', }); const buildWebsiteSchema = ({ mallName, siteUrl }) => pruneEmpty({ '@type': 'WebSite', name: mallName, url: siteUrl, potentialAction: buildSearchAction(siteUrl), }); // 메인페이지는 특정 상품이 아닌 쇼핑몰 자체가 주제이므로 Organization + WebSite로 표현한다 const buildMainSchema = ({ mallConfig, bannerConfig }) => { const mallName = mallConfig?.mall?.mallName; const siteUrl = location.origin; const logoUrl = resolveLogoImageUrl(bannerConfig); return { '@context': 'https://schema.org', '@graph': [buildOrganizationSchema({ mallName, siteUrl, logoUrl }), buildWebsiteSchema({ mallName, siteUrl })], }; }; /** * 메인페이지 구조화 데이터(JSON-LD)를 에 삽입한다. * schema.org Organization/WebSite 스펙에 맞춰 생성한다. * * 구조화 데이터가 필요한 페이지에서 아래와 같이 직접 호출한다. * ShopbySkin.structuredData.injectMainStructuredData(commonData) * * @param {object} commonData - PAGE_LOAD_COMPLETED 이벤트로 전달되는 공통 데이터(mallConfig, bannerConfig 포함) */ const injectMainStructuredData = (commonData) => { if (!commonData?.mallConfig?.mall?.mallName) { return; } try { injectJsonLd(buildMainSchema(commonData), 'shopby-main-jsonld'); } catch (error) { console.error('[structured-data] 구조화 데이터(JSON-LD) 생성 실패:', error); } }; const buildListItem = (position, name, url) => pruneEmpty({ '@type': 'ListItem', position, name, item: url }); // 카테고리/검색 결과 페이지의 카테고리 경로(categoryRoute)를 HOME부터 이어지는 BreadcrumbList로 표현한다 const buildBreadcrumbSchema = (categoryRoute) => { const siteUrl = location.origin; const items = [ buildListItem(1, 'HOME', `${siteUrl}/`), ...categoryRoute.map(({ categoryNo, depth, label }, index) => buildListItem( index + 2, label, `${siteUrl}/pages/product/product-list.html?categoryNo=${categoryNo}&depth=${depth}` ) ), ]; return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items, }; }; /** * 카테고리 경로 구조화 데이터(JSON-LD)를 에 삽입한다. * schema.org BreadcrumbList 스펙에 맞춰 생성한다. 카테고리 경로가 없는 검색 결과 페이지에서는 삽입하지 않는다. * * 구조화 데이터가 필요한 페이지에서 아래와 같이 직접 호출한다. * ShopbySkin.structuredData.injectBreadcrumbStructuredData(categoryData) * * @param {object} categoryData - QUERY_DISPLAY_CATEGORY 이벤트로 전달되는 카테고리 데이터(categoryRoute 포함) */ const injectBreadcrumbStructuredData = (categoryData) => { if (!categoryData?.categoryRoute?.length) { document.getElementById('shopby-breadcrumb-jsonld')?.remove(); return; } try { injectJsonLd(buildBreadcrumbSchema(categoryData.categoryRoute), 'shopby-breadcrumb-jsonld'); } catch (error) { console.error('[structured-data] 구조화 데이터(JSON-LD) 생성 실패:', error); } }; // 상세페이지 Product 스키마와의 중복을 피하기 위해 각 항목은 상세페이지 url만 참조한다 const buildProductListSchema = (products) => { const siteUrl = location.origin; return { '@context': 'https://schema.org', '@type': 'ItemList', itemListElement: products .filter(({ productNo }) => !isNil(productNo)) .map(({ productNo, productName }, index) => buildListItem(index + 1, productName, `${siteUrl}/pages/product/product-detail.html?productNo=${productNo}`) ), }; }; /** * 상품목록 구조화 데이터(JSON-LD)를 에 삽입한다. * schema.org ItemList 스펙에 맞춰 생성한다. 개별 상품 정보는 각 상품 상세페이지의 Product 스키마를 참조하도록 url만 포함한다. * * 구조화 데이터가 필요한 페이지에서 아래와 같이 직접 호출한다. * ShopbySkin.structuredData.injectProductListStructuredData(searchData) * * @param {object} searchData - QUERY_SEARCH_PRODUCTS_BY 이벤트로 전달되는 상품 검색 결과 데이터(products 포함) */ const injectProductListStructuredData = (searchData) => { if (!searchData?.products?.length) { document.getElementById('shopby-list-jsonld')?.remove(); return; } try { injectJsonLd(buildProductListSchema(searchData.products), 'shopby-list-jsonld'); } catch (error) { console.error('[structured-data] 구조화 데이터(JSON-LD) 생성 실패:', error); } }; // ShopbySkin.structuredData 네임스페이스에 등록 — 구조화 데이터가 필요한 페이지에서 직접 호출 ShopbySkin.structuredData = { injectProductStructuredData, injectMainStructuredData, injectBreadcrumbStructuredData, injectProductListStructuredData, }; })();