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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public enum ExtractionErrorCode {
IMAGE_UNSUPPORTED,
STORAGE_ERROR,

/** 실제 브라우저로도 차단(verdict=BLOCK). 일시 챌린지(429 등)가 섞이므로 fail-safe 로 일시 취급한다. */
/** 실제 브라우저로도 모든 홉이 차단 신호. 일시 챌린지(429 등)가 섞이므로 fail-safe 로 일시 취급한다. */
HEADLESS_BLOCKED,
HEADLESS_UPSTREAM,

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Integer> BLOCK_STATUSES = Set.of(401, 403, 405, 429, 490);
private static final String CLOUDFLARE_HEADER = "cf-mitigated";
private static final List<String> 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<String, String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,93 @@

import com.depromeet.piki.extractor.domain.ProductLink;
import com.depromeet.piki.extractor.domain.ProductSnapshot;
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 는 판단하지 않는다.
*
* <p>차단·빈 렌더·렌더 서비스 오류의 계약 번역은 {@code HeadlessRenderer} 구현이 책임진다.
* <p>후보 순서는 모든 홉의 dom 을 마지막 홉부터, 그다음 모든 홉의 body 를 마지막 홉부터다. 상품 페이지가 홈 피드로
* 튕겨 나간 경우 상품은 앞 홉에 있고, 하이드레이션된 dom 이 어느 홉의 서버 원문보다 알차다(에이블리는 body 가 3KB
* 셸이고 상품은 dom 에만 있다). 파싱은 후보를 볼 때 하고 구조화 데이터가 잡히면 즉시 끝낸다. 없으면 LLM 에 넘길
* 것이 있는 첫 후보 한 장으로 기존 파이프라인(셸 게이트 → LLM)을 탄다 — LLM 은 한 번만 부른다.
*
* <p>차단 신호는 후보를 거르지 않는다. 403 뒤에 온전한 상품 JSON-LD 가 실려 오기도 하므로(봇 방어의 위장 status)
* 구조화 데이터는 그대로 쓰고, 차단으로 보이는 문서는 LLM 후보에서만 뺀다. 아무것도 못 뽑았을 때 실패 코드를
* 차단과 장애로 가르는 데만 쓴다.
*/
@Slf4j
@RequiredArgsConstructor
@Component(LinkExtractionStrategy.HEADLESS)
public class HeadlessProductLinkExtractor implements LinkExtractionStrategy {

private final HeadlessRenderer headlessRenderer;
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();
PageContent page = headlessRenderer.render(link, authorized);
List<RenderedHop> hops = headlessRenderer.render(link, authorized);
long renderMs = (System.nanoTime() - renderStart) / 1_000_000;
String timing = "render=" + renderMs + "ms hops=" + hops.size();

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);
}
}
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);
}

