사용하지 않는 코드 정리 및 전송 데이터 포멧팅 변경

subtitle
김성경 2026-02-26 09:17:35 +09:00
parent a0c352f567
commit cb267192d2
5 changed files with 137 additions and 208 deletions

View File

@ -14,16 +14,17 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dashboard.exceptions import ( from app.dashboard.exceptions import (
NoVideosFoundError,
YouTubeAccountNotConnectedError, YouTubeAccountNotConnectedError,
YouTubeAccountNotFoundError, YouTubeAccountNotFoundError,
YouTubeAccountSelectionRequiredError, YouTubeAccountSelectionRequiredError,
YouTubeTokenExpiredError, YouTubeTokenExpiredError,
) )
from app.dashboard.schemas import ( from app.dashboard.schemas import (
AudienceData,
CacheDeleteResponse, CacheDeleteResponse,
ConnectedAccount, ConnectedAccount,
ConnectedAccountsResponse, ConnectedAccountsResponse,
ContentMetric,
DashboardResponse, DashboardResponse,
TopContent, TopContent,
) )
@ -53,7 +54,7 @@ router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
description=""" description="""
연결된 소셜 계정 목록을 반환합니다. 연결된 소셜 계정 목록을 반환합니다.
여러 계정이 연결된 경우, 반환된 `id` 값을 `/dashboard/stats?social_account_id=<id>` 전달하여 계정을 선택합니다. 여러 계정이 연결된 경우, 반환된 `platformUserId` 값을 `/dashboard/stats?platform_user_id=<>` 전달하여 계정을 선택합니다.
""", """,
) )
async def get_connected_accounts( async def get_connected_accounts(
@ -107,18 +108,17 @@ YouTube Analytics API를 활용한 대시보드 통계를 조회합니다.
## 주요 기능 ## 주요 기능
- 최근 30 업로드 영상 기준 통계 제공 - 최근 30 업로드 영상 기준 통계 제공
- KPI 지표: 업로드 영상 , 조회수, 시청 시간, 평균 시청 시간, 신규 구독자, 좋아요, 댓글, 공유 - KPI 지표: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상
- 월별 추이: 최근 12개월 vs 이전 12개월 비교 - 월별 추이: 최근 12개월 vs 이전 12개월 비교
- 인기 영상 TOP 4 - 인기 영상 TOP 4
- 시청자 분석: 연령/성별/지역 분포 - 시청자 분석: 연령/성별/지역 분포
## 성능 최적화 ## 성능 최적화
- 8 YouTube Analytics API를 병렬로 호출 - 7 YouTube Analytics API를 병렬로 호출
- Redis 캐싱 적용 (TTL: 12시간) - Redis 캐싱 적용 (TTL: 12시간)
## 사전 조건 ## 사전 조건
- YouTube 계정이 연동되어 있어야 합니다 - YouTube 계정이 연동되어 있어야 합니다
- 최소 1 이상의 영상이 업로드되어 있어야 합니다
## 조회 모드 ## 조회 모드
- `day`: 최근 30 통계 (현재 날짜 -2 기준) - `day`: 최근 30 통계 (현재 날짜 -2 기준)
@ -158,7 +158,6 @@ async def get_dashboard_stats(
YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음 YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음
YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택 YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택
YouTubeAccountNotFoundError: 지정한 계정을 찾을 없음 YouTubeAccountNotFoundError: 지정한 계정을 찾을 없음
NoVideosFoundError: 업로드된 영상이 없음
YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요) YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요)
YouTubeAPIError: YouTube Analytics API 호출 실패 YouTubeAPIError: YouTube Analytics API 호출 실패
""" """
@ -302,7 +301,7 @@ async def get_dashboard_stats(
response = DashboardResponse.model_validate(payload["response"]) response = DashboardResponse.model_validate(payload["response"])
for metric in response.content_metrics: for metric in response.content_metrics:
if metric.id == "uploaded-videos": if metric.id == "uploaded-videos":
metric.value = str(period_video_count) metric.value = float(period_video_count)
video_trend = float(period_video_count - prev_period_video_count) video_trend = float(period_video_count - prev_period_video_count)
metric.trend = video_trend metric.trend = video_trend
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-") metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
@ -332,14 +331,7 @@ async def get_dashboard_stats(
.limit(30) .limit(30)
) )
rows = result.all() rows = result.all()
# 테스트를 위해 주석처리 logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}")
# if not rows:
# logger.warning(
# f"[NO VIDEOS] 업로드된 영상 없음 - " f"user_uuid={current_user.user_uuid}"
# )
# raise NoVideosFoundError()
# logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}")
# 6. video_ids + 메타데이터 조회용 dict 구성 # 6. video_ids + 메타데이터 조회용 dict 구성
video_ids = [] video_ids = []
@ -354,6 +346,30 @@ async def get_dashboard_stats(
f"[6] 영상 메타데이터 구성 완료 - count={len(video_ids)}, sample={video_ids[:3]}" f"[6] 영상 메타데이터 구성 완료 - count={len(video_ids)}, sample={video_ids[:3]}"
) )
# 6.1 업로드 영상 없음 → YouTube API 호출 없이 빈 응답 반환
if not video_ids:
logger.info(
f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - "
f"user_uuid={current_user.user_uuid}"
)
return DashboardResponse(
content_metrics=[
ContentMetric(id="total-views", label="조회수", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="total-watch-time", label="시청시간", value=0.0, unit="hours", trend=0.0, trend_direction="-"),
ContentMetric(id="avg-view-duration", label="평균 시청시간", value=0.0, unit="minutes", trend=0.0, trend_direction="-"),
ContentMetric(id="new-subscribers", label="신규 구독자", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="likes", label="좋아요", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="comments", label="댓글", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="shares", label="공유", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="uploaded-videos", label="업로드 영상", value=0.0, unit="count", trend=0.0, trend_direction="-"),
],
monthly_data=[],
daily_data=[],
top_content=[],
audience_data=AudienceData(age_groups=[], gender={"male": 0, "female": 0}, top_regions=[]),
has_uploads=False,
)
# 7. 토큰 유효성 확인 및 자동 갱신 (만료 10분 전 갱신) # 7. 토큰 유효성 확인 및 자동 갱신 (만료 10분 전 갱신)
try: try:
access_token = await SocialAccountService().ensure_valid_token( access_token = await SocialAccountService().ensure_valid_token(
@ -367,7 +383,7 @@ async def get_dashboard_stats(
logger.debug("[7] 토큰 유효성 확인 완료") logger.debug("[7] 토큰 유효성 확인 완료")
# 8. YouTube Analytics API 호출 (8개 병렬) # 8. YouTube Analytics API 호출 (7개 병렬)
youtube_service = YouTubeAnalyticsService() youtube_service = YouTubeAnalyticsService()
raw_data = await youtube_service.fetch_all_metrics( raw_data = await youtube_service.fetch_all_metrics(
video_ids=video_ids, video_ids=video_ids,
@ -399,7 +415,7 @@ async def get_dashboard_stats(
title=title, title=title,
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg", thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
platform="youtube", platform="youtube",
views=DataProcessor._format_number(views), views=int(views),
engagement=f"{engagement_rate:.1f}%", engagement=f"{engagement_rate:.1f}%",
date=uploaded_at.strftime("%Y.%m.%d"), date=uploaded_at.strftime("%Y.%m.%d"),
) )
@ -464,7 +480,7 @@ async def get_dashboard_stats(
- 데이터 이상 발생 캐시 강제 갱신 - 데이터 이상 발생 캐시 강제 갱신
## 캐시 키 구조 ## 캐시 키 구조
`dashboard:{user_uuid}:{mode}` (mode: day 또는 month) `dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
## 파라미터 ## 파라미터
- `user_uuid`: 특정 사용자 캐시만 삭제. 미입력 전체 삭제 - `user_uuid`: 특정 사용자 캐시만 삭제. 미입력 전체 삭제

View File

@ -13,8 +13,6 @@ from app.dashboard.schemas.dashboard_schema import (
DailyData, DailyData,
DashboardResponse, DashboardResponse,
MonthlyData, MonthlyData,
PlatformData,
PlatformMetric,
TopContent, TopContent,
) )
@ -26,8 +24,6 @@ __all__ = [
"MonthlyData", "MonthlyData",
"TopContent", "TopContent",
"AudienceData", "AudienceData",
"PlatformMetric",
"PlatformData",
"DashboardResponse", "DashboardResponse",
"CacheDeleteResponse", "CacheDeleteResponse",
] ]

View File

@ -22,10 +22,10 @@ def to_camel(string: str) -> str:
"""snake_case를 camelCase로 변환 """snake_case를 camelCase로 변환
Args: Args:
string: snake_case 문자열 (: "label_en") string: snake_case 문자열 (: "content_metrics")
Returns: Returns:
camelCase 문자열 (: "labelEn") camelCase 문자열 (: "contentMetrics")
Example: Example:
>>> to_camel("content_metrics") >>> to_camel("content_metrics")
@ -43,28 +43,31 @@ class ContentMetric(BaseModel):
대시보드 상단에 표시되는 핵심 성과 지표(KPI) 카드입니다. 대시보드 상단에 표시되는 핵심 성과 지표(KPI) 카드입니다.
Attributes: Attributes:
id: 지표 고유 ID (: "total-views", "total-engagement") id: 지표 고유 ID (: "total-views", "total-watch-time", "new-subscribers")
label: 한글 라벨 (: "총 조회수") label: 한글 라벨 (: "조회수")
label_en: 영문 라벨 (: "Total Views") value: 원시 숫자값 (단위: unit 참조, 포맷팅은 프론트에서 처리)
value: 포맷팅된 (: "1.2M", "8.2%") unit: 값의 단위 "count" | "hours" | "minutes"
trend: 증감 추세 (: 12.5 12.5% 증가) - count: 조회수, 구독자, 좋아요, 댓글, 공유, 업로드 영상
trend_direction: 추세 방향 ("up" 또는 "down") - hours: 시청시간 (estimatedMinutesWatched / 60)
- minutes: 평균 시청시간 (averageViewDuration / 60)
trend: 이전 기간 대비 증감량 (unit과 동일한 단위)
trend_direction: 증감 방향 ("up": 증가, "down": 감소, "-": 변동 없음)
Example: Example:
>>> metric = ContentMetric( >>> metric = ContentMetric(
... id="total-views", ... id="total-views",
... label="조회수", ... label="조회수",
... label_en="Total Views", ... value=1200000.0,
... value="1.2M", ... unit="count",
... trend=12.5, ... trend=3800.0,
... trend_direction="up" ... trend_direction="up"
... ) ... )
""" """
id: str id: str
label: str label: str
label_en: str value: float
value: str unit: str = "count"
trend: float trend: float
trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection") trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
@ -133,7 +136,7 @@ class TopContent(BaseModel):
title: 영상 제목 title: 영상 제목
thumbnail: 썸네일 이미지 URL thumbnail: 썸네일 이미지 URL
platform: 플랫폼 ("youtube" 또는 "instagram") platform: 플랫폼 ("youtube" 또는 "instagram")
views: 포맷팅된 조회수 (: "125K") views: 원시 조회수 정수 (포맷팅은 프론트에서 처리, : 125400)
engagement: 참여율 (: "8.2%") engagement: 참여율 (: "8.2%")
date: 업로드 날짜 (: "2026.01.15") date: 업로드 날짜 (: "2026.01.15")
@ -143,7 +146,7 @@ class TopContent(BaseModel):
... title="힐링 영상", ... title="힐링 영상",
... thumbnail="https://i.ytimg.com/...", ... thumbnail="https://i.ytimg.com/...",
... platform="youtube", ... platform="youtube",
... views="125K", ... views=125400,
... engagement="8.2%", ... engagement="8.2%",
... date="2026.01.15" ... date="2026.01.15"
... ) ... )
@ -153,7 +156,7 @@ class TopContent(BaseModel):
title: str title: str
thumbnail: str thumbnail: str
platform: Literal["youtube", "instagram"] platform: Literal["youtube", "instagram"]
views: str views: int
engagement: str engagement: str
date: str date: str
@ -169,18 +172,18 @@ class AudienceData(BaseModel):
시청자의 연령대, 성별, 지역 분포 데이터입니다. 시청자의 연령대, 성별, 지역 분포 데이터입니다.
Attributes: Attributes:
age_groups: 연령대별 비율 리스트 age_groups: 연령대별 시청자 비율 리스트
[{"label": "18-24", "percentage": 35}, ...] [{"label": "18-24", "percentage": 35}, ...]
gender: 성별 조회수 gender: 성별 시청자 비율 (YouTube viewerPercentage 누적값)
{"male": 45000, "female": 55000} {"male": 45, "female": 55}
top_regions: 상위 지역 리스트 top_regions: 상위 국가 리스트 (최대 5)
[{"region": "서울", "percentage": 42}, ...] [{"region": "대한민국", "percentage": 42}, ...]
Example: Example:
>>> data = AudienceData( >>> data = AudienceData(
... age_groups=[{"label": "18-24", "percentage": 35}], ... age_groups=[{"label": "18-24", "percentage": 35}],
... gender={"male": 45, "female": 55}, ... gender={"male": 45, "female": 55},
... top_regions=[{"region": "서울", "percentage": 42}] ... top_regions=[{"region": "대한민국", "percentage": 42}]
... ) ... )
""" """
@ -194,90 +197,47 @@ class AudienceData(BaseModel):
) )
class PlatformMetric(BaseModel): # class PlatformMetric(BaseModel):
"""플랫폼별 메트릭 # """플랫폼별 메트릭 (미사용 — platform_data 기능 미구현)"""
#
플랫폼별 세부 지표입니다 (: 구독자 증가, 구독 취소). # id: str
# label: str
Attributes: # value: str
id: 메트릭 고유 ID (: "subscribers-gained") # unit: Optional[str] = None
label: 지표명 (: "구독자 증가") # trend: float
value: 포맷팅된 (: "1.2K") # trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
unit: 단위 (선택, : "", "%") #
trend: 증감 추세 # model_config = ConfigDict(
trend_direction: 추세 방향 ("up" 또는 "down") # alias_generator=to_camel,
# populate_by_name=True,
Example: # )
>>> metric = PlatformMetric( #
... id="subscribers-gained", #
... label="구독자 증가", # class PlatformData(BaseModel):
... value="1.2K", # """플랫폼별 데이터 (미사용 — platform_data 기능 미구현)"""
... trend=8.5, #
... trend_direction="up" # platform: Literal["youtube", "instagram"]
... ) # display_name: str = Field(alias="displayName")
""" # metrics: list[PlatformMetric]
#
id: str # model_config = ConfigDict(
label: str # alias_generator=to_camel,
value: str # populate_by_name=True,
unit: Optional[str] = None # )
trend: float
trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class PlatformData(BaseModel):
"""플랫폼별 데이터
플랫폼별로 그룹화된 메트릭 데이터입니다.
Attributes:
platform: 플랫폼 종류 ("youtube" 또는 "instagram")
display_name: 표시할 플랫폼 이름 (: "YouTube")
metrics: 플랫폼별 메트릭 리스트
Example:
>>> data = PlatformData(
... platform="youtube",
... display_name="YouTube",
... metrics=[
... PlatformMetric(
... id="subscribers",
... label="구독자 증가",
... value="1.2K",
... trend=8.5,
... trend_direction="up"
... )
... ]
... )
"""
platform: Literal["youtube", "instagram"]
display_name: str = Field(alias="displayName")
metrics: list[PlatformMetric]
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class DashboardResponse(BaseModel): class DashboardResponse(BaseModel):
"""대시보드 전체 응답 """대시보드 전체 응답
GET /api/dashboard/stats 엔드포인트의 전체 응답 스키마입니다. GET /dashboard/stats 엔드포인트의 전체 응답 스키마입니다.
모든 대시보드 데이터를 포함합니다.
Attributes: Attributes:
content_metrics: KPI 지표 카드 리스트 content_metrics: KPI 지표 카드 리스트 (8)
monthly_data: 월별 추이 데이터 (전년 대비) monthly_data: 월별 추이 데이터 (mode=month 채움, 최근 12개월 vs 이전 12개월)
top_content: 인기 영상 리스트 daily_data: 일별 추이 데이터 (mode=day 채움, 최근 30 vs 이전 30)
audience_data: 시청자 분석 데이터 top_content: 조회수 기준 인기 영상 TOP 4
platform_data: 플랫폼별 메트릭 데이터 audience_data: 시청자 분석 데이터 (연령/성별/지역)
has_uploads: 업로드 영상 존재 여부 (False 모든 지표가 0, 상태 UI 표시용)
Example: Example:
>>> response = DashboardResponse( >>> response = DashboardResponse(
@ -285,7 +245,6 @@ class DashboardResponse(BaseModel):
... monthly_data=[...], ... monthly_data=[...],
... top_content=[...], ... top_content=[...],
... audience_data=AudienceData(...), ... audience_data=AudienceData(...),
... platform_data=[...]
... ) ... )
>>> json_str = response.model_dump_json() # JSON 직렬화 >>> json_str = response.model_dump_json() # JSON 직렬화
""" """
@ -295,7 +254,8 @@ class DashboardResponse(BaseModel):
daily_data: list[DailyData] = Field(default=[], alias="dailyData") daily_data: list[DailyData] = Field(default=[], alias="dailyData")
top_content: list[TopContent] = Field(alias="topContent") top_content: list[TopContent] = Field(alias="topContent")
audience_data: AudienceData = Field(alias="audienceData") audience_data: AudienceData = Field(alias="audienceData")
platform_data: list[PlatformData] = Field(default=[], alias="platformData") has_uploads: bool = Field(default=True, alias="hasUploads")
# platform_data: list[PlatformData] = Field(default=[], alias="platformData") # 미사용
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=to_camel, alias_generator=to_camel,
@ -307,11 +267,12 @@ class ConnectedAccount(BaseModel):
"""연결된 소셜 계정 정보 """연결된 소셜 계정 정보
Attributes: Attributes:
id: SocialAccount 테이블 ID (계정 선택 값을 social_account_id로 전달) id: SocialAccount 테이블 PK
platform: 플랫폼 (: "youtube") platform: 플랫폼 (: "youtube")
platform_username: 플랫폼 사용자명 (: "@channelname") platform_username: 플랫폼 사용자명 (: "@channelname")
platform_user_id: 플랫폼 사용자 고유 ID platform_user_id: 플랫폼 채널 고유 ID 재연동해도 불변.
channel_title: YouTube 채널 제목 (platform_data에서 추출) /dashboard/stats?platform_user_id=<> 으로 계정 선택에 사용
channel_title: YouTube 채널 제목 (SocialAccount.platform_data JSON에서 추출)
connected_at: 연동 일시 connected_at: 연동 일시
is_active: 활성화 상태 is_active: 활성화 상태
""" """

View File

@ -204,64 +204,64 @@ class DataProcessor:
ContentMetric( ContentMetric(
id="total-views", id="total-views",
label="조회수", label="조회수",
label_en="Total Views", value=float(views),
value=self._format_number(int(views)), unit="count",
trend=round(float(views_trend), 1), trend=round(float(views_trend), 1),
trend_direction=views_dir, trend_direction=views_dir,
), ),
ContentMetric( ContentMetric(
id="total-watch-time", id="total-watch-time",
label="시청시간", label="시청시간",
label_en="Watch Time", value=round(estimated_minutes_watched / 60, 1),
value=f"{round(estimated_minutes_watched / 60, 1)}시간", unit="hours",
trend=round(watch_trend / 60, 1), trend=round(watch_trend / 60, 1),
trend_direction=watch_dir, trend_direction=watch_dir,
), ),
ContentMetric( ContentMetric(
id="avg-view-duration", id="avg-view-duration",
label="평균 시청시간", label="평균 시청시간",
label_en="Avg. View Duration", value=round(average_view_duration / 60, 1),
value=f"{round(average_view_duration / 60)}", unit="minutes",
trend=round(duration_trend / 60, 1), trend=round(duration_trend / 60, 1),
trend_direction=duration_dir, trend_direction=duration_dir,
), ),
ContentMetric( ContentMetric(
id="new-subscribers", id="new-subscribers",
label="신규 구독자", label="신규 구독자",
label_en="New Subscribers", value=float(subscribers_gained),
value=self._format_number(int(subscribers_gained)), unit="count",
trend=round(float(subs_trend), 1), trend=subs_trend,
trend_direction=subs_dir, trend_direction=subs_dir,
), ),
ContentMetric( ContentMetric(
id="likes", id="likes",
label="좋아요", label="좋아요",
label_en="Likes", value=float(likes),
value=self._format_number(int(likes)), unit="count",
trend=round(float(likes_trend), 1), trend=likes_trend,
trend_direction=likes_dir, trend_direction=likes_dir,
), ),
ContentMetric( ContentMetric(
id="comments", id="comments",
label="댓글", label="댓글",
label_en="Comments", value=float(comments),
value=self._format_number(int(comments)), unit="count",
trend=round(float(comments_trend), 1), trend=comments_trend,
trend_direction=comments_dir, trend_direction=comments_dir,
), ),
ContentMetric( ContentMetric(
id="shares", id="shares",
label="공유", label="공유",
label_en="Shares", value=float(shares),
value=self._format_number(int(shares)), unit="count",
trend=round(float(shares_trend), 1), trend=shares_trend,
trend_direction=shares_dir, trend_direction=shares_dir,
), ),
ContentMetric( ContentMetric(
id="uploaded-videos", id="uploaded-videos",
label="업로드 영상", label="업로드 영상",
label_en="Uploaded Videos", value=float(period_video_count),
value=str(period_video_count), unit="count",
trend=0.0, trend=0.0,
trend_direction="-", trend_direction="-",
), ),
@ -475,53 +475,6 @@ class DataProcessor:
top_regions=top_regions, top_regions=top_regions,
) )
@staticmethod
def _format_number(num: int) -> str:
"""숫자 포맷팅 (1234567 → "1.2M")
조회수, 구독자 숫자를 읽기 쉽게 포맷팅합니다.
Args:
num: 원본 숫자
Returns:
str: 포맷팅된 문자열
- 1,000,000 이상: "1.2M"
- 1,000 이상: "12.5K"
- 1,000 미만: "123"
Example:
>>> _format_number(1234567)
"1.2M"
>>> _format_number(12345)
"12.3K"
>>> _format_number(123)
"123"
"""
if num >= 1_000_000:
return f"{num / 1_000_000:.1f}M"
elif num >= 1_000:
return f"{num / 1_000:.1f}K"
else:
return str(num)
@staticmethod
def _format_duration(seconds: int) -> str:
"""초를 M:SS 형식으로 변환 (평균 시청 시간 표시용)
Args:
seconds: 단위 시간
Returns:
str: M:SS 형식 문자열
- 204 "3:24"
- 65 "1:05"
- 45 "0:45"
"""
minutes = seconds // 60
secs = seconds % 60
return f"{minutes}:{secs:02d}"
@staticmethod @staticmethod
def _translate_country_code(code: str) -> str: def _translate_country_code(code: str) -> str:
"""국가 코드를 한국어로 변환 """국가 코드를 한국어로 변환

View File

@ -44,19 +44,22 @@ class YouTubeAnalyticsService:
"""YouTube Analytics API 호출을 병렬로 실행 """YouTube Analytics API 호출을 병렬로 실행
Args: Args:
video_ids: YouTube 영상 ID 리스트 (최대 30) video_ids: YouTube 영상 ID 리스트 (최대 30, 리스트 허용)
start_date: 조회 시작일 (YYYY-MM-DD) start_date: 조회 시작일 (YYYY-MM-DD)
end_date: 조회 종료일 (YYYY-MM-DD) end_date: 조회 종료일 (YYYY-MM-DD)
access_token: YouTube OAuth 2.0 액세스 토큰 access_token: YouTube OAuth 2.0 액세스 토큰
mode: 조회 모드 ("month" | "day")
kpi_end_date: KPI 집계 종료일 (미전달 end_date와 동일)
Returns: Returns:
dict[str, Any]: API 응답 데이터 dict[str, Any]: API 응답 데이터 (7 )
- kpi: KPI 메트릭 (조회수, 좋아요, 댓글 ) - kpi: 최근 기간 KPI 메트릭 (조회수, 좋아요, 댓글 )
- monthly_recent: 최근 12개월 월별 조회수 - kpi_previous: 이전 기간 KPI 메트릭 (trend 계산용)
- monthly_previous: 이전 12개월 월별 조회수 - trend_recent: 최근 기간 추이 (월별 또는 일별 조회수)
- top_videos: 인기 영상 TOP 4 - trend_previous: 이전 기간 추이 (전년 또는 이전 30)
- demographics: 연령/성별 분포 - top_videos: 조회수 기준 인기 영상 TOP 4
- region: 지역별 조회수 - demographics: 연령/성별 시청자 분포
- region: 지역별 조회수 TOP 5
Raises: Raises:
YouTubeAPIError: API 호출 실패 YouTubeAPIError: API 호출 실패
@ -73,7 +76,7 @@ class YouTubeAnalyticsService:
... ) ... )
""" """
logger.debug( logger.debug(
f"[1/6] YouTube Analytics API 병렬 호출 시작 - " f"[1/7] YouTube Analytics API 병렬 호출 시작 - "
f"video_count={len(video_ids)}, period={start_date}~{end_date}, mode={mode}" f"video_count={len(video_ids)}, period={start_date}~{end_date}, mode={mode}"
) )
@ -255,7 +258,7 @@ class YouTubeAnalyticsService:
"endDate": end_date, "endDate": end_date,
"dimensions": "month", "dimensions": "month",
"metrics": "views", "metrics": "views",
# "filters": f"video=={','.join(video_ids)}", "filters": f"video=={','.join(video_ids)}",
"sort": "month", "sort": "month",
} }
@ -299,7 +302,7 @@ class YouTubeAnalyticsService:
"endDate": end_date, "endDate": end_date,
"dimensions": "day", "dimensions": "day",
"metrics": "views", "metrics": "views",
# "filters": f"video=={','.join(video_ids)}", "filters": f"video=={','.join(video_ids)}",
"sort": "day", "sort": "day",
} }
@ -340,7 +343,7 @@ class YouTubeAnalyticsService:
"endDate": end_date, "endDate": end_date,
"dimensions": "video", "dimensions": "video",
"metrics": "views,likes,comments", "metrics": "views,likes,comments",
# "filters": f"video=={','.join(video_ids)}", "filters": f"video=={','.join(video_ids)}",
"sort": "-views", "sort": "-views",
"maxResults": "4", "maxResults": "4",
} }
@ -410,7 +413,7 @@ class YouTubeAnalyticsService:
rows = [["KR", 1000000], ["US", 500000], ...] rows = [["KR", 1000000], ["US", 500000], ...]
조회수 내림차순으로 정렬된 상위 5 국가 조회수 내림차순으로 정렬된 상위 5 국가
""" """
logger.debug("[YouTubeAnalyticsService._fetch_country] START") logger.debug("[YouTubeAnalyticsService._fetch_region] START")
params = { params = {
"ids": "channel==MINE", "ids": "channel==MINE",
@ -418,7 +421,7 @@ class YouTubeAnalyticsService:
"endDate": end_date, "endDate": end_date,
"dimensions": "country", "dimensions": "country",
"metrics": "views", "metrics": "views",
# "filters": f"video=={','.join(video_ids)}", "filters": f"video=={','.join(video_ids)}",
"sort": "-views", "sort": "-views",
"maxResults": "5", "maxResults": "5",
} }