소개
1편에 이어서 롱폼 글쓰기까지 Make.com으로 구현한 2편 사례글이비다.
(1편: https://www.gpters.org/nocode/post/interest-keyword-based-youtube-LZGL9wVbJNzy02K/edit)
진행 방법
🛠 사용한 도구
Apps Script: 유튜브 키워드 검색 자동화 스크립트 구현
Claude: Apps Script 질문 및 롱폼 글 생성 보조 (코딩은 역시 Claude!)
Google Sheets: 유튜브 검색 결과 저장, 요약 결과 관리
Airtable: 구글 시트에 이미지 저장이 안되기 때문에, 이미지 저장을 위해서 추가 사용/연결
ChatGPT (4o mini high): 전반적인 과정에 포괄적으로 사용
Replicate: 이미지 생성
Make: 전체 자동화 흐름 설계 및 실행
Apify: 유튜브 자막 수집 (Actor 활용)
구현 내용
관심 키워드 기반 유튜브 검색하는 기능을 Apps Script로 구현/검색 → Google Sheets에 저장
Code.js const YOUTUBE_API_KEY = 'AIzaSyBdSp-wFQ9vvBUrFpL4OwQiXIL3bcr9Wvs'; function doGet() { return HtmlService.createHtmlOutputFromFile('index') .setTitle('YouTube 트렌드 분석') .setFaviconUrl('https://www.youtube.com/favicon.ico'); } function analyzeYouTubeTrends(keyword, startDate) { try { // Edu 폴더에서 기존 스프레드시트 찾기 또는 새로 생성 let ss = findOrCreateSpreadsheet(); // 기존 YouTubeTrends 시트가 있으면 현재 시간으로 이름 변경하고, 새로운 YouTubeTrends 시트 생성 const sheet = createYouTubeTrendsSheet(ss); // YouTube API 검색 실행 const videos = searchYouTubeVideos(keyword, startDate); // 데이터 필터링 및 정렬 const popularVideos = filterAndSortVideos(videos); // 스프레드시트에 데이터 작성 writeToSheet(sheet, popularVideos); // 스프레드시트 URL 반환 return ss.getUrl(); } catch (error) { Logger.log('Error: ' + error.toString()); throw new Error('분석 중 오류가 발생했습니다: ' + error.message); } } // 날짜와 시간을 yyyy-mm-dd-hh-mm 형식으로 포맷팅 function formatDateTime(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day}-${hours}-${minutes}`; } // Edu 폴더에서 기존 스프레드시트 찾기 또는 새로 생성 function findOrCreateSpreadsheet() { // Edu 폴더 찾기 또는 생성 const folders = DriveApp.getFoldersByName('Edu'); let eduFolder; if (folders.hasNext()) { eduFolder = folders.next(); } else { eduFolder = DriveApp.createFolder('Edu'); } // 기존 YouTube Trends Analysis 파일 찾기 const files = eduFolder.getFilesByName('YouTube Trends Analysis'); if (files.hasNext()) { const file = files.next(); return SpreadsheetApp.openById(file.getId()); } else { // 새 스프레드시트 생성 const ss = SpreadsheetApp.create('YouTube Trends Analysis'); const file = DriveApp.getFileById(ss.getId()); eduFolder.addFile(file); DriveApp.getRootFolder().removeFile(file); // 루트 폴더에서 제거 return ss; } } // YouTubeTrends 시트 생성 (기존 시트가 있으면 이름 변경 후 새로 생성) function createYouTubeTrendsSheet(ss) { const now = new Date(); const timeStamp = formatDateTime(now); // 기존 YouTubeTrends 시트가 있는지 확인 const existingSheet = ss.getSheetByName('YouTubeTrends'); if (existingSheet) { // 기존 시트를 현재 시간으로 이름 변경 existingSheet.setName(timeStamp); } // 새로운 YouTubeTrends 시트 생성 const sheet = ss.insertSheet('YouTubeTrends'); // 헤더 설정 sheet.getRange('A1:H1').setValues([[ '순위', '제목', '채널명', '업로드일', '조회수', '구독자수', '조회수/구독자 비율', 'URL' ]]); sheet.getRange('A1:H1') .setBackground('#f3f3f3') .setFontWeight('bold') .setHorizontalAlignment('center'); // 열 너비 설정 const columnWidths = [50, 300, 150, 100, 100, 100, 150, 250]; columnWidths.forEach((width, index) => { sheet.setColumnWidth(index + 1, width); }); return sheet; } // 기존 함수들 (변경사항 없음) function initializeSheet(ss) { let sheet = ss.getSheetByName('YouTube Trends'); if (!sheet) { sheet = ss.insertSheet('YouTube Trends'); } else { sheet.clear(); } sheet.getRange('A1:H1').setValues([[ '순위', '제목', '채널명', '업로드일', '조회수', '구독자수', '조회수/구독자 비율', 'URL' ]]); sheet.getRange('A1:H1') .setBackground('#f3f3f3') .setFontWeight('bold') .setHorizontalAlignment('center'); // 열 너비 설정 (반복문 사용) const columnWidths = [50, 300, 150, 100, 100, 100, 150, 250]; columnWidths.forEach((width, index) => { sheet.setColumnWidth(index + 1, width); // index + 1: 1부터 시작하는 열 번호 }); return sheet; } // 1. YouTube 동영상 검색 함수 function searchYouTubeVideos(keyword, startDate) { const searchUrl = `https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q=${encodeURIComponent(keyword)}&publishedAfter=${startDate}T00:00:00Z&key=${YOUTUBE_API_KEY}&maxResults=50`; const response = UrlFetchApp.fetch(searchUrl, { muteHttpExceptions: true }); const results = JSON.parse(response.getContentText()); if (results.error) { throw new Error(`YouTube API 오류: ${results.error.message}`); } return results.items.map(item => ({ videoId: item.id.videoId, title: item.snippet.title, channelTitle: item.snippet.channelTitle, channelId: item.snippet.channelId, // 채널 ID 추가 publishedAt: item.snippet.publishedAt, })); } // 2. 필터링 및 정렬 함수 function filterAndSortVideos(videos) { return videos.map(video => { const details = getVideoDetails(video.videoId); const channelDetails = getChannelDetails(video.channelId); if (!details || !channelDetails) return null; const subscribers = channelDetails.subscribers || 1; // 0으로 나누기 방지 const ratio = subscribers > 0 ? details.views / subscribers : details.views; return { ...video, views: details.views, subscribers: subscribers, ratio: ratio, url: `https://www.youtube.com/watch?v=${video.videoId}`, }; }) .filter(video => video && video.ratio >= 3) .sort((a, b) => b.ratio - a.ratio); } // 3. 동영상 상세 정보 가져오기 함수 (수정) function getVideoDetails(videoId) { const detailsUrl = `https://www.googleapis.com/youtube/v3/videos?part=statistics&id=${videoId}&key=${YOUTUBE_API_KEY}`; try { const response = UrlFetchApp.fetch(detailsUrl, { muteHttpExceptions: true }); const result = JSON.parse(response.getContentText()); if (!result.items || result.items.length === 0) { Logger.log(`No video details found for videoId: ${videoId}`); return null; } const stats = result.items[0].statistics; return { views: parseInt(stats.viewCount || '0', 10), }; } catch (error) { Logger.log(`Error fetching video details for ${videoId}: ${error.toString()}`); return null; } } // 4. 채널 상세 정보 가져오기 함수 (새로 추가) function getChannelDetails(channelId) { const channelUrl = `https://www.googleapis.com/youtube/v3/channels?part=statistics&id=${channelId}&key=${YOUTUBE_API_KEY}`; try { const response = UrlFetchApp.fetch(channelUrl, { muteHttpExceptions: true }); const result = JSON.parse(response.getContentText()); if (!result.items || result.items.length === 0) { Logger.log(`No channel details found for channelId: ${channelId}`); return { subscribers: 1 }; // 기본값 반환 } const stats = result.items[0].statistics; return { subscribers: parseInt(stats.subscriberCount || '1', 10), }; } catch (error) { Logger.log(`Error fetching channel details for ${channelId}: ${error.toString()}`); return { subscribers: 1 }; // 기본값 반환 } } // 5. 데이터를 스프레드시트에 기록하는 함수 function writeToSheet(sheet, videos) { videos.forEach((video, index) => { sheet.appendRow([ index + 1, video.title, video.channelTitle, new Date(video.publishedAt).toLocaleDateString('ko-KR'), // 날짜 포맷 개선 video.views.toLocaleString('ko-KR'), // 숫자 포맷 개선 video.subscribers.toLocaleString('ko-KR'), // 숫자 포맷 개선 video.ratio.toFixed(2), video.url, ]); }); }index.html <!DOCTYPE html> <html> <head> <title>YouTube 트렌드 분석</title> <style> body { font-family: Arial, sans-serif; margin: 20px; padding: 20px; background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 5px; max-width: 400px; margin: auto; } h3 { text-align: center; } input, button { margin: 10px 0; padding: 10px; width: 100%; box-sizing: border-box; } button { background-color: #4285f4; color: white; border: none; cursor: pointer; } button:hover { background-color: #357ae8; } </style> </head> <body> <h3>YouTube 트렌드 분석</h3> <label for="keyword">관심 키워드:</label> <input type="text" id="keyword" placeholder="키워드를 입력하세요"> <label for="date">검색 시작 날짜 (YYYY-MM-DD):</label> <input type="date" id="date"> <button onclick="runAnalysis()">실행</button> <script> function runAnalysis() { const keyword = document.getElementById('keyword').value; const date = document.getElementById('date').value; if (!keyword || !date) { alert("모든 필드를 채워주세요."); return; } // 로딩 메시지 표시 document.querySelector('button').disabled = true; document.querySelector('button').textContent = '분석 중...'; // Google Apps Script로 데이터를 전달 google.script.run .withSuccessHandler(function(url) { alert("분석이 완료되었습니다!"); // 새 탭에서 스프레드시트 열기 window.open(url, '_blank'); // 버튼 상태 복구 document.querySelector('button').disabled = false; document.querySelector('button').textContent = '실행'; }) .withFailureHandler(function(error) { alert("오류가 발생했습니다: " + error.message); // 버튼 상태 복구 document.querySelector('button').disabled = false; document.querySelector('button').textContent = '실행'; }) .analyzeYouTubeTrends(keyword, date); } </script> </body> </html>"Flowith"로 검색
New search 할때마나 "YouTubeTrends" 시트로 신규 생성
기존 내용들은 신규 시트 생성 시간이 시트 이름으로 저장되면서 archive
글감 리스트 관리 가능해짐
수업 중에 배운바와 같이 크게 3개의 영역으로 구분
[1단계] 1편에서 다룬 내용과 동일
Airtable 무료 전환된다는 메시지에 겁먹고 > Google Sheets로 변경해서 진행
위 Apps Script의 결과로 나온 Google Sheets에서 Apify Actor를 실행해 자막을 수집
Apify를 통해 수집된 자막을 4. Text aggregator로 통합
5번째 Google Sheets에 GPT 지침이 저장되어 있음
그 지침들을 기반으로 6번째 유튜브 요약 > 마지막 구글 시트에 해당 유튜브 요약분을 적재
[2단계] 타이틀 생성
유튜브 요약을 기반으로
# 타이틀 생성 및 ## 타이틀(3개, 3단락) 생성
마지막 "## 타이틀"에서 생성된 3개 제목을 뒤에서 배열로 받기 위해서 순차적으로 뿌려주는 Iterator 모듈로 마감
[3단계]
Router로 분개해서 Upper part(이미지 생성) + Lower part(블로그 본문 생성)
Upper flow(상단 흐름)
image prompter를 활용해서 이미지 생성 프롬프트 생성
Replicate 빌링 등록 > API 생성 > make에 등록
Tools : 'Image' 변수 선언 > 하단 흐름에서 글+이미지 합체를 위해서
Arry aggregator : Iterator에서 나뉘어서 생성된 3개 Image 들에 대해서 배열로 통합
Airtable : 구글 시트에는 이미지가 URL로 적재되기에, 이미지 그대로 저장하기 위해서 Airtable을 서치해서 적재(update)함
Lower flow(하단 흐름)
Tools(set variable): 중제목(## Title)으로 나뉘어진 3개의 단락을 변수 선언하여 Index화
### 글감 생성: 3개 단락으로 이루어진 블로그 본문글 생성
Tools(get variable) : Upper flow에서 선언한 image 변수를 받고
글+이미지 합체(Text Aggregator) : image와 3개 단락글을 합체
Airtable : 본문글도 이미지와 함께 Airtable에 최종적으로 archive하기 위해서 추가
HTML 변환 > 롱폼 글 작성(Create a Document) : 구글 Docs로 최종 산출
결과와 배운 점
좋은 글감을 찾기 위해서 Apps Script 성능 향상 필요
길게 만드는 것은 좋지 않은 듯. 유료로 전화하라는 메시지로 더 이상...
구글 시트에서 '체크 박스'를 두고, 체크한 것만 진행되도록 하는 반자동화가 더 효율적일 수도... 중간에 글에 대한 검수 및 오류 상황을 확인할 수 있어서...
반복되는 배운 점
자동화 여정의 키워드는: 인내 + 인내 + 끈기 + 열정
그리고 마지막 보상은 "Succeed"라는 한 단어인 듯 합니다! 😄