h-lab/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java
hehihoho3@gmail.com 92726b1c08 feat(comment-cards): 전체 댓글 수집 + 인사이트 분석 패널 추가
- 수집 상한 1,000→20,000개(안전 상한)로 상향해 전체 댓글 기준 분석
- 분석 패널: 많이 언급된 타임라인(클릭 시 해당 시점 유튜브 이동), 자주 나온 단어(조사 제거+불용어, 좋아요 가중 토글), 요약 통계(총/평균 좋아요·참여자·최다 좋아요 댓글·월별 댓글량)
- 모자이크 기본 ON, '모서리 둥글게' 토글 추가(기본 각진 모서리)
- 카드 렌더는 상위 2,000개로 제한(브라우저 보호), 분석은 전체 기준

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:37:15 +09:00

160 lines
6.6 KiB
Java

package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.RestClientResponseException;
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개 → 최대 20,000개. 대부분 영상은 이 이전에 nextPageToken이 끝나 전체 수집됨.
*/
private static final int MAX_PAGES = 200;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 일시적 오류(transient 403/5xx) 재시도 횟수와 백오프(ms). 테스트에서 0으로 낮춤. */
private static final int MAX_RETRIES = 2;
private long retryBackoffMs = 600;
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 = fetchPageWithRetry(b.build().encode().toUri());
} catch (RestClientResponseException e) {
// 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시)
if (!result.isEmpty()) break;
throw new IllegalArgumentException(messageFor(extractReason(e.getResponseBodyAsString())));
}
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;
}
/**
* 페이지 1개를 호출하되, 일시적 오류(generic 403 forbidden, rateLimitExceeded, 5xx 등)는 재시도한다.
* 회복 불가능한 오류(commentsDisabled/quotaExceeded 등)는 즉시 전파해 불필요한 대기를 막는다.
*/
private JsonNode fetchPageWithRetry(java.net.URI uri) {
RestClientResponseException last = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return restTemplate.getForObject(uri, JsonNode.class);
} catch (RestClientResponseException e) {
if (isNonRetryable(extractReason(e.getResponseBodyAsString()))) {
throw e;
}
last = e;
if (attempt < MAX_RETRIES) {
sleepQuietly(retryBackoffMs * (attempt + 1));
}
}
}
throw last;
}
/** 재시도해도 회복되지 않는 reason(영구적 거부). */
private static boolean isNonRetryable(String reason) {
return "commentsDisabled".equals(reason)
|| "quotaExceeded".equals(reason)
|| "dailyLimitExceeded".equals(reason);
}
private static void sleepQuietly(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
/** YouTube 오류 응답 본문에서 error.errors[0].reason 추출. 실패 시 null. */
private static String extractReason(String body) {
if (body == null || body.isBlank()) return null;
try {
JsonNode errs = MAPPER.readTree(body).path("error").path("errors");
if (errs.isArray() && errs.size() > 0) {
String reason = errs.get(0).path("reason").asText(null);
if (reason != null && !reason.isBlank()) return reason;
}
} catch (Exception ignore) {
// 본문이 JSON이 아니면 reason 없음
}
return null;
}
/** YouTube 오류 reason → 사용자용 안내 메시지. */
private static String messageFor(String reason) {
if ("commentsDisabled".equals(reason)) {
return "이 영상은 댓글이 비활성화되어 있습니다.";
}
if ("quotaExceeded".equals(reason) || "dailyLimitExceeded".equals(reason) || "rateLimitExceeded".equals(reason)) {
return "YouTube API 일일 쿼터를 초과했습니다. 잠시 후 다시 시도해주세요.";
}
return "YouTube API 호출에 실패했습니다" + (reason != null ? " (" + reason + ")" : "") + ".";
}
}