feat(comment-cards): commentThreads 댓글 수집 서비스 추가

CommentCardDto + YoutubeCommentService(최대 5페이지, 쿼터가드, 댓글비활성 처리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-29 17:05:27 +09:00
parent 39e2b42995
commit 15a655d7f9
2 changed files with 108 additions and 0 deletions

View File

@ -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<CommentCardDto> fetchComments(String urlOrId) {
String videoId = YoutubeVideoIdParser.parse(urlOrId);
if (videoId == null) {
throw new IllegalArgumentException("유효한 유튜브 링크가 아닙니다.");
}
List<CommentCardDto> 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;
}
}

View File

@ -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 문자열
}