From 7f8308973126a221b7106153869c55d1a2abd0a9 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: Sun, 23 Aug 2026 21:41:25 +0900
Subject: [PATCH] =?UTF-8?q?fix:=20og:image=20=EC=9D=98=20http=C2=B7?=
=?UTF-8?q?=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EC=83=81=EB=8C=80=20?=
=?UTF-8?q?=EC=A3=BC=EC=86=8C=EB=A5=BC=20=EB=B2=84=EB=A6=AC=EC=A7=80=20?=
=?UTF-8?q?=EC=95=8A=EA=B3=A0=20https=20=EB=A1=9C=20=EC=98=AC=EB=A6=B0?=
=?UTF-8?q?=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- prod 에서 postarchivefaction 상품이 이미지만 빠진 INCOMPLETE 로 떨어졌는데, og:image 가 HTML 에 있었다. 못 찾은 게 아니라 normalizeImageUrl 이 https 로 시작하지 않는다는 이유로 조용히 버린 것이었다
- 같은 주소가 https 로는 200 image/jpeg 로 정상이다(실측, http 는 301 로 https 에 넘긴다). 사이트가 og:image 에 http 를 적어뒀을 뿐 이미지는 멀쩡했다
- 값이 처음부터 있었는데 버려진 탓에 승격 체인이 통째로 헛돌았다: LLM 86k 토큰 -> 브라우저 렌더 3.3초 -> LLM 11k 토큰 -> 같은 http 값을 또 버림 -> INCOMPLETE
- http 그대로 저장하는 선택지는 없다. 클라이언트가 https 라 브라우저가 mixed content 로 막아 저장해도 안 보인다. 승격이 실패하면 이미지가 안 뜨는데, 버리면 애초에 없으므로 더 나빠지지 않는다
- 프로토콜 상대(//host/path)도 같은 성격이라 함께 살린다. 스킴만 없을 뿐 멀쩡한 주소다
- javascript:·data:·file: 은 그대로 거부한다. 가드가 원래 막으려던 것이 이쪽이고, 스킴을 갈아끼워 살릴 수 있는 값도 아니다. 뭉툭했던 건 위험한 값과 고쳐 쓸 수 있는 값을 한 덩어리로 취급한 것이었다
---
.../extractor/domain/ProductSnapshot.java | 21 +++++++++++---
.../extractor/domain/ProductSnapshotTest.java | 28 ++++++++++++++++---
.../StructuredDataExtractorTest.java | 19 ++++++++++---
3 files changed, 56 insertions(+), 12 deletions(-)
diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java
index 44a48ae..a629312 100644
--- a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java
+++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java
@@ -81,7 +81,8 @@ public String missingFieldNames() {
/**
* 구조화 파싱과 LLM 추출이 함께 통과하는 정규화·범위검증의 단일 진실 원천.
- * imageUrl 을 https 로만 좁히는 것은 클라이언트가 {@code } 로 쓸 때의 XSS 사다리를 끊기 위한 것이다.
+ * imageUrl 을 https 로 좁히는 것은 클라이언트가 {@code
} 로 쓸 때의 XSS 사다리를 끊기 위한 것이다.
+ * 다만 좁히는 방식은 스킴에 따라 다르다 — 자세한 근거는 normalizeImageUrl 참조.
*
범위를 벗어난 값은 {@link ProductSnapshotException#untrustworthyValue()} 로 막고, 그 뒤 처리는 호출부가 고른다:
* 구조화 경로는 예외를 흡수해 Miss(LLM fallback)로, LLM 경로는 그대로 흘려 확정 실패로 떨어뜨린다.
* 같은 검증, 실패 표현만 다르다.
@@ -121,9 +122,21 @@ private static String normalizeImageUrl(String imageUrl) {
if (imageUrl == null || imageUrl.isBlank()) {
return null;
}
- if (!imageUrl.regionMatches(true, 0, "https://", 0, "https://".length())) {
- return null;
+ if (imageUrl.regionMatches(true, 0, "https://", 0, "https://".length())) {
+ return imageUrl;
+ }
+ // http·프로토콜 상대(//host/path)는 버리지 않고 https 로 올린다. 스킴만 다를 뿐 같은 자원을 가리키고,
+ // 실제로 og:image 에 http 를 적어 둔 몰이 있다(실측: postarchivefaction, http 는 301→https).
+ // 올린 주소가 안 되면 이미지가 안 뜨는데, 버리면 애초에 이미지가 없으므로 더 나빠지지 않는다.
+ // 반대로 http 그대로 두는 선택지는 없다 — 클라이언트가 https 라 브라우저가 mixed content 로 막는다.
+ if (imageUrl.regionMatches(true, 0, "http://", 0, "http://".length())) {
+ return "https://" + imageUrl.substring("http://".length());
+ }
+ if (imageUrl.startsWith("//")) {
+ return "https:" + imageUrl;
}
- return imageUrl;
+ // 나머지 스킴(javascript:·data:·file: 등)은 그대로 거부한다 — 이쪽이 원래 막으려던 것이고,
+ // 스킴을 갈아끼워 살릴 수 있는 값도 아니다.
+ return null;
}
}
diff --git a/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java b/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java
index 254f126..a05e2bb 100644
--- a/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java
@@ -21,13 +21,12 @@ void blankNameToNull() {
}
@Test
- @DisplayName("imageUrl 은 https 가 아니면 null 로 정규화된다")
- void nonHttpsImageUrlToNull() {
+ @DisplayName("스킴을 갈아끼워 살릴 수 없는 imageUrl 만 null 로 정규화된다")
+ void unusableImageUrlToNull() {
List