feat: 내 채널 기간별 분석 리포트 추가
This commit is contained in:
parent
d70bb142c0
commit
118ca72281
@ -1,6 +1,7 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto;
|
||||
import com.hlab.yanalyst.domain.channel.dto.YoutubeChannelAnalyticsDto;
|
||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** 내 채널(OWN) 분석 API. 소재 파이프라인과 완전히 분리된 성과 추적용. */
|
||||
@RestController
|
||||
@ -18,6 +20,7 @@ import java.util.Map;
|
||||
public class MyChannelController {
|
||||
|
||||
private final MyChannelService myChannelService;
|
||||
private final YoutubeChannelAnalyticsService youtubeChannelAnalyticsService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "내 채널 요약", description = "등록 안 돼 있으면 registered=false")
|
||||
@ -43,6 +46,25 @@ public class MyChannelController {
|
||||
return ApiResponse.ok(myChannelService.growth());
|
||||
}
|
||||
|
||||
@GetMapping("/analytics")
|
||||
@Operation(summary = "OAuth 내 채널 기간 분석", description = "연결한 채널의 일별 추이, 영상별 성과, 업로드 리듬을 반환한다.")
|
||||
public ApiResponse<YoutubeChannelAnalyticsDto> analytics(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam LocalDate startDate,
|
||||
@RequestParam LocalDate endDate) {
|
||||
return ApiResponse.ok(youtubeChannelAnalyticsService.analyze(connectionId, startDate, endDate));
|
||||
}
|
||||
|
||||
@GetMapping("/analytics/videos/{videoId}/timeline")
|
||||
@Operation(summary = "내 영상 일별 추이", description = "선택 영상의 기간 내 일별 조회수와 구독자 증감 추이를 반환한다.")
|
||||
public ApiResponse<List<YoutubeChannelAnalyticsDto.DailyDto>> videoTimeline(
|
||||
@RequestParam Long connectionId,
|
||||
@PathVariable String videoId,
|
||||
@RequestParam LocalDate startDate,
|
||||
@RequestParam LocalDate endDate) {
|
||||
return ApiResponse.ok(youtubeChannelAnalyticsService.videoTimeline(connectionId, videoId, startDate, endDate));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "내 채널 등록", description = "채널 URL 로 등록하고 즉시 영상을 수집한다. 이미 있으면 교체.")
|
||||
public ApiResponse<MyChannelSummaryDto> register(@RequestBody Map<String, String> body) {
|
||||
|
||||
@ -0,0 +1,279 @@
|
||||
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) { }
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.hlab.yanalyst.domain.channel.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/** OAuth로 연결된 내 YouTube 채널의 기간별 분석 응답. */
|
||||
public record YoutubeChannelAnalyticsDto(
|
||||
ChannelDto channel,
|
||||
PeriodDto period,
|
||||
SummaryDto summary,
|
||||
List<DailyDto> daily,
|
||||
List<VideoDto> videos,
|
||||
UploadRhythmDto uploadRhythm
|
||||
) {
|
||||
public record ChannelDto(Long connectionId, String channelId, String title, String handle,
|
||||
String thumbnailUrl, Instant lastSyncedAt) { }
|
||||
|
||||
public record PeriodDto(LocalDate startDate, LocalDate endDate, long days) { }
|
||||
|
||||
public record SummaryDto(long views, long watchMinutes, double averageViewDurationSeconds,
|
||||
long subscribersGained, long subscribersLost, long netSubscribers,
|
||||
long likes, long comments, long uploads, double viewsPerUpload) { }
|
||||
|
||||
public record DailyDto(LocalDate date, long views, long watchMinutes,
|
||||
double averageViewDurationSeconds, long subscribersGained,
|
||||
long subscribersLost, long likes, long comments) { }
|
||||
|
||||
public record VideoDto(String videoId, String title, String thumbnailUrl, Instant publishedAt,
|
||||
long views, long watchMinutes, double averageViewDurationSeconds,
|
||||
long subscribersGained, long subscribersLost, long netSubscribers,
|
||||
long likes, long comments) { }
|
||||
|
||||
public record UploadRhythmDto(long uploads, double averageDaysBetweenUploads,
|
||||
int mostActiveWeekday, int mostActiveHour,
|
||||
List<BucketDto> weekdayCounts, List<BucketDto> hourCounts) { }
|
||||
|
||||
public record BucketDto(int value, long count) { }
|
||||
}
|
||||
@ -26,14 +26,16 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class YoutubeOAuthController {
|
||||
private static final String STATE_SESSION_KEY = "youtube.oauth.state";
|
||||
private static final String RETURN_TO_SESSION_KEY = "youtube.oauth.return-to";
|
||||
private final YoutubeOAuthService service;
|
||||
private final YoutubeOAuthLocalSettings localSettings;
|
||||
private final YoutubeUploadService uploadService;
|
||||
|
||||
@GetMapping("/oauth/start")
|
||||
public ResponseEntity<Void> start(HttpSession session) {
|
||||
public ResponseEntity<Void> start(@RequestParam(required = false) String returnTo, HttpSession session) {
|
||||
String state = UUID.randomUUID().toString();
|
||||
session.setAttribute(STATE_SESSION_KEY, state);
|
||||
session.setAttribute(RETURN_TO_SESSION_KEY, safeReturnTo(returnTo));
|
||||
return ResponseEntity.status(302).location(service.authorizationUri(state)).build();
|
||||
}
|
||||
|
||||
@ -43,15 +45,18 @@ public class YoutubeOAuthController {
|
||||
@RequestParam(required = false) String error,
|
||||
HttpSession session) {
|
||||
String expected = (String) session.getAttribute(STATE_SESSION_KEY);
|
||||
String returnTo = (String) session.getAttribute(RETURN_TO_SESSION_KEY);
|
||||
session.removeAttribute(STATE_SESSION_KEY);
|
||||
if (error != null) return redirect("/upload-studio?youtube=denied");
|
||||
if (expected == null || !expected.equals(state)) return redirect("/upload-studio?youtube=invalid-state");
|
||||
if (code == null || code.isBlank()) return redirect("/upload-studio?youtube=missing-code");
|
||||
session.removeAttribute(RETURN_TO_SESSION_KEY);
|
||||
String target = returnTo == null ? "/upload-studio" : returnTo;
|
||||
if (error != null) return redirect(target + "?youtube=denied");
|
||||
if (expected == null || !expected.equals(state)) return redirect(target + "?youtube=invalid-state");
|
||||
if (code == null || code.isBlank()) return redirect(target + "?youtube=missing-code");
|
||||
try {
|
||||
service.connect(code);
|
||||
return redirect("/upload-studio?youtube=connected");
|
||||
return redirect(target + "?youtube=connected");
|
||||
} catch (RuntimeException e) {
|
||||
return redirect("/upload-studio?youtube=failed");
|
||||
return redirect(target + "?youtube=failed");
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,6 +117,15 @@ public class YoutubeOAuthController {
|
||||
return ResponseEntity.status(302).location(URI.create(location)).build();
|
||||
}
|
||||
|
||||
private String safeReturnTo(String returnTo) {
|
||||
if (returnTo == null || returnTo.isBlank()) return "/upload-studio";
|
||||
if (!returnTo.startsWith("/") || returnTo.startsWith("//") || returnTo.contains("?") || returnTo.contains("#")
|
||||
|| returnTo.contains("\r") || returnTo.contains("\n")) {
|
||||
throw new IllegalArgumentException("허용되지 않은 OAuth 이동 경로입니다.");
|
||||
}
|
||||
return returnTo;
|
||||
}
|
||||
|
||||
public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { }
|
||||
public record SettingsStatus(boolean configured, String redirectUri) { }
|
||||
}
|
||||
|
||||
@ -1,384 +1,368 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/base}">
|
||||
|
||||
layout:decorate="~{layout/base}">
|
||||
<head>
|
||||
<title>h-lab - 내 채널</title>
|
||||
<title>h-lab - 내 채널 분석</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>내 채널</h1>
|
||||
<p class="sub">내가 발행한 영상의 성과와 어떤 소재가 잘 먹히는지 봅니다. 소재 파이프라인과는 분리돼 있습니다.</p>
|
||||
</div>
|
||||
<div class="actions" id="headerActions"></div>
|
||||
<div layout:fragment="content">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>내 채널 분석</h1>
|
||||
<p class="sub">업로드 리듬과 영상별 조회수·구독자 성과를 한 번에 보는 채널 리포트입니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 미등록 상태 -->
|
||||
<div id="setup" class="card hidden" style="max-width:640px;">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="setup-ic"><i data-lucide="trending-up"></i></div>
|
||||
<div>
|
||||
<div class="font-semibold">내 채널을 등록하세요</div>
|
||||
<div class="text-sm text-muted" style="margin-top:2px;">
|
||||
여기 등록한 채널의 영상은 수집함·칸반·피드에 섞이지 않습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seed-add">
|
||||
<label class="sr-only" for="ownUrl">채널 URL</label>
|
||||
<input id="ownUrl" type="text" placeholder="https://www.youtube.com/@mychannel">
|
||||
<button class="btn btn-primary" id="registerBtn" onclick="registerOwn()">등록</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted mt-2" id="setupHelp">
|
||||
등록하면 영상을 바로 수집합니다 (쿼터 약 12 units).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 등록 상태 -->
|
||||
<div id="main" class="hidden">
|
||||
<!-- 요약 -->
|
||||
<div class="card mb-4" id="summaryCard"></div>
|
||||
|
||||
<div class="mc-grid">
|
||||
<!-- 소재별 성과 -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:0.5rem;">
|
||||
<div>
|
||||
<h3 class="text-lg">소재별 성과</h3>
|
||||
<p class="text-xs text-muted" style="margin-top:2px;">
|
||||
해시태그 기준 · 2편 이상만 · 배수는 채널 평균 대비
|
||||
</p>
|
||||
</div>
|
||||
<span id="avgBadge" class="badge badge-muted"></span>
|
||||
</div>
|
||||
<div id="topics"></div>
|
||||
</div>
|
||||
|
||||
<!-- 성장 추이 -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg mb-3">성장 추이</h3>
|
||||
<div id="growthWrap" style="position:relative; height:260px;">
|
||||
<canvas id="growthChart"></canvas>
|
||||
</div>
|
||||
<div id="growthEmpty" class="text-sm text-muted text-center hidden" style="padding:2.5rem 0;">
|
||||
아직 스냅샷이 없습니다. 매일 새벽 3시에 기록되며 이틀치가 쌓이면 그래프가 그려집니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 내 영상 -->
|
||||
<div class="card mt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg">내 영상</h3>
|
||||
<span id="videoCount" class="badge badge-muted"></span>
|
||||
</div>
|
||||
<div id="videos" class="mc-videos"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<style>
|
||||
.mc-grid { display:grid; grid-template-columns:1.15fr 1fr; gap:1rem; align-items:start; }
|
||||
@media (max-width: 960px) { .mc-grid { grid-template-columns:1fr; } }
|
||||
|
||||
.setup-ic {
|
||||
width:38px; height:38px; border-radius:10px; flex-shrink:0;
|
||||
background:var(--accent-soft); color:var(--accent);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.seed-add { display:flex; gap:0.5rem; flex-wrap:wrap; }
|
||||
.seed-add input { flex:1; min-width:190px; }
|
||||
|
||||
/* 요약 */
|
||||
.mc-head { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; }
|
||||
.mc-head img { width:56px; height:56px; border-radius:50%; object-fit:cover; background:var(--inset); flex-shrink:0; }
|
||||
.mc-stats { display:flex; gap:1.75rem; flex-wrap:wrap; margin-left:auto; }
|
||||
.mc-stat .k { font-size:0.72rem; color:var(--text-3); font-weight:600; }
|
||||
.mc-stat .v {
|
||||
font-size:1.15rem; font-weight:700; font-family:var(--font-mono);
|
||||
font-variant-numeric:tabular-nums; margin-top:2px;
|
||||
}
|
||||
@media (max-width: 700px) { .mc-stats { margin-left:0; gap:1.1rem; } }
|
||||
|
||||
/* 소재 랭킹 */
|
||||
.topic-row {
|
||||
display:flex; align-items:center; gap:0.7rem;
|
||||
padding:0.6rem 0; border-bottom:1px solid var(--border);
|
||||
}
|
||||
.topic-row:last-child { border-bottom:none; }
|
||||
.topic-name { min-width:0; flex:0 0 30%; }
|
||||
.topic-name b { display:block; font-size:0.85rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.topic-name span { font-size:0.7rem; color:var(--text-3); }
|
||||
.topic-bar { flex:1; min-width:60px; }
|
||||
.topic-bar .track { height:7px; background:var(--inset); border-radius:var(--radius-full); overflow:hidden; }
|
||||
.topic-bar .fill { height:100%; border-radius:var(--radius-full); transition:width .5s cubic-bezier(.22,1,.36,1); }
|
||||
.topic-num {
|
||||
flex:0 0 auto; text-align:right; font-family:var(--font-mono);
|
||||
font-variant-numeric:tabular-nums; font-size:0.8rem; min-width:66px;
|
||||
}
|
||||
.topic-idx { flex:0 0 auto; min-width:52px; text-align:right; }
|
||||
|
||||
/* 영상 목록 */
|
||||
.mc-videos { display:grid; grid-template-columns:repeat(auto-fill, minmax(210px, 1fr)); gap:0.85rem; }
|
||||
.mcv { display:block; }
|
||||
.mcv .th { position:relative; aspect-ratio:16/9; border-radius:var(--r-sm); overflow:hidden; background:var(--inset); }
|
||||
.mcv .th img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.mcv:focus-visible .th { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
.mcv .t {
|
||||
font-size:0.8rem; font-weight:600; line-height:1.4; margin-top:0.4rem; color:var(--text);
|
||||
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
|
||||
}
|
||||
.mcv:hover .t { text-decoration:underline; }
|
||||
.mcv .m { font-size:0.72rem; color:var(--text-3); margin-top:2px; font-variant-numeric:tabular-nums; }
|
||||
|
||||
.toast {
|
||||
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px);
|
||||
background:var(--text); color:var(--bg);
|
||||
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600;
|
||||
box-shadow:var(--shadow-lg); z-index:2000;
|
||||
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease;
|
||||
max-width:88vw; text-align:center;
|
||||
}
|
||||
.toast.show { opacity:1; transform:translate(-50%, 0); }
|
||||
.toast.err { background:var(--danger); color:#fff; }
|
||||
.sr-only {
|
||||
position:absolute; width:1px; height:1px; padding:0; margin:-1px;
|
||||
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.topic-bar .fill, .toast { transition:none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
const API = '/api/my-channel';
|
||||
let growthChart = null;
|
||||
|
||||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function fmtNum(n){
|
||||
if(n==null) return '-';
|
||||
const v = Number(n);
|
||||
if(v >= 100000000) return (v/100000000).toFixed(1).replace(/\.0$/,'') + '억';
|
||||
if(v >= 10000) return (v/10000).toFixed(1).replace(/\.0$/,'') + '만';
|
||||
return v.toLocaleString();
|
||||
}
|
||||
function fmtDate(iso){
|
||||
if(!iso) return '-';
|
||||
const d = new Date(iso);
|
||||
return isNaN(d) ? '-' : `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg, isError){
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('err', !!isError);
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
async function api(url, opts){
|
||||
const res = await fetch(url, opts);
|
||||
const json = await res.json().catch(()=>({}));
|
||||
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// ---------- 요약 ----------
|
||||
function renderSummary(s){
|
||||
document.getElementById('summaryCard').innerHTML = `
|
||||
<div class="mc-head">
|
||||
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy">
|
||||
<div style="min-width:0;">
|
||||
<div class="text-lg font-bold">${esc(s.title)}</div>
|
||||
<a href="https://www.youtube.com/channel/${esc(s.channelId)}" target="_blank" rel="noopener"
|
||||
class="text-xs text-muted hover:underline">YouTube 에서 보기</a>
|
||||
</div>
|
||||
<div class="mc-stats">
|
||||
<div class="mc-stat"><div class="k">구독자</div><div class="v">${fmtNum(s.subscriberCount)}</div></div>
|
||||
<div class="mc-stat"><div class="k">총 조회수</div><div class="v">${fmtNum(s.viewCount)}</div></div>
|
||||
<div class="mc-stat"><div class="k">영상 평균</div><div class="v">${fmtNum(s.avgViews)}</div></div>
|
||||
<div class="mc-stat"><div class="k">최근 30일</div><div class="v">${fmtNum(s.last30Views)}</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('headerActions').innerHTML = `
|
||||
<button class="btn btn-secondary" id="syncBtn" onclick="syncOwn()">
|
||||
<i data-lucide="refresh-cw" style="width:15px;"></i> 동기화
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="unregisterOwn()" title="등록 해제">
|
||||
<i data-lucide="unlink" style="width:15px;"></i>
|
||||
</button>`;
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
// ---------- 소재별 성과 ----------
|
||||
function renderTopics(list, avg){
|
||||
document.getElementById('avgBadge').textContent = '채널 평균 ' + fmtNum(avg);
|
||||
const box = document.getElementById('topics');
|
||||
if(!list || list.length === 0){
|
||||
box.innerHTML = `<div class="text-sm text-muted text-center" style="padding:2.5rem 0; line-height:1.6;">
|
||||
아직 집계할 소재가 없습니다.<br>영상 설명의 해시태그(#유퀴즈 #핑계고)로 묶기 때문에,
|
||||
같은 소재가 2편 이상 쌓이면 나타납니다.</div>`;
|
||||
return;
|
||||
}
|
||||
const max = Math.max(...list.map(t => t.avgViews), 1);
|
||||
box.innerHTML = list.map(t => {
|
||||
// 평균 이상은 강조색, 이하는 중립색 — 색만으로 전달하지 않도록 배수 텍스트를 함께 둔다
|
||||
const strong = t.index >= 1;
|
||||
const color = strong ? 'var(--accent)' : 'var(--border-strong)';
|
||||
const idxCls = t.index >= 1.5 ? 'badge-success' : (strong ? 'badge-primary' : 'badge-muted');
|
||||
const w = Math.max(2, Math.round(t.avgViews / max * 100));
|
||||
return `<div class="topic-row">
|
||||
<div class="topic-name">
|
||||
<b title="${esc(t.topic)}">#${esc(t.topic)}</b>
|
||||
<span>${t.videoCount}편 · 최고 ${fmtNum(t.maxViews)}</span>
|
||||
</div>
|
||||
<div class="topic-bar"><div class="track"><div class="fill" style="width:${w}%; background:${color};"></div></div></div>
|
||||
<div class="topic-num">${fmtNum(t.avgViews)}</div>
|
||||
<div class="topic-idx"><span class="badge ${idxCls}">${t.index.toFixed(1)}x</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---------- 성장 추이 ----------
|
||||
function renderGrowth(rows){
|
||||
const wrap = document.getElementById('growthWrap');
|
||||
const empty = document.getElementById('growthEmpty');
|
||||
if(!rows || rows.length < 2){
|
||||
wrap.classList.add('hidden'); empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
wrap.classList.remove('hidden'); empty.classList.add('hidden');
|
||||
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const accent = css.getPropertyValue('--accent').trim() || '#ff4d23';
|
||||
const text3 = css.getPropertyValue('--text-3').trim() || '#999';
|
||||
const border = css.getPropertyValue('--border').trim() || '#ddd';
|
||||
|
||||
if(growthChart) growthChart.destroy();
|
||||
growthChart = new Chart(document.getElementById('growthChart').getContext('2d'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: rows.map(r => fmtDate(r.snapshotDate)),
|
||||
datasets: [{
|
||||
label: '구독자',
|
||||
data: rows.map(r => r.subscriberCount),
|
||||
borderColor: accent,
|
||||
backgroundColor: accent + '22',
|
||||
fill: true, tension: 0.3, pointRadius: 2, borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? false : undefined,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: border }, ticks: { color: text3, maxTicksLimit: 6 } },
|
||||
y: { grid: { color: border }, ticks: { color: text3 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 영상 ----------
|
||||
function renderVideos(list){
|
||||
document.getElementById('videoCount').textContent = (list?.length || 0) + '건';
|
||||
const box = document.getElementById('videos');
|
||||
if(!list || list.length === 0){
|
||||
box.innerHTML = '<div class="text-sm text-muted p-4">수집된 영상이 없습니다. 동기화를 눌러보세요.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = list.map(v => `
|
||||
<a class="mcv" href="https://www.youtube.com/watch?v=${encodeURIComponent(v.videoId)}"
|
||||
target="_blank" rel="noopener">
|
||||
<div class="th"><img src="${esc(v.thumbnailUrl)}" alt="" loading="lazy" width="320" height="180"></div>
|
||||
<div class="t">${esc(v.title)}</div>
|
||||
<div class="m">${fmtNum(v.viewCount)}회 · ${fmtDate(v.publishedAt)}</div>
|
||||
</a>`).join('');
|
||||
}
|
||||
|
||||
// ---------- 액션 ----------
|
||||
async function registerOwn(){
|
||||
const url = document.getElementById('ownUrl').value.trim();
|
||||
const help = document.getElementById('setupHelp');
|
||||
if(!url){
|
||||
help.textContent = '채널 URL 을 입력하세요.';
|
||||
help.style.color = 'var(--danger)';
|
||||
document.getElementById('ownUrl').focus();
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('registerBtn');
|
||||
btn.disabled = true;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '등록 중...';
|
||||
try {
|
||||
await api(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({url}) });
|
||||
toast('등록하고 영상을 수집했습니다');
|
||||
await load();
|
||||
} catch(e){
|
||||
help.textContent = '등록 실패: ' + e.message;
|
||||
help.style.color = 'var(--danger)';
|
||||
} finally { btn.disabled = false; btn.textContent = orig; }
|
||||
}
|
||||
|
||||
async function syncOwn(){
|
||||
const btn = document.getElementById('syncBtn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api(API + '/sync', { method:'POST' });
|
||||
toast('동기화했습니다');
|
||||
await load();
|
||||
} catch(e){ toast('동기화 실패: ' + e.message, true); }
|
||||
finally { const b = document.getElementById('syncBtn'); if(b) b.disabled = false; }
|
||||
}
|
||||
|
||||
async function unregisterOwn(){
|
||||
if(!confirm('내 채널 등록을 해제할까요?\n수집된 영상과 소재별 성과 기록도 함께 삭제됩니다.')) return;
|
||||
try {
|
||||
await api(API, { method:'DELETE' });
|
||||
toast('등록을 해제했습니다');
|
||||
await load();
|
||||
} catch(e){ toast('해제 실패: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ---------- init ----------
|
||||
async function load(){
|
||||
let s;
|
||||
try { s = await api(API); }
|
||||
catch(e){ toast('불러오기 실패: ' + e.message, true); return; }
|
||||
|
||||
if(!s || !s.registered){
|
||||
document.getElementById('setup').classList.remove('hidden');
|
||||
document.getElementById('main').classList.add('hidden');
|
||||
document.getElementById('headerActions').innerHTML = '';
|
||||
if(window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
document.getElementById('setup').classList.add('hidden');
|
||||
document.getElementById('main').classList.remove('hidden');
|
||||
renderSummary(s);
|
||||
|
||||
const [topics, videos, growth] = await Promise.all([
|
||||
api(API + '/topics').catch(()=>[]),
|
||||
api(API + '/videos').catch(()=>[]),
|
||||
api(API + '/growth').catch(()=>[])
|
||||
]);
|
||||
renderTopics(topics, s.avgViews);
|
||||
renderVideos(videos);
|
||||
renderGrowth(growth);
|
||||
}
|
||||
|
||||
load();
|
||||
/*]]>*/
|
||||
</script>
|
||||
<div class="actions" id="headerActions"></div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<section id="setup" class="card analytics-setup hidden">
|
||||
<div class="setup-icon"><i data-lucide="bar-chart-3"></i></div>
|
||||
<div>
|
||||
<h2 class="text-lg">분석할 내 YouTube 채널을 연결하세요</h2>
|
||||
<p class="text-sm text-muted">Google 계정으로 연결하면 일별 조회수, 시청 시간, 영상별 구독자 증가를 가져올 수 있습니다.</p>
|
||||
</div>
|
||||
<div class="setup-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="connectYoutube()"><i data-lucide="link"></i> 내 채널 연결</button>
|
||||
<a class="btn btn-secondary" href="/upload-studio">연결 설정 열기</a>
|
||||
</div>
|
||||
<p class="text-xs text-muted setup-note">연결만으로는 영상이 업로드되지 않습니다. 업로드 스튜디오는 그대로 유지됩니다.</p>
|
||||
</section>
|
||||
|
||||
<main id="analytics" class="hidden">
|
||||
<section class="card analytics-controls">
|
||||
<div class="channel-picker">
|
||||
<span class="picker-label">분석 채널</span>
|
||||
<select id="connectionSelect" aria-label="분석할 YouTube 채널"></select>
|
||||
</div>
|
||||
<div class="period-tabs" role="tablist" aria-label="분석 단위">
|
||||
<button type="button" role="tab" class="period-tab active" data-period="daily">일간</button>
|
||||
<button type="button" role="tab" class="period-tab" data-period="weekly">주간</button>
|
||||
<button type="button" role="tab" class="period-tab" data-period="monthly">월간</button>
|
||||
<button type="button" role="tab" class="period-tab" data-period="custom">기간 설정</button>
|
||||
</div>
|
||||
<div id="customDates" class="custom-dates hidden">
|
||||
<label>시작일 <input id="startDate" type="date"></label>
|
||||
<span>~</span>
|
||||
<label>종료일 <input id="endDate" type="date"></label>
|
||||
<button type="button" class="btn btn-secondary btn-small" onclick="loadReport()">적용</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary refresh-button" onclick="loadReport()"><i data-lucide="refresh-cw"></i> 새로고침</button>
|
||||
</section>
|
||||
|
||||
<section id="reportTitle" class="report-title"></section>
|
||||
<section id="summary" class="summary-grid" aria-live="polite"></section>
|
||||
|
||||
<section class="chart-grid">
|
||||
<article class="card chart-card chart-wide">
|
||||
<div class="chart-heading">
|
||||
<div>
|
||||
<h2 class="text-lg">조회수와 구독자 변화</h2>
|
||||
<p id="trendDescription" class="text-xs text-muted">기간별 성과 추이</p>
|
||||
</div>
|
||||
<span id="dataTimezone" class="badge badge-muted">YouTube 집계 기준</span>
|
||||
</div>
|
||||
<div class="chart-wrap"><canvas id="trendChart"></canvas></div>
|
||||
<div id="trendEmpty" class="empty-chart hidden">선택한 기간에 표시할 분석 데이터가 없습니다.</div>
|
||||
</article>
|
||||
<article class="card chart-card">
|
||||
<div class="chart-heading">
|
||||
<div>
|
||||
<h2 class="text-lg">업로드 리듬</h2>
|
||||
<p class="text-xs text-muted">선택 기간에 공개된 영상 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rhythmSummary" class="rhythm-summary"></div>
|
||||
<div class="rhythm-label">요일별 업로드</div>
|
||||
<div id="weekdayBars" class="mini-bars"></div>
|
||||
<div class="rhythm-label">시간대별 업로드</div>
|
||||
<div id="hourBars" class="hour-bars"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="report-grid">
|
||||
<article class="card ranking-card">
|
||||
<div class="chart-heading"><div><h2 class="text-lg">조회수 상승 영상</h2><p class="text-xs text-muted">선택 기간 조회수 상위</p></div></div>
|
||||
<div id="topViews" class="ranking-list"></div>
|
||||
</article>
|
||||
<article class="card ranking-card">
|
||||
<div class="chart-heading"><div><h2 class="text-lg">구독자 유입 영상</h2><p class="text-xs text-muted">영상 시청 페이지에서 발생한 구독 기준</p></div></div>
|
||||
<div id="topSubscribers" class="ranking-list"></div>
|
||||
</article>
|
||||
<article class="card ranking-card">
|
||||
<div class="chart-heading"><div><h2 class="text-lg">반응이 좋은 영상</h2><p class="text-xs text-muted">좋아요와 댓글 합계 기준</p></div></div>
|
||||
<div id="topEngagement" class="ranking-list"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card video-section">
|
||||
<div class="chart-heading">
|
||||
<div>
|
||||
<h2 class="text-lg">영상별 분석</h2>
|
||||
<p class="text-xs text-muted">영상을 선택하면 아래에 일별 성과 추이를 표시합니다.</p>
|
||||
</div>
|
||||
<span id="videoCount" class="badge badge-muted"></span>
|
||||
</div>
|
||||
<div id="videos" class="video-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="videoTimelineCard" class="card chart-card hidden">
|
||||
<div class="chart-heading">
|
||||
<div><h2 id="videoTimelineTitle" class="text-lg">영상 조회수 추이</h2><p class="text-xs text-muted">선택 기간의 일별 조회수와 순 구독자 변화</p></div>
|
||||
<a id="videoYoutubeLink" target="_blank" rel="noopener" class="btn btn-secondary btn-small">YouTube에서 보기</a>
|
||||
</div>
|
||||
<div class="chart-wrap"><canvas id="videoTimelineChart"></canvas></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<style>
|
||||
.hidden { display:none !important; }
|
||||
.analytics-setup { display:grid; grid-template-columns:auto 1fr auto; gap:1rem; align-items:center; max-width:880px; }
|
||||
.setup-icon { display:grid; place-items:center; width:46px; height:46px; border-radius:14px; color:var(--accent); background:var(--accent-soft); }
|
||||
.setup-actions { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.setup-note { grid-column:2 / -1; margin:0; }
|
||||
.analytics-controls { display:flex; gap:.75rem; align-items:center; flex-wrap:wrap; padding:1rem; }
|
||||
.channel-picker { display:flex; align-items:center; gap:.5rem; min-width:220px; }
|
||||
.picker-label { font-size:.75rem; color:var(--text-3); font-weight:700; white-space:nowrap; }
|
||||
.channel-picker select { flex:1; min-width:0; }
|
||||
.period-tabs { display:flex; gap:.2rem; padding:.2rem; background:var(--inset); border-radius:var(--radius); }
|
||||
.period-tab { border:0; background:transparent; color:var(--text-2); font:inherit; font-size:.82rem; font-weight:700; padding:.45rem .68rem; border-radius:calc(var(--radius) - 3px); cursor:pointer; }
|
||||
.period-tab.active { background:var(--surface); color:var(--text); box-shadow:var(--shadow-sm); }
|
||||
.custom-dates { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; font-size:.78rem; color:var(--text-3); }
|
||||
.custom-dates label { display:flex; align-items:center; gap:.3rem; white-space:nowrap; }
|
||||
.custom-dates input { padding:.37rem .45rem; font-size:.78rem; }
|
||||
.refresh-button { margin-left:auto; }
|
||||
.btn-small { font-size:.78rem; padding:.42rem .62rem; }
|
||||
.report-title { display:flex; justify-content:space-between; align-items:baseline; margin:1.25rem 0 .7rem; }
|
||||
.report-title h2 { margin:0; font-size:1rem; }
|
||||
.report-title p { margin:0; color:var(--text-3); font-size:.75rem; }
|
||||
.summary-grid { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:.7rem; }
|
||||
.metric-card { border:1px solid var(--border); background:var(--surface); border-radius:var(--radius); padding:.9rem; min-width:0; }
|
||||
.metric-label { font-size:.72rem; color:var(--text-3); font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.metric-value { margin-top:.3rem; font-size:1.25rem; color:var(--text); font-weight:800; letter-spacing:-.03em; font-variant-numeric:tabular-nums; }
|
||||
.metric-detail { margin-top:.18rem; font-size:.7rem; color:var(--text-3); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.metric-value.positive { color:var(--success, #1b9b69); }
|
||||
.metric-value.negative { color:var(--danger); }
|
||||
.chart-grid { display:grid; grid-template-columns:1.55fr 1fr; gap:1rem; margin-top:1rem; }
|
||||
.report-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; margin-top:1rem; }
|
||||
.chart-card, .ranking-card, .video-section { min-width:0; }
|
||||
.chart-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:.75rem; margin-bottom:.85rem; }
|
||||
.chart-heading h2 { margin:0; }
|
||||
.chart-heading p { margin:.18rem 0 0; }
|
||||
.chart-wrap { position:relative; height:286px; }
|
||||
.empty-chart { height:286px; display:grid; place-items:center; color:var(--text-3); font-size:.85rem; }
|
||||
.rhythm-summary { display:grid; grid-template-columns:1fr 1fr; gap:.6rem; margin:.2rem 0 1rem; }
|
||||
.rhythm-stat { background:var(--inset); border-radius:var(--r-sm); padding:.65rem; }
|
||||
.rhythm-stat b { display:block; font-size:1.05rem; font-variant-numeric:tabular-nums; }
|
||||
.rhythm-stat span, .rhythm-label { color:var(--text-3); font-size:.72rem; font-weight:600; }
|
||||
.rhythm-label { margin:.75rem 0 .35rem; }
|
||||
.mini-bars { display:grid; grid-template-columns:repeat(7,1fr); height:82px; gap:.35rem; align-items:end; }
|
||||
.mini-bar, .hour-bar { min-width:0; display:flex; flex-direction:column; gap:.2rem; align-items:center; justify-content:end; height:100%; }
|
||||
.mini-bar i, .hour-bar i { width:100%; display:block; min-height:3px; border-radius:4px 4px 1px 1px; background:var(--accent); opacity:.82; }
|
||||
.mini-bar span, .hour-bar span { font-style:normal; font-size:.63rem; color:var(--text-3); white-space:nowrap; }
|
||||
.hour-bars { display:grid; grid-template-columns:repeat(12,1fr); height:64px; gap:.18rem; align-items:end; }
|
||||
.ranking-list { display:flex; flex-direction:column; }
|
||||
.ranking-item { appearance:none; border:0; border-bottom:1px solid var(--border); background:none; width:100%; padding:.6rem 0; display:grid; grid-template-columns:22px 38px 1fr auto; gap:.5rem; align-items:center; text-align:left; color:var(--text); cursor:pointer; }
|
||||
.ranking-item:last-child { border-bottom:0; }
|
||||
.ranking-item:hover .ranking-title { text-decoration:underline; }
|
||||
.ranking-rank { color:var(--text-3); font-size:.73rem; font-weight:800; }
|
||||
.ranking-thumb { width:38px; height:24px; border-radius:4px; object-fit:cover; background:var(--inset); }
|
||||
.ranking-title { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; font-weight:600; }
|
||||
.ranking-value { font-size:.76rem; font-weight:800; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||
.ranking-empty { color:var(--text-3); font-size:.8rem; padding:1rem 0; }
|
||||
.video-section { margin-top:1rem; }
|
||||
.video-list { display:grid; grid-template-columns:repeat(auto-fill,minmax(285px,1fr)); gap:.65rem; }
|
||||
.video-row { appearance:none; border:1px solid var(--border); border-radius:var(--r-sm); background:var(--surface); color:var(--text); padding:.55rem; display:grid; grid-template-columns:94px 1fr; gap:.65rem; text-align:left; cursor:pointer; min-width:0; }
|
||||
.video-row:hover, .video-row.selected { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent-soft); }
|
||||
.video-thumb { width:94px; height:53px; border-radius:5px; background:var(--inset); object-fit:cover; }
|
||||
.video-name { font-size:.8rem; font-weight:700; line-height:1.35; overflow:hidden; display:-webkit-box; -webkit-line-clamp:1; -webkit-box-orient:vertical; }
|
||||
.video-meta { display:flex; gap:.45rem; margin-top:.3rem; color:var(--text-3); font-size:.7rem; font-variant-numeric:tabular-nums; flex-wrap:wrap; }
|
||||
.toast { position:fixed; left:50%; bottom:24px; transform:translate(-50%,12px); opacity:0; pointer-events:none; transition:.2s ease; background:var(--text); color:var(--bg); padding:.65rem 1rem; border-radius:var(--r-sm); box-shadow:var(--shadow-lg); z-index:2000; font-size:.84rem; font-weight:700; max-width:90vw; text-align:center; }
|
||||
.toast.show { opacity:1; transform:translate(-50%,0); }
|
||||
.toast.error { background:var(--danger); color:#fff; }
|
||||
@media (max-width:1180px) { .summary-grid { grid-template-columns:repeat(3,1fr); } .report-grid { grid-template-columns:1fr; } }
|
||||
@media (max-width:900px) { .chart-grid { grid-template-columns:1fr; } .refresh-button { margin-left:0; } }
|
||||
@media (max-width:620px) { .analytics-setup { grid-template-columns:auto 1fr; } .setup-actions, .setup-note { grid-column:1 / -1; justify-content:flex-start; } .summary-grid { grid-template-columns:repeat(2,1fr); } .analytics-controls { align-items:stretch; } .channel-picker { width:100%; } .period-tabs { width:100%; overflow:auto; } .period-tab { flex:1; white-space:nowrap; } .chart-wrap { height:230px; } .video-list { grid-template-columns:1fr; } }
|
||||
@media (prefers-reduced-motion:reduce) { .toast { transition:none; } }
|
||||
</style>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script>
|
||||
const API = '/api/my-channel';
|
||||
const DAY_NAMES = ['\uC6D4','\uD654','\uC218','\uBAA9','\uAE08','\uD1A0','\uC77C'];
|
||||
let connections = [];
|
||||
let selectedPeriod = 'daily';
|
||||
let report = null;
|
||||
let trendChart = null;
|
||||
let videoTimelineChart = null;
|
||||
let selectedVideoId = null;
|
||||
let toastTimer = null;
|
||||
|
||||
function esc(value) { return String(value ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
function number(value) { return Number(value || 0).toLocaleString('ko-KR'); }
|
||||
function compact(value) { return new Intl.NumberFormat('ko-KR',{notation:'compact',maximumFractionDigits:1}).format(Number(value || 0)); }
|
||||
function signed(value) { const n=Number(value || 0); return `${n>0?'+':''}${number(n)}`; }
|
||||
function dateString(date) { return date.toISOString().slice(0,10); }
|
||||
function formatDate(value) { const date = new Date(`${value}T00:00:00`); return isNaN(date) ? value : `${date.getMonth()+1}/${date.getDate()}`; }
|
||||
function formatMinutes(value) { const hours = Number(value || 0) / 60; return hours >= 10 ? `${hours.toFixed(0)}시간` : `${hours.toFixed(1)}시간`; }
|
||||
function formatDuration(seconds) { const total=Math.round(Number(seconds||0)); return total>=60 ? `${Math.floor(total/60)}분 ${total%60}초` : `${total}초`; }
|
||||
function toast(message, error=false) { const el=document.getElementById('toast'); el.textContent=message; el.classList.toggle('error',error); el.classList.add('show'); clearTimeout(toastTimer); toastTimer=setTimeout(()=>el.classList.remove('show'),3600); }
|
||||
async function api(url) { const res=await fetch(url); const body=await res.json().catch(()=>({})); if(!res.ok || body.success===false) throw new Error(body.message || '요청을 처리하지 못했습니다.'); return body.data; }
|
||||
|
||||
function currentRange() {
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
const end = new Date(today); end.setDate(end.getDate()-1);
|
||||
if (selectedPeriod === 'daily') { const start=new Date(end); start.setDate(start.getDate()-13); return [start,end]; }
|
||||
if (selectedPeriod === 'weekly') { const start=new Date(end); start.setDate(start.getDate()-83); return [start,end]; }
|
||||
if (selectedPeriod === 'monthly') { const start=new Date(end); start.setMonth(start.getMonth()-11); start.setDate(1); return [start,end]; }
|
||||
return [new Date(`${document.getElementById('startDate').value}T00:00:00`), new Date(`${document.getElementById('endDate').value}T00:00:00`)];
|
||||
}
|
||||
|
||||
function setDefaultDates() {
|
||||
const [start,end]=currentRange();
|
||||
document.getElementById('startDate').value=dateString(start);
|
||||
document.getElementById('endDate').value=dateString(end);
|
||||
document.getElementById('endDate').max=dateString(new Date());
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
connections=await api('/api/youtube/connections');
|
||||
const setup=document.getElementById('setup'); const analytics=document.getElementById('analytics');
|
||||
setup.classList.toggle('hidden',connections.length>0); analytics.classList.toggle('hidden',connections.length===0);
|
||||
if(!connections.length) return;
|
||||
const select=document.getElementById('connectionSelect'); const previous=select.value;
|
||||
select.innerHTML=connections.map(c=>`<option value="${c.id}">${esc(c.title)}${c.handle ? ` (${esc(c.handle)})` : ''}</option>`).join('');
|
||||
select.value=connections.some(c=>String(c.id)===previous) ? previous : connections[0].id;
|
||||
await loadReport();
|
||||
}
|
||||
|
||||
async function connectYoutube() {
|
||||
try {
|
||||
const status=await api('/api/youtube/settings/status');
|
||||
if(!status.configured) { toast('YouTube OAuth 설정이 필요합니다. 연결 설정 화면에서 먼저 등록하세요.',true); return; }
|
||||
location.href='/api/youtube/oauth/start?returnTo=/my-channel';
|
||||
} catch(error) { toast(error.message,true); }
|
||||
}
|
||||
|
||||
function selectedConnectionId() { return document.getElementById('connectionSelect').value; }
|
||||
function rangeLabel() { const [start,end]=currentRange(); return `${dateString(start).replaceAll('-','.')} ~ ${dateString(end).replaceAll('-','.')}`; }
|
||||
|
||||
async function loadReport() {
|
||||
const connectionId=selectedConnectionId(); if(!connectionId) return;
|
||||
const [start,end]=currentRange();
|
||||
if(isNaN(start) || isNaN(end)) { toast('시작일과 종료일을 선택하세요.',true); return; }
|
||||
const refresh=document.querySelector('.refresh-button'); refresh.disabled=true;
|
||||
try {
|
||||
report=await api(`${API}/analytics?connectionId=${encodeURIComponent(connectionId)}&startDate=${dateString(start)}&endDate=${dateString(end)}`);
|
||||
renderReport();
|
||||
} catch(error) { toast(error.message || '분석 데이터를 불러오지 못했습니다.',true); }
|
||||
finally { refresh.disabled=false; }
|
||||
}
|
||||
|
||||
function renderReport() {
|
||||
const channel=report.channel; const period=report.period;
|
||||
document.getElementById('reportTitle').innerHTML=`<div><h2>${esc(channel.title)} 채널 리포트</h2><p>${period.startDate} ~ ${period.endDate} · ${number(period.days)}일 분석</p></div><a href="https://www.youtube.com/channel/${encodeURIComponent(channel.channelId)}" class="btn btn-secondary btn-small" target="_blank" rel="noopener">채널 열기</a>`;
|
||||
const s=report.summary;
|
||||
const metrics=[
|
||||
['조회수',compact(s.views),`${number(s.views)}회`],
|
||||
['시청 시간',formatMinutes(s.watchMinutes),`${number(s.watchMinutes)}분`],
|
||||
['순 구독자',signed(s.netSubscribers),`획득 ${number(s.subscribersGained)} · 이탈 ${number(s.subscribersLost)}`,s.netSubscribers],
|
||||
['평균 시청 지속 시간',formatDuration(s.averageViewDurationSeconds),'조회수 가중 평균'],
|
||||
['좋아요',compact(s.likes),`${number(s.comments)}개 댓글`],
|
||||
['업로드',number(s.uploads),s.uploads ? `업로드당 평균 ${compact(s.viewsPerUpload)}회` : '선택 기간 업로드 없음']
|
||||
];
|
||||
document.getElementById('summary').innerHTML=metrics.map(([label,value,detail,change])=>`<article class="metric-card"><div class="metric-label">${label}</div><div class="metric-value ${change>0?'positive':change<0?'negative':''}">${value}</div><div class="metric-detail">${detail}</div></article>`).join('');
|
||||
document.getElementById('dataTimezone').textContent='YouTube 일자 기준';
|
||||
document.getElementById('videoCount').textContent=`상위 ${number(report.videos.length)}개 영상`;
|
||||
renderTrend(); renderRhythm(); renderRankings(); renderVideos();
|
||||
const first=report.videos.find(v=>v.videoId===selectedVideoId) || report.videos[0];
|
||||
if(first) selectVideo(first.videoId, false); else document.getElementById('videoTimelineCard').classList.add('hidden');
|
||||
}
|
||||
|
||||
function groupedDaily() {
|
||||
const grouped=new Map();
|
||||
for(const day of report.daily) {
|
||||
const date=new Date(`${day.date}T00:00:00`); let key=day.date;
|
||||
if(selectedPeriod==='weekly') { const offset=(date.getDay()+6)%7; date.setDate(date.getDate()-offset); key=dateString(date); }
|
||||
if(selectedPeriod==='monthly') key=day.date.slice(0,7);
|
||||
const current=grouped.get(key) || {key,views:0,netSubscribers:0,watchMinutes:0};
|
||||
current.views+=Number(day.views||0); current.netSubscribers+=Number(day.subscribersGained||0)-Number(day.subscribersLost||0); current.watchMinutes+=Number(day.watchMinutes||0); grouped.set(key,current);
|
||||
}
|
||||
return [...grouped.values()];
|
||||
}
|
||||
|
||||
function renderTrend() {
|
||||
const rows=groupedDaily(); const canvas=document.getElementById('trendChart'); const empty=document.getElementById('trendEmpty');
|
||||
if(trendChart) trendChart.destroy();
|
||||
if(!rows.length || typeof Chart==='undefined') { canvas.classList.add('hidden'); empty.classList.remove('hidden'); return; }
|
||||
canvas.classList.remove('hidden'); empty.classList.add('hidden');
|
||||
const labels=rows.map(row=>selectedPeriod==='monthly' ? row.key.replace('-','년 ')+'월' : formatDate(row.key));
|
||||
const labelsByPeriod={daily:'일별',weekly:'주별',monthly:'월별',custom:'일별'};
|
||||
document.getElementById('trendDescription').textContent=`${labelsByPeriod[selectedPeriod]} 조회수와 순 구독자 변화 · ${rangeLabel()}`;
|
||||
trendChart=new Chart(canvas,{type:'bar',data:{labels,datasets:[
|
||||
{type:'line',label:'조회수',data:rows.map(r=>r.views),borderColor:'#6657e8',backgroundColor:'rgba(102,87,232,.16)',borderWidth:2,fill:true,tension:.32,yAxisID:'views'},
|
||||
{type:'bar',label:'순 구독자',data:rows.map(r=>r.netSubscribers),backgroundColor:rows.map(r=>r.netSubscribers<0?'rgba(225,84,84,.75)':'rgba(37,166,112,.72)'),borderRadius:4,yAxisID:'subscribers'}
|
||||
]},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{position:'bottom',labels:{boxWidth:10,usePointStyle:true}},tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${number(c.raw)}`}}},scales:{x:{grid:{display:false},ticks:{maxRotation:0,autoSkip:true,maxTicksLimit:12}},views:{beginAtZero:true,position:'left',grid:{color:'rgba(125,125,125,.12)'},ticks:{callback:v=>compact(v)}},subscribers:{beginAtZero:true,position:'right',grid:{drawOnChartArea:false},ticks:{callback:v=>number(v)}}}}});
|
||||
}
|
||||
|
||||
function renderRhythm() {
|
||||
const rhythm=report.uploadRhythm; const weekdayMax=Math.max(1,...rhythm.weekdayCounts.map(v=>v.count)); const hourMax=Math.max(1,...rhythm.hourCounts.map(v=>v.count));
|
||||
document.getElementById('rhythmSummary').innerHTML=`<div class="rhythm-stat"><span>평균 업로드 간격</span><b>${rhythm.averageDaysBetweenUploads ? `${rhythm.averageDaysBetweenUploads}일` : '-'}</b></div><div class="rhythm-stat"><span>주요 업로드 시간</span><b>${DAY_NAMES[rhythm.mostActiveWeekday-1]}요일 ${String(rhythm.mostActiveHour).padStart(2,'0')}시</b></div>`;
|
||||
document.getElementById('weekdayBars').innerHTML=rhythm.weekdayCounts.map(bucket=>`<div class="mini-bar" title="${DAY_NAMES[bucket.value-1]}요일 ${number(bucket.count)}개"><i style="height:${Math.max(4,Math.round(bucket.count/weekdayMax*65))}px"></i><span>${DAY_NAMES[bucket.value-1]}</span></div>`).join('');
|
||||
document.getElementById('hourBars').innerHTML=rhythm.hourCounts.filter(bucket=>bucket.value%2===0).map(bucket=>`<div class="hour-bar" title="${bucket.value}시 ${number(bucket.count)}개"><i style="height:${Math.max(3,Math.round(bucket.count/hourMax*48))}px"></i><span>${String(bucket.value).padStart(2,'0')}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderRankings() {
|
||||
const videos=report.videos; const byViews=[...videos].sort((a,b)=>b.views-a.views).slice(0,5);
|
||||
const bySubscribers=[...videos].sort((a,b)=>b.netSubscribers-a.netSubscribers).slice(0,5);
|
||||
const byEngagement=[...videos].sort((a,b)=>(b.likes+b.comments)-(a.likes+a.comments)).slice(0,5);
|
||||
renderRanking('topViews',byViews,v=>`${compact(v.views)}회`);
|
||||
renderRanking('topSubscribers',bySubscribers,v=>signed(v.netSubscribers));
|
||||
renderRanking('topEngagement',byEngagement,v=>`${compact(v.likes+v.comments)} 반응`);
|
||||
}
|
||||
|
||||
function renderRanking(id, videos, metric) {
|
||||
document.getElementById(id).innerHTML=videos.length ? videos.map((video,index)=>`<button type="button" class="ranking-item" onclick="selectVideo('${esc(video.videoId)}')"><span class="ranking-rank">${index+1}</span>${video.thumbnailUrl?`<img class="ranking-thumb" src="${esc(video.thumbnailUrl)}" alt="">`:'<span class="ranking-thumb"></span>'}<span class="ranking-title">${esc(video.title)}</span><span class="ranking-value">${metric(video)}</span></button>`).join('') : '<p class="ranking-empty">표시할 영상 데이터가 없습니다.</p>';
|
||||
}
|
||||
|
||||
function renderVideos() {
|
||||
document.getElementById('videos').innerHTML=report.videos.length ? report.videos.map(video=>`<button type="button" class="video-row ${selectedVideoId===video.videoId?'selected':''}" data-video-id="${esc(video.videoId)}" onclick="selectVideo('${esc(video.videoId)}')"><img class="video-thumb" src="${esc(video.thumbnailUrl)}" alt="" loading="lazy"><span><span class="video-name">${esc(video.title)}</span><span class="video-meta"><span>조회수 ${compact(video.views)}</span><span>순 구독 ${signed(video.netSubscribers)}</span><span>반응 ${compact(video.likes+video.comments)}</span></span></span></button>`).join('') : '<p class="ranking-empty">선택한 기간에 성과가 집계된 영상이 없습니다.</p>';
|
||||
}
|
||||
|
||||
async function selectVideo(videoId, scroll=true) {
|
||||
const video=report.videos.find(item=>item.videoId===videoId); if(!video) return;
|
||||
selectedVideoId=videoId; renderVideos();
|
||||
const card=document.getElementById('videoTimelineCard'); card.classList.remove('hidden');
|
||||
document.getElementById('videoTimelineTitle').textContent=video.title;
|
||||
document.getElementById('videoYoutubeLink').href=`https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
|
||||
if(scroll) card.scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
try {
|
||||
const timeline=await api(`${API}/analytics/videos/${encodeURIComponent(videoId)}/timeline?connectionId=${encodeURIComponent(selectedConnectionId())}&startDate=${report.period.startDate}&endDate=${report.period.endDate}`);
|
||||
renderVideoTimeline(timeline);
|
||||
} catch(error) { toast(error.message || '영상 추이를 불러오지 못했습니다.',true); }
|
||||
}
|
||||
|
||||
function renderVideoTimeline(rows) {
|
||||
const canvas=document.getElementById('videoTimelineChart'); if(videoTimelineChart) videoTimelineChart.destroy();
|
||||
if(typeof Chart==='undefined') return;
|
||||
videoTimelineChart=new Chart(canvas,{type:'line',data:{labels:rows.map(r=>formatDate(r.date)),datasets:[
|
||||
{label:'조회수',data:rows.map(r=>r.views),borderColor:'#6657e8',backgroundColor:'rgba(102,87,232,.15)',fill:true,tension:.3,borderWidth:2,yAxisID:'views'},
|
||||
{label:'순 구독자',data:rows.map(r=>Number(r.subscribersGained)-Number(r.subscribersLost)),borderColor:'#25a670',backgroundColor:'rgba(37,166,112,.1)',tension:.3,borderWidth:2,yAxisID:'subscribers'}
|
||||
]},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{position:'bottom',labels:{boxWidth:10,usePointStyle:true}}},scales:{x:{grid:{display:false},ticks:{maxRotation:0,maxTicksLimit:12}},views:{beginAtZero:true,ticks:{callback:v=>compact(v)}},subscribers:{position:'right',grid:{drawOnChartArea:false},ticks:{callback:v=>number(v)}}}}});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.period-tab').forEach(tab=>tab.addEventListener('click',()=>{
|
||||
selectedPeriod=tab.dataset.period; document.querySelectorAll('.period-tab').forEach(item=>item.classList.toggle('active',item===tab));
|
||||
document.getElementById('customDates').classList.toggle('hidden',selectedPeriod!=='custom');
|
||||
if(selectedPeriod!=='custom') { setDefaultDates(); loadReport(); }
|
||||
}));
|
||||
document.getElementById('connectionSelect').addEventListener('change',()=>{ selectedVideoId=null; loadReport(); });
|
||||
|
||||
const oauthResult=new URLSearchParams(location.search).get('youtube');
|
||||
if(oauthResult) { const messages={connected:'YouTube 채널이 연결되었습니다.',denied:'Google 권한 요청이 취소되었습니다.',failed:'YouTube 채널 연결에 실패했습니다.', 'invalid-state':'안전하지 않은 OAuth 응답을 차단했습니다.', 'missing-code':'Google 인증 코드를 받지 못했습니다.'}; setTimeout(()=>toast(messages[oauthResult]||'채널 연결 상태를 확인하세요.',oauthResult!=='connected'),80); history.replaceState(null,'',location.pathname); }
|
||||
setDefaultDates();
|
||||
loadConnections().catch(error=>toast(error.message || '연결한 채널 목록을 불러오지 못했습니다.',true));
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
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.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class YoutubeChannelAnalyticsServiceTest {
|
||||
|
||||
@Mock RestTemplate restTemplate;
|
||||
@Mock YoutubeOAuthService oauthService;
|
||||
@Mock YoutubeConnectionRepository connectionRepository;
|
||||
|
||||
@Test
|
||||
void buildsPeriodReportFromAnalyticsAndUploadData() {
|
||||
YoutubeConnection connection = new YoutubeConnection("channel-1", "내 채널", "@mine", "", "ciphertext");
|
||||
when(connectionRepository.findById(3L)).thenReturn(Optional.of(connection));
|
||||
when(oauthService.accessToken(3L)).thenReturn("access-token");
|
||||
when(restTemplate.exchange(ArgumentMatchers.any(URI.class), ArgumentMatchers.eq(HttpMethod.GET),
|
||||
ArgumentMatchers.any(HttpEntity.class), ArgumentMatchers.eq(String.class)))
|
||||
.thenAnswer(invocation -> ResponseEntity.ok(responseFor(invocation.getArgument(0, URI.class))));
|
||||
|
||||
YoutubeChannelAnalyticsService service = new YoutubeChannelAnalyticsService(restTemplate, new ObjectMapper(),
|
||||
oauthService, connectionRepository);
|
||||
|
||||
YoutubeChannelAnalyticsDto result = service.analyze(3L,
|
||||
LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 2));
|
||||
|
||||
assertThat(result.summary().views()).isEqualTo(150);
|
||||
assertThat(result.summary().netSubscribers()).isEqualTo(4);
|
||||
assertThat(result.summary().uploads()).isEqualTo(1);
|
||||
assertThat(result.summary().averageViewDurationSeconds()).isEqualTo(30.7);
|
||||
assertThat(result.videos()).singleElement().satisfies(video -> {
|
||||
assertThat(video.title()).isEqualTo("성과 영상");
|
||||
assertThat(video.netSubscribers()).isEqualTo(4);
|
||||
});
|
||||
assertThat(result.uploadRhythm().mostActiveHour()).isEqualTo(10);
|
||||
}
|
||||
|
||||
private String responseFor(URI uri) {
|
||||
String url = uri.toString();
|
||||
if (url.contains("youtubeanalytics.googleapis.com") && url.contains("dimensions=day")) {
|
||||
return "{\"rows\":[[\"2026-09-01\",100,50,30,4,1,2,3],[\"2026-09-02\",50,20,32,1,0,1,1]]}";
|
||||
}
|
||||
if (url.contains("youtubeanalytics.googleapis.com") && url.contains("dimensions=video")) {
|
||||
return "{\"rows\":[[\"video-1\",150,70,31,5,1,3,4]]}";
|
||||
}
|
||||
if (url.contains("/youtube/v3/videos")) {
|
||||
return "{\"items\":[{\"id\":\"video-1\",\"snippet\":{\"title\":\"성과 영상\",\"publishedAt\":\"2026-09-01T01:00:00Z\",\"thumbnails\":{\"medium\":{\"url\":\"https://img.example/video.jpg\"}}}}]}";
|
||||
}
|
||||
if (url.contains("/youtube/v3/channels")) {
|
||||
return "{\"items\":[{\"contentDetails\":{\"relatedPlaylists\":{\"uploads\":\"uploads-1\"}}}]}";
|
||||
}
|
||||
if (url.contains("/youtube/v3/playlistItems")) {
|
||||
return "{\"items\":[{\"contentDetails\":{\"videoPublishedAt\":\"2026-09-01T01:00:00Z\"}}]}";
|
||||
}
|
||||
throw new AssertionError("Unexpected YouTube request: " + url);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user