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
@@ -1,24 +1,35 @@
package com.depromeet.piki.extractor.extraction;

import com.depromeet.piki.extractor.extraction.structured.StructuredDataExtractor;
import java.util.Objects;
import org.jsoup.nodes.DataNode;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;

/**
* LLM 입력에 남길 "데이터 script" 판정의 single source. sanitize({@link GeminiHtmlExtractor})가 보존하는 것과
* 게이트({@link LlmInputGate})가 "LLM 이 읽을 수 있다"고 보는 것이 같은 판정을 공유해야 한다 — 두 벌이 되면
* "sanitize 는 남기는데 게이트는 없다고 판정"하는 식으로 조용히 어긋난다.
* script 를 남길지 버릴지의 판정을 모은 곳. 판정이 **두 벌**이라는 사실 자체가 여기 박혀 있어야 한다.
*
* <ul>
* <li>{@link #retainForParsing} - 수신 가지치기({@link PruningHtmlParser})가 남길 것. 구조화 파서가 읽을
* script 를 하나라도 빠뜨리면 그 사이트의 추출이 조용히 깨지므로 더 넓다.</li>
* <li>{@link #isDataScript} - LLM 입력에 남길 것. sanitize({@link GeminiHtmlExtractor})가 보존하는 것과
* 게이트({@link LlmInputGate})가 "LLM 이 읽을 수 있다"고 보는 것이 같아야 해, 두 벌이 되면 "sanitize 는
* 남기는데 게이트는 없다고 판정"하는 식으로 조용히 어긋난다.</li>
* </ul>
*
* <p>둘이 갈리는 지점은 embedded JS state 다 - 구조화 파서는 거기서 가격을 꺼내지만, LLM 에는 코드 덩어리라
* 토큰만 먹고 오판을 부른다.
*/
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=} 파라미터 변형에도 정확하다.
* type 이 없거나 {@code text/javascript} 인 JS 코드 script 는, 가격이 inline 변수에 묻혀 있더라도 코드
* 덩어리라 토큰만 먹고 오판을 부르므로 데이터로 치지 않는다 - 그런 거대 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();
Expand All @@ -31,4 +42,31 @@ static boolean hasDataScript(Document document) {
return document.select("script").stream()
.anyMatch(element -> isDataScript(element.attr("type")));
}

