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 최대화:
- *
- * - BLOCK → {@link HeadlessRenderException#blocked()}. 일시 신호가 섞여 영구/일시를 가를 수 없어
- * fail-safe 로 일시에 두는 근거는 그 팩토리에 있다.
- * - 그 외 + html 있음 → 진행. verdict 는 렌더 서비스가 자기 정규식 파서로 title·price 를 찾았는지일 뿐이라,
- * EMPTY 여도 그 DOM 에서 우리 파이프라인(구조화·LLM)이 뽑아낼 수 있다 — verdict 로 HTML 을 버리면
- * 그 recall 을 잃는다.
- * - 그 외 + html 없음 → {@link HeadlessRenderException#upstream(String, Throwable)}. 빈 렌더·브라우저 오류.
- *
+ * 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() {