From 3826bc2031cc65e5dbbdda022b70faaa337858ec 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: Thu, 20 Aug 2026 13:58:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=83=81=ED=92=88=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EC=8A=A4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EB=B0=8D=20=EA=B0=80=EC=A7=80=EC=B9=98=EA=B8=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=20=EC=88=98=EC=8B=A0=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A9=94=EB=AA=A8=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EC=9A=B4=EB=93=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답을 전부 메모리에 올린 뒤 자르던 것을, jsoup StreamParser 로 흘리며 하류가 읽지 않을 노드(style·주석·데이터 아닌 script)를 닫히는 즉시 버리는 방식으로 교체 - 원안(#47)은 Content-Length 검사 + 상한 초과 시 payload-too-large 실패였으나 두 이유로 전환. 실패는 지금 앞부분으로 추출에 성공하던 페이지를 확정 실패로 바꾸고 새 permanent code 가 infra→core→extractor 3 repo 순차 PR 을 부른다. 절단도 마찬가지로 손해다 - sanitize 가 절단을 LLM 직전으로 미뤄 둔 이유가 코드에 적혀 있듯, JS·style 을 걷어낸 뒤라야 같은 길이에 상품 정보가 더 담긴다. 가지치기는 버리는 대상이 어차피 하류가 버릴 것들이라 둘 다 피한다 - 작업 중 발견한 함정: StructuredDataExtractor 가 일반 JS script 를 훑어 window.__PRELOADED_STATE__ 를 찾는다(유니클로 계열). "데이터 script 아니면 버린다"로 짰다면 그 경로가 조용히 깨졌다. 보존 규칙이 두 벌(수신용·LLM 입력용) 이라는 사실을 DataScripts 에 박고, 표지 상수는 그것을 찾는 쪽이 소유하게 함 - Content-Type 게이트 신설. 지금까지 검사가 0건이라 링크가 50MB mp4·zip 을 가리켜도 전부 받았다. recall 보호를 위해 명백한 바이너리만 막고 미상은 통과 - PageContent 가 String 대신 Document 를 든다. 하류가 전부 Document 를 원해 문자열은 중간 표현일 뿐이었고, 그 결과 HtmlSnapshotPipeline 과 EmptyShellDetector 가 같은 HTML 을 두 번 파싱하던 것도 사라짐 - 상한은 둘. 와이어 바이트(끝나지 않는 스트림용, 해제 후 바이트에 걸린다)와 보존분 문자(가지쳐도 안 줄어드는 문서용). 정상 페이지는 어느 쪽에도 안 닿는다 --- .../extractor/extraction/DataScripts.java | 54 +++- .../DefaultProductLinkExtractor.java | 2 +- .../extraction/EmptyShellDetector.java | 8 +- .../HeadlessExtractionProperties.java | 4 +- .../extraction/HtmlSnapshotPipeline.java | 11 +- .../extractor/extraction/PageContent.java | 30 +- .../extraction/PruningHtmlParser.java | 133 +++++++++ .../headless/HttpHeadlessRenderer.java | 23 +- .../extraction/http/FetchProperties.java | 12 +- .../extraction/http/HttpPageFetcher.java | 264 +++++++++++++----- .../structured/StructuredDataExtractor.java | 21 +- .../api/ExtractionLinkIntegrationTest.java | 2 +- .../extraction/EmptyShellDetectorTest.java | 16 +- .../extraction/PruningHtmlParserTest.java | 118 ++++++++ .../headless/HttpHeadlessRendererTest.java | 21 +- .../http/HttpPageFetcherCharsetTest.java | 14 +- .../http/HttpPageFetcherIntakeTest.java | 142 ++++++++++ .../http/HttpPageFetcherRedirectE2ETest.java | 8 +- .../http/HttpPageFetcherRedirectTest.java | 8 +- 19 files changed, 744 insertions(+), 147 deletions(-) create mode 100644 src/main/java/com/depromeet/piki/extractor/extraction/PruningHtmlParser.java create mode 100644 src/test/java/com/depromeet/piki/extractor/extraction/PruningHtmlParserTest.java create mode 100644 src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherIntakeTest.java 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

본문

" + ); + + assertNull(document.selectFirst("style")); + assertFalse(document.body().html().contains("주석")); + assertEquals("본문", document.text()); + } + + @Test + @DisplayName("보존분 상한을 넘으면 그 뒤 내용은 들어오지 않는다") + void stopsAtRetainedCap() { + String html = "

aaaaaaaaaa

bbbbbbbbbb

cccccccccc

"; + + PruningHtmlParser.Pruned pruned = PruningHtmlParser.parse(html, BASE_URI, 12); + + assertTrue(pruned.truncated()); + assertTrue(pruned.document().text().contains("aaaaaaaaaa"), "상한 전까지는 남아야 한다"); + assertFalse(pruned.document().text().contains("cccccccccc"), "상한을 넘긴 뒤는 들어오지 않아야 한다"); + } + + @Test + @DisplayName("상한에 닿지 않은 문서는 truncated 가 아니다 - 정상 페이지가 조사 신호를 내지 않게") + void normalDocumentIsNotTruncated() { + PruningHtmlParser.Pruned pruned = + PruningHtmlParser.parse("

본문

", BASE_URI, PruningHtmlParser.UNBOUNDED); + + assertFalse(pruned.truncated()); + assertTrue(pruned.retainedChars() > 0); + } + + @Test + @DisplayName("거대 inline JS 가 섞여 있어도 보존분은 실제 콘텐츠 크기에 머문다 - 이 파서를 두는 이유 그 자체") + void hugeInlineScriptDoesNotInflateRetained() { + String huge = "var payload = \"" + "x".repeat(5_000_000) + "\";"; + String html = "

운동화

"; + + PruningHtmlParser.Pruned pruned = PruningHtmlParser.parse(html, BASE_URI, PruningHtmlParser.UNBOUNDED); + + assertFalse(pruned.truncated(), "상한이 아니라 가지치기로 줄어야 한다"); + assertTrue(pruned.retainedChars() < 1_000, "5MB script 가 보존분에 들어오면 안 된다 - 실제 " + pruned.retainedChars()); + assertEquals("운동화", pruned.document().text(), "정작 필요한 내용은 그대로 남아야 한다"); + } + + @Test + @DisplayName("baseUri 가 문서에 박혀 상대 URL 이 그 기준으로 풀린다 - redirect 를 따라갔으면 최종 host 가 기준이다") + void baseUriResolvesRelativeUrls() { + Document document = prune("상세"); + + assertEquals("https://shop.example.com/detail/1", document.selectFirst("a").absUrl("href")); + } + + private static Document prune(String html) { + return PruningHtmlParser.parse(html, BASE_URI, PruningHtmlParser.UNBOUNDED).document(); + } +} 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 0e765f9..a59f636 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 @@ -94,7 +94,7 @@ void okRenderReturnsPageContent() { PageContent page = renderer.render(link, false); - assertEquals(html, page.html()); + 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()); @@ -111,7 +111,7 @@ void anyNonBlockVerdictWithHtmlProceeds() { MediaType.APPLICATION_JSON ))); - assertEquals("rendered dom", renderer.render(link, false).html(), "verdict=" + verdict); + assertEquals("rendered dom", renderer.render(link, false).document().text(), "verdict=" + verdict); } } @@ -155,7 +155,7 @@ void zstdResponseIsDecompressed() { .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess(packed, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("")))); - assertEquals("compressed dom", renderer.render(link, false).html()); + assertEquals("compressed dom", renderer.render(link, false).document().text()); } @Test @@ -172,7 +172,7 @@ void zstdDictResponseUsesSharedDictionary() { .andRespond(withSuccess(packed, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("mall-v1.dict"))) ); - assertEquals("dict compressed", renderer.render(link, false).html()); + assertEquals("dict compressed", renderer.render(link, false).document().text()); } @Test @@ -283,19 +283,22 @@ void renderServiceErrorIsTransient() { } @Test - @DisplayName("렌더된 HTML 은 maxHtmlChars 안전 상한으로 절단한다") - void htmlIsCappedAtMaxChars() { + @DisplayName("렌더된 HTML 도 보존분 상한을 넘으면 거기서 파싱을 멈춘다") + void renderedHtmlStopsAtRetainedCap() { HeadlessExtractionProperties small = new HeadlessExtractionProperties( - true, BASE_URL, Duration.ofSeconds(2), Duration.ofSeconds(20), 10, true, "" + true, BASE_URL, Duration.ofSeconds(2), Duration.ofSeconds(20), 12, true, "" ); HttpHeadlessRenderer renderer = rendererWith(small, publicIp, ZstdDictionaries.none(), server -> server .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess( - "{\"verdict\":\"OK\",\"html\":\"0123456789ABCDEF\"}", + "{\"verdict\":\"OK\",\"html\":\"

aaaaaaaaaa

bbbbbbbbbb

cccccccccc

\"}", MediaType.APPLICATION_JSON ))); - assertEquals("0123456789", renderer.render(link, false).html()); + String text = renderer.render(link, false).document().text(); + + assertTrue(text.contains("aaaaaaaaaa"), "상한 전까지의 내용은 남아야 한다"); + assertFalse(text.contains("cccccccccc"), "상한을 넘긴 뒤의 내용은 들어오지 않아야 한다"); } @Test diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherCharsetTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherCharsetTest.java index dd067bf..6877521 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherCharsetTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherCharsetTest.java @@ -1,6 +1,6 @@ package com.depromeet.piki.extractor.extraction.http; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @@ -43,7 +43,11 @@ void utf8WithoutHeaderCharsetKeepsKorean() { PageContent page = fetcher.fetch(ProductLink.parse("https://shop.example.com/p")); - assertTrue(page.html().contains("나이키 운동화"), "charset 없는 UTF-8 응답이 UTF-8 로 디코딩돼 한글이 보존돼야 한다"); + assertEquals( + "나이키 운동화", + page.document().selectFirst("meta[property=og:title]").attr("content"), + "charset 없는 UTF-8 응답이 UTF-8 로 디코딩돼 한글이 보존돼야 한다" + ); } @Test @@ -55,7 +59,7 @@ void followsHeaderCharsetEucKr() { PageContent page = fetcher.fetch(ProductLink.parse("https://shop.example.com/p")); - assertTrue(page.html().contains("운동화"), "응답 Content-Type 의 EUC-KR charset 으로 디코딩돼야 한다"); + assertEquals("운동화", page.document().text(), "응답 Content-Type 의 EUC-KR charset 으로 디코딩돼야 한다"); } @Test @@ -67,7 +71,7 @@ void fallsBackToMetaCharset() { PageContent page = fetcher.fetch(ProductLink.parse("https://shop.example.com/p")); - assertTrue(page.html().contains("장바구니"), "헤더 charset 이 없으면 HTML meta charset(euc-kr)으로 디코딩돼야 한다"); + assertEquals("장바구니", page.document().text(), "헤더 charset 이 없으면 HTML meta charset(euc-kr)으로 디코딩돼야 한다"); } @Test @@ -80,6 +84,6 @@ void headerCharsetTakesPrecedenceOverMeta() { PageContent page = fetcher.fetch(ProductLink.parse("https://shop.example.com/p")); - assertTrue(page.html().contains("운동화"), "Content-Type charset(UTF-8)이 HTML meta charset(euc-kr)보다 우선해야 한다"); + assertEquals("운동화", page.document().text(), "Content-Type charset(UTF-8)이 HTML meta charset(euc-kr)보다 우선해야 한다"); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherIntakeTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherIntakeTest.java new file mode 100644 index 0000000..c97661b --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherIntakeTest.java @@ -0,0 +1,142 @@ +package com.depromeet.piki.extractor.extraction.http; + +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 static org.junit.jupiter.api.Assertions.assertTrue; + +import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode; +import com.depromeet.piki.extractor.domain.ProductLink; +import com.depromeet.piki.extractor.domain.ProductSnapshotException; +import com.depromeet.piki.extractor.extraction.PageContent; +import com.google.common.io.CountingInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.web.client.RestClient; + +/** + * 수신 단계의 두 방어 검증: 상품 페이지일 수 없는 응답을 읽기 전에 끊는가, 그리고 스트림이 바이트 상한에서 + * 멈추는가. + * + *

MockRestServiceServer 대신 응답 스트림을 직접 쥐는 rig 를 쓴다 - "본문을 안 읽었다"와 "여기까지만 읽었다"는 + * 실제로 읽힌 바이트 수로만 증명되고, 그 수는 스트림을 우리가 들고 있어야 셀 수 있다. + */ +class HttpPageFetcherIntakeTest { + + private static final String URL = "https://shop.example.com/p"; + + /** 모든 host 를 공인 IP 로 해석해 SSRF 가드를 통과시킨다 - 여기선 수신 게이트만 격리해 본다. */ + private final RequestScopedDnsResolver.HostResolver publicIp = + host -> new InetAddress[] {InetAddress.getByName("93.184.216.34")}; + + @Test + @DisplayName("영상·압축파일 응답은 본문을 한 바이트도 읽지 않고 상품 페이지가 아님으로 끊는다") + void binaryContentTypeIsRejectedBeforeReadingBody() { + for (String binary : new String[] {"video/mp4", "audio/mpeg", "image/jpeg", "application/zip", + "application/pdf", "application/octet-stream"}) { + CountingInputStream body = new CountingInputStream(new ByteArrayInputStream(new byte[4096])); + HttpPageFetcher fetcher = fetcherReturning(body, MediaType.parseMediaType(binary), FetchProperties.defaults()); + + ProductSnapshotException e = assertThrows( + ProductSnapshotException.class, + () -> fetcher.fetch(ProductLink.parse(URL)), + binary + ); + + assertEquals(ExtractionErrorCode.NOT_PRODUCT_PAGE, e.code(), binary); + assertEquals(0, body.getCount(), binary + " 본문을 읽지 않아야 한다"); + } + } + + @Test + @DisplayName("Content-Type 이 없어도 통과한다 - 무헤더로 HTML 을 주는 몰이 실재해 미상까지 막으면 recall 을 잃는다") + void missingContentTypePasses() { + PageContent page = fetch(html("운동화"), null); + + assertEquals("운동화", page.document().text()); + } + + @Test + @DisplayName("text/plain 으로 온 HTML 도 통과한다 - 게이트는 명백한 바이너리만 막는다") + void textPlainPasses() { + PageContent page = fetch(html("운동화"), MediaType.TEXT_PLAIN); + + assertEquals("운동화", page.document().text()); + } + + @Test + @DisplayName("바이트 상한을 넘으면 거기서 읽기를 끊는다 - 끝나지 않는 스트림은 read timeout 에 안 걸린다") + void stopsReadingAtByteCap() { + String body = "

" + "a".repeat(400) + "

TAIL

"; + CountingInputStream stream = new CountingInputStream(html(body)); + HttpPageFetcher fetcher = fetcherReturning(stream, MediaType.TEXT_HTML, propertiesWithMaxFetchBytes(100)); + + PageContent page = fetcher.fetch(ProductLink.parse(URL)); + + assertTrue(stream.getCount() <= 100, "상한을 넘겨 읽으면 안 된다 - 실제 " + stream.getCount()); + assertFalse(page.document().text().contains("TAIL"), "상한 뒤의 내용은 들어오지 않아야 한다"); + } + + @Test + @DisplayName("본문을 읽는 중 연결이 끊기면 일시 실패다 - 대상 페이지의 확정된 문제가 아니라 재시도할 값이 있다") + void bodyReadFailureIsTransient() { + InputStream failing = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("connection reset"); + } + }; + HttpPageFetcher fetcher = fetcherReturning(failing, MediaType.TEXT_HTML, FetchProperties.defaults()); + + PageFetchException e = assertThrows(PageFetchException.class, () -> fetcher.fetch(ProductLink.parse(URL))); + + assertEquals(ExtractionErrorCode.UPSTREAM_ERROR, e.code()); + assertFalse(e.permanent()); + } + + private PageContent fetch(InputStream body, MediaType contentType) { + return fetcherReturning(body, contentType, FetchProperties.defaults()).fetch(ProductLink.parse(URL)); + } + + private static InputStream html(String html) { + return new ByteArrayInputStream(html.getBytes(StandardCharsets.UTF_8)); + } + + private HttpPageFetcher fetcherReturning(InputStream body, MediaType contentType, FetchProperties properties) { + MockClientHttpResponse response = new MockClientHttpResponse(body, HttpStatus.OK); + if (contentType != null) { + response.getHeaders().setContentType(contentType); + } + ClientHttpRequestFactory factory = (uri, method) -> { + MockClientHttpRequest request = new MockClientHttpRequest(method, uri); + request.setResponse(response); + return request; + }; + RestClient restClient = RestClient.builder().requestFactory(factory).build(); + return new HttpPageFetcher(restClient, new RequestScopedDnsResolver(publicIp), properties); + } + + private static FetchProperties propertiesWithMaxFetchBytes(int maxFetchBytes) { + FetchProperties defaults = FetchProperties.defaults(); + return new FetchProperties( + defaults.userAgent(), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + Duration.ofSeconds(2), + defaults.maxRedirects(), + maxFetchBytes, + defaults.maxRetainedChars() + ); + } +} diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectE2ETest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectE2ETest.java index 5f5cfcf..1fb149a 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectE2ETest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectE2ETest.java @@ -1,5 +1,6 @@ package com.depromeet.piki.extractor.extraction.http; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import com.depromeet.piki.extractor.domain.ProductLink; @@ -30,7 +31,7 @@ void zigzagWwwFollowsSameDomainRedirect() { PageContent page = fetcher.fetch(link); - assertTrue(page.html().length() > 1_000, "redirect 를 따라가 실제 본문을 받았어야 한다"); + assertTrue(page.retainedChars() > 1_000, "redirect 를 따라가 실제 본문을 받았어야 한다"); } @Test @@ -49,6 +50,9 @@ void musinsaOneLinkFollowsCrossDomainRedirect() { host = ""; } assertTrue(host.equals("musinsa.com") || host.endsWith(".musinsa.com"), "최종 호스트가 musinsa.com 계열이어야 한다"); - assertTrue(page.html().contains("og:title"), "최종 무신사 상품 페이지(OG 메타태그)를 받았어야 한다"); + assertNotNull( + page.document().selectFirst("meta[property=og:title]"), + "최종 무신사 상품 페이지(OG 메타태그)를 받았어야 한다" + ); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java index 15a82bc..9a75007 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java @@ -50,7 +50,7 @@ void followsSameDomainRedirect() { PageContent page = fetcher.fetch(ProductLink.parse("https://www.zigzag.kr/p")); - assertEquals("product", page.html()); + assertEquals("product", page.document().text()); // link 는 사용자 등록 원본, finalUrl 은 redirect 를 따라간 최종 URL(baseUri 용으로 구분). assertEquals("https://www.zigzag.kr/p", page.link().value().toString()); assertEquals("https://zigzag.kr/p", page.finalUrl().value().toString()); @@ -71,7 +71,7 @@ void followsCrossDomainRedirect() { PageContent page = fetcher.fetch(ProductLink.parse("https://musinsa.onelink.me/x")); - assertEquals("product", page.html()); + assertEquals("product", page.document().text()); assertEquals("https://musinsa.com/p", page.finalUrl().value().toString()); } @@ -144,7 +144,7 @@ void resolvesRelativeLocation() { PageContent page = fetcher.fetch(ProductLink.parse("https://zigzag.kr/old")); - assertEquals("moved", page.html()); + assertEquals("moved", page.document().text()); } @Test @@ -157,7 +157,7 @@ void returnsBodyDirectlyWithoutRedirect() { PageContent page = fetcher.fetch(ProductLink.parse("https://zigzag.kr/p")); - assertEquals("direct", page.html()); + assertEquals("direct", page.document().text()); assertEquals(page.link(), page.finalUrl()); } } From 6fb7a1821f698f1601dd632f921392c675ce62da 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, 25 Aug 2026 15:46:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=ED=81=AC=EA=B8=B0=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=20=EC=83=81=ED=95=9C=EC=9D=84=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=BD=94=EB=93=9C=20=EC=83=81=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EB=82=B4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수신 바이트 상한과 보존분 문자 상한을 @ConfigurationProperties 에서 빼고 HttpPageFetcher.MAX_FETCH_BYTES · PruningHtmlParser.MAX_RETAINED_CHARS 상수로 둔다 - 이 값들이 설정이던 근거는 이관 규율("하드코딩 상수를 외부화하되 기본값 유지")인데 그 규율은 명시적으로 이관 기간 한정이었고 이관은 끝났다. 되돌아본 적이 없었을 뿐 - 밖에서 조절할 대상이 아니다. 튜닝 손잡이가 아니라 안전장치이고, SSM 실측 결과 /piki-extractor/app/ 에 있는 파라미터 4개 중 이 값들은 하나도 없었다 - 설정으로 둔 내내 아무도 지정한 적이 없다 - 같은 성격의 다른 상한(GeminiHtmlExtractor.MAX_LLM_CHARS 20만, HttpHeadlessRenderer 의 해제 상한 64MB)이 이미 상수라, 이쪽만 설정이면 같은 종류가 두 방식으로 갈린다. 그 불일치를 없앤다 - 부수효과로 이번 PR 이 만든 설정 키 개명 리스크(옛 키로 지정해 둔 값이 조용히 무시되는 것)가 사라진다. 키 자체가 없어졌다 - 상한 파라미터 오버로드는 package-private 으로 남긴다. 상한 동작 검증에 3백만 자 픽스처가 필요해 실용적이지 않기 때문이고, 패키지 밖에서는 안 보여 우회할 수 없다 - 바이트 상한 테스트는 주입 대신 실제 상수를 넘기는 17MB 본문으로 검증한다. 단언은 정확히 16MiB 에서 끊겼는지를 본다 - 부등호로 두면 몇 바이트만 읽고 끝나도 통과해 상한이 걸렸다는 증거가 안 된다 - 헤드리스 상한 주입 테스트는 가지치기 배선 테스트로 교체한다. 상한을 못 넣게 됐고, 그 테스트가 실제로 지키려던 것은 "렌더 경로도 같은 가지치기를 통과하는가" 다 --- .../HeadlessExtractionProperties.java | 4 --- .../extractor/extraction/PageContent.java | 8 ++--- .../extraction/PruningHtmlParser.java | 33 ++++++++++++++----- .../headless/HttpHeadlessRenderer.java | 5 ++- .../extraction/http/FetchProperties.java | 17 +++------- .../extraction/http/HttpPageFetcher.java | 22 ++++++++----- .../extraction/EmptyShellDetectorTest.java | 2 +- .../HeadlessExtractionPropertiesTest.java | 2 +- .../extraction/PruningHtmlParserTest.java | 6 ++-- .../headless/HttpHeadlessRendererTest.java | 21 ++++++------ .../http/HttpPageFetcherIntakeTest.java | 23 ++++--------- 11 files changed, 71 insertions(+), 72 deletions(-) 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 42e965d..b13f67c 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java @@ -18,8 +18,6 @@ * @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 를 받아 해제 불가(일시 실패)가 된다. @@ -30,7 +28,6 @@ public record HeadlessExtractionProperties( @DefaultValue("") String baseUrl, @DefaultValue("2s") Duration connectTimeout, @DefaultValue("20s") Duration readTimeout, - @DefaultValue("3000000") int maxRetainedChars, @DefaultValue("true") boolean compress, @DefaultValue("") String zstdDictDir ) { @@ -71,7 +68,6 @@ public static HeadlessExtractionProperties of(boolean enabled) { enabled ? "http://headless.test:8000" : "", Duration.ofSeconds(2), Duration.ofSeconds(20), - 3_000_000, true, "" ); 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 a478f24..8e74c7c 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/PageContent.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/PageContent.java @@ -21,18 +21,14 @@ public record PageContent( int retainedChars ) { - /** - * 이미 문자열로 들고 있는 HTML 로 조립하는 편의 팩토리(redirect 를 따라가지 않은 경우). 스트림이 아니라 - * 바운드할 대상이 없으므로 상한을 두지 않는다 - 상한은 스트림을 쥔 수신 경계가 자기 설정으로 정한다. - */ + /** 이미 문자열로 들고 있는 HTML 로 조립하는 편의 팩토리(redirect 를 따라가지 않은 경우). */ public static PageContent of(ProductLink link, String html) { 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); + PruningHtmlParser.Pruned pruned = PruningHtmlParser.parse(html, finalUrl.value().toString()); 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 index e7249b8..1f16282 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/PruningHtmlParser.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/PruningHtmlParser.java @@ -31,10 +31,18 @@ public final class PruningHtmlParser { /** - * 상한 없음. 수신 경계(fetch · render)는 각자 자기 설정값을 넘기고, 이 값은 이미 메모리에 올라와 있는 - * 문자열을 파싱하는 편의 경로가 쓴다 - 그 경로에는 바운드할 스트림이 없다. + * 가지친 뒤에도 남는 분량의 상한(백스톱). 가지치기가 걷어내지 못하는 병리적 문서(마크업만으로 부푼 응답)를 + * 끊는다. 정상 페이지는 닿지 않는다. + * + *

설정이 아니라 상수인 이유: 이건 튜닝 손잡이가 아니라 안전장치다. 밖에서 바꿀 수 있게 두면 조절할 일도 + * 없으면서 키 이름이 드리프트할 자리만 생긴다(실제로 이 값이 설정이던 동안 아무도 지정한 적이 없다). 같은 + * 성격의 다른 상한들({@code GeminiHtmlExtractor.MAX_LLM_CHARS} · {@code HttpHeadlessRenderer} 의 해제 상한)도 + * 상수라, 이쪽만 설정이면 같은 종류가 두 방식으로 갈린다. + * + *

상한은 Element 단위로 걸린다(닫힌 것부터 센다). 거대한 텍스트 노드 하나짜리 문서는 여기가 아니라 + * 수신 바이트 상한이 잡는다. */ - public static final int UNBOUNDED = Integer.MAX_VALUE; + static final int MAX_RETAINED_CHARS = 3_000_000; private static final String SCRIPT = "script"; private static final String STYLE = "style"; @@ -42,11 +50,21 @@ public final class PruningHtmlParser { private PruningHtmlParser() { } + /** 수신 경계(fetch · render)가 쓰는 진입점. 상한은 {@link #MAX_RETAINED_CHARS} 로 고정이다. */ + public static Pruned parse(Reader reader, String baseUri) throws IOException { + return parse(reader, baseUri, MAX_RETAINED_CHARS); + } + + /** 이미 문자열로 들고 있는 HTML 용(렌더 응답 · 테스트). 스트림이 아니므로 메모리 이득은 없고 가지치기만 같다. */ + public static Pruned parse(String html, String baseUri) { + return parse(html, baseUri, MAX_RETAINED_CHARS); + } + /** - * @param maxRetainedChars 가지친 뒤에도 남는 분량의 상한(백스톱). 가지치기가 걷어내지 못하는 병리적 문서 - * (마크업만으로 부푼 응답)를 끊는다. 정상 페이지는 닿지 않는다. + * 상한을 명시하는 오버로드. 상한 동작 자체를 검증하려면 3백만 자 픽스처가 필요해 실용적이지 않아 열어 두되, + * 패키지 밖(수신 경계)에서는 안 보이게 해 안전장치를 우회할 수 없게 한다. */ - public static Pruned parse(Reader reader, String baseUri, int maxRetainedChars) throws IOException { + 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; @@ -72,8 +90,7 @@ public static Pruned parse(Reader reader, String baseUri, int maxRetainedChars) } } - /** 이미 문자열로 들고 있는 HTML 용(렌더 응답 · 테스트). 스트림이 아니므로 메모리 이득은 없고 가지치기만 같다. */ - public static Pruned parse(String html, String baseUri, int maxRetainedChars) { + static Pruned parse(String html, String baseUri, int maxRetainedChars) { try { return parse(new StringReader(html), baseUri, maxRetainedChars); } catch (IOException e) { 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 02f1e5c..0fbb902 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 @@ -58,7 +58,7 @@ public class HttpHeadlessRenderer implements HeadlessRenderer { private static final String ZSTD_ENCODING = "zstd"; /** * 해제 결과(JSON = 렌더 HTML + 메타)의 안전 상한. 신뢰 경계 안(내부망 renderer)이라도 해제 폭탄·오배선을 - * 바운드하려 둔다 — 정상 압축비로는 닿지 않을 만큼 넉넉하다. 가지친 뒤 보존분 상한(maxRetainedChars)은 별도다. + * 바운드하려 둔다 — 정상 압축비로는 닿지 않을 만큼 넉넉하다. 가지친 뒤 보존분 상한은 별도다(PruningHtmlParser). */ private static final int MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024; @@ -128,8 +128,7 @@ private PageContent renderVerified(ProductLink link, boolean authorized) { ProductLink finalUrl = resolveFinalUrl(response.finalUrl(), link); // 렌더된 DOM 은 정적 fetch 보다 커질 수 있어 같은 가지치기를 통과시킨다 — 하류가 보는 문서의 모양이 // 두 전략 사이에서 갈리지 않게 하려는 것이기도 하다. - PruningHtmlParser.Pruned pruned = - PruningHtmlParser.parse(html, finalUrl.value().toString(), properties.maxRetainedChars()); + PruningHtmlParser.Pruned pruned = PruningHtmlParser.parse(html, finalUrl.value().toString()); if (pruned.truncated()) { log.warn( "headless render stopped at retained cap chars={} url={}", diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/FetchProperties.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/FetchProperties.java index f68b6d9..4bfa2a2 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/FetchProperties.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/FetchProperties.java @@ -14,12 +14,9 @@ * 분 단위라, 풀 고갈·느린 upstream 에서 워커가 오래 붙잡히지 않게 짧게 명시한다(fail-fast). * @param maxRedirects 수동 redirect 추적 hop 상한. 단축·딥링크 체인이 실측 2 hop 까지 쓰므로 여유를 두되 무한·과도한 * 체인은 여기서 끊는다. hop 마다 host 를 재검증(SSRF)하므로 상한을 늘려도 보안 부담이 커지지 않는다. - * @param maxFetchBytes 응답 스트림에서 읽을 바이트 상한. 가지치기가 걷어내는 분량은 메모리에 남지 않지만, - * 끝나지 않는 스트림은 read timeout 에도 안 걸리므로(데이터가 계속 도착한다) 읽기 자체를 여기서 끊는다. - * PageFetchHttpClientConfig 가 HttpClient5 의 기본 content decompression 을 끄지 않으므로 이 상한은 - * 해제 후 바이트에 걸린다 - 압축 폭탄이 노리는 지점이 바로 거기다. - * @param maxRetainedChars 가지친 뒤에도 남는 분량의 상한(백스톱). 가지치기로 안 줄어드는 병리적 문서를 끊어 - * 동시 파싱 메모리(상한 x 동시 수)를 바운드한다. 정상 페이지는 여기 닿지 않는다. + * + *

크기 안전 상한(수신 바이트 · 보존분 문자)은 여기 없다 - 튜닝 손잡이가 아니라 안전장치라 + * {@code HttpPageFetcher.MAX_FETCH_BYTES} · {@code PruningHtmlParser.MAX_RETAINED_CHARS} 상수로 둔다. */ @ConfigurationProperties("fetch") public record FetchProperties( @@ -27,9 +24,7 @@ public record FetchProperties( @DefaultValue("5s") Duration connectTimeout, @DefaultValue("15s") Duration readTimeout, @DefaultValue("2s") Duration connectionRequestTimeout, - @DefaultValue("5") int maxRedirects, - @DefaultValue("16777216") int maxFetchBytes, - @DefaultValue("3000000") int maxRetainedChars + @DefaultValue("5") int maxRedirects ) { static final String DEFAULT_USER_AGENT = @@ -46,9 +41,7 @@ public static FetchProperties defaults() { Duration.ofSeconds(5), Duration.ofSeconds(15), Duration.ofSeconds(2), - 5, - 16 * 1024 * 1024, - 3_000_000 + 5 ); } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java index dd5a7a1..f526b30 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java @@ -68,6 +68,17 @@ public class HttpPageFetcher implements PageFetcher { * 없이 주는 몰이 실재해서(무헤더 UTF-8 페이지는 charset 폴백 근거이기도 하다) "HTML 계열만 통과"로 막으면 * 정상 상품 페이지가 함께 잘린다. 그래서 명백한 것만 막고 미상은 통과시킨다. */ + /** + * 응답 스트림에서 읽을 바이트 상한. 가지치기가 걷어내는 분량은 메모리에 남지 않지만, 끝나지 않는 스트림은 + * read timeout 에도 안 걸리므로(데이터가 계속 도착한다) 읽기 자체를 여기서 끊는다. PageFetchHttpClientConfig 가 + * HttpClient5 의 기본 content decompression 을 끄지 않으므로 이 상한은 해제 후 바이트에 걸린다 - + * 압축 폭탄이 노리는 지점이 바로 거기다. + * + *

설정이 아니라 상수인 이유는 {@code PruningHtmlParser.MAX_RETAINED_CHARS} 와 같다: 안전장치라 밖에서 + * 조절할 대상이 아니다. 정상 상품 페이지는 실측 수 MB 라 닿지 않을 만큼 넉넉하다. + */ + private static final int MAX_FETCH_BYTES = 16 * 1024 * 1024; + private static final Set BINARY_TYPES = Set.of("video", "audio", "image", "font"); private static final Set BINARY_APPLICATION_SUBTYPES = Set.of("octet-stream", "pdf", "zip", "gzip", "x-gzip", "x-tar", "x-7z-compressed", "x-rar-compressed"); @@ -76,8 +87,6 @@ public class HttpPageFetcher implements PageFetcher { private final RequestScopedDnsResolver dnsResolver; private final InternalHostGuard internalHostGuard; private final int maxRedirects; - private final int maxFetchBytes; - private final int maxRetainedChars; /** * RestClient 빈이 둘(pageFetch·headlessRender)이라 {@code @Qualifier} 로 어느 쪽인지 명시한다 — 없으면 @@ -93,8 +102,6 @@ public HttpPageFetcher( // 가드와 연결이 같은 resolver 를 봐야 IP pin 이 성립한다 — 같은 인스턴스로 직접 조립해 그 계약을 코드로 박는다. this.internalHostGuard = new InternalHostGuard(dnsResolver); this.maxRedirects = properties.maxRedirects(); - this.maxFetchBytes = properties.maxFetchBytes(); - this.maxRetainedChars = properties.maxRetainedChars(); } @Override @@ -220,7 +227,7 @@ private void rejectBinary(ProductLink current, MediaType contentType) { private PruningHtmlParser.Pruned readPruned(ProductLink current, MediaType contentType, InputStream body) throws IOException { CountingInputStream counting = new CountingInputStream(body); - BufferedInputStream buffered = new BufferedInputStream(ByteStreams.limit(counting, maxFetchBytes)); + BufferedInputStream buffered = new BufferedInputStream(ByteStreams.limit(counting, MAX_FETCH_BYTES)); buffered.mark(META_SCAN_BYTES); byte[] head = buffered.readNBytes(META_SCAN_BYTES); if (head.length == 0) { @@ -230,9 +237,8 @@ private PruningHtmlParser.Pruned readPruned(ProductLink current, MediaType conte buffered.reset(); Reader reader = new InputStreamReader(buffered, charsetOf(contentType, head)); - PruningHtmlParser.Pruned pruned = - PruningHtmlParser.parse(reader, current.value().toString(), maxRetainedChars); - if (counting.getCount() >= maxFetchBytes) { + PruningHtmlParser.Pruned pruned = PruningHtmlParser.parse(reader, current.value().toString()); + if (counting.getCount() >= MAX_FETCH_BYTES) { log.warn("link fetch stopped at byte cap bytes={} url={}", counting.getCount(), current.safeLogString()); } return pruned; diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java index 8da0d20..edd554b 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java @@ -55,6 +55,6 @@ void contentRichPageIsNotEmptyShell() { /** 운영에서 판정에 닿는 문서는 항상 가지친 것이므로 같은 경로로 만든다. */ private static Document document(String html) { - return PruningHtmlParser.parse(html, "https://shop.example.com/p", PruningHtmlParser.UNBOUNDED).document(); + return PruningHtmlParser.parse(html, "https://shop.example.com/p").document(); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionPropertiesTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionPropertiesTest.java index fb0a021..50b578b 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionPropertiesTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionPropertiesTest.java @@ -17,7 +17,7 @@ class HeadlessExtractionPropertiesTest { private HeadlessExtractionProperties propertiesWith(boolean enabled, String baseUrl) { - return new HeadlessExtractionProperties(enabled, baseUrl, Duration.ofSeconds(2), Duration.ofSeconds(20), 3_000_000, true, ""); + return new HeadlessExtractionProperties(enabled, baseUrl, Duration.ofSeconds(2), Duration.ofSeconds(20), true, ""); } @ParameterizedTest(name = "enabled=true + base-url [{0}] 은 부팅에서 거부된다") diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/PruningHtmlParserTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/PruningHtmlParserTest.java index ac3f7d8..72e4f34 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/PruningHtmlParserTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/PruningHtmlParserTest.java @@ -85,7 +85,7 @@ void stopsAtRetainedCap() { @DisplayName("상한에 닿지 않은 문서는 truncated 가 아니다 - 정상 페이지가 조사 신호를 내지 않게") void normalDocumentIsNotTruncated() { PruningHtmlParser.Pruned pruned = - PruningHtmlParser.parse("

본문

", BASE_URI, PruningHtmlParser.UNBOUNDED); + PruningHtmlParser.parse("

본문

", BASE_URI); assertFalse(pruned.truncated()); assertTrue(pruned.retainedChars() > 0); @@ -97,7 +97,7 @@ void hugeInlineScriptDoesNotInflateRetained() { String huge = "var payload = \"" + "x".repeat(5_000_000) + "\";"; String html = "

운동화

"; - PruningHtmlParser.Pruned pruned = PruningHtmlParser.parse(html, BASE_URI, PruningHtmlParser.UNBOUNDED); + PruningHtmlParser.Pruned pruned = PruningHtmlParser.parse(html, BASE_URI); assertFalse(pruned.truncated(), "상한이 아니라 가지치기로 줄어야 한다"); assertTrue(pruned.retainedChars() < 1_000, "5MB script 가 보존분에 들어오면 안 된다 - 실제 " + pruned.retainedChars()); @@ -113,6 +113,6 @@ void baseUriResolvesRelativeUrls() { } private static Document prune(String html) { - return PruningHtmlParser.parse(html, BASE_URI, PruningHtmlParser.UNBOUNDED).document(); + return PruningHtmlParser.parse(html, BASE_URI).document(); } } 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 a59f636..3f80f42 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,6 +1,7 @@ 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; @@ -25,6 +26,7 @@ import java.time.Duration; import java.util.List; 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; @@ -283,22 +285,21 @@ void renderServiceErrorIsTransient() { } @Test - @DisplayName("렌더된 HTML 도 보존분 상한을 넘으면 거기서 파싱을 멈춘다") - void renderedHtmlStopsAtRetainedCap() { - HeadlessExtractionProperties small = new HeadlessExtractionProperties( - true, BASE_URL, Duration.ofSeconds(2), Duration.ofSeconds(20), 12, true, "" - ); - HttpHeadlessRenderer renderer = rendererWith(small, publicIp, ZstdDictionaries.none(), server -> server + @DisplayName("렌더된 HTML 도 정적 fetch 와 같은 가지치기를 통과한다 - 두 전략이 하류에 넘기는 문서 모양이 갈리면 안 된다") + void renderedHtmlIsPruned() { + HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess( - "{\"verdict\":\"OK\",\"html\":\"

aaaaaaaaaa

bbbbbbbbbb

cccccccccc

\"}", + "{\"verdict\":\"OK\",\"html\":\"" + + "

운동화

\"}", MediaType.APPLICATION_JSON ))); - String text = renderer.render(link, false).document().text(); + Document document = renderer.render(link, false).document(); - assertTrue(text.contains("aaaaaaaaaa"), "상한 전까지의 내용은 남아야 한다"); - assertFalse(text.contains("cccccccccc"), "상한을 넘긴 뒤의 내용은 들어오지 않아야 한다"); + assertNull(document.selectFirst("style"), "style 은 버려져야 한다"); + assertNull(document.selectFirst("script"), "데이터 아닌 script 는 버려져야 한다"); + assertEquals("운동화", document.text(), "본문은 남아야 한다"); } @Test diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherIntakeTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherIntakeTest.java index c97661b..0a53437 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherIntakeTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherIntakeTest.java @@ -15,7 +15,6 @@ import java.io.InputStream; import java.net.InetAddress; import java.nio.charset.StandardCharsets; -import java.time.Duration; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -78,14 +77,18 @@ void textPlainPasses() { @Test @DisplayName("바이트 상한을 넘으면 거기서 읽기를 끊는다 - 끝나지 않는 스트림은 read timeout 에 안 걸린다") void stopsReadingAtByteCap() { - String body = "

" + "a".repeat(400) + "

TAIL

"; + // 상한이 상수라 주입할 수 없으므로 진짜로 상한을 넘기는 본문을 준다. 내용은 전부 가지치기 대상(JS)이라 + // 상한이 없다면 끝의 TAIL 까지 읽혀 문서에 들어온다 - 그게 안 들어오는 것이 상한이 걸렸다는 증거다. + String body = "

TAIL

"; CountingInputStream stream = new CountingInputStream(html(body)); - HttpPageFetcher fetcher = fetcherReturning(stream, MediaType.TEXT_HTML, propertiesWithMaxFetchBytes(100)); + HttpPageFetcher fetcher = fetcherReturning(stream, MediaType.TEXT_HTML, FetchProperties.defaults()); PageContent page = fetcher.fetch(ProductLink.parse(URL)); - assertTrue(stream.getCount() <= 100, "상한을 넘겨 읽으면 안 된다 - 실제 " + stream.getCount()); + // 정확히 상한에서 멈췄음을 본다. <= 로 두면 몇 바이트만 읽고 끝나도 통과해 상한이 걸렸다는 증거가 안 된다. + assertEquals(16 * 1024 * 1024, stream.getCount(), "상한 지점에서 정확히 끊겨야 한다"); assertFalse(page.document().text().contains("TAIL"), "상한 뒤의 내용은 들어오지 않아야 한다"); + assertTrue(page.retainedChars() < 1_000, "가지치기로 보존분은 작아야 한다 - 실제 " + page.retainedChars()); } @Test @@ -127,16 +130,4 @@ private HttpPageFetcher fetcherReturning(InputStream body, MediaType contentType return new HttpPageFetcher(restClient, new RequestScopedDnsResolver(publicIp), properties); } - private static FetchProperties propertiesWithMaxFetchBytes(int maxFetchBytes) { - FetchProperties defaults = FetchProperties.defaults(); - return new FetchProperties( - defaults.userAgent(), - Duration.ofSeconds(5), - Duration.ofSeconds(15), - Duration.ofSeconds(2), - defaults.maxRedirects(), - maxFetchBytes, - defaults.maxRetainedChars() - ); - } }