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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
| `MALFORMED_REDIRECT` | `PageFetchException.malformedRedirect` |
| `PERMANENT_UPSTREAM` | `PageFetchException.permanentUpstreamError` — 대상 500/501 (봇 차단 추정) |
| `EMPTY_SHELL` | `PageFetchException.emptyShell` — fetch 는 2xx 지만 본문이 데이터 없는 CSR 셸(파싱 no-data 를 재분류). 헤드리스 에스컬레이션 대상이라, 헤드리스가 켜진 구성에선 헤드리스 결과가 대신 응답된다 |
| `NO_EXTRACTABLE_CONTENT` | `ProductSnapshotException.noExtractableContent` — 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정(빈 셸 환각 차단). plain 경로는 EMPTY_SHELL 재분류가 선행하므로 사실상 헤드리스 렌더 결과까지 셸일 때 나온다 |
| `LLM_INVALID_RESPONSE` | `GeminiApiException` clientError/parseError/noTextPart — 재시도 무의미한 LLM 실패 |
| `INVALID_URL` | url 형식·스킴 위반. 정상 흐름에선 호출자가 동기 검증해 도달하지 않는다(방어) |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ public enum ExtractionErrorCode {
PERMANENT_UPSTREAM,
/** fetch 는 2xx 였지만 본문이 데이터 없는 CSR 셸 — 파싱 no-data 를 escalatable 로 재분류한 것(EmptyShellDetector). */
EMPTY_SHELL,
/**
* 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정한 것(LlmInputGate) — 빈 입력의 LLM 은
* 실존하지 않는 상품을 지어낸다(환각). EMPTY_SHELL(일시, 에스컬레이션 대상)과 달리 확정이다 — plain 경로는
* 셸 재분류가 선행하므로, 이 code 는 사실상 헤드리스 렌더 결과까지 셸일 때 표면화된다.
*/
NO_EXTRACTABLE_CONTENT,
LLM_INVALID_RESPONSE,
INVALID_URL,
UPSTREAM_ERROR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode;
import com.depromeet.piki.extractor.common.exception.ExtractionException;

/** 둘 다 호출자가 재시도해도 얻을 것이 없는 사유라 확정 실패로 답한다. */
/** 전부 호출자가 재시도해도 얻을 것이 없는 사유라 확정 실패로 답한다. */
public final class ProductSnapshotException extends ExtractionException {

private ProductSnapshotException(String message, ExtractionErrorCode code) {
Expand All @@ -18,4 +18,13 @@ public static ProductSnapshotException notProductPage() {
public static ProductSnapshotException untrustworthyValue() {
return new ProductSnapshotException("상품 정보를 확인하지 못했어요.", ExtractionErrorCode.UNTRUSTWORTHY_VALUE);
}

/**
* LLM 에 넘겨도 지어낼 뿐인 빈 문서(LlmInputGate) — NOT_PRODUCT_PAGE 와 code 를 나누는 이유는 호출자
* 관측이다: "사용자가 상품 아닌 링크를 넣음"과 "몰을 우리가 못 읽음"이 한 code 로 섞이면 후자의 빈도를
* 추적할 수 없다.
*/
public static ProductSnapshotException noExtractableContent() {
return new ProductSnapshotException("상품 정보를 읽을 수 없는 페이지예요.", ExtractionErrorCode.NO_EXTRACTABLE_CONTENT);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.depromeet.piki.extractor.extraction;

import java.util.Objects;
import org.jsoup.nodes.Document;

/**
* LLM 입력에 남길 "데이터 script" 판정의 single source. sanitize({@link GeminiHtmlExtractor})가 보존하는 것과
* 게이트({@link LlmInputGate})가 "LLM 이 읽을 수 있다"고 보는 것이 같은 판정을 공유해야 한다 — 두 벌이 되면
* "sanitize 는 남기는데 게이트는 없다고 판정"하는 식으로 조용히 어긋난다.
*/
final class DataScripts {

private DataScripts() {
}

/**
* type 이 없거나 {@code text/javascript} 인 JS 코드 script 는, 가격이 inline 변수
* ({@code window.__PRELOADED_STATE__} 등)에 묻혀 있더라도 코드 덩어리라 토큰만 먹고 오판을 부르므로 데이터로
* 치지 않는다 — 그런 거대 state 사이트는 LLM 토큰 상한에도 안 맞아, 전용 파서가 답이다. 남기는 것은
* schema.org JSON-LD 와 일반 JSON data island(Next.js 의 {@code __NEXT_DATA__} 등)뿐이다. prefix 비교라
* {@code ;charset=} 파라미터 변형에도 정확하다.
*/
static boolean isDataScript(String type) {
String normalized = type.trim();
return normalized.regionMatches(true, 0, "application/ld+json", 0, "application/ld+json".length())
|| normalized.regionMatches(true, 0, "application/json", 0, "application/json".length());
}

static boolean hasDataScript(Document document) {
Objects.requireNonNull(document, "document");
return document.select("script").stream()
.anyMatch(element -> isDataScript(element.attr("type")));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,21 @@ public ProductSnapshot extract(Document document, ProductLink link, String model
* LLM 입력에서 토큰 낭비·오판 요소(JS {@code <script>}, {@code <style>}, 주석)를 걷어내고 토큰 비용
* 상한({@code MAX_LLM_CHARS})으로 자른다.
*
* <p>단 데이터를 담은 script 는 보존한다 — schema.org JSON-LD(product schema)와 일반 JSON data island
* (Next.js 의 {@code <script id="__NEXT_DATA__" type="application/json">} 등)에 상품명·가격이 들어 있어,
* JSON-LD/OG 파서가 놓친 사이트를 LLM 이 건져내는 fallback 의 유일한 근거다. 이걸 통째로 지우면 fallback 에
* 가격이 빠진 HTML 이 들어가 LLM 도 손쓸 수 없었다. type 판별은 jsoup 이 파싱한 attr 기준이라 charset
* 파라미터·공백 변형에도 정확하다.
* <p>단 데이터를 담은 script({@link DataScripts})는 보존한다 — schema.org JSON-LD(product schema)와 일반
* JSON data island(Next.js 의 {@code <script id="__NEXT_DATA__" type="application/json">} 등)에 상품명·가격이
* 들어 있어, JSON-LD/OG 파서가 놓친 사이트를 LLM 이 건져내는 fallback 의 유일한 근거다. 이걸 통째로 지우면
* fallback 에 가격이 빠진 HTML 이 들어가 LLM 도 손쓸 수 없었다. type 판별은 jsoup 이 파싱한 attr 기준이라
* charset 파라미터·공백 변형에도 정확하다.
*
* <p>절단을 fetch 단계가 아니라 여기서 하는 이유: JS·style 을 걷어낸 뒤라 같은 길이 안에 실제 상품 정보가
* 훨씬 더 담긴다. 순수 함수라(인스턴스 상태 무의존) static 으로 둬 stub 없이 단위 테스트한다.
*/
static String sanitize(Document document) {
document.select("script").stream()
.filter(element -> !isDataScript(element.attr("type")))
.filter(element -> !DataScripts.isDataScript(element.attr("type")))
.forEach(Element::remove);
document.select("style").remove();
String cleaned = COMMENT_PATTERN.matcher(document.outerHtml()).replaceAll("");
return cleaned.length() > MAX_LLM_CHARS ? cleaned.substring(0, MAX_LLM_CHARS) : cleaned;
}

/**
* LLM 입력에 남길 "데이터 script" 판정. type 이 없거나 {@code text/javascript} 인 JS 코드 script 는, 가격이
* inline 변수({@code window.__PRELOADED_STATE__} 등)에 묻혀 있더라도 코드 덩어리라 토큰만 먹고 오판을
* 부르므로 제거 대상이다 — 그런 거대 state 사이트는 LLM 토큰 상한에도 안 맞아, 전용 파서가 답이다.
*/
private static boolean isDataScript(String type) {
String normalized = type.trim();
return normalized.regionMatches(true, 0, "application/ld+json", 0, "application/ld+json".length())
|| normalized.regionMatches(true, 0, "application/json", 0, "application/json".length());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.depromeet.piki.extractor.domain.ExtractionMethod;
import com.depromeet.piki.extractor.domain.ProductSnapshot;
import com.depromeet.piki.extractor.domain.ProductSnapshotException;
import com.depromeet.piki.extractor.extraction.structured.StructuredDataExtractor;
import com.depromeet.piki.extractor.extraction.structured.StructuredExtraction;
import io.micrometer.core.instrument.MeterRegistry;
Expand All @@ -13,12 +14,14 @@

/**
* HTML → ProductSnapshot 의 공통 후반부: 구조화 데이터(JSON-LD/OpenGraph) 우선, 미달이면 같은 HTML 을 Gemini 로
* 넘긴다(재fetch 없음). HTML 을 어디서 얻었는지(정적 fetch, 헤드리스 렌더)와 무관한 파싱 파이프라인이라 두 전략이
* 공유한다.
* 넘긴다(재fetch 없음). 단 LLM 이 볼 게 아무것도 없는 문서({@link LlmInputGate})는 넘기지 않고 확정 실패로
* 끊는다 — 빈 입력의 LLM 은 실존하지 않는 상품을 지어내고, 그 값은 형식이 유효해 하류 검증을 전부 통과한다.
* HTML 을 어디서 얻었는지(정적 fetch, 헤드리스 렌더)와 무관한 파싱 파이프라인이라 두 전략이 공유한다.
*
* <p>추출 방법을 카운터로 집계한다 — {@code via=structured} 대 {@code via=llm} 의 비율이 곧 비싼 LLM 호출을 얼마나
* 줄였는지의 비용 지표다. fallback 은 reason 라벨로 사유를 분해해 "직접 파싱 적중률을 올리려면 어디를 보강할지"를
* 본다. application 태그는 {@code management.metrics.tags} 가 자동 부착한다.
* <p>추출 방법을 카운터로 집계한다 — {@code via=structured} 대 {@code via=llm} 의 비율이 곧 비싼 LLM 호출을
* 얼마나 줄였는지의 비용 지표이고, {@code via=skipped_shell} 이 게이트가 아낀 호출 수다. fallback 은 reason
* 라벨로 사유를 분해해 "직접 파싱 적중률을 올리려면 어디를 보강할지"를 본다. application 태그는
* {@code management.metrics.tags} 가 자동 부착한다.
*/
@Slf4j
@RequiredArgsConstructor
Expand All @@ -29,6 +32,7 @@ public class HtmlSnapshotPipeline {
private static final String TAG_VIA = "via";
private static final String TAG_REASON = "reason";
private static final String VIA_STRUCTURED = "structured";
private static final String VIA_SKIPPED_SHELL = "skipped_shell";
private static final String VIA_LLM = "llm";
private static final String REASON_NONE = "none";

Expand All @@ -44,46 +48,84 @@ public class HtmlSnapshotPipeline {
* 내려갈 때만 소비된다.
*/
public ProductSnapshot extract(PageContent page, String timing, String model) {
// 한 번만 파싱해 구조화 파서와 Gemini fallback 이 같은 Document 를 공유한다(파싱·ld+json 식별 중복 제거).
// 한 번만 파싱해 구조화 파서·게이트·Gemini fallback 이 같은 Document 를 공유한다(파싱·ld+json 식별 중복 제거).
// baseUri 는 html 의 출처인 최종 URL 기준 — redirect 를 따라갔으면 원본 link 와 host 가 다를 수 있다.
Document document = Jsoup.parse(page.html(), page.finalUrl().value().toString());

StructuredExtraction result = structuredDataExtractor.extract(document, page.link());
// 게이트 판정은 sanitize(GeminiHtmlExtractor) 전이어야 한다 — sanitize 는 공유 Document 에서 script 를
// 제거하므로, 순서가 뒤집히면 데이터 script 존재 판정이 깨진다.
boolean nothingForLlm = result instanceof StructuredExtraction.Miss && LlmInputGate.hasNothingForLlm(document);

// 카운터는 한 곳에서 항상 {via, reason} 두 키로 발행한다 — 경로마다 태그 키가 갈라지면 Prometheus 가
// 같은 메트릭 이름의 뒤 시계열을 조용히 드롭한다(라벨 키 집합 불일치).
// Miss 일 때 LLM 호출 전에 올려, LLM 이 실패해도 "직접 파싱으로 못 끝내 LLM 에 의존한 비율"에 포함되게 한다.
// 전략(plain/headless) 라벨은 두지 않는다 — 헤드리스 볼륨의 관측은 escalation 메트릭·render 로그가 진다.
String via = result instanceof StructuredExtraction.Extracted ? VIA_STRUCTURED : VIA_LLM;
String reason = result instanceof StructuredExtraction.Miss miss ? miss.reason() : REASON_NONE;
meterRegistry.counter(EXTRACT_METRIC, TAG_VIA, via, TAG_REASON, reason).increment();
countExtract(result, nothingForLlm);

return switch (result) {
case StructuredExtraction.Extracted extracted -> {
log.info(
"extract via=structured {} html={}chars url={}",
timing,
page.html().length(),
page.link().safeLogString()
);
// 출처 표기는 값 생산자(파서·LLM)가 아니라 여기서 — finalUrl 을 아는 유일한 층이고,
// 두 분기가 각자 method 를 확정하는 지점이라 표기가 갈라질 수 없다.
yield extracted.snapshot().withOrigin(page.finalUrl(), ExtractionMethod.STRUCTURED);
}
case StructuredExtraction.Miss miss -> {
long llmStart = System.nanoTime();
ProductSnapshot snapshot = geminiHtmlExtractor.extract(document, page.link(), model);
long llmMs = (System.nanoTime() - llmStart) / 1_000_000;
log.info(
"extract via=llm reason={} {} llm={}ms html={}chars url={}",
miss.reason(),
timing,
llmMs,
page.html().length(),
page.link().safeLogString()
);
yield snapshot.withOrigin(page.finalUrl(), ExtractionMethod.LLM);
}
case StructuredExtraction.Extracted extracted -> structuredSnapshot(extracted, page, timing);
case StructuredExtraction.Miss miss when nothingForLlm -> throw skippedShell(miss, page, timing);
case StructuredExtraction.Miss miss -> llmSnapshot(miss, document, page, timing, model);
};
}

/**
* 카운터는 이 한 곳에서 항상 {@code {via, reason}} 두 키로 발행한다 — 경로마다 태그 키가 갈라지면 Prometheus 가
* 같은 메트릭 이름의 뒤 시계열을 조용히 드롭한다(라벨 키 집합 불일치). Miss 계열은 실행 전에 올려, LLM 이
* 실패해도 "직접 파싱으로 못 끝내 LLM 에 의존한 비율"에 포함되게 한다. 전략(plain/headless) 라벨은 두지
* 않는다 — 헤드리스 볼륨의 관측은 escalation 메트릭·render 로그가 진다.
*/
private void countExtract(StructuredExtraction result, boolean nothingForLlm) {
String via = switch (result) {
case StructuredExtraction.Extracted extracted -> VIA_STRUCTURED;
case StructuredExtraction.Miss miss -> nothingForLlm ? VIA_SKIPPED_SHELL : VIA_LLM;
};
String reason = result instanceof StructuredExtraction.Miss miss ? miss.reason() : REASON_NONE;
meterRegistry.counter(EXTRACT_METRIC, TAG_VIA, via, TAG_REASON, reason).increment();
}

private ProductSnapshot structuredSnapshot(StructuredExtraction.Extracted extracted, PageContent page, String timing) {
log.info(
"extract via=structured {} html={}chars url={}",
timing,
page.html().length(),
page.link().safeLogString()
);
// 출처 표기는 값 생산자(파서·LLM)가 아니라 여기서 — finalUrl 을 아는 유일한 층이고,
// 각 분기가 method 를 확정하는 지점이라 표기가 갈라질 수 없다.
return extracted.snapshot().withOrigin(page.finalUrl(), ExtractionMethod.STRUCTURED);
}

/**
* 게이트 발동 원장 — url(host 포함)을 남기는 이유는 오탐 감시다: "가시 텍스트도 데이터 script 도 없는 정상
* 상품 페이지"가 실재하면 그 몰의 host 가 이 라인에 반복 등장하므로, 배포 후 분포로 판정을 조정한다.
*/
private ProductSnapshotException skippedShell(StructuredExtraction.Miss miss, PageContent page, String timing) {
log.info(
"extract via=skipped_shell reason={} {} html={}chars url={}",
miss.reason(),
timing,
page.html().length(),
page.link().safeLogString()
);
return ProductSnapshotException.noExtractableContent();
}

private ProductSnapshot llmSnapshot(
StructuredExtraction.Miss miss,
Document document,
PageContent page,
String timing,
String model
) {
long llmStart = System.nanoTime();
ProductSnapshot snapshot = geminiHtmlExtractor.extract(document, page.link(), model);
long llmMs = (System.nanoTime() - llmStart) / 1_000_000;
log.info(
"extract via=llm reason={} {} llm={}ms html={}chars url={}",
miss.reason(),
timing,
llmMs,
page.html().length(),
page.link().safeLogString()
);
return snapshot.withOrigin(page.finalUrl(), ExtractionMethod.LLM);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.depromeet.piki.extractor.extraction;

import java.util.Objects;
import org.jsoup.nodes.Document;

/**
* "LLM 이 볼 게 아무것도 없는가" 판정 — LLM fallback 직전의 게이트. 빈 CSR 셸을 LLM 에 넘기면 "모르겠다"
* 대신 그럴듯한 상품을 지어내고(에이블리 mobile.* 실측: 같은 URL 15회 중 200 이 8회, 전부 서로 다른
* 실존하지 않는 상품·죽은 이미지 URL), 지어낸 값은 형식이 유효해 응답 경계도 호출자(core) 검증도 통과한다.
* 입력이 비었음을 아는 이 지점이 유일한 차단 기회다.
*
* <p>{@link EmptyShellDetector}(에스컬레이션 축)와 판정을 분리하는 이유는 오탐 비용의 비대칭이다 — 그쪽
* 오탐은 헤드리스 1회 낭비(fail-open)로 그치지만, 이 게이트의 오탐은 확정 실패(422)로 굳는다. 그래서 길이
* 임계("짧다") 같은 판단을 두지 않고 사실만 본다: 가시 텍스트가 전혀 없고, 데이터 script(JSON-LD·data
* island — {@link GeminiHtmlExtractor} sanitize 가 보존하는 것)도 없다. 환각 사고 케이스는 렌더 후에도
* 0자였다. jsoup 의 {@code text()} 는 script 내용을 세지 않으므로 데이터 script 유무는 별도 조건이 진다.
*
* <p>수용한 잔존 위험: 가시 텍스트가 몇십 자(접근성 링크·푸터 보일러플레이트)뿐인 셸은 게이트를 지나 LLM 으로
* 간다 — 그런 셸은 실측된 바 없고, 임계 마진으로 선제 차단하면 그 마진 크기가 자의적이 되어 정상 미니멀
* 페이지를 확정 실패로 굳힐 수 있다. 감시는 {@code via=llm} 로그의 html 크기·host 분포로 한다(셸은 본문이
* 극단적으로 작다).
*/
final class LlmInputGate {

private LlmInputGate() {
}

static boolean hasNothingForLlm(Document document) {
Objects.requireNonNull(document, "document");
return document.body().text().isEmpty() && !DataScripts.hasDataScript(document);
}
}
Loading
Loading