2026-01-16 21:11:02 +09:00
|
|
|
import config from '../../config/index.js';
|
|
|
|
|
import { formatDate, formatTime } from '../../utils/date.js';
|
|
|
|
|
|
2026-02-03 14:05:30 +09:00
|
|
|
const API_KEY = config.google.apiKey;
|
2026-01-16 21:11:02 +09:00
|
|
|
const API_BASE = 'https://www.googleapis.com/youtube/v3';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ISO 8601 duration (PT1M30S) → 초 변환
|
|
|
|
|
*/
|
|
|
|
|
function parseDuration(duration) {
|
|
|
|
|
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
|
|
|
|
if (!match) return 0;
|
|
|
|
|
return (
|
|
|
|
|
parseInt(match[1] || 0) * 3600 +
|
|
|
|
|
parseInt(match[2] || 0) * 60 +
|
|
|
|
|
parseInt(match[3] || 0)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 영상 URL 생성
|
|
|
|
|
*/
|
|
|
|
|
function getVideoUrl(videoId, isShorts) {
|
|
|
|
|
return isShorts
|
|
|
|
|
? `https://www.youtube.com/shorts/${videoId}`
|
|
|
|
|
: `https://www.youtube.com/watch?v=${videoId}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 채널의 업로드 플레이리스트 ID 조회
|
|
|
|
|
*/
|
2026-01-19 12:32:04 +09:00
|
|
|
export async function getUploadsPlaylistId(channelId) {
|
2026-01-16 21:11:02 +09:00
|
|
|
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
throw new Error(data.error.message);
|
|
|
|
|
}
|
|
|
|
|
if (!data.items?.length) {
|
|
|
|
|
throw new Error('채널을 찾을 수 없습니다');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data.items[0].contentDetails.relatedPlaylists.uploads;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:51:45 +09:00
|
|
|
/**
|
|
|
|
|
* 핸들로 채널 조회
|
|
|
|
|
* @param {string} handle - @username 형식 (@ 제외)
|
|
|
|
|
*/
|
|
|
|
|
export async function getChannelByHandle(handle) {
|
|
|
|
|
// @ 제거
|
|
|
|
|
const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
|
|
|
|
|
const url = `${API_BASE}/channels?part=snippet,brandingSettings&forHandle=${cleanHandle}&key=${API_KEY}`;
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
throw new Error(data.error.message);
|
|
|
|
|
}
|
|
|
|
|
if (!data.items?.length) {
|
|
|
|
|
throw new Error('채널을 찾을 수 없습니다');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const channel = data.items[0];
|
|
|
|
|
const { snippet, brandingSettings } = channel;
|
|
|
|
|
|
|
|
|
|
// 배너 URL에 고해상도 파라미터 추가
|
|
|
|
|
const bannerBase = brandingSettings?.image?.bannerExternalUrl;
|
|
|
|
|
const bannerUrl = bannerBase ? `${bannerBase}=w2560` : null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
channelId: channel.id,
|
|
|
|
|
handle: cleanHandle,
|
|
|
|
|
title: snippet.title,
|
|
|
|
|
description: snippet.description,
|
|
|
|
|
thumbnailUrl: snippet.thumbnails?.high?.url || snippet.thumbnails?.default?.url,
|
|
|
|
|
bannerUrl,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 18:20:49 +09:00
|
|
|
/**
|
|
|
|
|
* 채널 정보 조회 (배너 이미지 포함)
|
|
|
|
|
*/
|
|
|
|
|
export async function getChannelInfo(channelId) {
|
|
|
|
|
const url = `${API_BASE}/channels?part=snippet,brandingSettings&id=${channelId}&key=${API_KEY}`;
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
throw new Error(data.error.message);
|
|
|
|
|
}
|
|
|
|
|
if (!data.items?.length) {
|
|
|
|
|
throw new Error('채널을 찾을 수 없습니다');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const channel = data.items[0];
|
|
|
|
|
const { snippet, brandingSettings } = channel;
|
|
|
|
|
|
2026-02-03 18:22:45 +09:00
|
|
|
// 배너 URL에 고해상도 파라미터 추가
|
|
|
|
|
const bannerBase = brandingSettings?.image?.bannerExternalUrl;
|
|
|
|
|
const bannerUrl = bannerBase ? `${bannerBase}=w2560` : null;
|
|
|
|
|
|
2026-02-03 18:20:49 +09:00
|
|
|
return {
|
|
|
|
|
channelId,
|
|
|
|
|
title: snippet.title,
|
|
|
|
|
description: snippet.description,
|
|
|
|
|
thumbnailUrl: snippet.thumbnails?.high?.url || snippet.thumbnails?.default?.url,
|
2026-02-03 18:22:45 +09:00
|
|
|
bannerUrl,
|
2026-02-03 18:20:49 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 21:11:02 +09:00
|
|
|
/**
|
|
|
|
|
* 영상 ID 목록으로 duration 조회 (Shorts 판별용)
|
|
|
|
|
*/
|
|
|
|
|
async function getVideoDurations(videoIds) {
|
|
|
|
|
const url = `${API_BASE}/videos?part=contentDetails&id=${videoIds.join(',')}&key=${API_KEY}`;
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
const durations = {};
|
|
|
|
|
if (data.items) {
|
|
|
|
|
for (const v of data.items) {
|
|
|
|
|
const seconds = parseDuration(v.contentDetails.duration);
|
|
|
|
|
durations[v.id] = seconds <= 60;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return durations;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-02 15:52:42 +09:00
|
|
|
* 최근 영상 ID 목록만 조회 (Activities API - 1 unit)
|
2026-01-19 12:32:04 +09:00
|
|
|
* @param {string} channelId - 채널 ID
|
|
|
|
|
* @param {number} maxResults - 최대 결과 수
|
2026-01-16 21:11:02 +09:00
|
|
|
*/
|
2026-03-02 15:52:42 +09:00
|
|
|
export async function fetchRecentVideoIds(channelId, maxResults = 10) {
|
2026-02-06 21:56:27 +09:00
|
|
|
const fetchCount = Math.min(maxResults * 2, 50);
|
|
|
|
|
const url = `${API_BASE}/activities?part=snippet,contentDetails&channelId=${channelId}&type=upload&maxResults=${fetchCount}&key=${API_KEY}`;
|
2026-01-16 21:11:02 +09:00
|
|
|
const res = await fetch(url);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
throw new Error(data.error.message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 15:52:42 +09:00
|
|
|
return (data.items || [])
|
2026-02-06 21:56:27 +09:00
|
|
|
.filter(item => item.snippet.type === 'upload')
|
2026-03-02 15:52:42 +09:00
|
|
|
.slice(0, maxResults)
|
|
|
|
|
.map(item => item.contentDetails.upload.videoId);
|
2026-01-16 21:11:02 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 전체 영상 조회 (페이지네이션)
|
2026-01-19 12:32:04 +09:00
|
|
|
* @param {string} channelId - 채널 ID
|
|
|
|
|
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
|
2026-01-16 21:11:02 +09:00
|
|
|
*/
|
2026-01-19 12:32:04 +09:00
|
|
|
export async function fetchAllVideos(channelId, uploadsPlaylistId = null) {
|
|
|
|
|
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
|
2026-01-16 21:11:02 +09:00
|
|
|
const videos = [];
|
|
|
|
|
let pageToken = '';
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=50&key=${API_KEY}${pageToken ? `&pageToken=${pageToken}` : ''}`;
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
throw new Error(data.error.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const videoIds = data.items.map(item => item.snippet.resourceId.videoId);
|
|
|
|
|
const shortsMap = await getVideoDurations(videoIds);
|
|
|
|
|
|
|
|
|
|
for (const item of data.items) {
|
|
|
|
|
const { snippet } = item;
|
|
|
|
|
const videoId = snippet.resourceId.videoId;
|
|
|
|
|
const isShorts = shortsMap[videoId] || false;
|
|
|
|
|
const publishedAt = new Date(snippet.publishedAt);
|
|
|
|
|
|
|
|
|
|
videos.push({
|
|
|
|
|
videoId,
|
|
|
|
|
title: snippet.title,
|
|
|
|
|
description: snippet.description || '',
|
|
|
|
|
channelId: snippet.channelId,
|
|
|
|
|
channelTitle: snippet.channelTitle,
|
|
|
|
|
publishedAt,
|
|
|
|
|
date: formatDate(publishedAt),
|
|
|
|
|
time: formatTime(publishedAt),
|
|
|
|
|
videoType: isShorts ? 'shorts' : 'video',
|
|
|
|
|
videoUrl: getVideoUrl(videoId, isShorts),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pageToken = data.nextPageToken || '';
|
|
|
|
|
} while (pageToken);
|
|
|
|
|
|
|
|
|
|
// 과거순 정렬
|
|
|
|
|
videos.sort((a, b) => a.publishedAt - b.publishedAt);
|
|
|
|
|
return videos;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 단일 영상 정보 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function fetchVideoInfo(videoId) {
|
|
|
|
|
const url = `${API_BASE}/videos?part=snippet,contentDetails&id=${videoId}&key=${API_KEY}`;
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (!data.items?.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const video = data.items[0];
|
|
|
|
|
const { snippet, contentDetails } = video;
|
|
|
|
|
const seconds = parseDuration(contentDetails.duration);
|
|
|
|
|
const isShorts = seconds > 0 && seconds <= 60;
|
|
|
|
|
const publishedAt = new Date(snippet.publishedAt);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
videoId,
|
|
|
|
|
title: snippet.title,
|
|
|
|
|
description: snippet.description || '',
|
|
|
|
|
channelId: snippet.channelId,
|
|
|
|
|
channelTitle: snippet.channelTitle,
|
|
|
|
|
publishedAt,
|
|
|
|
|
date: formatDate(publishedAt),
|
|
|
|
|
time: formatTime(publishedAt),
|
|
|
|
|
videoType: isShorts ? 'shorts' : 'video',
|
|
|
|
|
videoUrl: getVideoUrl(videoId, isShorts),
|
|
|
|
|
};
|
|
|
|
|
}
|