/**
* 수신 단계에서 이 script 를 남겨야 하는가. 데이터 script 에 더해, 구조화 파서가 훑는 embedded state 를
* 담은 JS script 까지 남긴다 - 여기서 버리면 {@code StructuredDataExtractor} 의 embedded state 경로가
* 입력을 잃는다. 그 경로가 무엇을 찾는지는 그쪽이 정본이라 상수를 그쪽에서 가져다 쓴다.
*
* <p>{@code id} 검사는 방어다: Next.js 는 {@code type="application/json"} 을 함께 실어 앞 조건에 이미
* 걸리지만, type 없이 id 만 있는 변형이 오면 앞 조건만으로는 버려진다.
*/
static boolean retainForParsing(Element script) {
Objects.requireNonNull(script, "script");
if (isDataScript(script.attr("type"))) {
return true;
}
if (StructuredDataExtractor.NEXT_DATA_ID.equals(script.id())) {
return true;
}
// data() 가 아니라 DataNode 를 직접 보는 것은 사본 때문이다 - 곧 버릴 거대 script 마다 내용을 한 벌씩
// 더 뜨면 스트리밍으로 아낀 메모리를 여기서 도로 쓴다.
for (int i = 0; i < script.childNodeSize(); i++) {
if (script.childNode(i) instanceof DataNode data
&& data.getWholeData().contains(StructuredDataExtractor.EMBEDDED_STATE_MARKER)) {
return true;
}
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public ProductSnapshot extract(ProductLink link, boolean authorized, String mode
// 같은 축으로 재분류해(escalatable) 헤드리스가 이어받게 한다. 본문 텍스트가 충분한 페이지의 no-data 는
// 진짜 "상품 페이지가 아님"이므로 그대로 전파한다. LLM 일시 오류(GeminiApiException)는 페이지의 문제가
// 아니라 재분류하지 않는다(호출자 재시도 축이 흡수).
if (EmptyShellDetector.isEmptyShell(page.html())) {
if (EmptyShellDetector.isEmptyShell(page.document())) {
throw PageFetchException.emptyShell(e);
}
throw e;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.depromeet.piki.extractor.extraction;

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

/**
* "2xx 인데 데이터 없는 CSR 셸" 판정. 정적 fetch 가 성공해도 본문이 JS 부트스트랩뿐인 SPA 셸이면 파싱은
Expand All @@ -23,8 +23,8 @@ final class EmptyShellDetector {
private EmptyShellDetector() {
}

static boolean isEmptyShell(String html) {
Objects.requireNonNull(html, "html");
return Jsoup.parse(html).body().text().length() < MIN_VISIBLE_TEXT_CHARS;
static boolean isEmptyShell(Document document) {
Objects.requireNonNull(document, "document");
return document.body().text().length() < MIN_VISIBLE_TEXT_CHARS;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* @param compress 응답 zstd 압축 전송 요청(서버간 전송량 절감). 해제는 응답 헤더({@code X-Encoding}) 기준이라
* compress 필드를 모르는 구버전 renderer(무시하고 plain JSON 으로 답한다)와도 호환된다 — 켜 둔 채로 배포
* 순서와 무관하게 안전하고, 이 스위치는 압축 경로에 문제가 생겼을 때 끄는 kill-switch 다.
* @param maxRetainedChars 가지친 뒤에도 남는 분량의 상한(백스톱). 렌더된 DOM 은 정적 fetch 보다 커질 수 있어
* 정적 경로와 같은 성격의 상한을 둔다 - 정본 설명은 {@code FetchProperties.maxRetainedChars}.
* @param zstdDictDir zstd 학습 사전 디렉토리(파일명 = 사전ID, renderer {@code compress.py} 의 DICT_ID 규약).
* 빈값이면 사전 없음. 롤아웃 순서: 사전 파일을 여기 먼저 배포한 뒤 renderer 쪽 사전 경로를 켠다 — 순서가
* 뒤집히면 미보유 사전ID 를 받아 해제 불가(일시 실패)가 된다.
Expand All @@ -28,7 +30,7 @@ public record HeadlessExtractionProperties(
@DefaultValue("") String baseUrl,
@DefaultValue("2s") Duration connectTimeout,
@DefaultValue("20s") Duration readTimeout,
@DefaultValue("3000000") int maxHtmlChars,
@DefaultValue("3000000") int maxRetainedChars,
@DefaultValue("true") boolean compress,
@DefaultValue("") String zstdDictDir
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.stereotype.Component;

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

StructuredExtraction result = structuredDataExtractor.extract(document, page.link());
// 게이트 판정은 sanitize(GeminiHtmlExtractor) 전이어야 한다 — sanitize 는 공유 Document 에서 script 를
Expand Down Expand Up @@ -85,7 +84,7 @@ private ProductSnapshot structuredSnapshot(StructuredExtraction.Extracted extrac
log.info(
"extract via=structured {} html={}chars url={}",
timing,
page.html().length(),
page.retainedChars(),
page.link().safeLogString()
);
// 출처 표기는 값 생산자(파서·LLM)가 아니라 여기서 — finalUrl 을 아는 유일한 층이고,
Expand All @@ -102,7 +101,7 @@ private ProductSnapshotException skippedShell(StructuredExtraction.Miss miss, Pa
"extract via=skipped_shell reason={} {} html={}chars url={}",
miss.reason(),
timing,
page.html().length(),
page.retainedChars(),
page.link().safeLogString()
);
return ProductSnapshotException.noExtractableContent();
Expand All @@ -123,7 +122,7 @@ private ProductSnapshot llmSnapshot(
miss.reason(),
timing,
llmMs,
page.html().length(),
page.retainedChars(),
page.link().safeLogString()
);
return snapshot.withOrigin(page.finalUrl(), ExtractionMethod.LLM);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
package com.depromeet.piki.extractor.extraction;

import com.depromeet.piki.extractor.domain.ProductLink;
import org.jsoup.nodes.Document;

/**
* 수신이 끝난 한 페이지. 문자열이 아니라 파싱된 Document 를 드는 이유는 하류가 전부 Document 를 원하기
* 때문이다 - 구조화 파서 · LLM 게이트 · sanitize · 셸 판정이 모두 그렇고, 문자열은 중간 표현일 뿐이었다.
* Document 를 들고 다니면 수신 단계에서 스트리밍 가지치기({@link PruningHtmlParser})가 성립하고, 같은 HTML 을
* 두 번 파싱하던 것도 사라진다.
*
* @param link 요청받은 원본 URL. 호출자 쪽 저장·식별의 정체성이라 redirect 와 무관하게 원본을 유지한다.
* @param finalUrl redirect 를 따라간 최종 페이지 URL. html 의 출처이므로 상대 URL resolve(Jsoup baseUri)는 이 값을
* 기준으로 해야 한다.
* @param document 가지친 문서. baseUri 는 finalUrl 로 박혀 있어 상대 URL resolve 가 최종 host 기준이 된다.
* @param finalUrl redirect 를 따라간 최종 페이지 URL. document 의 출처다.
* @param retainedChars 가지친 뒤 남은 분량. 로그의 크기 지표이며, 셸은 이 값이 극단적으로 작다.
*/
public record PageContent(
ProductLink link,
String html,
ProductLink finalUrl
Document document,
ProductLink finalUrl,
int retainedChars
) {

/** redirect 를 따라가지 않은 경우 — finalUrl 은 link 그대로다. */
/**
* 이미 문자열로 들고 있는 HTML 로 조립하는 편의 팩토리(redirect 를 따라가지 않은 경우). 스트림이 아니라
* 바운드할 대상이 없으므로 상한을 두지 않는다 - 상한은 스트림을 쥔 수신 경계가 자기 설정으로 정한다.
*/
public static PageContent of(ProductLink link, String html) {
return new PageContent(link, html, link);
return of(link, html, link);
}

/** redirect 를 따라간 경우 — baseUri 는 html 의 출처인 finalUrl 을 쓴다. */
public static PageContent of(ProductLink link, String html, ProductLink finalUrl) {
PruningHtmlParser.Pruned pruned =
PruningHtmlParser.parse(html, finalUrl.value().toString(), PruningHtmlParser.UNBOUNDED);
return new PageContent(link, pruned.document(), finalUrl, pruned.retainedChars());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.depromeet.piki.extractor.extraction;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.util.Iterator;
import org.jsoup.nodes.Attribute;
import org.jsoup.nodes.Comment;
import org.jsoup.nodes.DataNode;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import org.jsoup.parser.Parser;
import org.jsoup.parser.StreamParser;

/**
* HTML 을 흘려보내며 파싱하고, 하류가 읽지 않을 노드는 닫히는 즉시 버리는 수신구.
*
* <p>왜 스트리밍인가: 전체를 문자열로 받아 두면 절단 전 피크가 두 배로 뛴다(바이트 배열이 살아 있는 채 UTF-16
* 사본이 만들어진다). 그렇다고 수신 단계에서 raw 로 자르면 {@link GeminiHtmlExtractor} 가 절단을 LLM 직전으로
* 미뤄 둔 이득 - JS·style 을 걷어낸 뒤라야 같은 길이에 상품 정보가 훨씬 더 담긴다 - 을 버린다. 가지치기는 둘
* 다 피한다: 걷어내는 대상이 어차피 하류가 버릴 것들이라 잃는 정보가 없고, 거대 inline JS·CSS 가 메모리에 한
* 번에 하나씩만 스쳐 간다. 문서 끝까지 훑으므로 body 하단의 JSON-LD 도 그대로 잡힌다.
*
* <p>버리는 것은 {@code sanitize} 가 이미 버리는 것과 같다({@code <style>} · 주석 · 데이터 아닌
* {@code <script>}). 단 하나 다른 점은 {@code __PRELOADED_STATE__} 를 담은 JS script 로, 구조화 파서가 그것을
* 읽으므로 여기서는 남긴다 - 판정은 {@link DataScripts} 가 소유한다.
*/
public final class PruningHtmlParser {

/**
* 상한 없음. 수신 경계(fetch · render)는 각자 자기 설정값을 넘기고, 이 값은 이미 메모리에 올라와 있는
* 문자열을 파싱하는 편의 경로가 쓴다 - 그 경로에는 바운드할 스트림이 없다.
*/
public static final int UNBOUNDED = Integer.MAX_VALUE;

private static final String SCRIPT = "script";
private static final String STYLE = "style";

private PruningHtmlParser() {
}

/**
* @param maxRetainedChars 가지친 뒤에도 남는 분량의 상한(백스톱). 가지치기가 걷어내지 못하는 병리적 문서
* (마크업만으로 부푼 응답)를 끊는다. 정상 페이지는 닿지 않는다.
*/
public static Pruned parse(Reader reader, String baseUri, int maxRetainedChars) throws IOException {
try (StreamParser streamParser = new StreamParser(Parser.htmlParser())) {
streamParser.parse(reader, baseUri);
int retained = 0;
// StreamParser 는 Iterable 이 아니라 Iterator 만 내준다.
Iterator<Element> elements = streamParser.iterator();
while (elements.hasNext()) {
Element element = elements.next();
if (prune(element)) {
element.remove();
continue;
}
dropComments(element);
retained += weigh(element);
if (retained > maxRetainedChars) {
streamParser.stop();
return new Pruned(streamParser.document(), retained, true);
}
}
return new Pruned(streamParser.document(), retained, false);
} catch (UncheckedIOException e) {
// Iterator 계약상 checked 를 못 던져 감싸 온 것 - 호출부가 IO 실패를 IO 로 다루게 되돌린다.
throw e.getCause();
}
}

/** 이미 문자열로 들고 있는 HTML 용(렌더 응답 · 테스트). 스트림이 아니므로 메모리 이득은 없고 가지치기만 같다. */
public static Pruned parse(String html, String baseUri, int maxRetainedChars) {
try {
return parse(new StringReader(html), baseUri, maxRetainedChars);
} catch (IOException e) {
// StringReader 는 IO 를 하지 않는다 - 도달 불가.
throw new UncheckedIOException(e);
}
}

/**
* @param retainedChars 가지친 뒤 남은 분량. 로그의 크기 지표로 쓴다 - 셸은 이 값이 극단적으로 작다.
* @param truncated 상한에 걸려 문서 끝까지 못 갔는지. 정상 경로에서는 항상 false 라, true 는 조사 신호다.
*/
public record Pruned(Document document, int retainedChars, boolean truncated) {
}

private static boolean prune(Element element) {
String tag = element.normalName();
if (STYLE.equals(tag)) {
return true;
}
if (SCRIPT.equals(tag)) {
return !DataScripts.retainForParsing(element);
}
return false;
}

/** 주석은 Element 가 아니라 StreamParser 가 따로 내주지 않는다 - 부모가 닫힐 때 그 자식으로 걷어낸다. */
private static void dropComments(Element element) {
// 뒤에서부터 도는 것은 remove 가 인덱스를 당기기 때문이고, childNodes() 뷰를 순회하며 지우면
// ConcurrentModification 이 된다.
for (int i = element.childNodeSize() - 1; i >= 0; i--) {
if (element.childNode(i) instanceof Comment comment) {
comment.remove();
}
}
}

/**
* 이 Element 가 새로 더하는 분량. 자식 Element 는 닫힐 때 각자 세어졌으므로 여기서는 자기 태그·속성과
* 직속 텍스트만 본다 - 노드마다 정확히 한 번씩 세어져 합이 문서 크기에 비례한다.
*/
private static int weigh(Element element) {
int chars = element.normalName().length();
for (Attribute attribute : element.attributes()) {
chars += attribute.getKey().length() + attribute.getValue().length();
}
for (int i = 0; i < element.childNodeSize(); i++) {
Node child = element.childNode(i);
if (child instanceof TextNode text) {
chars += text.getWholeText().length();
} else if (child instanceof DataNode data) {
chars += data.getWholeData().length();
}
}
return chars;
}
}
Loading
Loading