h-lab/src/main/java/com/hlab/yanalyst/domain/channel/YoutubeChannelAnalyticsService.java

280 lines
16 KiB
Java

package com.hlab.yanalyst.domain.channel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.domain.channel.dto.YoutubeChannelAnalyticsDto;
import com.hlab.yanalyst.domain.upload.YoutubeConnection;
import com.hlab.yanalyst.domain.upload.YoutubeConnectionRepository;
import com.hlab.yanalyst.domain.upload.YoutubeOAuthService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** YouTube Analytics API와 Data API를 조합해 내 채널 분석용 보고서를 만든다. */
@Service
public class YoutubeChannelAnalyticsService {
private static final String ANALYTICS_URL = "https://youtubeanalytics.googleapis.com/v2/reports";
private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels";
private static final String PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems";
private static final String VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos";
private static final String METRICS = "views,estimatedMinutesWatched,averageViewDuration,subscribersGained,subscribersLost,likes,comments";
private static final ZoneId KOREA = ZoneId.of("Asia/Seoul");
private static final int MAX_RANGE_DAYS = 730;
private static final int MAX_UPLOADS = 500;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final YoutubeOAuthService oauthService;
private final YoutubeConnectionRepository connectionRepository;
public YoutubeChannelAnalyticsService(@Qualifier("restTemplate") RestTemplate restTemplate,
ObjectMapper objectMapper,
YoutubeOAuthService oauthService,
YoutubeConnectionRepository connectionRepository) {
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
this.oauthService = oauthService;
this.connectionRepository = connectionRepository;
}
public YoutubeChannelAnalyticsDto analyze(Long connectionId, LocalDate startDate, LocalDate endDate) {
validatePeriod(startDate, endDate);
YoutubeConnection connection = connectionRepository.findById(connectionId)
.orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾을 수 없습니다."));
String accessToken = oauthService.accessToken(connectionId);
List<YoutubeChannelAnalyticsDto.DailyDto> daily = queryDaily(accessToken, startDate, endDate);
List<RawVideoMetric> videoMetrics = queryVideoMetrics(accessToken, startDate, endDate);
Map<String, VideoMetadata> metadata = loadVideoMetadata(accessToken,
videoMetrics.stream().map(RawVideoMetric::videoId).toList());
List<YoutubeChannelAnalyticsDto.VideoDto> videos = videoMetrics.stream()
.map(metric -> toVideoDto(metric, metadata.get(metric.videoId())))
.toList();
List<Upload> uploads = loadUploads(accessToken, connection.getChannelId()).stream()
.filter(upload -> !upload.publishedAt().atZone(KOREA).toLocalDate().isBefore(startDate))
.filter(upload -> !upload.publishedAt().atZone(KOREA).toLocalDate().isAfter(endDate))
.toList();
long views = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::views).sum();
long watchMinutes = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::watchMinutes).sum();
long subscribersGained = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::subscribersGained).sum();
long subscribersLost = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::subscribersLost).sum();
long likes = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::likes).sum();
long comments = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::comments).sum();
double averageViewDuration = weightedAverageDuration(daily);
return new YoutubeChannelAnalyticsDto(
new YoutubeChannelAnalyticsDto.ChannelDto(connection.getId(), connection.getChannelId(),
connection.getChannelTitle(), connection.getChannelHandle(), connection.getThumbnailUrl(),
connection.getLastSyncedAt() == null ? null : connection.getLastSyncedAt().atZone(KOREA).toInstant()),
new YoutubeChannelAnalyticsDto.PeriodDto(startDate, endDate,
Duration.between(startDate.atStartOfDay(KOREA), endDate.plusDays(1).atStartOfDay(KOREA)).toDays()),
new YoutubeChannelAnalyticsDto.SummaryDto(views, watchMinutes, averageViewDuration,
subscribersGained, subscribersLost, subscribersGained - subscribersLost, likes, comments,
uploads.size(), uploads.isEmpty() ? 0 : Math.round((double) views / uploads.size())),
daily,
videos,
uploadRhythm(uploads)
);
}
public List<YoutubeChannelAnalyticsDto.DailyDto> videoTimeline(Long connectionId, String videoId,
LocalDate startDate, LocalDate endDate) {
if (videoId == null || videoId.isBlank()) throw new IllegalArgumentException("영상 ID가 필요합니다.");
validatePeriod(startDate, endDate);
String accessToken = oauthService.accessToken(connectionId);
return queryDaily(accessToken, startDate, endDate, "video==" + videoId);
}
private List<YoutubeChannelAnalyticsDto.DailyDto> queryDaily(String accessToken, LocalDate startDate,
LocalDate endDate) {
return queryDaily(accessToken, startDate, endDate, null);
}
private List<YoutubeChannelAnalyticsDto.DailyDto> queryDaily(String accessToken, LocalDate startDate,
LocalDate endDate, String filters) {
JsonNode report = queryReport(accessToken, startDate, endDate, "day", METRICS, filters, "day", null);
List<YoutubeChannelAnalyticsDto.DailyDto> result = new ArrayList<>();
for (JsonNode row : report.path("rows")) {
if (!row.isArray() || row.size() < 8) continue;
result.add(new YoutubeChannelAnalyticsDto.DailyDto(
LocalDate.parse(row.path(0).asText()), row.path(1).asLong(), row.path(2).asLong(),
row.path(3).asDouble(), row.path(4).asLong(), row.path(5).asLong(),
row.path(6).asLong(), row.path(7).asLong()));
}
return result;
}
private List<RawVideoMetric> queryVideoMetrics(String accessToken, LocalDate startDate, LocalDate endDate) {
JsonNode report = queryReport(accessToken, startDate, endDate, "video", METRICS, null, "-views", 50);
List<RawVideoMetric> result = new ArrayList<>();
for (JsonNode row : report.path("rows")) {
if (!row.isArray() || row.size() < 8) continue;
result.add(new RawVideoMetric(row.path(0).asText(), row.path(1).asLong(), row.path(2).asLong(),
row.path(3).asDouble(), row.path(4).asLong(), row.path(5).asLong(), row.path(6).asLong(),
row.path(7).asLong()));
}
return result;
}
private JsonNode queryReport(String accessToken, LocalDate startDate, LocalDate endDate,
String dimensions, String metrics, String filters, String sort, Integer maxResults) {
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(ANALYTICS_URL)
.queryParam("ids", "channel==MINE")
.queryParam("startDate", startDate)
.queryParam("endDate", endDate)
.queryParam("dimensions", dimensions)
.queryParam("metrics", metrics)
.queryParam("sort", sort);
if (filters != null) builder.queryParam("filters", filters);
if (maxResults != null) builder.queryParam("maxResults", maxResults);
return getJson(builder.build().encode().toUri(), accessToken);
}
private Map<String, VideoMetadata> loadVideoMetadata(String accessToken, List<String> ids) {
Map<String, VideoMetadata> result = new HashMap<>();
for (int index = 0; index < ids.size(); index += 50) {
List<String> batch = ids.subList(index, Math.min(index + 50, ids.size()));
if (batch.isEmpty()) continue;
URI uri = UriComponentsBuilder.fromUriString(VIDEOS_URL)
.queryParam("part", "snippet,contentDetails")
.queryParam("id", String.join(",", batch)).build().encode().toUri();
for (JsonNode item : getJson(uri, accessToken).path("items")) {
JsonNode snippet = item.path("snippet");
String publishedAt = snippet.path("publishedAt").asText("");
result.put(item.path("id").asText(), new VideoMetadata(
snippet.path("title").asText("제목을 불러올 수 없는 영상"),
thumbnailUrl(snippet), publishedAt.isBlank() ? null : Instant.parse(publishedAt)));
}
}
return result;
}
private List<Upload> loadUploads(String accessToken, String channelId) {
URI channelUri = UriComponentsBuilder.fromUriString(CHANNELS_URL)
.queryParam("part", "contentDetails").queryParam("id", channelId).build().encode().toUri();
String playlistId = getJson(channelUri, accessToken).path("items").path(0)
.path("contentDetails").path("relatedPlaylists").path("uploads").asText("");
if (playlistId.isBlank()) return List.of();
List<Upload> uploads = new ArrayList<>();
String pageToken = null;
while (uploads.size() < MAX_UPLOADS) {
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(PLAYLIST_ITEMS_URL)
.queryParam("part", "contentDetails").queryParam("playlistId", playlistId).queryParam("maxResults", 50);
if (pageToken != null) builder.queryParam("pageToken", pageToken);
JsonNode page = getJson(builder.build().encode().toUri(), accessToken);
for (JsonNode item : page.path("items")) {
String publishedAt = item.path("contentDetails").path("videoPublishedAt").asText("");
if (!publishedAt.isBlank()) uploads.add(new Upload(Instant.parse(publishedAt)));
if (uploads.size() >= MAX_UPLOADS) break;
}
pageToken = page.path("nextPageToken").asText("");
if (pageToken.isBlank()) break;
}
return uploads;
}
private YoutubeChannelAnalyticsDto.UploadRhythmDto uploadRhythm(List<Upload> uploads) {
long[] weekdays = new long[7];
long[] hours = new long[24];
List<Instant> published = uploads.stream().map(Upload::publishedAt).sorted().toList();
for (Instant instant : published) {
var dateTime = instant.atZone(KOREA);
weekdays[dateTime.getDayOfWeek().getValue() - 1]++;
hours[dateTime.getHour()]++;
}
List<YoutubeChannelAnalyticsDto.BucketDto> weekdayCounts = new ArrayList<>();
List<YoutubeChannelAnalyticsDto.BucketDto> hourCounts = new ArrayList<>();
for (int i = 0; i < weekdays.length; i++) weekdayCounts.add(new YoutubeChannelAnalyticsDto.BucketDto(i + 1, weekdays[i]));
for (int i = 0; i < hours.length; i++) hourCounts.add(new YoutubeChannelAnalyticsDto.BucketDto(i, hours[i]));
return new YoutubeChannelAnalyticsDto.UploadRhythmDto(uploads.size(), averageGap(published),
mostActiveIndex(weekdays) + 1, mostActiveIndex(hours), weekdayCounts, hourCounts);
}
private double averageGap(List<Instant> published) {
if (published.size() < 2) return 0;
long minutes = 0;
for (int i = 1; i < published.size(); i++) minutes += Duration.between(published.get(i - 1), published.get(i)).toMinutes();
return Math.round((minutes / 1440.0 / (published.size() - 1)) * 10) / 10.0;
}
private int mostActiveIndex(long[] counts) {
int best = 0;
for (int i = 1; i < counts.length; i++) if (counts[i] > counts[best]) best = i;
return best;
}
private YoutubeChannelAnalyticsDto.VideoDto toVideoDto(RawVideoMetric metric, VideoMetadata metadata) {
return new YoutubeChannelAnalyticsDto.VideoDto(metric.videoId(),
metadata == null ? "제목을 불러올 수 없는 영상" : metadata.title(),
metadata == null ? "" : metadata.thumbnailUrl(), metadata == null ? null : metadata.publishedAt(),
metric.views(), metric.watchMinutes(), metric.averageViewDurationSeconds(), metric.subscribersGained(),
metric.subscribersLost(), metric.subscribersGained() - metric.subscribersLost(), metric.likes(), metric.comments());
}
private double weightedAverageDuration(List<YoutubeChannelAnalyticsDto.DailyDto> daily) {
long totalViews = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::views).sum();
if (totalViews == 0) return 0;
double totalDuration = daily.stream().mapToDouble(day -> day.averageViewDurationSeconds() * day.views()).sum();
return Math.round(totalDuration / totalViews * 10) / 10.0;
}
private JsonNode getJson(URI uri, String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
String body;
try {
body = restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(headers), String.class).getBody();
} catch (HttpStatusCodeException e) {
if (e.getStatusCode().value() == 401 || e.getStatusCode().value() == 403) {
throw new IllegalStateException("YouTube 분석 권한이 만료되었거나 부족합니다. 채널을 다시 연결해 주세요.", e);
}
throw new IllegalStateException("YouTube 분석 데이터를 불러오지 못했습니다.", e);
}
try {
return objectMapper.readTree(body);
} catch (Exception e) {
throw new IllegalStateException("YouTube 응답을 해석하지 못했습니다.", e);
}
}
private String thumbnailUrl(JsonNode snippet) {
JsonNode thumbnails = snippet.path("thumbnails");
String high = thumbnails.path("high").path("url").asText("");
return high.isBlank() ? thumbnails.path("medium").path("url").asText("") : high;
}
private void validatePeriod(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) throw new IllegalArgumentException("시작일과 종료일을 모두 선택하세요.");
if (startDate.isAfter(endDate)) throw new IllegalArgumentException("시작일은 종료일보다 앞서야 합니다.");
if (endDate.isAfter(LocalDate.now(KOREA))) throw new IllegalArgumentException("종료일은 오늘 이후로 선택할 수 없습니다.");
if (startDate.plusDays(MAX_RANGE_DAYS - 1L).isBefore(endDate)) {
throw new IllegalArgumentException("분석 기간은 최대 " + MAX_RANGE_DAYS + "일입니다.");
}
}
private record RawVideoMetric(String videoId, long views, long watchMinutes, double averageViewDurationSeconds,
long subscribersGained, long subscribersLost, long likes, long comments) { }
private record VideoMetadata(String title, String thumbnailUrl, Instant publishedAt) { }
private record Upload(Instant publishedAt) { }
}