- 수집 상한 1,000→20,000개(안전 상한)로 상향해 전체 댓글 기준 분석 - 분석 패널: 많이 언급된 타임라인(클릭 시 해당 시점 유튜브 이동), 자주 나온 단어(조사 제거+불용어, 좋아요 가중 토글), 요약 통계(총/평균 좋아요·참여자·최다 좋아요 댓글·월별 댓글량) - 모자이크 기본 ON, '모서리 둥글게' 토글 추가(기본 각진 모서리) - 카드 렌더는 상위 2,000개로 제한(브라우저 보호), 분석은 전체 기준 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
6.6 KiB
Java
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 + ")" : "") + ".";
|
|
}
|
|
}
|