feat(comment-cards): commentThreads 댓글 수집 서비스 추가
CommentCardDto + YoutubeCommentService(최대 5페이지, 쿼터가드, 댓글비활성 처리) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39e2b42995
commit
15a655d7f9
@ -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;
|
||||
}
|
||||
}
|
||||
21
src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java
Normal file
21
src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java
Normal 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 문자열
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user