From b49ac76a4b230bf7417731209fe4af6508e5ae44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:48:57 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20JSON-LD=20=EC=9D=98=20ProductGroup=20?= =?UTF-8?q?=EC=9D=84=20=EC=83=81=ED=92=88=20=EB=85=B8=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=8B=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shopify 계열은 최상위가 ProductGroup 이고 실제 Product 는 hasVariant 안에 있어 노드 수집이 전부 미스했다. OG 경로도 표준 가격 태그가 없어 함께 미스해 매 요청이 LLM 으로 갔고, 비결정적이라 같은 사이트의 상품명이 요청마다 갈렸다 - 처음엔 OG 경로에 og:price 폴백을 넣어 푸는 방향으로 갔으나(#51, PR #52) 그 경로는 상품명을 og:title 즉 페이지 제목에서 가져온다. 사이트가 상품명 필드에 선언한 이름은 "컷아웃 부츠" 이고 그쪽이 정확해, JSON-LD 를 고치는 방향으로 전환하고 폴백은 폐기했다 - 변형을 상품 노드로 수집하지 않고 그룹 노드만 수집한다. 변형 이름에는 사이즈가 붙어 있어(컷아웃 부츠 - 250) 상품명으로 쓸 수 없다. 대신 그룹에 없는 가격·이미지만 첫 변형에서 보강한다 - 실측한 페이지의 ProductGroup 에는 offers 도 image 도 없었다 - 저장해둔 실제 페이지로 전후를 확인했다. 수정 전 MISSING_FIELD, 수정 후 컷아웃 부츠 / 800,000 KRW / https 이미지 --- .../structured/StructuredDataExtractor.java | 29 +++++++++++++--- .../StructuredDataExtractorTest.java | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) 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() {