From 15a655d7f9946673e1ffcc9b383b121de390a784 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 29 Jun 2026 17:05:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(comment-cards):=20commentThreads=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=88=98=EC=A7=91=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommentCardDto + YoutubeCommentService(최대 5페이지, 쿼터가드, 댓글비활성 처리) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/YoutubeCommentService.java | 87 +++++++++++++++++++ .../hlab/yanalyst/web/dto/CommentCardDto.java | 21 +++++ 2 files changed, 108 insertions(+) create mode 100644 src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java create mode 100644 src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java diff --git a/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java b/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java new file mode 100644 index 0000000..73169e1 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java @@ -0,0 +1,87 @@ +package com.hlab.yanalyst.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; +import com.hlab.yanalyst.web.dto.CommentCardDto; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; + +/** + * YouTube Data API commentThreads.list 로 영상의 최상위 댓글을 수집한다. + * DB 저장 없이 일회성 조회. 댓글 카드(/comment-cards) 페이지에서 사용. + */ +@Service +@RequiredArgsConstructor +public class YoutubeCommentService { + + private final RestTemplate restTemplate; + private final YoutubeQuotaGuard quotaGuard; + + @Value("${youtube.api.key}") + private String youtubeApiKey; + + /** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */ + private static final int MAX_PAGES = 5; + + public List fetchComments(String urlOrId) { + String videoId = YoutubeVideoIdParser.parse(urlOrId); + if (videoId == null) { + throw new IllegalArgumentException("유효한 유튜브 링크가 아닙니다."); + } + + List result = new ArrayList<>(); + String apiUrl = "https://www.googleapis.com/youtube/v3/commentThreads"; + String pageToken = null; + + for (int page = 0; page < MAX_PAGES; page++) { + if (!quotaGuard.tryConsume(1)) break; // 쿼터 소진 시 모은 만큼 반환 + + UriComponentsBuilder b = UriComponentsBuilder.fromHttpUrl(apiUrl) + .queryParam("part", "snippet") + .queryParam("videoId", videoId) + .queryParam("order", "relevance") + .queryParam("maxResults", 100) + .queryParam("key", youtubeApiKey); + if (pageToken != null) { + b.queryParam("pageToken", pageToken); + } + + JsonNode root; + try { + root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class); + } catch (HttpClientErrorException.Forbidden e) { + if (result.isEmpty()) { + throw new IllegalArgumentException("이 영상은 댓글이 비활성화되어 있거나 접근할 수 없습니다."); + } + break; + } + + if (root == null || !root.has("items")) break; + + for (JsonNode item : root.get("items")) { + JsonNode top = item.path("snippet").path("topLevelComment").path("snippet"); + CommentCardDto dto = CommentCardDto.builder() + .authorName(top.path("authorDisplayName").asText("")) + .profileImageUrl(top.path("authorProfileImageUrl").asText("")) + .text(top.path("textDisplay").asText("")) + .likeCount(top.path("likeCount").asLong(0)) + .replyCount(item.path("snippet").path("totalReplyCount").asLong(0)) + .publishedAt(top.path("publishedAt").asText("")) + .build(); + result.add(dto); + } + + if (!root.has("nextPageToken")) break; + pageToken = root.get("nextPageToken").asText(); + } + + return result; + } +} diff --git a/src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java b/src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java new file mode 100644 index 0000000..9077317 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java @@ -0,0 +1,21 @@ +package com.hlab.yanalyst.web.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CommentCardDto { + private String authorName; + private String profileImageUrl; + private String text; // YouTube textDisplay (HTML 포함 가능 — 프론트에서 안전 렌더) + private long likeCount; + private long replyCount; + private String publishedAt; // ISO-8601 문자열 +}