Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ private StructuredExtraction toSnapshotFromProduct(JsonNode product, ProductLink

/**
* 최상위 배열 · {@code @graph} 래핑 · {@code ItemList.itemListElement[].item} 중첩을 재귀로 평탄화해
* 모든 Product 노드를 모은다 — 사이트마다 Product 를 감싸는 형태가 달라 한 형태만 보면 놓친다.
* 모든 상품 노드(Product · ProductGroup)를 모은다 — 사이트마다 감싸는 형태가 달라 한 형태만 보면 놓친다.
*/
private List<JsonNode> collectProductNodes(JsonNode node) {
List<JsonNode> products = new ArrayList<>();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading