소개
구글 앱스 스크립트로 Youtube 영상 검색기 만들어보기
유튜브 영상을 많이 보는데 최근 재미있는 영상이나 볼만한 영상이 무엇이 있는지 빠르게 보기 위해서 만들어보게되었습니다.
(구글 로그인 하면 사용 가능)
(Edge 브라우저 말고 Chrome 브라우저 사용 권장, Edge에서 안열리기도 함)
진행 방법
어떤 도구를 사용했고, 어떻게 활용하셨나요?
Tip: 사용한 프롬프트
유튜브 실시간 조회수 10만이 하루만에 달성한 것을 찾아서 나에게 알려주는게 가능한가?
api 키 입력했고 나의 이메일 주소 입력했는데 이메일이 안보이는데 일단 그냥 index.html을 통해서 즉각 받아보고 싶어 (이메일로 보내주는 것으로 만들어주었기에 이렇게 다시 보내봄)
구글 앱스 스크립트에서 실행하는 index.html (단독으로 실행하는 index.html을 만들어주었기에 이렇게 정확히 구글 앱스 스크립트 코드에서 사용할 것임을 다시 명시함)
새로고침
조건에 맞는 영상이 없습니다.왜 이렇게 나오지? ( 안되는 것들을 정확히 그대로 복사해서 알려줌)
사용자가 시간, 조회수를 선택할 수 있게끔 해주고
정렬 방식도 사용자가 원하는대로 설정할 수 있게끔 해줘24시간은 아예 지원을 안하는데 몇시간 몇시간 단위만 지원하는지 정확하게 알아보고 썸네일도 같이 가져와서 보여줄 수 있는지 살펴봐, 그리고 키워드를 입력해서 그 키워드가 있는 것까지도 필터링이 가능하게끔 해줘
index 완벽한 코드를줘 (중간에 생략한 것이 있기에 이렇게 명시)
키워드를 넣었을 때는 1시간 단위로도 검색이 가능한데
키워드를 넣지 않으면 왜 48시간 이후로만 검색이 가능한걸까? (안되는 오류 상황을 명시)키워드를 넣으면 검색이 되지만 키워드를 넣지 않으면 검색이 여전히 안됨 (24시간 미만의 시간 범위를 선택한 경우) (제대로 안고쳐주었기에 한번 더 명시)
이렇게 해서 결국 아래와 같은 코드를 얻어내 었습니다.
Tip: 코드 전문은 코드블록에 감싸서 작성해주세요. ( / 을 눌러 '코드 블록'을 선택)
code.gs
// 웹 앱으로 배포할 때 필요한 doGet 함수
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('유튜브 영상 검색기')
.setFaviconUrl('https://www.youtube.com/favicon.ico')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function getViralVideos(timeWindow, viewThreshold, sortBy, keyword) {
const API_KEY = '이곳에 자신의 youtube api키 입력';
try {
const now = new Date();
const timeAgo = new Date(now.getTime() - (timeWindow * 60 * 60 * 1000));
const timeAgoISOString = timeAgo.toISOString();
let videos = [];
if (keyword) {
// 키워드 검색 로직
const searchUrl = `https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&order=date&publishedAfter=${timeAgoISOString}&q=${encodeURIComponent(keyword)}®ionCode=KR&maxResults=50&key=${API_KEY}`;
const searchResponse = UrlFetchApp.fetch(searchUrl);
const searchData = JSON.parse(searchResponse.getContentText());
if (searchData.items && searchData.items.length > 0) {
const videoIds = searchData.items.map(item => item.id.videoId).join(',');
const videoDetailsUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics&id=${videoIds}&key=${API_KEY}`;
const videoDetailsResponse = UrlFetchApp.fetch(videoDetailsUrl);
const videoDetailsData = JSON.parse(videoDetailsResponse.getContentText());
videos = videoDetailsData.items || [];
}
} else {
// 키워드 없을 때 - search API를 시간 기반으로 사용
const searchUrl = `https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&order=viewCount&publishedAfter=${timeAgoISOString}®ionCode=KR&maxResults=50&key=${API_KEY}`;
const searchResponse = UrlFetchApp.fetch(searchUrl);
const searchData = JSON.parse(searchResponse.getContentText());
if (searchData.items && searchData.items.length > 0) {
const videoIds = searchData.items.map(item => item.id.videoId).join(',');
const videoDetailsUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics&id=${videoIds}&key=${API_KEY}`;
const videoDetailsResponse = UrlFetchApp.fetch(videoDetailsUrl);
const videoDetailsData = JSON.parse(videoDetailsResponse.getContentText());
videos = videoDetailsData.items || [];
}
}
// 조회수 필터링
let filteredVideos = videos.filter(video => {
const viewCount = parseInt(video.statistics.viewCount);
return viewCount >= viewThreshold;
});
// 정렬 로직
switch(sortBy) {
case 'views':
filteredVideos.sort((a, b) => parseInt(b.statistics.viewCount) - parseInt(a.statistics.viewCount));
break;
case 'likes':
filteredVideos.sort((a, b) => parseInt(b.statistics.likeCount || 0) - parseInt(a.statistics.likeCount || 0));
break;
case 'comments':
filteredVideos.sort((a, b) => parseInt(b.statistics.commentCount || 0) - parseInt(a.statistics.commentCount || 0));
break;
case 'newest':
filteredVideos.sort((a, b) => new Date(b.snippet.publishedAt) - new Date(a.snippet.publishedAt));
break;
}
return filteredVideos;
} catch (error) {
Logger.log('Error:', error);
return { error: error.toString() };
}
}
// 비디오 상세 정보를 가져오는 헬퍼 함수
function getVideoDetails(videoIds) {
try {
const videoDetailsUrl = buildApiUrl('videos', {
part: 'snippet,statistics',
id: videoIds,
key: API_KEY
});
const videoDetailsResponse = UrlFetchApp.fetch(videoDetailsUrl);
const videoDetailsData = JSON.parse(videoDetailsResponse.getContentText());
return videoDetailsData.items;
} catch (error) {
Logger.log('Error in getVideoDetails:', error);
return null;
}
}
// API URL 생성 헬퍼 함수
function buildApiUrl(endpoint, params) {
const baseUrl = 'https://www.googleapis.com/youtube/v3/';
const queryString = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
return `${baseUrl}${endpoint}?${queryString}`;
}
// 조회수 기준 필터링 함수
function filterVideosByViews(videos, viewThreshold) {
return videos.filter(video => {
const viewCount = parseInt(video.statistics.viewCount);
return viewCount >= viewThreshold;
});
}
// 비디오 정렬 함수
function sortVideos(videos, sortBy) {
const sortFunctions = {
views: (a, b) => parseInt(b.statistics.viewCount) - parseInt(a.statistics.viewCount),
likes: (a, b) => parseInt(b.statistics.likeCount || 0) - parseInt(a.statistics.likeCount || 0),
comments: (a, b) => parseInt(b.statistics.commentCount || 0) - parseInt(a.statistics.commentCount || 0),
newest: (a, b) => new Date(b.snippet.publishedAt) - new Date(a.snippet.publishedAt)
};
return videos.sort(sortFunctions[sortBy] || sortFunctions.views);
}
// API 할당량 모니터링 함수
function checkApiQuota() {
try {
const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet&id=dummy&key=${API_KEY}`;
const response = UrlFetchApp.fetch(url);
const quotaHeaders = response.getHeaders();
return {
success: true,
quotaUsed: quotaHeaders['x-quota-used'],
quotaLimit: quotaHeaders['x-quota-limit']
};
} catch (error) {
return {
success: false,
error: error.toString()
};
}
}
// 캐시 관리 함수들
function setCacheData(key, data, expirationInSeconds) {
const cache = CacheService.getScriptCache();
const expirationDate = new Date().getTime() + (expirationInSeconds * 1000);
const cacheData = {
data: data,
expiration: expirationDate
};
cache.put(key, JSON.stringify(cacheData), expirationInSeconds);
}
function getCacheData(key) {
const cache = CacheService.getScriptCache();
const cacheData = cache.get(key);
if (!cacheData) return null;
const parsedData = JSON.parse(cacheData);
if (new Date().getTime() > parsedData.expiration) {
cache.remove(key);
return null;
}
return parsedData.data;
}
// 에러 로깅 함수
function logError(error, functionName, params) {
const timestamp = new Date().toISOString();
const errorLog = {
timestamp: timestamp,
function: functionName,
parameters: params,
error: error.toString(),
stack: error.stack
};
Logger.log(JSON.stringify(errorLog));
// 선택적: 스프레드시트나 다른 저장소에 에러 로그 저장
// const sheet = SpreadsheetApp.openById('스프레드시트ID').getSheetByName('에러로그');
// sheet.appendRow([timestamp, functionName, JSON.stringify(params), error.toString()]);
}
// 유틸리티 함수들
function validateParams(timeWindow, viewThreshold, sortBy, region) {
const validTimeWindows = [1, 3, 6, 12, 24, 48, 72];
const validViewThresholds = [10000, 50000, 100000, 500000, 1000000];
const validSortBy = ['views', 'likes', 'comments', 'newest'];
const validRegions = ['KR', 'JP', 'US', 'GB', 'FR', 'DE', 'CA', 'AU', 'HK', 'TW', 'SG'];
if (!validTimeWindows.includes(parseInt(timeWindow))) {
throw new Error('Invalid time window');
}
if (!validViewThresholds.includes(parseInt(viewThreshold))) {
throw new Error('Invalid view threshold');
}
if (!validSortBy.includes(sortBy)) {
throw new Error('Invalid sort parameter');
}
if (!validRegions.includes(region)) {
throw new Error('Invalid region code');
}
return true;
}
// 테스트 함수
function testApiConnection() {
try {
const testUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet&chart=mostPopular&maxResults=1&key=${API_KEY}`;
const response = UrlFetchApp.fetch(testUrl);
const responseCode = response.getResponseCode();
return {
success: responseCode === 200,
responseCode: responseCode,
message: responseCode === 200 ? 'API connection successful' : 'API connection failed'
};
} catch (error) {
return {
success: false,
error: error.toString()
};
}
}
// API 호출 디버깅을 위한 함수
function debugApiCall(timeWindow, viewThreshold, sortBy, keyword, region = 'KR') {
try {
const now = new Date();
const timeAgo = new Date(now.getTime() - (timeWindow * 60 * 60 * 1000));
const timeAgoISOString = timeAgo.toISOString();
// 검색 파라미터 설정
const searchParams = {
part: 'snippet',
type: 'video',
maxResults: 50,
regionCode: region,
key: API_KEY
};
if (keyword) {
searchParams.q = keyword;
searchParams.order = 'date';
searchParams.publishedAfter = timeAgoISOString;
} else {
searchParams.order = 'viewCount';
searchParams.publishedAfter = timeAgoISOString;
}
// URL 생성 및 로깅
const searchUrl = buildApiUrl('search', searchParams);
Logger.log('Search URL:', searchUrl);
// muteHttpExceptions 옵션 추가
const options = {
muteHttpExceptions: true
};
// API 호출 및 응답 로깅
const searchResponse = UrlFetchApp.fetch(searchUrl, options);
const responseCode = searchResponse.getResponseCode();
const responseText = searchResponse.getContentText();
Logger.log('Response Code:', responseCode);
Logger.log('Response Text:', responseText);
return {
url: searchUrl,
responseCode: responseCode,
response: responseText,
params: searchParams
};
} catch (error) {
Logger.log('Error:', error);
return {
error: error.toString(),
stack: error.stack
};
}
}
// URL 생성 함수 수정
function buildApiUrl(endpoint, params) {
const baseUrl = 'https://www.googleapis.com/youtube/v3/';
const queryString = Object.entries(params)
.filter(([_, value]) => value !== undefined && value !== null) // undefined/null 값 제거
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
const url = `${baseUrl}${endpoint}?${queryString}`;
return url;
}
// API 키 유효성 검사 함수
function validateApiKey() {
const options = {
muteHttpExceptions: true
};
const testUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet&chart=mostPopular&maxResults=1&key=${API_KEY}`;
try {
const response = UrlFetchApp.fetch(testUrl, options);
const responseCode = response.getResponseCode();
const responseText = response.getContentText();
return {
success: responseCode === 200,
responseCode: responseCode,
response: responseText,
apiKey: API_KEY.substring(0, 8) + '...' // API 키의 일부만 표시
};
} catch (error) {
return {
success: false,
error: error.toString(),
apiKey: API_KEY.substring(0, 8) + '...'
};
}
}
// 테스트를 위한 실행 함수
function runDebugTest() {
// 먼저 API 키 확인
const apiKeyCheck = validateApiKey();
Logger.log('API Key Check:', apiKeyCheck);
// API 호출 테스트
const testParams = {
timeWindow: 24,
viewThreshold: 100000,
sortBy: 'views',
keyword: '',
region: 'KR'
};
const debugResult = debugApiCall(
testParams.timeWindow,
testParams.viewThreshold,
testParams.sortBy,
testParams.keyword,
testParams.region
);
Logger.log('Debug Result:', debugResult);
return {
apiKeyCheck: apiKeyCheck,
debugResult: debugResult
};
}
Index.html (맨 앞글자 대문자임. 소문자로 할 경우 에러발생 - code.gs에서 Index.html을 참고중)
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: 'Arial', sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
background-color: #f5f5f5;
}
h1 {
color: #1a1a1a;
text-align: center;
margin-bottom: 30px;
}
.controls {
background-color: white;
padding: 20px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.control-group {
margin-bottom: 15px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.control-item {
display: flex;
flex-direction: column;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #4a4a4a;
}
select, input {
padding: 10px;
border-radius: 6px;
border: 1px solid #ddd;
font-size: 14px;
background-color: white;
width: 100%;
}
select:focus, input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0,102,204,0.2);
}
#searchButton {
background-color: #0066cc;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.2s ease;
width: 100%;
max-width: 200px;
margin: 20px auto 0;
display: block;
}
#searchButton:hover {
background-color: #0052a3;
transform: translateY(-1px);
}
#searchButton:active {
transform: translateY(1px);
}
.video-card {
background-color: white;
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
gap: 20px;
padding: 15px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.video-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.video-thumbnail {
width: 280px;
height: 157px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
}
.video-content {
flex: 1;
min-width: 0;
}
.video-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
line-height: 1.4;
}
.video-title a {
color: #1a1a1a;
text-decoration: none;
transition: color 0.2s ease;
}
.video-title a:hover {
color: #0066cc;
}
.video-stats {
color: #666;
font-size: 14px;
line-height: 1.6;
}
.stat-item {
display: inline-flex;
align-items: center;
margin-right: 15px;
margin-bottom: 8px;
}
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
color: #666;
background-color: white;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.error {
color: #dc3545;
padding: 20px;
background-color: #fff;
border: 1px solid #dc3545;
border-radius: 8px;
margin: 20px 0;
}
.last-updated {
color: #666;
font-size: 14px;
text-align: center;
margin: 20px 0;
font-style: italic;
}
.no-results {
text-align: center;
padding: 40px;
color: #666;
background-color: white;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-size: 16px;
}
@media (max-width: 768px) {
.video-card {
flex-direction: column;
}
.video-thumbnail {
width: 100%;
height: auto;
aspect-ratio: 16/9;
}
.control-group {
grid-template-columns: 1fr;
gap: 15px;
}
}
</style>
</head>
<body>
<h1>유튜브 영상 검색기</h1>
<div class="controls">
<div class="control-group">
<div class="control-item">
<label for="timeWindow">시간 범위</label>
<select id="timeWindow">
<option value="1">1시간</option>
<option value="3">3시간</option>
<option value="6">6시간</option>
<option value="12">12시간</option>
<option value="24" selected>24시간</option>
<option value="48">48시간</option>
<option value="72">72시간</option>
</select>
</div>
<div class="control-item">
<label for="viewThreshold">최소 조회수</label>
<select id="viewThreshold">
<option value="10000">1만</option>
<option value="50000">5만</option>
<option value="100000" selected>10만</option>
<option value="500000">50만</option>
<option value="1000000">100만</option>
</select>
</div>
<div class="control-item">
<label for="sortBy">정렬 기준</label>
<select id="sortBy">
<option value="views" selected>조회수순</option>
<option value="likes">좋아요순</option>
<option value="comments">댓글순</option>
<option value="newest">최신순</option>
</select>
</div>
<div class="control-item">
<label for="keyword">키워드 검색</label>
<input type="text" id="keyword" placeholder="검색어를 입력하세요">
</div>
</div>
<button id="searchButton" onclick="searchVideos()">검색하기</button>
</div>
<style>
.controls {
max-width: 1200px;
width: 90%;
margin: 0 auto;
padding: 20px;
background-color: white;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.control-group {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 20px;
padding: 0 20px;
}
.control-item {
flex: 1;
min-width: 200px;
max-width: 250px;
margin: 0 10px;
}
.control-item label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #4a4a4a;
text-align: center;
}
.control-item select,
.control-item input {
width: 100%;
padding: 10px;
border-radius: 6px;
border: 1px solid #ddd;
font-size: 14px;
background-color: white;
}
.control-item select:hover,
.control-item input:hover {
border-color: #0066cc;
}
.control-item select:focus,
.control-item input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0,102,204,0.2);
}
#searchButton {
display: block;
margin: 0 auto;
background-color: #0066cc;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
min-width: 200px;
transition: background-color 0.2s;
}
#searchButton:hover {
background-color: #0052a3;
}
#searchButton:active {
background-color: #004080;
}
/* 반응형 디자인 */
@media (max-width: 1200px) {
.control-item {
max-width: 200px;
}
}
@media (max-width: 768px) {
.controls {
width: 95%;
padding: 15px;
}
.control-item {
width: 100%;
max-width: none;
margin: 0;
}
.control-group {
padding: 0 10px;
}
#searchButton {
width: 100%;
max-width: 300px;
}
}
</style>
<div class="last-updated" id="lastUpdated"></div>
<div id="results"></div>
<script>
function formatNumber(num) {
if (num >= 100000000) {
return (num / 100000000).toFixed(1) + '억';
} else if (num >= 10000) {
return (num / 10000).toFixed(1) + '만';
} else {
return num.toLocaleString();
}
}
function formatDate(hours) {
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `${days}일 전`;
}
return `${hours}시간 전`;
}
function searchVideos() {
const resultsDiv = document.getElementById('results');
const lastUpdatedDiv = document.getElementById('lastUpdated');
const timeWindow = document.getElementById('timeWindow').value;
const viewThreshold = document.getElementById('viewThreshold').value;
const sortBy = document.getElementById('sortBy').value;
const keyword = document.getElementById('keyword').value.trim();
resultsDiv.innerHTML = '<div class="loading">데이터를 불러오는 중...</div>';
google.script.run
.withSuccessHandler(function(videos) {
if (videos.error) {
resultsDiv.innerHTML = `<div class="error">에러 발생: ${videos.error}</div>`;
return;
}
if (!videos || videos.length === 0) {
resultsDiv.innerHTML = '<div class="no-results">조건에 맞는 영상이 없습니다.</div>';
return;
}
let html = '';
videos.forEach(video => {
const publishedTime = new Date(video.snippet.publishedAt);
const timeSincePublished = Math.floor((new Date() - publishedTime) / (1000 * 60 * 60));
const thumbnail = video.snippet.thumbnails.medium.url;
const viewCount = parseInt(video.statistics.viewCount);
const likeCount = parseInt(video.statistics.likeCount || 0);
const commentCount = parseInt(video.statistics.commentCount || 0);
html += `
<div class="video-card">
<img src="${thumbnail}" alt="썸네일" class="video-thumbnail">
<div class="video-content">
<div class="video-title">
<a href="https://www.youtube.com/watch?v=${video.id}" target="_blank">
${video.snippet.title}
</a>
</div>
<div class="video-stats">
<div class="stat-item">👤 ${video.snippet.channelTitle}</div>
<div class="stat-item">👁️ ${formatNumber(viewCount)}회</div>
<div class="stat-item">⌛ ${formatDate(timeSincePublished)}</div>
<div class="stat-item">👍 ${formatNumber(likeCount)}</div>
<div class="stat-item">💬 ${formatNumber(commentCount)}</div>
</div>
</div>
</div>
`;
});
resultsDiv.innerHTML = html;
lastUpdatedDiv.innerHTML = `마지막 업데이트: ${new Date().toLocaleString()}`;
})
.withFailureHandler(function(error) {
resultsDiv.innerHTML = `<div class="error">에러 발생: ${error.toString()}</div>`;
})
.getViralVideos(parseInt(timeWindow), parseInt(viewThreshold), sortBy, keyword);
}
// Enter 키로 검색 실행
document.getElementById('keyword').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchVideos();
}
});
// 페이지 로드 시 자동 실행
window.onload = searchVideos;
</script>
</body>
</html>
결과와 배운 점
시간 범위 1~72시간 설정 가능
최소 조회수 1만~100만 설정 가능
정렬 기준 조회수, 댓글, 좋아요, 최신 순 정렬 가능
키워드 검색 기능도 구현 - 원하는 키워드 안에서만 검색 (마케팅 트렌드에 사용)
생성형 인공지능의 도움을 받아 조금 더 기능을 더 추가해나가 볼 생각입니다.
도움 받은 글 (옵션)
사이드프로젝트 스터디 참여