diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java index 78f830f..06194cd 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java @@ -2,6 +2,7 @@ import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.domain.ProductSnapshot; +import com.depromeet.piki.extractor.domain.ProductSnapshotException; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderException; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderer; import com.depromeet.piki.extractor.extraction.headless.RenderedHop; @@ -40,7 +41,7 @@ public class HeadlessProductLinkExtractor implements LinkExtractionStrategy { private record Candidate(RenderedHop hop, String html) { } - private record Chosen(PageContent page, StructuredExtraction result) { + private record Chosen(Candidate candidate, PageContent page, StructuredExtraction result) { } @Override @@ -72,15 +73,15 @@ public ProductSnapshot extract(ProductLink link, boolean authorized, String mode continue; } if (first == null) { - first = new Chosen(page, result); + first = new Chosen(candidate, page, result); } if (fallback == null && !LlmInputGate.hasNothingForLlm(page.document())) { - fallback = new Chosen(page, result); + fallback = new Chosen(candidate, page, result); } } Chosen chosen = fallback != null ? fallback : first; if (chosen != null) { - return htmlSnapshotPipeline.extract(chosen.page(), chosen.result(), timing, model); + return extractChosen(chosen, link, hops, timing, model); } if (blocked) { log.warn("headless hops unusable blocked=true hops={} url={}", hops.size(), link.safeLogString()); @@ -94,6 +95,37 @@ public ProductSnapshot extract(ProductLink link, boolean authorized, String mode throw HeadlessRenderException.upstream("렌더 HTML 이 없다", null); } + /** + * 고른 홉이 파이프라인에서 실패하면 어느 홉이 LLM 에 갔는지 남긴다. 성공 쪽은 extract 원장이 지지만 실패 쪽은 + * 요청 URL 만 남아, 차단 페이지가 신호를 피해 후보로 남았는지와 상품 홉을 두고 다른 홉을 골랐는지를 가를 수 + * 없었다. title 은 페이지 본문이라 남기지 않고 경로·status·분량으로만 가른다. + */ + private ProductSnapshot extractChosen( + Chosen chosen, + ProductLink link, + List hops, + String timing, + String model + ) { + try { + return htmlSnapshotPipeline.extract(chosen.page(), chosen.result(), timing, model); + } catch (ProductSnapshotException e) { + RenderedHop hop = chosen.candidate().hop(); + log.info( + "headless chosen hop failed code={} hop={}/{} status={} source={} html={}chars hopUrl={} url={}", + e.code(), + hops.indexOf(hop) + 1, + hops.size(), + hop.status(), + chosen.candidate().html().equals(hop.dom()) ? "dom" : "body", + chosen.page().retainedChars(), + chosen.page().finalUrl().safeLogString(), + link.safeLogString() + ); + throw e; + } + } + private static List candidates(List hops) { List candidates = new ArrayList<>(); for (RenderedHop hop : hops.reversed()) {