return htmlSnapshotPipeline.extract(page, "render=" + renderMs + "ms", model);
private static List<Candidate> candidates(List<RenderedHop> hops) {
List<Candidate> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,25 @@ private HeadlessRenderException(String message, ExtractionErrorCode code, boolea
}

/**
* 실제 브라우저로도 차단(verdict=BLOCK). 렌더 서비스의 BLOCK 판정은 429·"잠시 후 다시" 챌린지 같은
* 일시 신호를 포함해 영구/일시를 못 가르므로, fail-safe 원칙(분류 불가 실패는 일시)대로 일시 실패로 둔다.
* 실제 브라우저로도 모든 홉이 차단 신호다(HeadlessProductLinkExtractor 의 분류). 차단 신호에 429·"잠시 후 다시" 같은 일시
* 신호가 섞여 영구/일시를 못 가르므로, fail-safe 원칙(분류 불가 실패는 일시)대로 일시 실패로 둔다.
* 결정론적 차단의 재시도 낭비는 호출자의 bounded 재시도가 바운드한다(docs/api-contract.md).
*
* <p>code 를 HEADLESS_UPSTREAM 과 분리해 두는 이유: "차단" 과 "렌더 서비스 장애" 는 관측·대응이 다르다
* (차단 추세 = UNSUPPORTED 정책 후보, 장애 추세 = 렌더 박스 점검).
*/
public static HeadlessRenderException blocked() {
return new HeadlessRenderException(
"헤드리스 렌더가 차단됐다(verdict=BLOCK) — 일시 챌린지가 섞여 있어 일시 실패로 분류한다.",
"헤드리스 렌더의 모든 홉이 차단 신호다 — 일시 챌린지가 섞여 있어 일시 실패로 분류한다.",
ExtractionErrorCode.HEADLESS_BLOCKED,
false,
null
);
}

/**
* 렌더 서비스 쪽 실패(연결·타임아웃·비-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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
package com.depromeet.piki.extractor.extraction.headless;

import com.fasterxml.jackson.annotation.JsonProperty;

/**
* POST /render 요청 wire 모델. 렌더 서비스(Python/FastAPI)의 snake_case 필드에 맞춘다.
*
* <p>include_html 을 켜는 이유: renderer 는 파싱하지 않으므로(HTML 렌더러) 파싱(구조화/LLM)은 우리가 렌더된
* HTML 로 직접 한다.
*
* <p>authorized 는 "이 대상이 허락을 받았는가" 다 — 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정·
* 프록시)을 연다. 이 필드를 모르는 구버전 renderer 는 무시하고 정직 모드로 돌므로, 배포 순서와 무관하게
* 안전한 쪽으로만 어긋난다.
* <p>authorized 는 "이 대상이 허락을 받았는가" 다 — 렌더 서비스는 이 값이 true 일 때만 우회 수단(프록시)을 연다.
* 이 필드를 모르는 구버전 renderer 는 무시하고 정직 모드로 돌므로, 배포 순서와 무관하게 안전한 쪽으로만 어긋난다.
*
* <p>compress 를 모르는 구버전 renderer 는 이 필드를 무시하고(pydantic 기본) plain JSON 을 준다 — 그래서
* 해제 판별을 요청이 아니라 응답 헤더로 두면(HttpHeadlessRenderer 참조) 켠 채로도 배포 순서와 무관하게 안전하다.
*/
record HeadlessRenderRequest(
String url,
boolean authorized,
@JsonProperty("include_html") boolean includeHtml,
boolean compress
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,54 @@

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 는 판단하지 않고 거쳐 간 홉을 그대로 돌려준다 — 어느 홉이 상품이고
* 차단인지는 우리가 정한다.
*
* <p>verdict 는 렌더 서비스의 판정 문자열이고 "가격 찾음" 이 아니라 "html 확보" 여부다(renderer 는 파싱하지
* 않는다). 계약으로의 번역 규칙은 HttpHeadlessRenderer 가 정본이다.
*
* <p>전 필드 nullable(박싱 타입) — 렌더 서비스의 예외 격리 경로는 platform·verdict·error 만 싣는다. primitive 를
* 쓰면 Jackson 3(FAIL_ON_NULL_FOR_PRIMITIVES 기본 on)가 필드 부재를 역직렬화 실패로 만들어, verdict 번역에
* 닿기도 전에 일시 실패로 오분류된다.
* <p>전 필드 nullable(박싱 타입) — 브라우저 예외 격리 경로는 error 만 싣는다. primitive 면 Jackson 3 가 필드 부재를
* 역직렬화 실패로 만들어 계약 번역에 닿기도 전에 일시 실패로 오분류된다.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
record HeadlessRenderResponse(
String verdict,
Boolean proxied,
Integer status,
@JsonProperty("final_url") String finalUrl,
String error,
List<Hop> hops,
/**
* 홉 계약 이전 renderer 의 필드. 그쪽 배포가 수동이라 뒤처지는 동안 홉 하나로 읽는다.
* 제거 조건: renderer 홉 계약(#34) 배포 완료.
*/
String verdict,
String html,
String error
@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<Hop> hopsOrLegacy() {
if (hops != null && !hops.isEmpty()) {
return hops;
}
if (status == null && (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<String, String> headers,
String body,
String dom
) {
}
}
Original file line number Diff line number Diff line change
@@ -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<RenderedHop> render(ProductLink link, boolean authorized);
}
Loading
Loading