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