From aa12f9ec35dc5aa312dec7bbe173abb51f7b40c0 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: Tue, 8 Sep 2026 19:59:27 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=97=A4=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=20=ED=99=89=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EB=B0=9B?= =?UTF-8?q?=EC=95=84=20=EC=83=81=ED=92=88=20=EB=AC=B8=EC=84=9C=C2=B7?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=ED=8C=90=EC=A0=95=EC=9D=84=20extractor=20?= =?UTF-8?q?=EA=B0=80=20=EB=A7=A1=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderer 계약이 "최선의 HTML 한 장 + verdict" 에서 "거쳐 간 홉 목록(url·status·headers·body·dom)" 으로 바뀐다. 2026-09-07 prod 에이블리 공유링크 실패는 renderer 가 상품 페이지 여부를 스스로 판단하다 오판해 3KB 셸 본문을 준 것이었고, 그 판단을 이쪽으로 옮긴다 - 후보는 마지막 홉부터 거슬러 dom → body 순. 홈 피드로 튕긴 경우 상품은 앞 홉에 있고, 에이블리는 body 가 셸이고 상품은 하이드레이션된 dom 에만 있다. 구조화 데이터가 잡히는 첫 후보를 쓰고 없으면 마지막 홉 dom 으로 기존 파이프라인(셸 게이트 → LLM)을 탄다. LLM 은 한 번만 - 차단 판정(status 401·403·405·429·490, 챌린지 title)은 renderer 의 목록을 그대로 옮겨 홉 단위로 본다(HeadlessBlockSignal). 모든 홉이 차단이어야 HEADLESS_BLOCKED, 챌린지를 지나 상품 홉에 도달했으면 차단이 아니다 - 매 홉 url 을 SSRF 가드에 통과시킨다. 어느 홉이든 내부망이면 렌더 전체 거부 - renderer 배포가 수동이라 뒤처질 수 있어 구계약(html·final_url) 응답을 홉 하나로 읽는 호환을 둔다. 이 PR 을 먼저 배포한다 - 차단 status 세분화(404·410 은 상품 없음)는 infra 에러 코드 계약이 먼저라 범위 밖 --- .../HeadlessProductLinkExtractor.java | 48 +++- .../headless/HeadlessBlockSignal.java | 30 +++ .../headless/HeadlessRenderException.java | 10 +- .../headless/HeadlessRenderRequest.java | 11 +- .../headless/HeadlessRenderResponse.java | 43 +++- .../extraction/headless/HeadlessRenderer.java | 13 +- .../headless/HttpHeadlessRenderer.java | 118 +++------- .../extraction/headless/RenderedHop.java | 17 ++ .../HeadlessProductLinkExtractorTest.java | 138 +++++++---- .../headless/HttpHeadlessRendererTest.java | 222 +++++++----------- .../support/StubHeadlessRenderer.java | 7 +- 11 files changed, 355 insertions(+), 302 deletions(-) create mode 100644 src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessBlockSignal.java create mode 100644 src/main/java/com/depromeet/piki/extractor/extraction/headless/RenderedHop.java 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 b628b91..87b7e1a 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java @@ -2,30 +2,66 @@ import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.domain.ProductSnapshot; +import com.depromeet.piki.extractor.extraction.headless.HeadlessBlockSignal; +import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderException; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderer; +import com.depromeet.piki.extractor.extraction.headless.RenderedHop; +import com.depromeet.piki.extractor.extraction.structured.StructuredDataExtractor; +import com.depromeet.piki.extractor.extraction.structured.StructuredExtraction; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** - * 차단 우회 헤드리스 추출 전략. 정적 HTTP fetch 가 봇 차단에 막히는 플랫폼을, 실제 브라우저를 띄우는 별도 - * 서비스(renderer 의 {@code POST /render})로 뚫는다. 렌더된 HTML 을 정적 fetch 와 같은 - * {@link HtmlSnapshotPipeline} 에 흘려넣으므로 READY 불변식 검증도 동일하다. + * 차단 우회 헤드리스 추출 전략. 실제 브라우저를 띄우는 별도 서비스(renderer)가 거쳐 간 홉 전부를 받아, + * 그중 어느 문서를 파이프라인에 태울지 여기서 정한다 — renderer 는 판단하지 않는다. * - *

차단·빈 렌더·렌더 서비스 오류의 계약 번역은 {@code HeadlessRenderer} 구현이 책임진다. + *

