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 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 = 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 + ")" : "") + "."; } }