diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractor.java index ee88019..bd76c70 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractor.java @@ -117,7 +117,7 @@ private StructuredExtraction toSnapshotFromProduct(JsonNode product, ProductLink /** * 최상위 배열 · {@code @graph} 래핑 · {@code ItemList.itemListElement[].item} 중첩을 재귀로 평탄화해 - * 모든 Product 노드를 모은다 — 사이트마다 Product 를 감싸는 형태가 달라 한 형태만 보면 놓친다. + * 모든 상품 노드(Product · ProductGroup)를 모은다 — 사이트마다 감싸는 형태가 달라 한 형태만 보면 놓친다. */ private List collectProductNodes(JsonNode node) { List products = new ArrayList<>(); @@ -166,16 +166,21 @@ private boolean isProductType(JsonNode node) { return isProductTypeValue(type); } + /** ProductGroup 은 구매 가능한 변형들을 묶은 상품 노드라 Product 와 같이 상품으로 본다. */ private boolean isProductTypeValue(JsonNode node) { String text = textOf(node); - return text != null && text.equalsIgnoreCase("Product"); + return text != null && (text.equalsIgnoreCase("Product") || text.equalsIgnoreCase("ProductGroup")); } - /** offers 는 객체 또는 배열(AggregateOffer 의 offers 배열 등)로 오므로 둘 다 받는다. */ + /** + * offers 는 객체 또는 배열(AggregateOffer 의 offers 배열 등)로 오므로 둘 다 받는다. + * 자기 노드에 없으면 첫 변형에서 찾는다. + */ private JsonNode firstOffer(JsonNode product) { JsonNode offers = product.get("offers"); if (offers == null) { - return null; + JsonNode variant = firstVariant(product); + return variant == null ? null : firstOffer(variant); } if (offers.isArray()) { return offers.size() > 0 ? offers.get(0) : null; @@ -205,10 +210,24 @@ private JsonNode priceNode(JsonNode offer) { return null; } + /** + * ProductGroup 은 이름만 자기 노드에 두고 가격·이미지는 hasVariant 안의 변형에만 두는 경우가 있어 + * (Shopify 계열) 그 두 필드를 첫 변형에서 보강한다. 변형 자체를 상품 노드로 수집하지는 않는다 — + * 변형 이름에는 사이즈 같은 옵션이 붙어 상품명으로 쓸 수 없다. + */ + private JsonNode firstVariant(JsonNode product) { + JsonNode variants = product.get("hasVariant"); + if (variants == null || !variants.isArray() || variants.size() == 0) { + return null; + } + return variants.get(0); + } + private String imageUrlOf(JsonNode product) { JsonNode image = product.get("image"); if (image == null) { - return null; + JsonNode variant = firstVariant(product); + return variant == null ? null : imageUrlOf(variant); } return firstImageUrl(image); } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractorTest.java index 0171533..4c927e8 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractorTest.java @@ -83,6 +83,39 @@ void recognizesArrayType() { assertEquals(15_000, snapshot.currentPrice()); } + @Test + @DisplayName("ProductGroup 은 그룹 이름을 쓰고 가격과 이미지는 첫 변형에서 가져온다") + void extractsFromProductGroupUsingFirstVariant() { + ProductSnapshot snapshot = snapshotOrNull(extractor.extract(pageOf(jsonLd( + """ + {"@type":"ProductGroup","name":"컷아웃 부츠","hasVariant":[{"@type":"Product","name":"컷아웃 부츠 - 250","image":"https://cdn.example.com/boots.jpg","offers":{"@type":"Offer","price":"800000","priceCurrency":"KRW"}},{"@type":"Product","name":"컷아웃 부츠 - 260","offers":{"price":"800000"}}]}""")))); + + assertEquals("컷아웃 부츠", snapshot.name()); + assertEquals(800_000, snapshot.currentPrice()); + assertEquals("KRW", snapshot.currency()); + assertEquals("https://cdn.example.com/boots.jpg", snapshot.imageUrl()); + } + + @Test + @DisplayName("ProductGroup 에 offers 가 있으면 변형보다 그것을 먼저 쓴다") + void prefersGroupOfferOverVariantOffer() { + ProductSnapshot snapshot = snapshotOrNull(extractor.extract(pageOf(jsonLd( + """ + {"@type":"ProductGroup","name":"그룹상품","offers":{"price":"10000","priceCurrency":"KRW"},"hasVariant":[{"@type":"Product","name":"그룹상품 - S","offers":{"price":"99999","priceCurrency":"JPY"}}]}""")))); + + assertEquals(10_000, snapshot.currentPrice()); + assertEquals("KRW", snapshot.currency()); + } + + @Test + @DisplayName("ProductGroup 에 변형도 offers 도 없으면 missing_field 로 fallback 한다") + void productGroupWithoutVariantsFallsBackToMissingField() { + String html = jsonLd(""" + {"@type":"ProductGroup","name":"가격없는그룹"}"""); + + assertEquals(StructuredExtraction.Miss.MISSING_FIELD, extractor.extract(pageOf(html))); + } + @Test @DisplayName("offers 가 배열이면 첫 유효 price 를 쓴다") void usesFirstOfferWhenArray() {