280 lines
16 KiB
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) { }
|
|
}
|