파싱 no-data + CSR 셸을 헤드리스 에스컬레이션 대상으로 재분류 - #22
Conversation
- 헤드리스 에스컬레이션이 fetch 예외(403·500 등)에서만 발동해, 카카오 톡딜(store.kakao.com·clink 브리지)처럼 200 으로 데이터 없는 SPA 셸을 주는 몰은 LLM 까지 가서 확정 실패했다(실측). 파싱 no-data(ProductSnapshotException) 이면서 본문 가시 텍스트가 셸 수준(300자 미만)이면 PageFetchException.emptyShell(escalatable, EMPTY_SHELL)로 재분류해 헤드리스가 이어받게 한다 - 판정은 가시 텍스트 길이 단일 기준 — 셸은 수십 자, 콘텐츠 페이지(블로그 등)는 수백 자 이상이라 간극이 넓고, 짧은 정상 페이지의 오탐은 헤드리스 1회 낭비로 그쳐 fail-open 이 싸다(escalation 메트릭 category=EMPTY_SHELL 로 관측) - LLM 일시 오류(GeminiApiException)는 페이지 문제가 아니라 재분류하지 않고 호출자 재시도 축에 남긴다. READY 필드 미달(UNTRUSTWORTHY_VALUE)의 응답 계층 throw 는 이 경로 밖이라 영향 없다 - 이로써 HEADLESS_FIRST 정책 행은 정확성의 전제에서 느린 실패를 건너뛰는 latency 최적화로 강등된다 - DB 초기화로 정책 행이 사라져도(kream 사례) 추출 자체는 동작한다
📝 WalkthroughWalkthroughCSR 빈 셸을 감지하는 ChangesCSR 빈 셸 처리
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant DefaultProductLinkExtractor
participant EmptyShellDetector
participant PageFetchException
participant ExtractionLinkAPI
DefaultProductLinkExtractor->>EmptyShellDetector: HTML 가시 텍스트 검사
EmptyShellDetector-->>DefaultProductLinkExtractor: 빈 셸 여부 반환
DefaultProductLinkExtractor->>PageFetchException: EMPTY_SHELL 예외 생성
PageFetchException-->>ExtractionLinkAPI: 422 EMPTY_SHELL 응답
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java`:
- Around line 29-35: Restrict the EMPTY_SHELL conversion in the
ProductSnapshotException catch block to NOT_PRODUCT_PAGE errors only. Before
calling EmptyShellDetector.isEmptyShell(page.html()) and throwing
PageFetchException.emptyShell(e), validate the exception’s classification;
propagate UNTRUSTWORTHY_VALUE and other non-no-data errors unchanged.
In
`@src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java`:
- Around line 25-26: Enforce the non-null contract at both method boundaries: in
EmptyShellDetector.isEmptyShell, validate html with Objects.requireNonNull and
an appropriate message before Jsoup.parse; in PageFetchException, validate cause
with Objects.requireNonNull and an appropriate message before constructing the
exception. Apply the requested changes in
src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java
lines 25-26 and
src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java
lines 78-79.
- Line 20: Externalize the MIN_VISIBLE_TEXT_CHARS policy value from
EmptyShellDetector into a `@ConfigurationProperties-backed` configuration
property, preserving the Kotlin source’s default value of 300. Update
EmptyShellDetector to inject and use that property instead of the hardcoded
constant, following the project’s existing configuration-property conventions.
In
`@src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java`:
- Around line 37-45: Update contentRichPageIsNotEmptyShell to directly cover the
299/300-character boundary: assert that a 299-character page is an empty shell
and a 300-character page is not, while retaining the existing representative
content case if appropriate.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 2c084d44-3254-4a62-96b4-60994e49fdeb
📒 Files selected for processing (8)
docs/api-contract.mdsrc/main/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCode.javasrc/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.javasrc/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.javasrc/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.javasrc/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.javasrc/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.javasrc/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java
- isEmptyShell(html)·emptyShell(cause) 는 non-null 이 전제인데 호출 체인 내부 예외(jsoup NPE 등)에 기대고 있었다 - repo 규약(Objects.requireNonNull 로 경계 강제)대로 각 메서드 진입부에서 검증 - emptyShell 은 다른 팩토리와 달리 cause 필수(재분류 예외라 원래의 파싱 실패 없이 만들어질 수 없음)임을 주석으로 명시
- 대표 사례만 있던 셸 판정에 임계값 경계(299자=셸, 300자=아님)를 직접 단언해 분기 망라
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java (1)
9-35: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
null입력 계약을 직접 검증하세요.
EmptyShellDetector.isEmptyShell은 진입부에서Objects.requireNonNull(html, "html")을 호출합니다. 현재 테스트는 정상적인 HTML만 검증합니다.null입력이NullPointerException과"html"메시지를 발생시키는지 테스트를 추가하세요.테스트 예시
+ `@Test` + `@DisplayName`("html이 null이면 예외를 발생시킨다") + void nullHtmlIsRejected() { + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> EmptyShellDetector.isEmptyShell(null) + ); + assertEquals("html", exception.getMessage()); + }As per coding guidelines, “단위는 Spring 없이 분기를 망라한다.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java` around lines 9 - 35, EmptyShellDetectorTest에 null 입력 계약 검증을 추가하세요. EmptyShellDetector.isEmptyShell(null)을 호출할 때 NullPointerException이 발생하고 예외 메시지가 "html"인지 함께 검증하며, 기존 정상 HTML 테스트는 유지하세요.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In
`@src/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java`:
- Around line 9-35: EmptyShellDetectorTest에 null 입력 계약 검증을 추가하세요.
EmptyShellDetector.isEmptyShell(null)을 호출할 때 NullPointerException이 발생하고 예외 메시지가
"html"인지 함께 검증하며, 기존 정상 HTML 테스트는 유지하세요.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3bf9da99-5df5-4d6c-81c0-b59a50cbdb30
📒 Files selected for processing (3)
src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.javasrc/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.javasrc/test/java/com/depromeet/piki/extractor/extraction/EmptyShellDetectorTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
- src/main/java/com/depromeet/piki/extractor/extraction/EmptyShellDetector.java
- src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java
* feat: LLM 이 볼 게 없는 빈 셸을 LLM 호출 전에 확정 실패로 차단 - 렌더 후에도 빈 CSR 셸을 LLM 에 넘기면 실존하지 않는 상품을 지어낸다(에이블리 mobile.* 실측: 같은 URL 15회 중 200 이 8회, 전부 서로 다른 가짜 상품에 이미지 URL 은 NXDOMAIN·403). 지어낸 값은 형식이 유효해 응답 경계도 호출자(core) 검증도 통과해 READY 로 저장됐다 - 입력이 비었음을 아는 파이프라인이 LLM 호출 전에 NO_EXTRACTABLE_CONTENT 확정 실패로 끊는다 - 판정(LlmInputGate)은 가시 텍스트 50자 미만 AND 데이터 script 부재. 에스컬레이션 축(EmptyShellDetector 300자)의 임계를 재사용하지 않는다 - 그쪽 오탐은 헤드리스 1회 낭비지만 게이트 오탐은 확정 422 로 굳어 비용이 다르다. 가시 텍스트가 없어도 JSON-LD·data island 가 있으면 LLM 이 읽을 수 있으므로 통과시키며, 그 데이터 script 판정은 DataScripts 로 이관해 sanitize 와 single source 를 공유한다 - 전략 코드는 무수정: plain 은 기존 셸 재분류 catch(#22)가 게이트 예외를 받아 그대로 헤드리스로 승격하고, 헤드리스 결과까지 셸일 때만 새 code 가 422 로 표면화된다. NOT_PRODUCT_PAGE 와 code 를 나눈 이유는 호출자 관측 - "상품 아닌 링크"와 "몰을 못 읽음"이 한 code 로 섞이면 후자를 추적할 수 없다 - 카운터 via 를 structured/skipped_shell/llm 3분기로 재구성({via,reason} 키 집합 불변, 발행 지점 한 곳 유지)하고 게이트 발동 시 host 포함 로그를 남긴다 - "가시 텍스트도 script 도 없는 정상 상품 페이지" 오탐 실재 여부는 배포 후 이 로그의 host 분포로 감시한다 * refactor: 게이트의 길이 임계를 제거하고 가시 텍스트 전무(0자)만 본다 - "50자도 결국 자의적 임계값" 지적로 재검토: 0-300자 구간에 실측된 정상 페이지가 없어 마진 크기(30이든 80이든)를 정당화할 데이터가 없다. 판단(짧다)을 사실(전혀 없다)로 치환해 임계값 논쟁 자체를 없앤다 - 상수가 사라지고 판정은 text().isEmpty() 가 된다 - 트레이드오프를 알고 내린 선택이다: 놓친 셸(몇십 자 보일러플레이트만 있는 미지의 셸)은 LLM 으로 흘러 환각 가능성이 남고 그 실패는 조용하다. 반면 임계 오탐(정상 미니멀 페이지 차단)은 영구 422 로 굳는다. 잔존 위험 감시는 via=llm 로그의 html 크기·host 분포가 담당한다(셸은 본문이 극단적으로 작아 분포에서 드러난다) - 실측된 사고 케이스는 전부 렌더 후 0자라 차단 효과는 동일하다. 경계 테스트는 49/50 에서 "공백뿐 body 발동 / 한 글자면 통과"로 교체
Situation
Task
Action
EMPTY_SHELL, 확정 실패)로 바꿔 던진다. 기존 에스컬레이션 판정(escalatable 인 fetch 예외만 헤드리스로)은 그대로라, 재분류만으로 헤드리스 폴백이 이어진다.EmptyShellDetector(jsoup body 텍스트 길이) 신설,PageFetchException.emptyShell(cause)팩토리와ExtractionErrorCode.EMPTY_SHELL추가,DefaultProductLinkExtractor가ProductSnapshotException만 잡아 재분류. 헤드리스 전략 쪽 파이프라인은 건드리지 않는다 (이미 헤드리스인데 또 에스컬레이트할 곳이 없다).docs/api-contract.md의 422 code 표에EMPTY_SHELL추가 (additive, 호출자 core 는 status 만 보므로 tolerant).Result
연관 이슈
Summary by CodeRabbit
개선 사항
EMPTY_SHELL오류로 분류되며, 헤드리스 추출이 활성화된 경우 헤드리스 결과를 우선 제공합니다.문서
EMPTY_SHELL이 추가되었습니다.