diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/DataScripts.java b/src/main/java/com/depromeet/piki/extractor/extraction/DataScripts.java index 1b5ef4e..5fd2641 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/DataScripts.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/DataScripts.java @@ -1,12 +1,24 @@ 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 를 남길지 버릴지의 판정을 모은 곳. 판정이 **두 벌**이라는 사실 자체가 여기 박혀 있어야 한다. + * + *
둘이 갈리는 지점은 embedded JS state 다 - 구조화 파서는 거기서 가격을 꺼내지만, LLM 에는 코드 덩어리라 + * 토큰만 먹고 오판을 부른다. */ final class DataScripts { @@ -14,11 +26,10 @@ 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(); @@ -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 경로가 + * 입력을 잃는다. 그 경로가 무엇을 찾는지는 그쪽이 정본이라 상수를 그쪽에서 가져다 쓴다. + * + *
{@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; + } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java index 6008802..410e84b 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java @@ -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; diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java b/src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java index 6223cc1..6978d19 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java @@ -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 셸이면 파싱은 @@ -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; } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java index c6524ff..42e965d 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java @@ -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 를 받아 해제 불가(일시 실패)가 된다. @@ -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 ) { 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 db4c916..8445ae8 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java @@ -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; @@ -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 를 @@ -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 을 아는 유일한 층이고, @@ -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(); @@ -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); diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/PageContent.java b/src/main/java/com/depromeet/piki/extractor/extraction/PageContent.java index 7473d6e..a478f24 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/PageContent.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/PageContent.java @@ -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()); } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/PruningHtmlParser.java b/src/main/java/com/depromeet/piki/extractor/extraction/PruningHtmlParser.java new file mode 100644 index 0000000..e7249b8 --- /dev/null +++ b/src/main/java/com/depromeet/piki/extractor/extraction/PruningHtmlParser.java @@ -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 을 흘려보내며 파싱하고, 하류가 읽지 않을 노드는 닫히는 즉시 버리는 수신구. + * + *
왜 스트리밍인가: 전체를 문자열로 받아 두면 절단 전 피크가 두 배로 뛴다(바이트 배열이 살아 있는 채 UTF-16 + * 사본이 만들어진다). 그렇다고 수신 단계에서 raw 로 자르면 {@link GeminiHtmlExtractor} 가 절단을 LLM 직전으로 + * 미뤄 둔 이득 - JS·style 을 걷어낸 뒤라야 같은 길이에 상품 정보가 훨씬 더 담긴다 - 을 버린다. 가지치기는 둘 + * 다 피한다: 걷어내는 대상이 어차피 하류가 버릴 것들이라 잃는 정보가 없고, 거대 inline JS·CSS 가 메모리에 한 + * 번에 하나씩만 스쳐 간다. 문서 끝까지 훑으므로 body 하단의 JSON-LD 도 그대로 잡힌다. + * + *
버리는 것은 {@code sanitize} 가 이미 버리는 것과 같다({@code
본문