feat: 내 채널 기간별 분석 리포트 추가

This commit is contained in:
hehihoho3@gmail.com 2026-09-08 08:17:40 +09:00
parent d70bb142c0
commit 118ca72281
6 changed files with 796 additions and 381 deletions

View File

@ -1,6 +1,7 @@
package com.hlab.yanalyst.domain.channel; package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto; import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto;
import com.hlab.yanalyst.domain.channel.dto.YoutubeChannelAnalyticsDto;
import com.hlab.yanalyst.global.common.ApiResponse; import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; 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.List;
import java.util.Map; import java.util.Map;
import java.time.LocalDate;
/** 내 채널(OWN) 분석 API. 소재 파이프라인과 완전히 분리된 성과 추적용. */ /** 내 채널(OWN) 분석 API. 소재 파이프라인과 완전히 분리된 성과 추적용. */
@RestController @RestController
@ -18,6 +20,7 @@ import java.util.Map;
public class MyChannelController { public class MyChannelController {
private final MyChannelService myChannelService; private final MyChannelService myChannelService;
private final YoutubeChannelAnalyticsService youtubeChannelAnalyticsService;
@GetMapping @GetMapping
@Operation(summary = "내 채널 요약", description = "등록 안 돼 있으면 registered=false") @Operation(summary = "내 채널 요약", description = "등록 안 돼 있으면 registered=false")
@ -43,6 +46,25 @@ public class MyChannelController {
return ApiResponse.ok(myChannelService.growth()); 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 @PostMapping
@Operation(summary = "내 채널 등록", description = "채널 URL 로 등록하고 즉시 영상을 수집한다. 이미 있으면 교체.") @Operation(summary = "내 채널 등록", description = "채널 URL 로 등록하고 즉시 영상을 수집한다. 이미 있으면 교체.")
public ApiResponse<MyChannelSummaryDto> register(@RequestBody Map<String, String> body) { public ApiResponse<MyChannelSummaryDto> register(@RequestBody Map<String, String> body) {

View File

@ -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) { }
}

View File

@ -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) { }
}

View File

@ -26,14 +26,16 @@ import java.util.UUID;
@RequiredArgsConstructor @RequiredArgsConstructor
public class YoutubeOAuthController { public class YoutubeOAuthController {
private static final String STATE_SESSION_KEY = "youtube.oauth.state"; 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 YoutubeOAuthService service;
private final YoutubeOAuthLocalSettings localSettings; private final YoutubeOAuthLocalSettings localSettings;
private final YoutubeUploadService uploadService; private final YoutubeUploadService uploadService;
@GetMapping("/oauth/start") @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(); String state = UUID.randomUUID().toString();
session.setAttribute(STATE_SESSION_KEY, state); session.setAttribute(STATE_SESSION_KEY, state);
session.setAttribute(RETURN_TO_SESSION_KEY, safeReturnTo(returnTo));
return ResponseEntity.status(302).location(service.authorizationUri(state)).build(); return ResponseEntity.status(302).location(service.authorizationUri(state)).build();
} }
@ -43,15 +45,18 @@ public class YoutubeOAuthController {
@RequestParam(required = false) String error, @RequestParam(required = false) String error,
HttpSession session) { HttpSession session) {
String expected = (String) session.getAttribute(STATE_SESSION_KEY); String expected = (String) session.getAttribute(STATE_SESSION_KEY);
String returnTo = (String) session.getAttribute(RETURN_TO_SESSION_KEY);
session.removeAttribute(STATE_SESSION_KEY); session.removeAttribute(STATE_SESSION_KEY);
if (error != null) return redirect("/upload-studio?youtube=denied"); session.removeAttribute(RETURN_TO_SESSION_KEY);
if (expected == null || !expected.equals(state)) return redirect("/upload-studio?youtube=invalid-state"); String target = returnTo == null ? "/upload-studio" : returnTo;
if (code == null || code.isBlank()) return redirect("/upload-studio?youtube=missing-code"); 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 { try {
service.connect(code); service.connect(code);
return redirect("/upload-studio?youtube=connected"); return redirect(target + "?youtube=connected");
} catch (RuntimeException e) { } 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(); 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 SettingsRequest(String clientId, String clientSecret, String redirectUri) { }
public record SettingsStatus(boolean configured, String redirectUri) { } public record SettingsStatus(boolean configured, String redirectUri) { }
} }

View File

@ -1,384 +1,368 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" <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> <head>
<title>h-lab - 내 채널</title> <title>h-lab - 내 채널 분석</title>
</head> </head>
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>내 채널</h1> <h1>내 채널 분석</h1>
<p class="sub">내가 발행한 영상의 성과와 어떤 소재가 잘 먹히는지 봅니다. 소재 파이프라인과는 분리돼 있습니다.</p> <p class="sub">업로드 리듬과 영상별 조회수·구독자 성과를 한 번에 보는 채널 리포트입니다.</p>
</div> </div>
<div class="actions" id="headerActions"></div> <div class="actions" id="headerActions"></div>
</div> </div>
<!-- 미등록 상태 --> <section id="setup" class="card analytics-setup hidden">
<div id="setup" class="card hidden" style="max-width:640px;"> <div class="setup-icon"><i data-lucide="bar-chart-3"></i></div>
<div class="flex items-center gap-3 mb-3">
<div class="setup-ic"><i data-lucide="trending-up"></i></div>
<div> <div>
<div class="font-semibold">내 채널을 등록하세요</div> <h2 class="text-lg">분석할 내 YouTube 채널을 연결하세요</h2>
<div class="text-sm text-muted" style="margin-top:2px;"> <p class="text-sm text-muted">Google 계정으로 연결하면 일별 조회수, 시청 시간, 영상별 구독자 증가를 가져올 수 있습니다.</p>
여기 등록한 채널의 영상은 수집함·칸반·피드에 섞이지 않습니다.
</div> </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> </div>
</div> <p class="text-xs text-muted setup-note">연결만으로는 영상이 업로드되지 않습니다. 업로드 스튜디오는 그대로 유지됩니다.</p>
<div class="seed-add"> </section>
<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>
<!-- 등록 상태 --> <main id="analytics" class="hidden">
<div id="main" class="hidden"> <section class="card analytics-controls">
<!-- 요약 --> <div class="channel-picker">
<div class="card mb-4" id="summaryCard"></div> <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>
<div class="mc-grid"> <section id="reportTitle" class="report-title"></section>
<!-- 소재별 성과 --> <section id="summary" class="summary-grid" aria-live="polite"></section>
<div class="card">
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:0.5rem;"> <section class="chart-grid">
<article class="card chart-card chart-wide">
<div class="chart-heading">
<div> <div>
<h3 class="text-lg">소재별 성과</h3> <h2 class="text-lg">조회수와 구독자 변화</h2>
<p class="text-xs text-muted" style="margin-top:2px;"> <p id="trendDescription" class="text-xs text-muted">기간별 성과 추이</p>
해시태그 기준 · 2편 이상만 · 배수는 채널 평균 대비
</p>
</div> </div>
<span id="avgBadge" class="badge badge-muted"></span> <span id="dataTimezone" class="badge badge-muted">YouTube 집계 기준</span>
</div> </div>
<div id="topics"></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>
<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">
<div class="card"> <article class="card ranking-card">
<h3 class="text-lg mb-3">성장 추이</h3> <div class="chart-heading"><div><h2 class="text-lg">조회수 상승 영상</h2><p class="text-xs text-muted">선택 기간 조회수 상위</p></div></div>
<div id="growthWrap" style="position:relative; height:260px;"> <div id="topViews" class="ranking-list"></div>
<canvas id="growthChart"></canvas> </article>
</div> <article class="card ranking-card">
<div id="growthEmpty" class="text-sm text-muted text-center hidden" style="padding:2.5rem 0;"> <div class="chart-heading"><div><h2 class="text-lg">구독자 유입 영상</h2><p class="text-xs text-muted">영상 시청 페이지에서 발생한 구독 기준</p></div></div>
아직 스냅샷이 없습니다. 매일 새벽 3시에 기록되며 이틀치가 쌓이면 그래프가 그려집니다. <div id="topSubscribers" class="ranking-list"></div>
</div> </article>
</div> <article class="card ranking-card">
</div> <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="card mt-4"> <div class="chart-heading">
<div class="flex items-center justify-between mb-3"> <div>
<h3 class="text-lg">내 영상</h3> <h2 class="text-lg">영상별 분석</h2>
<p class="text-xs text-muted">영상을 선택하면 아래에 일별 성과 추이를 표시합니다.</p>
</div>
<span id="videoCount" class="badge badge-muted"></span> <span id="videoCount" class="badge badge-muted"></span>
</div> </div>
<div id="videos" class="mc-videos"></div> <div id="videos" class="video-list"></div>
</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>
<div class="chart-wrap"><canvas id="videoTimelineChart"></canvas></div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div> <div id="toast" class="toast" role="status" aria-live="polite"></div>
<style> <style>
.mc-grid { display:grid; grid-template-columns:1.15fr 1fr; gap:1rem; align-items:start; } .hidden { display:none !important; }
@media (max-width: 960px) { .mc-grid { grid-template-columns:1fr; } } .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-ic { .setup-actions { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:flex-end; }
width:38px; height:38px; border-radius:10px; flex-shrink:0; .setup-note { grid-column:2 / -1; margin:0; }
background:var(--accent-soft); color:var(--accent); .analytics-controls { display:flex; gap:.75rem; align-items:center; flex-wrap:wrap; padding:1rem; }
display:flex; align-items:center; justify-content:center; .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; }
.seed-add { display:flex; gap:0.5rem; flex-wrap:wrap; } .channel-picker select { flex:1; min-width:0; }
.seed-add input { flex:1; min-width:190px; } .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); }
.mc-head { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; } .custom-dates { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; font-size:.78rem; color:var(--text-3); }
.mc-head img { width:56px; height:56px; border-radius:50%; object-fit:cover; background:var(--inset); flex-shrink:0; } .custom-dates label { display:flex; align-items:center; gap:.3rem; white-space:nowrap; }
.mc-stats { display:flex; gap:1.75rem; flex-wrap:wrap; margin-left:auto; } .custom-dates input { padding:.37rem .45rem; font-size:.78rem; }
.mc-stat .k { font-size:0.72rem; color:var(--text-3); font-weight:600; } .refresh-button { margin-left:auto; }
.mc-stat .v { .btn-small { font-size:.78rem; padding:.42rem .62rem; }
font-size:1.15rem; font-weight:700; font-family:var(--font-mono); .report-title { display:flex; justify-content:space-between; align-items:baseline; margin:1.25rem 0 .7rem; }
font-variant-numeric:tabular-nums; margin-top:2px; .report-title h2 { margin:0; font-size:1rem; }
} .report-title p { margin:0; color:var(--text-3); font-size:.75rem; }
@media (max-width: 700px) { .mc-stats { margin-left:0; gap:1.1rem; } } .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; }
.topic-row { .metric-value { margin-top:.3rem; font-size:1.25rem; color:var(--text); font-weight:800; letter-spacing:-.03em; font-variant-numeric:tabular-nums; }
display:flex; align-items:center; gap:0.7rem; .metric-detail { margin-top:.18rem; font-size:.7rem; color:var(--text-3); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
padding:0.6rem 0; border-bottom:1px solid var(--border); .metric-value.positive { color:var(--success, #1b9b69); }
} .metric-value.negative { color:var(--danger); }
.topic-row:last-child { border-bottom:none; } .chart-grid { display:grid; grid-template-columns:1.55fr 1fr; gap:1rem; margin-top:1rem; }
.topic-name { min-width:0; flex:0 0 30%; } .report-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; margin-top:1rem; }
.topic-name b { display:block; font-size:0.85rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .chart-card, .ranking-card, .video-section { min-width:0; }
.topic-name span { font-size:0.7rem; color:var(--text-3); } .chart-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:.75rem; margin-bottom:.85rem; }
.topic-bar { flex:1; min-width:60px; } .chart-heading h2 { margin:0; }
.topic-bar .track { height:7px; background:var(--inset); border-radius:var(--radius-full); overflow:hidden; } .chart-heading p { margin:.18rem 0 0; }
.topic-bar .fill { height:100%; border-radius:var(--radius-full); transition:width .5s cubic-bezier(.22,1,.36,1); } .chart-wrap { position:relative; height:286px; }
.topic-num { .empty-chart { height:286px; display:grid; place-items:center; color:var(--text-3); font-size:.85rem; }
flex:0 0 auto; text-align:right; font-family:var(--font-mono); .rhythm-summary { display:grid; grid-template-columns:1fr 1fr; gap:.6rem; margin:.2rem 0 1rem; }
font-variant-numeric:tabular-nums; font-size:0.8rem; min-width:66px; .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; }
.topic-idx { flex:0 0 auto; min-width:52px; text-align:right; } .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; }
.mc-videos { display:grid; grid-template-columns:repeat(auto-fill, minmax(210px, 1fr)); gap:0.85rem; } .mini-bar, .hour-bar { min-width:0; display:flex; flex-direction:column; gap:.2rem; align-items:center; justify-content:end; height:100%; }
.mcv { display:block; } .mini-bar i, .hour-bar i { width:100%; display:block; min-height:3px; border-radius:4px 4px 1px 1px; background:var(--accent); opacity:.82; }
.mcv .th { position:relative; aspect-ratio:16/9; border-radius:var(--r-sm); overflow:hidden; background:var(--inset); } .mini-bar span, .hour-bar span { font-style:normal; font-size:.63rem; color:var(--text-3); white-space:nowrap; }
.mcv .th img { width:100%; height:100%; object-fit:cover; display:block; } .hour-bars { display:grid; grid-template-columns:repeat(12,1fr); height:64px; gap:.18rem; align-items:end; }
.mcv:focus-visible .th { outline:2px solid var(--accent); outline-offset:2px; } .ranking-list { display:flex; flex-direction:column; }
.mcv .t { .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; }
font-size:0.8rem; font-weight:600; line-height:1.4; margin-top:0.4rem; color:var(--text); .ranking-item:last-child { border-bottom:0; }
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; .ranking-item:hover .ranking-title { text-decoration:underline; }
} .ranking-rank { color:var(--text-3); font-size:.73rem; font-weight:800; }
.mcv:hover .t { text-decoration:underline; } .ranking-thumb { width:38px; height:24px; border-radius:4px; object-fit:cover; background:var(--inset); }
.mcv .m { font-size:0.72rem; color:var(--text-3); margin-top:2px; font-variant-numeric:tabular-nums; } .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; }
.toast { .ranking-empty { color:var(--text-3); font-size:.8rem; padding:1rem 0; }
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px); .video-section { margin-top:1rem; }
background:var(--text); color:var(--bg); .video-list { display:grid; grid-template-columns:repeat(auto-fill,minmax(285px,1fr)); gap:.65rem; }
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600; .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; }
box-shadow:var(--shadow-lg); z-index:2000; .video-row:hover, .video-row.selected { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent-soft); }
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease; .video-thumb { width:94px; height:53px; border-radius:5px; background:var(--inset); object-fit:cover; }
max-width:88vw; text-align:center; .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.show { opacity:1; transform:translate(-50%,0); }
.toast.err { background:var(--danger); color:#fff; } .toast.error { background:var(--danger); color:#fff; }
.sr-only { @media (max-width:1180px) { .summary-grid { grid-template-columns:repeat(3,1fr); } .report-grid { grid-template-columns:1fr; } }
position:absolute; width:1px; height:1px; padding:0; margin:-1px; @media (max-width:900px) { .chart-grid { grid-template-columns:1fr; } .refresh-button { margin-left:0; } }
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border: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; } }
@media (prefers-reduced-motion: reduce) {
.topic-bar .fill, .toast { transition:none; }
}
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script th:inline="javascript"> <script>
/*<![CDATA[*/
const API = '/api/my-channel'; const API = '/api/my-channel';
let growthChart = null; const DAY_NAMES = ['\uC6D4','\uD654','\uC218','\uBAA9','\uAE08','\uD1A0','\uC77C'];
let connections = [];
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } let selectedPeriod = 'daily';
function fmtNum(n){ let report = null;
if(n==null) return '-'; let trendChart = null;
const v = Number(n); let videoTimelineChart = null;
if(v >= 100000000) return (v/100000000).toFixed(1).replace(/\.0$/,'') + '억'; let selectedVideoId = null;
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; let toastTimer = null;
function toast(msg, isError){
const el = document.getElementById('toast'); function esc(value) { return String(value ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
el.textContent = msg; function number(value) { return Number(value || 0).toLocaleString('ko-KR'); }
el.classList.toggle('err', !!isError); function compact(value) { return new Intl.NumberFormat('ko-KR',{notation:'compact',maximumFractionDigits:1}).format(Number(value || 0)); }
el.classList.add('show'); function signed(value) { const n=Number(value || 0); return `${n>0?'+':''}${number(n)}`; }
clearTimeout(toastTimer); function dateString(date) { return date.toISOString().slice(0,10); }
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500); 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`)];
} }
async function api(url, opts){ function setDefaultDates() {
const res = await fetch(url, opts); const [start,end]=currentRange();
const json = await res.json().catch(()=>({})); document.getElementById('startDate').value=dateString(start);
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status)); document.getElementById('endDate').value=dateString(end);
return json.data; document.getElementById('endDate').max=dateString(new Date());
} }
// ---------- 요약 ---------- async function loadConnections() {
function renderSummary(s){ connections=await api('/api/youtube/connections');
document.getElementById('summaryCard').innerHTML = ` const setup=document.getElementById('setup'); const analytics=document.getElementById('analytics');
<div class="mc-head"> setup.classList.toggle('hidden',connections.length>0); analytics.classList.toggle('hidden',connections.length===0);
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy"> if(!connections.length) return;
<div style="min-width:0;"> const select=document.getElementById('connectionSelect'); const previous=select.value;
<div class="text-lg font-bold">${esc(s.title)}</div> select.innerHTML=connections.map(c=>`<option value="${c.id}">${esc(c.title)}${c.handle ? ` (${esc(c.handle)})` : ''}</option>`).join('');
<a href="https://www.youtube.com/channel/${esc(s.channelId)}" target="_blank" rel="noopener" select.value=connections.some(c=>String(c.id)===previous) ? previous : connections[0].id;
class="text-xs text-muted hover:underline">YouTube 에서 보기</a> await loadReport();
</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();
} }
// ---------- 소재별 성과 ---------- async function connectYoutube() {
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 { try {
await api(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({url}) }); const status=await api('/api/youtube/settings/status');
toast('등록하고 영상을 수집했습니다'); if(!status.configured) { toast('YouTube OAuth 설정이 필요합니다. 연결 설정 화면에서 먼저 등록하세요.',true); return; }
await load(); location.href='/api/youtube/oauth/start?returnTo=/my-channel';
} catch(e){ } catch(error) { toast(error.message,true); }
help.textContent = '등록 실패: ' + e.message;
help.style.color = 'var(--danger)';
} finally { btn.disabled = false; btn.textContent = orig; }
} }
async function syncOwn(){ function selectedConnectionId() { return document.getElementById('connectionSelect').value; }
const btn = document.getElementById('syncBtn'); function rangeLabel() { const [start,end]=currentRange(); return `${dateString(start).replaceAll('-','.')} ~ ${dateString(end).replaceAll('-','.')}`; }
btn.disabled = true;
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 { try {
await api(API + '/sync', { method:'POST' }); report=await api(`${API}/analytics?connectionId=${encodeURIComponent(connectionId)}&startDate=${dateString(start)}&endDate=${dateString(end)}`);
toast('동기화했습니다'); renderReport();
await load(); } catch(error) { toast(error.message || '분석 데이터를 불러오지 못했습니다.',true); }
} catch(e){ toast('동기화 실패: ' + e.message, true); } finally { refresh.disabled=false; }
finally { const b = document.getElementById('syncBtn'); if(b) b.disabled = false; }
} }
async function unregisterOwn(){ function renderReport() {
if(!confirm('내 채널 등록을 해제할까요?\n수집된 영상과 소재별 성과 기록도 함께 삭제됩니다.')) return; 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 { try {
await api(API, { method:'DELETE' }); const timeline=await api(`${API}/analytics/videos/${encodeURIComponent(videoId)}/timeline?connectionId=${encodeURIComponent(selectedConnectionId())}&startDate=${report.period.startDate}&endDate=${report.period.endDate}`);
toast('등록을 해제했습니다'); renderVideoTimeline(timeline);
await load(); } catch(error) { toast(error.message || '영상 추이를 불러오지 못했습니다.',true); }
} catch(e){ toast('해제 실패: ' + e.message, true); }
} }
// ---------- init ---------- function renderVideoTimeline(rows) {
async function load(){ const canvas=document.getElementById('videoTimelineChart'); if(videoTimelineChart) videoTimelineChart.destroy();
let s; if(typeof Chart==='undefined') return;
try { s = await api(API); } videoTimelineChart=new Chart(canvas,{type:'line',data:{labels:rows.map(r=>formatDate(r.date)),datasets:[
catch(e){ toast('불러오기 실패: ' + e.message, true); return; } {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'}
if(!s || !s.registered){ ]},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.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(); 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> </script>
</div> </div>
</body> </body>
</html> </html>

View File

@ -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);
}
}