후보는 마지막 홉부터 거슬러 dom → body 순이다. 상품 페이지가 홈 피드·앱 유도로 튕겨 나간 경우 상품은 앞 홉에 + * 있고, 같은 홉 안에서는 하이드레이션된 dom 이 서버 원문보다 알차다(에이블리는 body 가 3KB 셸이고 상품은 dom 에만 + * 있다). 구조화 데이터가 잡히는 첫 후보를 쓰고, 없으면 마지막 홉의 dom 을 그대로 파이프라인(셸 게이트 → LLM)에 + * 넘긴다 — LLM 은 한 번만 부른다. */ +@Slf4j @RequiredArgsConstructor @Component(LinkExtractionStrategy.HEADLESS) public class HeadlessProductLinkExtractor implements LinkExtractionStrategy { private final HeadlessRenderer headlessRenderer; + private final StructuredDataExtractor structuredDataExtractor; private final HtmlSnapshotPipeline htmlSnapshotPipeline; @Override public ProductSnapshot extract(ProductLink link, boolean authorized, String model) { long renderStart = System.nanoTime(); - PageContent page = headlessRenderer.render(link, authorized); + List hops = headlessRenderer.render(link, authorized); long renderMs = (System.nanoTime() - renderStart) / 1_000_000; - return htmlSnapshotPipeline.extract(page, "render=" + renderMs + "ms", model); + List candidates = new ArrayList<>(); + boolean blocked = false; + for (RenderedHop hop : hops.reversed()) { + for (String html : List.of(hop.dom(), hop.body())) { + if (html.isBlank()) { + continue; + } + PageContent page = PageContent.of(link, html, hop.url()); + if (HeadlessBlockSignal.isBlocked(hop.status(), page.document())) { + blocked = true; + continue; + } + candidates.add(page); + } + } + if (candidates.isEmpty()) { + log.warn("headless hops unusable blocked={} hops={} url={}", blocked, hops.size(), link.safeLogString()); + throw blocked ? HeadlessRenderException.blocked() : HeadlessRenderException.upstream("렌더 HTML 이 없다", null); + } + + PageContent chosen = candidates.stream() + .filter(page -> structuredDataExtractor.extract(page) instanceof StructuredExtraction.Extracted) + .findFirst() + .orElse(candidates.getFirst()); + return htmlSnapshotPipeline.extract(chosen, "render=" + renderMs + "ms hops=" + hops.size(), model); } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessBlockSignal.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessBlockSignal.java new file mode 100644 index 0000000..92a9d4f --- /dev/null +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessBlockSignal.java @@ -0,0 +1,30 @@ +package com.depromeet.piki.extractor.extraction.headless; + +import java.util.List; +import java.util.Set; +import org.jsoup.nodes.Document; + +/** + * 렌더된 홉이 차단·챌린지 페이지인가. 실제 브라우저도 프록시 출구 평판이나 지문에 걸리면 챌린지 페이지를 받으므로, + * 그 HTML 을 "내용 없는 상품 페이지" 로 오판하지 않기 위한 분류다. + */ +public final class HeadlessBlockSignal { + + /** 490 은 네이버 캡차 커스텀 코드. */ + private static final Set BLOCK_STATUSES = Set.of(401, 403, 405, 429, 490); + private static final List CHALLENGE_TITLE_MARKERS = List.of( + "잠시만 기다", "보안 확인", "access denied", "pardon our", + "are you a robot", "잠시 후 다시", "캡차", "captcha", "시스템오류" + ); + + private HeadlessBlockSignal() { + } + + public static boolean isBlocked(int status, Document document) { + if (BLOCK_STATUSES.contains(status)) { + return true; + } + String title = document.title().toLowerCase(); + return CHALLENGE_TITLE_MARKERS.stream().anyMatch(title::contains); + } +} diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderException.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderException.java index 482eae1..65d193c 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderException.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderException.java @@ -11,8 +11,8 @@ private HeadlessRenderException(String message, ExtractionErrorCode code, boolea } /** - * 실제 브라우저로도 차단(verdict=BLOCK). 렌더 서비스의 BLOCK 판정은 429·"잠시 후 다시" 챌린지 같은 - * 일시 신호를 포함해 영구/일시를 못 가르므로, fail-safe 원칙(분류 불가 실패는 일시)대로 일시 실패로 둔다. + * 실제 브라우저로도 모든 홉이 차단됐다({@link HeadlessBlockSignal}). 차단 신호에 429·"잠시 후 다시" 같은 일시 + * 신호가 섞여 영구/일시를 못 가르므로, fail-safe 원칙(분류 불가 실패는 일시)대로 일시 실패로 둔다. * 결정론적 차단의 재시도 낭비는 호출자의 bounded 재시도가 바운드한다(docs/api-contract.md). * *

code 를 HEADLESS_UPSTREAM 과 분리해 두는 이유: "차단" 과 "렌더 서비스 장애" 는 관측·대응이 다르다 @@ -20,7 +20,7 @@ private HeadlessRenderException(String message, ExtractionErrorCode code, boolea */ public static HeadlessRenderException blocked() { return new HeadlessRenderException( - "헤드리스 렌더가 차단됐다(verdict=BLOCK) — 일시 챌린지가 섞여 있어 일시 실패로 분류한다.", + "헤드리스 렌더의 모든 홉이 차단 신호다 — 일시 챌린지가 섞여 있어 일시 실패로 분류한다.", ExtractionErrorCode.HEADLESS_BLOCKED, false, null @@ -28,8 +28,8 @@ public static HeadlessRenderException blocked() { } /** - * 렌더 서비스 쪽 실패(연결·타임아웃·비-2xx·빈 렌더·미지의 verdict) 일괄 번역. 일시적일 수 있어 일시 - * 실패로 두고, 호출자 recover 의 bounded 재시도가 흡수한다(docs/api-contract.md). + * 렌더 서비스 쪽 실패(연결·타임아웃·비-2xx·홉 없음) 일괄 번역. 일시적일 수 있어 일시 실패로 두고, 호출자 + * recover 의 bounded 재시도가 흡수한다(docs/api-contract.md). */ public static HeadlessRenderException upstream(String detail, Throwable cause) { return new HeadlessRenderException( diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java index a8f7888..874d414 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java @@ -1,16 +1,10 @@ package com.depromeet.piki.extractor.extraction.headless; -import com.fasterxml.jackson.annotation.JsonProperty; - /** * POST /render 요청 wire 모델. 렌더 서비스(Python/FastAPI)의 snake_case 필드에 맞춘다. * - *

include_html 을 켜는 이유: renderer 는 파싱하지 않으므로(HTML 렌더러) 파싱(구조화/LLM)은 우리가 렌더된 - * HTML 로 직접 한다. - * - *

authorized 는 "이 대상이 허락을 받았는가" 다 — 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정· - * 프록시)을 연다. 이 필드를 모르는 구버전 renderer 는 무시하고 정직 모드로 돌므로, 배포 순서와 무관하게 - * 안전한 쪽으로만 어긋난다. + *

authorized 는 "이 대상이 허락을 받았는가" 다 — 렌더 서비스는 이 값이 true 일 때만 우회 수단(프록시)을 연다. + * 이 필드를 모르는 구버전 renderer 는 무시하고 정직 모드로 돌므로, 배포 순서와 무관하게 안전한 쪽으로만 어긋난다. * *

compress 를 모르는 구버전 renderer 는 이 필드를 무시하고(pydantic 기본) plain JSON 을 준다 — 그래서 * 해제 판별을 요청이 아니라 응답 헤더로 두면(HttpHeadlessRenderer 참조) 켠 채로도 배포 순서와 무관하게 안전하다. @@ -18,7 +12,6 @@ record HeadlessRenderRequest( String url, boolean authorized, - @JsonProperty("include_html") boolean includeHtml, boolean compress ) { } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderResponse.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderResponse.java index 6f5b106..c3cfbea 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderResponse.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderResponse.java @@ -2,25 +2,44 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; /** - * POST /render 응답 wire 모델. 우리가 쓰는 필드만 선언하는 tolerant reader 라, 구버전 renderer 가 주던 - * title/price/source 나 새로 붙는 필드나 똑같이 흡수된다. + * POST /render 응답 wire 모델. renderer 는 판단하지 않고 거쳐 간 홉을 그대로 돌려준다 — 어느 홉이 상품이고 + * 차단인지는 우리가 정한다. * - *

verdict 는 렌더 서비스의 판정 문자열이고 "가격 찾음" 이 아니라 "html 확보" 여부다(renderer 는 파싱하지 - * 않는다). 계약으로의 번역 규칙은 HttpHeadlessRenderer 가 정본이다. - * - *

전 필드 nullable(박싱 타입) — 렌더 서비스의 예외 격리 경로는 platform·verdict·error 만 싣는다. primitive 를 - * 쓰면 Jackson 3(FAIL_ON_NULL_FOR_PRIMITIVES 기본 on)가 필드 부재를 역직렬화 실패로 만들어, verdict 번역에 - * 닿기도 전에 일시 실패로 오분류된다. + *

전 필드 nullable(박싱 타입) — 브라우저 예외 격리 경로는 error 만 싣는다. primitive 면 Jackson 3 가 필드 부재를 + * 역직렬화 실패로 만들어 계약 번역에 닿기도 전에 일시 실패로 오분류된다. */ @JsonIgnoreProperties(ignoreUnknown = true) record HeadlessRenderResponse( - String verdict, Boolean proxied, - Integer status, - @JsonProperty("final_url") String finalUrl, + String error, + List hops, + /** 홉 계약 이전 renderer 의 단일 HTML. 그쪽 배포가 수동이라 뒤처지는 동안 홉 하나로 읽는다. */ String html, - String error + @JsonProperty("final_url") String finalUrl, + Integer status ) { + + List hopsOrLegacy() { + if (hops != null && !hops.isEmpty()) { + return hops; + } + if (html == null || html.isBlank()) { + return List.of(); + } + return List.of(new Hop(finalUrl, status, Map.of(), "", html)); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record Hop( + String url, + Integer status, + Map headers, + String body, + String dom + ) { + } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java index b0baaf8..ce681d2 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java @@ -1,19 +1,18 @@ package com.depromeet.piki.extractor.extraction.headless; import com.depromeet.piki.extractor.domain.ProductLink; -import com.depromeet.piki.extractor.extraction.PageContent; +import java.util.List; /** * 헤드리스 브라우저 렌더(renderer 의 POST /render) 외부 경계 — 테스트 stub 지점 (PageFetcher 와 같은 역할). - * 렌더된 HTML 이 있는 페이지만 PageContent 로 반환하고, 실패는 계약 예외({@link HeadlessRenderException} · - * SSRF 차단 host 는 PageFetchException.blockedHost)로 번역해 던진다 — - * 소비자(HeadlessProductLinkExtractor)는 verdict 를 모른다. + * 거쳐 간 홉을 순서대로 돌려주고, 렌더 서비스 실패는 {@link HeadlessRenderException}, SSRF 차단 host 는 + * PageFetchException.blockedHost 로 번역해 던진다. 홉의 해석(상품·차단 판정)은 소비자 몫이다. */ public interface HeadlessRenderer { /** - * @param authorized 허락받은 대상인가. 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정·프록시)을 - * 연다. 이 경계는 판정하지 않고 전달만 한다 — 원장은 호출자(core)에 있다. + * @param authorized 허락받은 대상인가. 렌더 서비스는 이 값이 true 일 때만 우회 수단(프록시)을 연다. + * 이 경계는 판정하지 않고 전달만 한다 — 원장은 호출자(core)에 있다. */ - PageContent render(ProductLink link, boolean authorized); + List render(ProductLink link, boolean authorized); } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java index 0fbb902..ea0284b 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java @@ -4,14 +4,15 @@ import com.depromeet.piki.extractor.common.exception.ExtractionException; import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.extraction.HeadlessExtractionProperties; -import com.depromeet.piki.extractor.extraction.PageContent; -import com.depromeet.piki.extractor.extraction.PruningHtmlParser; import com.depromeet.piki.extractor.extraction.http.InternalHostGuard; import com.depromeet.piki.extractor.extraction.http.PageFetchException; import com.depromeet.piki.extractor.extraction.http.RequestScopedDnsResolver; import com.github.luben.zstd.ZstdInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.MediaType; @@ -24,42 +25,24 @@ import tools.jackson.databind.ObjectMapper; /** - * renderer 의 POST /render 호출 + verdict 를 계약으로 번역하는 wire 구현. - * 렌더 서비스는 실제 브라우저(patchright)로 페이지를 열어 최선의 HTML 을 돌려준다 — 파싱은 그쪽이 하지 않으므로 - * 렌더된 HTML 을 PageContent 로 되돌려 기존 파이프라인(구조화 → LLM)에 흘려넣는다. - * - *

verdict 번역 규칙 — recall 최대화: - *

+ * renderer 의 POST /render 호출 wire 구현. 응답의 홉 목록을 {@link RenderedHop} 으로 옮기고 매 홉의 host 를 + * SSRF 가드에 통과시킨다 — 렌더 서비스가 대신 따라간 redirect 로 내부망 응답이 상품 HTML 로 흘러드는 것을 막는 + * 마지막 층이다(요청 전의 원본 URL 검증과 이중). 홉의 해석은 하지 않는다. * *

전송: 요청 compress=true 면 응답이 zstd raw 바이트로 온다. 해제 분기는 요청이 아니라 응답 헤더 * ({@code X-Encoding: zstd}) 기준 — compress 를 모르는 구버전 renderer 는 plain JSON 을 주므로, 헤더 분기여야 * renderer 와 어느 쪽이 먼저 배포되든 안전하다. 사전({@code X-Zstd-Dict})은 {@code ZstdDictionaries} 의 * 롤아웃 규약 참조. - * - *

SSRF: 브라우저 직행(headlessFirst) 경로는 plain fetch 를 타지 않아 그 안의 가드로 커버되지 않는다 — - * 렌더 서비스에 URL 을 넘기기 전에 같은 판정({@link InternalHostGuard})을 거쳐, 내부망·메타데이터 주소를 사설망의 - * 실제 브라우저로 렌더시키는 구멍을 닫는다(에스컬레이션 경로에선 이중 검증 = 다층 방어). */ @Slf4j @Component public class HttpHeadlessRenderer implements HeadlessRenderer { private static final String RENDER_PATH = "/render"; - private static final String VERDICT_BLOCK = "BLOCK"; private static final String ENCODING_HEADER = "X-Encoding"; private static final String ZSTD_DICT_HEADER = "X-Zstd-Dict"; private static final String ZSTD_ENCODING = "zstd"; - /** - * 해제 결과(JSON = 렌더 HTML + 메타)의 안전 상한. 신뢰 경계 안(내부망 renderer)이라도 해제 폭탄·오배선을 - * 바운드하려 둔다 — 정상 압축비로는 닿지 않을 만큼 넉넉하다. 가지친 뒤 보존분 상한은 별도다(PruningHtmlParser). - */ + /** 해제 결과(홉 전체의 JSON)의 안전 상한. 내부망이라도 해제 폭탄·오배선을 바운드하려 둔다. */ private static final int MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024; private final RestClient restClient; @@ -85,9 +68,8 @@ public HttpHeadlessRenderer( } @Override - public PageContent render(ProductLink link, boolean authorized) { + public List render(ProductLink link, boolean authorized) { try { - // 여기서 걸리면 PageFetchException.blockedHost 로 — plain 경로와 같은 계약 코드로 떨어진다. internalHostGuard.verify(link); return renderVerified(link, authorized); } finally { @@ -95,48 +77,32 @@ public PageContent render(ProductLink link, boolean authorized) { } } - private PageContent renderVerified(ProductLink link, boolean authorized) { + private List renderVerified(ProductLink link, boolean authorized) { HeadlessRenderResponse response = requestRender(link, authorized); - - String verdict = response.verdict(); - if (VERDICT_BLOCK.equals(verdict)) { - log.warn("headless render verdict=BLOCK status={} url={}", response.status(), link.safeLogString()); - throw HeadlessRenderException.blocked(); + List hops = response.hopsOrLegacy(); + if (hops.isEmpty()) { + log.warn("headless render no hops error={} url={}", maskUrls(response.error()), link.safeLogString()); + throw HeadlessRenderException.upstream("렌더 홉이 없다: " + maskUrls(response.error()), null); } - - String html = response.html(); - if (html == null || html.isBlank()) { - log.warn( - "headless render no html verdict={} status={} error={} url={}", - verdict, - response.status(), - maskUrls(response.error()), - link.safeLogString() - ); - throw HeadlessRenderException.upstream("verdict=" + verdict + " 인데 렌더 HTML 이 없다", null); - } - log.info( - "headless render verdict={} proxied={} status={} html={}chars url={}", - verdict, + "headless render hops={} status={} proxied={} error={} url={}", + hops.size(), + hops.getLast().status(), response.proxied(), - response.status(), - html.length(), + maskUrls(response.error()), link.safeLogString() ); + return hops.stream().map(hop -> toRendered(hop, link)).toList(); + } - ProductLink finalUrl = resolveFinalUrl(response.finalUrl(), link); - // 렌더된 DOM 은 정적 fetch 보다 커질 수 있어 같은 가지치기를 통과시킨다 — 하류가 보는 문서의 모양이 - // 두 전략 사이에서 갈리지 않게 하려는 것이기도 하다. - PruningHtmlParser.Pruned pruned = PruningHtmlParser.parse(html, finalUrl.value().toString()); - if (pruned.truncated()) { - log.warn( - "headless render stopped at retained cap chars={} url={}", - pruned.retainedChars(), - link.safeLogString() - ); - } - return new PageContent(link, pruned.document(), finalUrl, pruned.retainedChars()); + private RenderedHop toRendered(HeadlessRenderResponse.Hop hop, ProductLink link) { + return new RenderedHop( + resolveHopUrl(hop.url(), link), + hop.status() == null ? 0 : hop.status(), + hop.headers() == null ? Map.of() : hop.headers(), + Objects.requireNonNullElse(hop.body(), ""), + Objects.requireNonNullElse(hop.dom(), "") + ); } private HeadlessRenderResponse requestRender(ProductLink link, boolean authorized) { @@ -145,11 +111,11 @@ private HeadlessRenderResponse requestRender(ProductLink link, boolean authorize entity = restClient.post() .uri(RENDER_PATH) .contentType(MediaType.APPLICATION_JSON) - .body(new HeadlessRenderRequest(link.value().toString(), authorized, true, properties.compress())) + .body(new HeadlessRenderRequest(link.value().toString(), authorized, properties.compress())) .retrieve() .toEntity(byte[].class); } catch (RestClientResponseException e) { - // 차단·빈 렌더는 200 + verdict 로 오므로, 렌더 서비스의 비-2xx 는 그쪽 장애·배포 중 신호다. + // 브라우저 실패는 200 + error 로 오므로, 렌더 서비스의 비-2xx 는 그쪽 장애·배포 중 신호다. throw HeadlessRenderException.upstream("render 서비스 응답 " + e.getStatusCode().value(), e); } catch (RestClientException e) { throw HeadlessRenderException.upstream("render 서비스 호출 실패", e); @@ -183,7 +149,6 @@ private byte[] decompress(byte[] compressed, String dictId) { throw HeadlessRenderException.upstream( "zstd 해제 결과가 상한(" + MAX_DECOMPRESSED_BYTES + " bytes)을 넘는다", null); } - // 압축 롤아웃 관측용 — renderer 재배포 후 압축이 실제로 도는지, 절감률이 기대치인지 확인한다. log.info("headless render transfer=zstd dict=[{}] {}→{} bytes", dictId, compressed.length, json.length); return json; } catch (IOException e) { @@ -205,26 +170,17 @@ private byte[] resolveDict(String dictId) { } /** - * 렌더 서비스가 redirect 를 따라간 최종 URL. Jsoup baseUri 이자 응답 계약의 finalUrl 로 호출자에게 나간다. - *

형식 위반은 원본 link 로 폴백한다 — baseUri 부정확은 치명이 아니고, 여기서 INVALID_URL 을 새면 렌더는 - * 성공했는데 확정 실패로 종결되는 오판이 된다. - *

SSRF 판정은 폴백하지 않고 렌더 전체를 거부한다. 원본 URL 만 검증하면 "외부 URL → 내부 주소" - * redirect 를 렌더 서비스가 대신 따라가 준 셈이 되어, 내부망 응답이 상품 HTML 로 흘러들고 그 주소가 호출자의 - * 정체성(canonical) 입력으로까지 나간다. - * - *

한계 — 이 검증은 사후(post-hoc)다. 렌더 응답을 받은 뒤에 도는 것이라 내부 주소로의 요청 자체는 - * 막지 못하고, "외부 → 내부 → 외부"로 되돌아오는 체인은 최종 URL 이 외부라 여기서 걸리지 않는다. 정적 fetch 처럼 - * hop 마다 막으려면 브라우저의 navigation 계층(자동 redirect 를 끄고 Location 마다 같은 판정) 또는 렌더 박스의 - * egress 정책이 보안 경계여야 하며, 그건 renderer repo 소관이다. 여기 검증은 "내부망 콘텐츠를 상품으로 소비하고 - * 그 주소를 호출자에게 넘기는 것"을 막는 다층 방어의 마지막 층이다. + * 홉 URL 은 Jsoup baseUri 이자 응답 계약의 finalUrl 로 호출자에게 나간다. 형식 위반은 원본 link 로 폴백한다 — + * baseUri 부정확은 치명이 아니고, 여기서 INVALID_URL 을 새면 렌더는 성공했는데 확정 실패로 종결되는 오판이 된다. + * 단 SSRF 판정은 폴백하지 않고 렌더 전체를 거부한다. */ - private ProductLink resolveFinalUrl(String finalUrl, ProductLink link) { - if (finalUrl == null || finalUrl.isBlank()) { + private ProductLink resolveHopUrl(String url, ProductLink link) { + if (url == null || url.isBlank()) { return link; } ProductLink parsed; try { - parsed = ProductLink.parse(finalUrl); + parsed = ProductLink.parse(url); } catch (ExtractionException e) { return link; } @@ -234,8 +190,8 @@ private ProductLink resolveFinalUrl(String finalUrl, ProductLink link) { if (e.code() == ExtractionErrorCode.BLOCKED_HOST) { throw e; } - // 그 외(DNS 미해결 등)는 검증 불가일 뿐 내부망 근거가 아니다 — 원본 link 로 폴백해 렌더 결과는 살린다. - log.warn("headless render finalUrl 검증 실패 code={} url={}", e.code(), link.safeLogString()); + // 그 외(DNS 미해결 등)는 검증 불가일 뿐 내부망 근거가 아니다. + log.warn("headless render hop url 검증 실패 code={} url={}", e.code(), link.safeLogString()); return link; } return parsed; diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/RenderedHop.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/RenderedHop.java new file mode 100644 index 0000000..51b7d71 --- /dev/null +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/RenderedHop.java @@ -0,0 +1,17 @@ +package com.depromeet.piki.extractor.extraction.headless; + +import com.depromeet.piki.extractor.domain.ProductLink; +import java.util.Map; + +/** + * 브라우저가 거쳐 간 문서 하나. body 는 서버 원문(3xx 는 빈값), dom 은 그 문서를 떠나기 직전(마지막 홉은 정착 후)의 + * 렌더 결과다. 어느 홉이 상품이고 차단인지는 소비자가 정한다. + */ +public record RenderedHop( + ProductLink url, + int status, + Map headers, + String body, + String dom +) { +} diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java index 2aa9ae5..a5b2280 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java @@ -1,6 +1,7 @@ package com.depromeet.piki.extractor.extraction; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode; @@ -10,93 +11,150 @@ import com.depromeet.piki.extractor.extraction.gemini.GeminiExtractionResult; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderException; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderer; +import com.depromeet.piki.extractor.extraction.headless.RenderedHop; import com.depromeet.piki.extractor.extraction.structured.StructuredDataExtractor; import com.depromeet.piki.extractor.support.StubGeminiClient; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import java.util.Map; import java.util.function.Function; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; /** - * 헤드리스 전략의 조립 — 렌더된 HTML 이 plain 과 동일한 파이프라인(구조화 우선 → LLM fallback)으로 흐르는지 본다. - * - *

렌더 자체의 wire·verdict 번역은 HttpHeadlessRendererTest 가, 직행/에스컬레이션 라우팅은 - * FallbackProductLinkExtractorTest 가 진다. 외부 경계(HeadlessRenderer·GeminiClient)만 fake/stub. + * 헤드리스 전략의 홉 해석 — 어느 홉의 어느 문서를 파이프라인에 태우는지, 차단·빈 홉을 어떻게 닫는지 본다. + * 렌더 wire 는 HttpHeadlessRendererTest 가, 직행/에스컬레이션 라우팅은 FallbackProductLinkExtractorTest 가 진다. */ class HeadlessProductLinkExtractorTest { - private final ProductLink link = ProductLink.parse("https://kream.co.kr/products/6963"); + private static final String SHELL = "

"; + private static final String CHALLENGE = "보안 확인 중.." + "확인 ".repeat(200) + ""; + + private final ProductLink link = ProductLink.parse("https://m.a-bly.com/goods/1"); + private final ProductLink mobile = ProductLink.parse("https://mobile.a-bly.com/goods/1"); + private final ProductLink feed = ProductLink.parse("https://mobile.a-bly.com/today"); private final StubGeminiClient stubGemini = new StubGeminiClient(); - private HeadlessProductLinkExtractor extractorWith(Function render) { + private static String product(String name, int price) { + return ""; + } + + private static RenderedHop hop(ProductLink url, int status, String body, String dom) { + return new RenderedHop(url, status, Map.of(), body, dom); + } + + private HeadlessProductLinkExtractor extractorWith(Function> render) { HeadlessRenderer renderer = (target, authorized) -> render.apply(target); + StructuredDataExtractor structured = new StructuredDataExtractor(new ObjectMapper()); return new HeadlessProductLinkExtractor( renderer, - new HtmlSnapshotPipeline( - new StructuredDataExtractor(new ObjectMapper()), - new GeminiHtmlExtractor(stubGemini), - new SimpleMeterRegistry() - ) + structured, + new HtmlSnapshotPipeline(structured, new GeminiHtmlExtractor(stubGemini), new SimpleMeterRegistry()) ); } @Test - @DisplayName("렌더된 HTML 의 구조화 데이터로 LLM 없이 스냅샷을 만든다") - void structuredDataFromRenderedHtml() { - String html = ""; - HeadlessProductLinkExtractor extractor = extractorWith(l -> PageContent.of(l, html)); + @DisplayName("서버 본문이 셸이어도 같은 홉의 dom 에 구조화 데이터가 있으면 그것으로 LLM 없이 스냅샷을 만든다") + void domBeatsShellBody() { + HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of( + hop(link, 302, "", ""), + hop(mobile, 200, SHELL, product("가죽 벨트", 6_380)) + )); ProductSnapshot snapshot = extractor.extract(link, false, null); - assertEquals("렌더 상품", snapshot.name()); - assertEquals(209_000, snapshot.currentPrice()); + assertEquals("가죽 벨트", snapshot.name()); + assertEquals(6_380, snapshot.currentPrice()); + assertEquals(mobile, snapshot.finalUrl()); assertEquals(0, stubGemini.invocations()); } @Test - @DisplayName("렌더된 HTML 에 구조화 데이터가 없으면 같은 HTML 로 LLM fallback 을 탄다") - void llmFallbackOnRenderedHtml() { - stubGemini.build = request -> new GeminiExtractionResult(true, "엘엘엠 상품", 50_000, "KRW", "https://cdn.example.com/i.png"); - HeadlessProductLinkExtractor extractor = extractorWith(l -> PageContent.of( - l, - "" + "구조화 데이터 없이 렌더된 상품 상세 설명 텍스트. ".repeat(3) + "" + @DisplayName("홈 피드로 튕겨 나간 뒤여도 앞 홉에 남은 상품 문서를 찾는다 — 마지막 홉이 곧 상품이 아니다") + void productInEarlierHopWins() { + HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of( + hop(mobile, 200, SHELL, product("앞 홉 상품", 10_000)), + hop(feed, 200, "" + "추천 피드 ".repeat(100) + "", "" + "추천 피드 ".repeat(100) + "") )); ProductSnapshot snapshot = extractor.extract(link, false, null); + assertEquals("앞 홉 상품", snapshot.name()); + assertEquals(0, stubGemini.invocations()); + } + + @Test + @DisplayName("구조화 데이터가 어디에도 없으면 마지막 홉의 dom 한 장으로만 LLM fallback 을 탄다") + void llmFallbackUsesLastDomOnce() { + stubGemini.build = request -> new GeminiExtractionResult(true, "엘엘엠 상품", 50_000, "KRW", "https://cdn.example.com/i.png"); + String text = "" + "구조화 데이터 없이 렌더된 상품 상세 설명 텍스트. ".repeat(3) + ""; + HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of(hop(link, 200, text, text), hop(mobile, 200, text, text))); + + ProductSnapshot snapshot = extractor.extract(link, false, null); + assertEquals("엘엘엠 상품", snapshot.name()); + assertEquals(mobile, snapshot.finalUrl()); assertEquals(1, stubGemini.invocations()); } @Test - @DisplayName("렌더 결과까지 셸이면 LLM 호출 없이 NO_EXTRACTABLE_CONTENT 가 그대로 전파된다 — plain 과 달리 재분류가 없다") - void renderedShellFailsPermanentlyWithoutLlm() { - // 헤드리스는 마지막 수단이라 셸 재분류(escalation)가 없다 — 게이트의 확정 실패가 곧 최종 응답이 된다. - // 에이블리 mobile.* 환각 사고(렌더 후에도 가시 텍스트 0자)가 이 경로로 닫힌다. - HeadlessProductLinkExtractor extractor = extractorWith(l -> PageContent.of( - l, - "
" - )); + @DisplayName("dom 까지 셸이면 LLM 호출 없이 NO_EXTRACTABLE_CONTENT 로 닫는다 — 헤드리스는 마지막 수단이라 재분류가 없다") + void shellEverywhereFailsWithoutLlm() { + HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of(hop(mobile, 200, SHELL, SHELL))); - ProductSnapshotException e = assertThrows( - ProductSnapshotException.class, - () -> extractor.extract(link, false, null) - ); + ProductSnapshotException e = assertThrows(ProductSnapshotException.class, () -> extractor.extract(link, false, null)); assertEquals(ExtractionErrorCode.NO_EXTRACTABLE_CONTENT, e.code()); assertEquals(0, stubGemini.invocations()); } @Test - @DisplayName("렌더 실패는 그대로 전파된다 — 계약 번역은 렌더러가 이미 끝냈다") + @DisplayName("모든 홉이 차단 status 거나 챌린지 title 이면 일시 실패(HEADLESS_BLOCKED)다") + void allHopsBlockedIsTransient() { + for (List hops : List.of( + List.of(hop(link, 403, product("차단 뒤에 숨은 상품", 1), product("차단 뒤에 숨은 상품", 1))), + List.of(hop(link, 200, CHALLENGE, CHALLENGE), hop(mobile, 429, "", "")) + )) { + HeadlessProductLinkExtractor extractor = extractorWith(l -> hops); + + HeadlessRenderException e = assertThrows(HeadlessRenderException.class, () -> extractor.extract(link, false, null)); + + assertEquals(ExtractionErrorCode.HEADLESS_BLOCKED, e.code()); + assertFalse(e.permanent()); + } + } + + @Test + @DisplayName("챌린지 홉을 지나 실제 상품 홉에 도달했으면 차단이 아니다") + void challengeThenProductIsNotBlocked() { + HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of( + hop(link, 403, CHALLENGE, CHALLENGE), + hop(mobile, 200, "", product("통과한 상품", 2_000)) + )); + + assertEquals("통과한 상품", extractor.extract(link, false, null).name()); + } + + @Test + @DisplayName("홉은 있는데 HTML 이 전부 비어 있으면 일시 실패(HEADLESS_UPSTREAM)다") + void hopsWithoutHtmlAreTransient() { + HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of(hop(link, 302, "", ""), hop(mobile, 200, "", " "))); + + HeadlessRenderException e = assertThrows(HeadlessRenderException.class, () -> extractor.extract(link, false, null)); + + assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, e.code()); + } + + @Test + @DisplayName("렌더 실패는 그대로 전파된다") void renderFailurePropagates() { HeadlessProductLinkExtractor extractor = extractorWith(l -> { - throw HeadlessRenderException.blocked(); + throw HeadlessRenderException.upstream("boom", null); }); assertThrows(HeadlessRenderException.class, () -> extractor.extract(link, false, null)); diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java index 3f80f42..050aa4e 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java @@ -1,7 +1,6 @@ package com.depromeet.piki.extractor.extraction.headless; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -14,7 +13,6 @@ import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode; import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.extraction.HeadlessExtractionProperties; -import com.depromeet.piki.extractor.extraction.PageContent; import com.depromeet.piki.extractor.extraction.http.PageFetchException; import com.depromeet.piki.extractor.extraction.http.RequestScopedDnsResolver; import com.github.luben.zstd.ZstdOutputStream; @@ -23,10 +21,9 @@ import java.io.UncheckedIOException; import java.net.InetAddress; import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.function.Consumer; -import org.jsoup.nodes.Document; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; @@ -37,16 +34,17 @@ import tools.jackson.databind.ObjectMapper; /** - * POST /render 의 wire 계약(요청 필드·verdict 번역·SSRF 가드·final_url 폴백·html 상한·zstd 해제)을 네트워크 - * 없이 검증한다. - * - *

verdict 를 계약으로 번역하는 것이 렌더러의 단일 책임이라, 소비자(HeadlessProductLinkExtractor)는 여기서 - * 통과하지 못한 PageContent 를 절대 받지 않는다. DNS 는 가짜 공인 IP 로 주입해 SSRF 가드를 통과시킨다 - * (HttpPageFetcher 테스트와 같은 방식). + * POST /render 의 wire 계약(요청 필드·홉 변환·SSRF 가드·홉 url 폴백·zstd 해제)을 네트워크 없이 검증한다. + * 홉의 해석은 HeadlessProductLinkExtractorTest 가 진다. DNS 는 가짜 공인 IP 로 주입해 SSRF 가드를 통과시킨다. */ class HttpHeadlessRendererTest { private static final String BASE_URL = "http://headless.test:8000"; + private static final String TWO_HOPS = + "{\"proxied\":true,\"hops\":[" + + "{\"url\":\"https://kream.co.kr/products/6963\",\"status\":302,\"headers\":{\"location\":\"/p\"}}," + + "{\"url\":\"https://kream.co.kr/p\",\"status\":200,\"headers\":{\"content-type\":\"text/html\"}," + + "\"body\":\"ssr\",\"dom\":\"dom\"}]}"; private final ProductLink link = ProductLink.parse("https://kream.co.kr/products/6963"); @@ -54,7 +52,6 @@ class HttpHeadlessRendererTest { host -> new InetAddress[] {InetAddress.getByName("93.184.216.34")}; private HttpHeadlessRenderer rendererWith( - HeadlessExtractionProperties properties, RequestScopedDnsResolver.HostResolver hostResolver, ZstdDictionaries dictionaries, Consumer configure @@ -64,7 +61,7 @@ private HttpHeadlessRenderer rendererWith( configure.accept(server); return new HttpHeadlessRenderer( builder.build(), - properties, + HeadlessExtractionProperties.of(true), new RequestScopedDnsResolver(hostResolver), new ObjectMapper(), dictionaries @@ -72,71 +69,37 @@ private HttpHeadlessRenderer rendererWith( } private HttpHeadlessRenderer rendererWith(Consumer configure) { - return rendererWith(HeadlessExtractionProperties.of(true), publicIp, ZstdDictionaries.none(), configure); + return rendererWith(publicIp, ZstdDictionaries.none(), configure); } @Test - @DisplayName("OK 렌더는 url·include_html=true·compress=true 로 요청하고, html 과 final_url 기준 PageContent 를 돌려준다") - void okRenderReturnsPageContent() { - String html = "rendered"; - // X-Encoding 헤더 없는 plain JSON 응답 — compress 필드를 모르는 구버전 renderer 호환 경로이기도 하다. + @DisplayName("url·compress=true 로 요청하고, 홉을 순서·status·headers·body·dom 그대로 옮긴다") + void hopsAreMappedInOrder() { HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) .andExpect(method(HttpMethod.POST)) .andExpect(jsonPath("$.url").value(link.value().toString())) - // 파싱(구조화/LLM)은 우리가 하므로 렌더된 HTML 을 항상 요구한다. - .andExpect(jsonPath("$.include_html").value(true)) - // 서버간 전송량 절감을 위해 응답 zstd 압축을 요청한다. .andExpect(jsonPath("$.compress").value(true)) - .andRespond(withSuccess( - "{\"verdict\":\"OK\",\"proxied\":true,\"status\":200," - + "\"final_url\":\"https://kream.co.kr/products/6963?after-redirect\",\"html\":\"" + html.replace("\"", "\\\"") + "\"}", - MediaType.APPLICATION_JSON - ))); - - PageContent page = renderer.render(link, false); + .andRespond(withSuccess(TWO_HOPS, MediaType.APPLICATION_JSON))); - assertEquals("rendered", page.document().text()); - // 정체성(원본 link)은 유지하고, baseUri 용 finalUrl 은 렌더가 따라간 최종 URL 을 쓴다. - assertEquals(link, page.link()); - assertEquals("https://kream.co.kr/products/6963?after-redirect", page.finalUrl().value().toString()); - } - - @Test - @DisplayName("BLOCK 아닌 어떤 verdict 든(구계약 잔재 PARTIAL·미지 포함) html 이 있으면 진행한다 — verdict 로 HTML 을 버리면 recall 을 잃는다") - void anyNonBlockVerdictWithHtmlProceeds() { - for (String verdict : List.of("PARTIAL", "EMPTY", "SOMETHING_NEW")) { - HttpHeadlessRenderer renderer = rendererWith(server -> server - .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess( - "{\"verdict\":\"" + verdict + "\",\"html\":\"rendered dom\"}", - MediaType.APPLICATION_JSON - ))); + List hops = renderer.render(link, false); - assertEquals("rendered dom", renderer.render(link, false).document().text(), "verdict=" + verdict); - } + assertEquals(2, hops.size()); + assertEquals(new RenderedHop(link, 302, Map.of("location", "/p"), "", ""), hops.getFirst()); + RenderedHop last = hops.getLast(); + assertEquals("https://kream.co.kr/p", last.url().value().toString()); + assertEquals(200, last.status()); + assertEquals("ssr", last.body()); + assertEquals("dom", last.dom()); } @Test - @DisplayName("BLOCK 은 일시 실패(HEADLESS_BLOCKED)다 — 렌더 서비스 BLOCK 판정에 429·일시 챌린지가 섞여 fail-safe 로 일시") - void blockIsTransient() { - HttpHeadlessRenderer renderer = rendererWith(server -> server - .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess("{\"verdict\":\"BLOCK\",\"status\":429}", MediaType.APPLICATION_JSON))); - - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); - - assertEquals(ExtractionErrorCode.HEADLESS_BLOCKED, ex.code()); - assertFalse(ex.permanent()); - } - - @Test - @DisplayName("html 이 없으면(브라우저 오류 ERROR·빈 렌더 EMPTY) 일시 실패(HEADLESS_UPSTREAM)다") - void missingHtmlIsTransient() { + @DisplayName("홉이 없으면(브라우저 오류·빈 응답) 일시 실패(HEADLESS_UPSTREAM)다") + void noHopsIsTransient() { for (String body : List.of( - "{\"verdict\":\"ERROR\",\"error\":\"TimeoutError: boom\"}", - "{\"verdict\":\"OK\"}", - "{\"verdict\":\"EMPTY\",\"html\":\"\"}" + "{\"error\":\"TimeoutError: boom\",\"hops\":[]}", + "{\"error\":\"TimeoutError: boom\"}", + "{}" )) { HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) @@ -149,73 +112,76 @@ void missingHtmlIsTransient() { } } + @Test + @DisplayName("홉 계약 이전 renderer 의 html·final_url 응답은 홉 하나로 읽는다 — renderer 배포가 수동이라 뒤처질 수 있다") + void legacySingleHtmlBecomesOneHop() { + HttpHeadlessRenderer renderer = rendererWith(server -> server + .expect(requestTo(BASE_URL + "/render")) + .andRespond(withSuccess( + "{\"verdict\":\"OK\",\"status\":200,\"final_url\":\"https://kream.co.kr/p\",\"html\":\"legacy\"}", + MediaType.APPLICATION_JSON + ))); + + List hops = renderer.render(link, false); + + assertEquals(1, hops.size()); + assertEquals("https://kream.co.kr/p", hops.getFirst().url().value().toString()); + assertEquals(200, hops.getFirst().status()); + assertEquals("legacy", hops.getFirst().dom()); + } + @Test @DisplayName("zstd 압축 응답(X-Encoding: zstd, 사전 없음)은 해제해 plain JSON 과 같은 계약으로 처리한다") void zstdResponseIsDecompressed() { - byte[] packed = zstdCompress("{\"verdict\":\"OK\",\"html\":\"compressed dom\"}", null); HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess(packed, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("")))); + .andRespond(withSuccess(zstdCompress(TWO_HOPS, null), MediaType.APPLICATION_OCTET_STREAM) + .headers(zstdHeaders("")))); - assertEquals("compressed dom", renderer.render(link, false).document().text()); + assertEquals("dom", renderer.render(link, false).getLast().dom()); } @Test @DisplayName("사전 압축 응답(X-Zstd-Dict: 사전ID)은 보유한 같은 사전으로 해제한다") void zstdDictResponseUsesSharedDictionary() { byte[] dict = "공용 boilerplate".getBytes(StandardCharsets.UTF_8); - byte[] packed = zstdCompress("{\"verdict\":\"OK\",\"html\":\"dict compressed\"}", dict); - HttpHeadlessRenderer renderer = rendererWith( - HeadlessExtractionProperties.of(true), - publicIp, - ZstdDictionaries.of("mall-v1.dict", dict), - server -> server - .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess(packed, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("mall-v1.dict"))) - ); + HttpHeadlessRenderer renderer = rendererWith(publicIp, ZstdDictionaries.of("mall-v1.dict", dict), server -> server + .expect(requestTo(BASE_URL + "/render")) + .andRespond(withSuccess(zstdCompress(TWO_HOPS, dict), MediaType.APPLICATION_OCTET_STREAM) + .headers(zstdHeaders("mall-v1.dict")))); - assertEquals("dict compressed", renderer.render(link, false).document().text()); + assertEquals("dom", renderer.render(link, false).getLast().dom()); } @Test @DisplayName("미보유 사전ID 는 일시 실패(HEADLESS_UPSTREAM)다 — 사전은 extractor 에 먼저 배포하는 롤아웃 규약 위반 신호") void unknownZstdDictIsTransient() { - byte[] packed = zstdCompress("{\"verdict\":\"OK\",\"html\":\"x\"}", null); HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess(packed, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("future-v2.dict")))); + .andRespond(withSuccess(zstdCompress(TWO_HOPS, null), MediaType.APPLICATION_OCTET_STREAM) + .headers(zstdHeaders("future-v2.dict")))); HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); - assertFalse(ex.permanent()); } @Test - @DisplayName("zstd 해제 실패(손상 바이트)는 일시 실패(HEADLESS_UPSTREAM)다") - void corruptZstdIsTransient() { - byte[] garbage = {1, 2, 3, 4, 5}; - HttpHeadlessRenderer renderer = rendererWith(server -> server - .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess(garbage, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("")))); - - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); - - assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); - assertFalse(ex.permanent()); - } - - @Test - @DisplayName("JSON 이 아닌 응답 body 는 일시 실패(HEADLESS_UPSTREAM)다") - void nonJsonBodyIsTransient() { - HttpHeadlessRenderer renderer = rendererWith(server -> server - .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess("not-json", MediaType.TEXT_PLAIN))); + @DisplayName("zstd 해제 실패(손상 바이트)·JSON 아닌 body 는 일시 실패(HEADLESS_UPSTREAM)다") + void undecodableBodyIsTransient() { + for (Consumer configure : List.>of( + server -> server.expect(requestTo(BASE_URL + "/render")) + .andRespond(withSuccess(new byte[] {1, 2, 3, 4, 5}, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders(""))), + server -> server.expect(requestTo(BASE_URL + "/render")) + .andRespond(withSuccess("not-json", MediaType.TEXT_PLAIN)) + )) { + HttpHeadlessRenderer renderer = rendererWith(configure); - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); - assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); - assertFalse(ex.permanent()); + assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); + assertFalse(ex.permanent()); + } } @Test @@ -224,8 +190,7 @@ void internalHostIsBlockedBeforeRender() { RequestScopedDnsResolver.HostResolver internalIp = host -> new InetAddress[] {InetAddress.getByName("169.254.169.254")}; // 서버에 expect 를 하나도 걸지 않는다 — 가드가 먼저 던지므로 렌더 서비스로 요청이 나가면 안 된다. - HttpHeadlessRenderer renderer = - rendererWith(HeadlessExtractionProperties.of(true), internalIp, ZstdDictionaries.none(), server -> { }); + HttpHeadlessRenderer renderer = rendererWith(internalIp, ZstdDictionaries.none(), server -> { }); PageFetchException ex = assertThrows(PageFetchException.class, () -> renderer.render(link, false)); @@ -234,37 +199,34 @@ void internalHostIsBlockedBeforeRender() { } @Test - @DisplayName("final_url 이 없거나 우리 형식이 아니면 원본 link 로 폴백한다 — 렌더 성공을 실패로 오판하지 않는다") - void invalidFinalUrlFallsBackToLink() { - for (String finalUrlField : List.of("", ",\"final_url\":\"chrome-error://failed\"")) { + @DisplayName("홉 url 이 없거나 우리 형식이 아니면 원본 link 로 폴백한다 — 렌더 성공을 실패로 오판하지 않는다") + void invalidHopUrlFallsBackToLink() { + for (String urlField : List.of("", "\"url\":\"chrome-error://failed\",")) { HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess( - "{\"verdict\":\"OK\",\"html\":\"ok\"" + finalUrlField + "}", + "{\"hops\":[{" + urlField + "\"status\":200,\"dom\":\"ok\"}]}", MediaType.APPLICATION_JSON ))); - assertEquals(link, renderer.render(link, false).finalUrl()); + assertEquals(link, renderer.render(link, false).getFirst().url()); } } @Test - @DisplayName("final_url 이 내부망으로 resolve 되면 렌더 전체를 거부한다 — 원본만 검증하면 redirect 로 가드를 우회한다") - void internalFinalUrlIsBlocked() { - // 원본 host 는 공인 IP, 렌더 서비스가 따라간 최종 host 만 내부망인 상황 — 정적 fetch 가 매 hop 을 - // 검증하는 것과 달리 여기엔 검증이 없어, 내부망 응답이 상품 HTML 로 흘러들 수 있었다. + @DisplayName("어느 홉이든 내부망으로 resolve 되면 렌더 전체를 거부한다 — 원본만 검증하면 redirect 로 가드를 우회한다") + void internalHopIsBlocked() { RequestScopedDnsResolver.HostResolver byHost = host -> "metadata.internal".equals(host) ? new InetAddress[] {InetAddress.getByName("169.254.169.254")} : new InetAddress[] {InetAddress.getByName("93.184.216.34")}; - - HttpHeadlessRenderer renderer = rendererWith( - HeadlessExtractionProperties.of(true), byHost, ZstdDictionaries.none(), server -> server - .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess( - "{\"verdict\":\"OK\",\"html\":\"ok\"," - + "\"final_url\":\"https://metadata.internal/latest/meta-data/\"}", - MediaType.APPLICATION_JSON - ))); + HttpHeadlessRenderer renderer = rendererWith(byHost, ZstdDictionaries.none(), server -> server + .expect(requestTo(BASE_URL + "/render")) + .andRespond(withSuccess( + "{\"hops\":[" + + "{\"url\":\"https://metadata.internal/latest/meta-data/\",\"status\":200,\"dom\":\"secret\"}," + + "{\"url\":\"https://kream.co.kr/p\",\"status\":200,\"dom\":\"ok\"}]}", + MediaType.APPLICATION_JSON + ))); PageFetchException ex = assertThrows(PageFetchException.class, () -> renderer.render(link, false)); @@ -284,24 +246,6 @@ void renderServiceErrorIsTransient() { assertFalse(ex.permanent()); } - @Test - @DisplayName("렌더된 HTML 도 정적 fetch 와 같은 가지치기를 통과한다 - 두 전략이 하류에 넘기는 문서 모양이 갈리면 안 된다") - void renderedHtmlIsPruned() { - HttpHeadlessRenderer renderer = rendererWith(server -> server - .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess( - "{\"verdict\":\"OK\",\"html\":\"" - + "

운동화

\"}", - MediaType.APPLICATION_JSON - ))); - - Document document = renderer.render(link, false).document(); - - assertNull(document.selectFirst("style"), "style 은 버려져야 한다"); - assertNull(document.selectFirst("script"), "데이터 아닌 script 는 버려져야 한다"); - assertEquals("운동화", document.text(), "본문은 남아야 한다"); - } - @Test @DisplayName("렌더 error 원문의 URL(쿼리스트링 포함)은 마스킹된다 — playwright 예외가 대상 URL 을 통째로 싣는다") void errorUrlsAreMasked() { diff --git a/src/test/java/com/depromeet/piki/extractor/support/StubHeadlessRenderer.java b/src/test/java/com/depromeet/piki/extractor/support/StubHeadlessRenderer.java index 280e303..9838628 100644 --- a/src/test/java/com/depromeet/piki/extractor/support/StubHeadlessRenderer.java +++ b/src/test/java/com/depromeet/piki/extractor/support/StubHeadlessRenderer.java @@ -1,8 +1,9 @@ package com.depromeet.piki.extractor.support; import com.depromeet.piki.extractor.domain.ProductLink; -import com.depromeet.piki.extractor.extraction.PageContent; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderer; +import com.depromeet.piki.extractor.extraction.headless.RenderedHop; +import java.util.List; import java.util.function.Function; /** @@ -15,14 +16,14 @@ */ public class StubHeadlessRenderer implements HeadlessRenderer { - public Function build = link -> { + public Function> build = link -> { throw new IllegalStateException("stub.build 를 테스트 본문에서 명시 세팅해야 한다. CLAUDE.md '테스트' 절 참고."); }; public Boolean lastAuthorized; @Override - public PageContent render(ProductLink link, boolean authorized) { + public List render(ProductLink link, boolean authorized) { lastAuthorized = authorized; return build.apply(link); } From 30c05cbaad4f9efa3dc7b3f91bed8064b16a8f98 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: Tue, 8 Sep 2026 20:28:32 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=ED=99=89=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A7=80=EC=97=B0=20=ED=8C=8C=EC=8B=B1=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=B0=A8=EB=8B=A8=20=EC=8B=A0=ED=98=B8=EB=A5=BC=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EC=95=84=EB=8B=8C=20=EC=8B=A4=ED=8C=A8=20=EB=B6=84=EB=A5=98?= =?UTF-8?q?=EA=B8=B0=EB=A1=9C=20=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 후보 순서를 "모든 홉의 dom 을 마지막부터, 그다음 모든 홉의 body" 로 바꾼다. 앞 홉 dom 이 마지막 홉 body 보다 먼저여야 홈 피드 body 의 사이트 공통 JSON-LD 에 밀리지 않는다. body 가 dom 과 같으면 건너뛴다 - 모든 후보를 미리 파싱해 리스트로 들던 것을 루프 안 지연 파싱으로 바꾼다. 구조화 데이터가 잡히면 즉시 끝내고, 그 결과를 파이프라인 오버로드로 넘겨 같은 Document 를 두 번 파싱하지 않는다 - 차단 status 뒤에 실린 온전한 구조화 데이터는 그대로 쓴다(봇 방어는 어떤 status 로도 위장한다). 차단 신호는 LLM 후보에서만 빼고, 아무것도 못 뽑았을 때 HEADLESS_BLOCKED/UPSTREAM 을 가르는 데만 쓴다. 본문 없는 403 도 홉 단위로 차단으로 센다 - LLM fallback 은 마지막 홉 dom 이 아니라 LLM 에 넘길 것이 있는 첫 후보다. 마지막 홉이 앱 유도 셸이고 앞 홉에 텍스트가 있으면 앞 홉으로 간다 - 차단 신호에 cf-mitigated: challenge 헤더와 Cloudflare 계열 title 마커를 더한다. headers 필드가 이제 읽힌다. HeadlessBlockSignal 은 형제 분류기(EmptyShellDetector·LlmInputGate)와 같은 extraction 패키지의 package-private 으로 옮긴다 - 구계약 호환: verdict=BLOCK 은 html 이 실려 와도 HEADLESS_BLOCKED, status 만 있는 응답도 홉 하나로 읽어 차단 분류가 된다. 제거 조건(renderer #34 배포)을 주석에 남긴다 - Locale.ROOT, URL 마스킹 Pattern 상수화, 성공 로그의 항상 null 인 error 제거 --- .../common/exception/ExtractionErrorCode.java | 2 +- .../extraction/HeadlessBlockSignal.java | 36 +++++++++ .../HeadlessProductLinkExtractor.java | 79 +++++++++++++------ .../extraction/HtmlSnapshotPipeline.java | 6 +- .../headless/HeadlessBlockSignal.java | 30 ------- .../headless/HeadlessRenderException.java | 2 +- .../headless/HeadlessRenderResponse.java | 14 +++- .../headless/HttpHeadlessRenderer.java | 14 ++-- .../HeadlessProductLinkExtractorTest.java | 44 ++++++----- .../headless/HttpHeadlessRendererTest.java | 17 ++++ 10 files changed, 160 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/depromeet/piki/extractor/extraction/HeadlessBlockSignal.java delete mode 100644 src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessBlockSignal.java diff --git a/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java b/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java index f9fd47f..50f7611 100644 --- a/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java +++ b/src/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.java @@ -33,7 +33,7 @@ public enum ExtractionErrorCode { IMAGE_UNSUPPORTED, STORAGE_ERROR, - /** 실제 브라우저로도 차단(verdict=BLOCK). 일시 챌린지(429 등)가 섞이므로 fail-safe 로 일시 취급한다. */ + /** 실제 브라우저로도 모든 홉이 차단 신호. 일시 챌린지(429 등)가 섞이므로 fail-safe 로 일시 취급한다. */ HEADLESS_BLOCKED, HEADLESS_UPSTREAM, diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessBlockSignal.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessBlockSignal.java new file mode 100644 index 0000000..b99a88c --- /dev/null +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessBlockSignal.java @@ -0,0 +1,36 @@ +package com.depromeet.piki.extractor.extraction; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.jsoup.nodes.Document; + +/** + * 렌더된 홉이 차단·챌린지 페이지인가. 실제 브라우저도 프록시 출구 평판이나 지문에 걸리면 챌린지 페이지를 받는다. + * 후보를 거르는 게이트가 아니라, 아무것도 못 뽑았을 때 실패 코드를 가르는 분류기다 — 봇 방어는 어떤 status 로도 + * 위장하므로 status 하나로 recall 을 버리지 않는다. + */ +final class HeadlessBlockSignal { + + /** 490 은 네이버 캡차 커스텀 코드. */ + private static final Set BLOCK_STATUSES = Set.of(401, 403, 405, 429, 490); + private static final String CLOUDFLARE_HEADER = "cf-mitigated"; + private static final List CHALLENGE_TITLE_MARKERS = List.of( + "잠시만 기다", "보안 확인", "access denied", "pardon our", "are you a robot", "잠시 후 다시", + "캡차", "captcha", "시스템오류", "just a moment", "checking your browser", "attention required" + ); + + private HeadlessBlockSignal() { + } + + /** 문서 없이도 알 수 있는 신호 — 본문 없는 403·429 도 차단으로 센다. */ + static boolean isBlocked(int status, Map headers) { + return BLOCK_STATUSES.contains(status) || "challenge".equalsIgnoreCase(headers.get(CLOUDFLARE_HEADER)); + } + + static boolean isChallenge(Document document) { + String title = document.title().toLowerCase(Locale.ROOT); + return CHALLENGE_TITLE_MARKERS.stream().anyMatch(title::contains); + } +} 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 87b7e1a..965703a 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java @@ -2,7 +2,6 @@ import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.domain.ProductSnapshot; -import com.depromeet.piki.extractor.extraction.headless.HeadlessBlockSignal; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderException; import com.depromeet.piki.extractor.extraction.headless.HeadlessRenderer; import com.depromeet.piki.extractor.extraction.headless.RenderedHop; @@ -18,10 +17,14 @@ * 차단 우회 헤드리스 추출 전략. 실제 브라우저를 띄우는 별도 서비스(renderer)가 거쳐 간 홉 전부를 받아, * 그중 어느 문서를 파이프라인에 태울지 여기서 정한다 — renderer 는 판단하지 않는다. * - *

후보는 마지막 홉부터 거슬러 dom → body 순이다. 상품 페이지가 홈 피드·앱 유도로 튕겨 나간 경우 상품은 앞 홉에 - * 있고, 같은 홉 안에서는 하이드레이션된 dom 이 서버 원문보다 알차다(에이블리는 body 가 3KB 셸이고 상품은 dom 에만 - * 있다). 구조화 데이터가 잡히는 첫 후보를 쓰고, 없으면 마지막 홉의 dom 을 그대로 파이프라인(셸 게이트 → LLM)에 - * 넘긴다 — LLM 은 한 번만 부른다. + *

후보 순서는 모든 홉의 dom 을 마지막 홉부터, 그다음 모든 홉의 body 를 마지막 홉부터다. 상품 페이지가 홈 피드로 + * 튕겨 나간 경우 상품은 앞 홉에 있고, 하이드레이션된 dom 이 어느 홉의 서버 원문보다 알차다(에이블리는 body 가 3KB + * 셸이고 상품은 dom 에만 있다). 파싱은 후보를 볼 때 하고 구조화 데이터가 잡히면 즉시 끝낸다. 없으면 LLM 에 넘길 + * 것이 있는 첫 후보 한 장으로 기존 파이프라인(셸 게이트 → LLM)을 탄다 — LLM 은 한 번만 부른다. + * + *

차단 신호는 후보를 거르지 않는다. 403 뒤에 온전한 상품 JSON-LD 가 실려 오기도 하므로(봇 방어의 위장 status) + * 구조화 데이터는 그대로 쓰고, 차단으로 보이는 문서는 LLM 후보에서만 뺀다. 아무것도 못 뽑았을 때 실패 코드를 + * 차단과 장애로 가르는 데만 쓴다. */ @Slf4j @RequiredArgsConstructor @@ -32,36 +35,60 @@ public class HeadlessProductLinkExtractor implements LinkExtractionStrategy { private final StructuredDataExtractor structuredDataExtractor; private final HtmlSnapshotPipeline htmlSnapshotPipeline; + private record Candidate(RenderedHop hop, String html) { + } + + private record Chosen(PageContent page, StructuredExtraction result) { + } + @Override public ProductSnapshot extract(ProductLink link, boolean authorized, String model) { long renderStart = System.nanoTime(); List hops = headlessRenderer.render(link, authorized); long renderMs = (System.nanoTime() - renderStart) / 1_000_000; + String timing = "render=" + renderMs + "ms hops=" + hops.size(); - List candidates = new ArrayList<>(); - boolean blocked = false; - for (RenderedHop hop : hops.reversed()) { - for (String html : List.of(hop.dom(), hop.body())) { - if (html.isBlank()) { - continue; - } - PageContent page = PageContent.of(link, html, hop.url()); - if (HeadlessBlockSignal.isBlocked(hop.status(), page.document())) { - blocked = true; - continue; - } - candidates.add(page); + boolean blocked = hops.stream().anyMatch(hop -> HeadlessBlockSignal.isBlocked(hop.status(), hop.headers())); + Chosen first = null; // 차단 신호 없는 첫 후보 — 전부 셸이면 이걸로 게이트가 확정 실패를 닫는다 + Chosen fallback = null; // 그중 LLM 에 넘길 것이 있는 첫 후보 + for (Candidate candidate : candidates(hops)) { + PageContent page = PageContent.of(link, candidate.html(), candidate.hop().url()); + StructuredExtraction result = structuredDataExtractor.extract(page); + if (result instanceof StructuredExtraction.Extracted) { + return htmlSnapshotPipeline.extract(page, result, timing, model); + } + boolean challenge = HeadlessBlockSignal.isChallenge(page.document()); + blocked |= challenge; + if (challenge || HeadlessBlockSignal.isBlocked(candidate.hop().status(), candidate.hop().headers())) { + continue; + } + if (first == null) { + first = new Chosen(page, result); + } + if (fallback == null && !LlmInputGate.hasNothingForLlm(page.document())) { + fallback = new Chosen(page, result); } } - if (candidates.isEmpty()) { - log.warn("headless hops unusable blocked={} hops={} url={}", blocked, hops.size(), link.safeLogString()); - throw blocked ? HeadlessRenderException.blocked() : HeadlessRenderException.upstream("렌더 HTML 이 없다", null); + Chosen chosen = fallback != null ? fallback : first; + if (chosen != null) { + return htmlSnapshotPipeline.extract(chosen.page(), chosen.result(), timing, model); } + log.warn("headless hops unusable blocked={} hops={} url={}", blocked, hops.size(), link.safeLogString()); + throw blocked ? HeadlessRenderException.blocked() : HeadlessRenderException.upstream("렌더 HTML 이 없다", null); + } - PageContent chosen = candidates.stream() - .filter(page -> structuredDataExtractor.extract(page) instanceof StructuredExtraction.Extracted) - .findFirst() - .orElse(candidates.getFirst()); - return htmlSnapshotPipeline.extract(chosen, "render=" + renderMs + "ms hops=" + hops.size(), model); + private static List candidates(List hops) { + List candidates = new ArrayList<>(); + for (RenderedHop hop : hops.reversed()) { + if (!hop.dom().isBlank()) { + candidates.add(new Candidate(hop, hop.dom())); + } + } + for (RenderedHop hop : hops.reversed()) { + if (!hop.body().isBlank() && !hop.body().equals(hop.dom())) { + candidates.add(new Candidate(hop, hop.body())); + } + } + return candidates; } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java b/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java index 8445ae8..2607b14 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java @@ -47,11 +47,15 @@ public class HtmlSnapshotPipeline { * 내려갈 때만 소비된다. */ public ProductSnapshot extract(PageContent page, String timing, String model) { + return extract(page, structuredDataExtractor.extract(page.document(), page.link()), timing, model); + } + + /** 호출 전략이 후보 선별에 쓴 구조화 결과를 그대로 받는다 — 같은 Document 를 두 번 파싱하지 않는다. */ + public ProductSnapshot extract(PageContent page, StructuredExtraction result, String timing, String model) { // 수신 단계가 이미 파싱해 둔 Document 를 구조화 파서·게이트·Gemini fallback 이 그대로 공유한다. // baseUri 는 html 의 출처인 최종 URL 기준 — redirect 를 따라갔으면 원본 link 와 host 가 다를 수 있다. Document document = page.document(); - StructuredExtraction result = structuredDataExtractor.extract(document, page.link()); // 게이트 판정은 sanitize(GeminiHtmlExtractor) 전이어야 한다 — sanitize 는 공유 Document 에서 script 를 // 제거하므로, 순서가 뒤집히면 데이터 script 존재 판정이 깨진다. boolean nothingForLlm = result instanceof StructuredExtraction.Miss && LlmInputGate.hasNothingForLlm(document); diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessBlockSignal.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessBlockSignal.java deleted file mode 100644 index 92a9d4f..0000000 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessBlockSignal.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.depromeet.piki.extractor.extraction.headless; - -import java.util.List; -import java.util.Set; -import org.jsoup.nodes.Document; - -/** - * 렌더된 홉이 차단·챌린지 페이지인가. 실제 브라우저도 프록시 출구 평판이나 지문에 걸리면 챌린지 페이지를 받으므로, - * 그 HTML 을 "내용 없는 상품 페이지" 로 오판하지 않기 위한 분류다. - */ -public final class HeadlessBlockSignal { - - /** 490 은 네이버 캡차 커스텀 코드. */ - private static final Set BLOCK_STATUSES = Set.of(401, 403, 405, 429, 490); - private static final List CHALLENGE_TITLE_MARKERS = List.of( - "잠시만 기다", "보안 확인", "access denied", "pardon our", - "are you a robot", "잠시 후 다시", "캡차", "captcha", "시스템오류" - ); - - private HeadlessBlockSignal() { - } - - public static boolean isBlocked(int status, Document document) { - if (BLOCK_STATUSES.contains(status)) { - return true; - } - String title = document.title().toLowerCase(); - return CHALLENGE_TITLE_MARKERS.stream().anyMatch(title::contains); - } -} diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderException.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderException.java index 65d193c..7860423 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderException.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderException.java @@ -11,7 +11,7 @@ private HeadlessRenderException(String message, ExtractionErrorCode code, boolea } /** - * 실제 브라우저로도 모든 홉이 차단됐다({@link HeadlessBlockSignal}). 차단 신호에 429·"잠시 후 다시" 같은 일시 + * 실제 브라우저로도 모든 홉이 차단 신호다(HeadlessProductLinkExtractor 의 분류). 차단 신호에 429·"잠시 후 다시" 같은 일시 * 신호가 섞여 영구/일시를 못 가르므로, fail-safe 원칙(분류 불가 실패는 일시)대로 일시 실패로 둔다. * 결정론적 차단의 재시도 낭비는 호출자의 bounded 재시도가 바운드한다(docs/api-contract.md). * diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderResponse.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderResponse.java index c3cfbea..453497a 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderResponse.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderResponse.java @@ -17,17 +17,27 @@ record HeadlessRenderResponse( Boolean proxied, String error, List hops, - /** 홉 계약 이전 renderer 의 단일 HTML. 그쪽 배포가 수동이라 뒤처지는 동안 홉 하나로 읽는다. */ + /** + * 홉 계약 이전 renderer 의 필드. 그쪽 배포가 수동이라 뒤처지는 동안 홉 하나로 읽는다. + * 제거 조건: renderer 홉 계약(#34) 배포 완료. + */ + String verdict, String html, @JsonProperty("final_url") String finalUrl, Integer status ) { + private static final String LEGACY_BLOCK = "BLOCK"; + + boolean legacyBlocked() { + return (hops == null || hops.isEmpty()) && LEGACY_BLOCK.equals(verdict); + } + List hopsOrLegacy() { if (hops != null && !hops.isEmpty()) { return hops; } - if (html == null || html.isBlank()) { + if (status == null && (html == null || html.isBlank())) { return List.of(); } return List.of(new Hop(finalUrl, status, Map.of(), "", html)); diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java index ea0284b..1c9741e 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.MediaType; @@ -44,6 +45,7 @@ public class HttpHeadlessRenderer implements HeadlessRenderer { private static final String ZSTD_ENCODING = "zstd"; /** 해제 결과(홉 전체의 JSON)의 안전 상한. 내부망이라도 해제 폭탄·오배선을 바운드하려 둔다. */ private static final int MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024; + private static final Pattern URL_PATTERN = Pattern.compile("https?://\\S+"); private final RestClient restClient; private final HeadlessExtractionProperties properties; @@ -79,17 +81,19 @@ public List render(ProductLink link, boolean authorized) { private List renderVerified(ProductLink link, boolean authorized) { HeadlessRenderResponse response = requestRender(link, authorized); + if (response.legacyBlocked()) { + throw HeadlessRenderException.blocked(); + } List hops = response.hopsOrLegacy(); if (hops.isEmpty()) { log.warn("headless render no hops error={} url={}", maskUrls(response.error()), link.safeLogString()); throw HeadlessRenderException.upstream("렌더 홉이 없다: " + maskUrls(response.error()), null); } log.info( - "headless render hops={} status={} proxied={} error={} url={}", + "headless render hops={} status={} proxied={} url={}", hops.size(), hops.getLast().status(), response.proxied(), - maskUrls(response.error()), link.safeLogString() ); return hops.stream().map(hop -> toRendered(hop, link)).toList(); @@ -98,8 +102,8 @@ private List renderVerified(ProductLink link, boolean authorized) { private RenderedHop toRendered(HeadlessRenderResponse.Hop hop, ProductLink link) { return new RenderedHop( resolveHopUrl(hop.url(), link), - hop.status() == null ? 0 : hop.status(), - hop.headers() == null ? Map.of() : hop.headers(), + Objects.requireNonNullElse(hop.status(), 0), + Objects.requireNonNullElse(hop.headers(), Map.of()), Objects.requireNonNullElse(hop.body(), ""), Objects.requireNonNullElse(hop.dom(), "") ); @@ -205,6 +209,6 @@ static String maskUrls(String error) { if (error == null) { return null; } - return error.replaceAll("https?://\\S+", ""); + return URL_PATTERN.matcher(error).replaceAll(""); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java index a5b2280..b79fd19 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java @@ -29,7 +29,8 @@ class HeadlessProductLinkExtractorTest { private static final String SHELL = "

"; - private static final String CHALLENGE = "보안 확인 중.." + "확인 ".repeat(200) + ""; + private static final String CHALLENGE = "보안 확인 중..확인 중"; + private static final String TEXT = "" + "구조화 데이터 없이 렌더된 상품 상세 설명 텍스트. ".repeat(3) + ""; private final ProductLink link = ProductLink.parse("https://m.a-bly.com/goods/1"); private final ProductLink mobile = ProductLink.parse("https://mobile.a-bly.com/goods/1"); @@ -75,30 +76,27 @@ void domBeatsShellBody() { } @Test - @DisplayName("홈 피드로 튕겨 나간 뒤여도 앞 홉에 남은 상품 문서를 찾는다 — 마지막 홉이 곧 상품이 아니다") - void productInEarlierHopWins() { + @DisplayName("앞 홉의 dom 이 마지막 홉의 body 보다 먼저다 — 홈 피드 body 의 사이트 공통 JSON-LD 에 밀리지 않는다") + void earlierDomBeatsLaterBody() { HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of( hop(mobile, 200, SHELL, product("앞 홉 상품", 10_000)), - hop(feed, 200, "" + "추천 피드 ".repeat(100) + "", "" + "추천 피드 ".repeat(100) + "") + hop(feed, 200, product("피드 대표 상품", 1), SHELL) )); - ProductSnapshot snapshot = extractor.extract(link, false, null); - - assertEquals("앞 홉 상품", snapshot.name()); - assertEquals(0, stubGemini.invocations()); + assertEquals("앞 홉 상품", extractor.extract(link, false, null).name()); } @Test - @DisplayName("구조화 데이터가 어디에도 없으면 마지막 홉의 dom 한 장으로만 LLM fallback 을 탄다") - void llmFallbackUsesLastDomOnce() { + @DisplayName("구조화 데이터가 어디에도 없으면 LLM 에 넘길 것이 있는 첫 후보 한 장으로만 LLM fallback 을 탄다") + void llmFallbackUsesFirstUsableCandidateOnce() { stubGemini.build = request -> new GeminiExtractionResult(true, "엘엘엠 상품", 50_000, "KRW", "https://cdn.example.com/i.png"); - String text = "" + "구조화 데이터 없이 렌더된 상품 상세 설명 텍스트. ".repeat(3) + ""; - HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of(hop(link, 200, text, text), hop(mobile, 200, text, text))); + // 마지막 홉은 셸(앱 유도), 상품 텍스트는 앞 홉에만 있다 — 셸을 LLM 에 넣어 확정 실패로 닫으면 안 된다. + HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of(hop(link, 200, TEXT, TEXT), hop(mobile, 200, SHELL, SHELL))); ProductSnapshot snapshot = extractor.extract(link, false, null); assertEquals("엘엘엠 상품", snapshot.name()); - assertEquals(mobile, snapshot.finalUrl()); + assertEquals(link, snapshot.finalUrl()); assertEquals(1, stubGemini.invocations()); } @@ -114,11 +112,20 @@ void shellEverywhereFailsWithoutLlm() { } @Test - @DisplayName("모든 홉이 차단 status 거나 챌린지 title 이면 일시 실패(HEADLESS_BLOCKED)다") - void allHopsBlockedIsTransient() { + @DisplayName("차단 status 뒤에 실린 온전한 구조화 데이터는 그대로 쓴다 — 봇 방어는 어떤 status 로도 위장한다") + void structuredDataBehindBlockStatusStillExtracts() { + HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of(hop(link, 403, product("위장 403 상품", 1_000), ""))); + + assertEquals("위장 403 상품", extractor.extract(link, false, null).name()); + } + + @Test + @DisplayName("차단 신호 홉만 있고 구조화 데이터가 없으면 일시 실패(HEADLESS_BLOCKED)다 — 본문 없는 403·챌린지 title·cf-mitigated 헤더") + void blockedWithoutStructuredDataIsTransient() { for (List hops : List.of( - List.of(hop(link, 403, product("차단 뒤에 숨은 상품", 1), product("차단 뒤에 숨은 상품", 1))), - List.of(hop(link, 200, CHALLENGE, CHALLENGE), hop(mobile, 429, "", "")) + List.of(hop(link, 403, "", "")), + List.of(hop(link, 200, CHALLENGE, CHALLENGE), hop(mobile, 429, TEXT, TEXT)), + List.of(new RenderedHop(link, 200, Map.of("cf-mitigated", "challenge"), TEXT, TEXT)) )) { HeadlessProductLinkExtractor extractor = extractorWith(l -> hops); @@ -126,6 +133,7 @@ void allHopsBlockedIsTransient() { assertEquals(ExtractionErrorCode.HEADLESS_BLOCKED, e.code()); assertFalse(e.permanent()); + assertEquals(0, stubGemini.invocations()); } } @@ -141,7 +149,7 @@ void challengeThenProductIsNotBlocked() { } @Test - @DisplayName("홉은 있는데 HTML 이 전부 비어 있으면 일시 실패(HEADLESS_UPSTREAM)다") + @DisplayName("홉은 있는데 HTML 이 전부 비어 있고 차단 신호도 없으면 일시 실패(HEADLESS_UPSTREAM)다") void hopsWithoutHtmlAreTransient() { HeadlessProductLinkExtractor extractor = extractorWith(l -> List.of(hop(link, 302, "", ""), hop(mobile, 200, "", " "))); diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java index 050aa4e..4d7a1d7 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java @@ -130,6 +130,23 @@ void legacySingleHtmlBecomesOneHop() { assertEquals("legacy", hops.getFirst().dom()); } + @Test + @DisplayName("구계약 renderer 의 verdict=BLOCK 은 html 이 실려 와도 일시 실패(HEADLESS_BLOCKED)다 — 챌린지 페이지를 내용으로 흘리지 않는다") + void legacyBlockVerdictIsTransient() { + for (String body : List.of( + "{\"verdict\":\"BLOCK\",\"status\":429}", + "{\"verdict\":\"BLOCK\",\"status\":200,\"html\":\"okchallenge\"}" + )) { + HttpHeadlessRenderer renderer = rendererWith(server -> server + .expect(requestTo(BASE_URL + "/render")) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON))); + + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); + + assertEquals(ExtractionErrorCode.HEADLESS_BLOCKED, ex.code()); + } + } + @Test @DisplayName("zstd 압축 응답(X-Encoding: zstd, 사전 없음)은 해제해 plain JSON 과 같은 계약으로 처리한다") void zstdResponseIsDecompressed() {