[2편] 관심 키워드 기반 유튜브 요약부터 롱폼 글쓰기까지, Apps Script와 Make로 구현하는 자동화!

소개

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 활용)


구현 내용

  1. 관심 키워드 기반 유튜브 검색하는 기능을 Apps Script로 구현/검색 → Google Sheets에 저장

    YouTube 계정 로그인 한국어
    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>
    
  2. "Flowith"로 검색

    1. New search 할때마나 "YouTubeTrends" 시트로 신규 생성

    2. 기존 내용들은 신규 시트 생성 시간이 시트 이름으로 저장되면서 archive

    3. 글감 리스트 관리 가능해짐

      항목 목록을 보여주는 Google 스프레드 시트의 스크린 샷
  3. 수업 중에 배운바와 같이 크게 3개의 영역으로 구분

  4. [1단계] 1편에서 다룬 내용과 동일

    1. Airtable 무료 전환된다는 메시지에 겁먹고 > Google Sheets로 변경해서 진행

    2. 위 Apps Script의 결과로 나온 Google Sheets에서 Apify Actor를 실행해 자막을 수집

    3. Apify를 통해 수집된 자막을 4. Text aggregator로 통합

    4. 5번째 Google Sheets에 GPT 지침이 저장되어 있음

    5. 그 지침들을 기반으로 6번째 유튜브 요약 > 마지막 구글 시트에 해당 유튜브 요약분을 적재

      휴대 전화의 다른 단계를 보여주는 다이어그램

  5. [2단계] 타이틀 생성

    1. 유튜브 요약을 기반으로

    2. # 타이틀 생성 및 ## 타이틀(3개, 3단락) 생성

    3. 마지막 "## 타이틀"에서 생성된 3개 제목을 뒤에서 배열로 받기 위해서 순차적으로 뿌려주는 Iterator 모듈로 마감

  6. [3단계]

    1. Router로 분개해서 Upper part(이미지 생성) + Lower part(블로그 본문 생성)

    2. Upper flow(상단 흐름)

      1. image prompter를 활용해서 이미지 생성 프롬프트 생성

      2. Replicate 빌링 등록 > API 생성 > make에 등록

      3. Tools : 'Image' 변수 선언 > 하단 흐름에서 글+이미지 합체를 위해서

      4. Arry aggregator : Iterator에서 나뉘어서 생성된 3개 Image 들에 대해서 배열로 통합

      5. Airtable : 구글 시트에는 이미지가 URL로 적재되기에, 이미지 그대로 저장하기 위해서 Airtable을 서치해서 적재(update)함

    3. Lower flow(하단 흐름)

      1. Tools(set variable): 중제목(## Title)으로 나뉘어진 3개의 단락을 변수 선언하여 Index화

      2. ### 글감 생성: 3개 단락으로 이루어진 블로그 본문글 생성

      3. Tools(get variable) : Upper flow에서 선언한 image 변수를 받고

      4. 글+이미지 합체(Text Aggregator) : image와 3개 단락글을 합체

      5. Airtable : 본문글도 이미지와 함께 Airtable에 최종적으로 archive하기 위해서 추가

      6. HTML 변환 > 롱폼 글 작성(Create a Document) : 구글 Docs로 최종 산출

결과와 배운 점

  • 좋은 글감을 찾기 위해서 Apps Script 성능 향상 필요

  • 길게 만드는 것은 좋지 않은 듯. 유료로 전화하라는 메시지로 더 이상...

  • 구글 시트에서 '체크 박스'를 두고, 체크한 것만 진행되도록 하는 반자동화가 더 효율적일 수도... 중간에 글에 대한 검수 및 오류 상황을 확인할 수 있어서...

  • 반복되는 배운 점

    • 자동화 여정의 키워드는: 인내 + 인내 + 끈기 + 열정

      그리고 마지막 보상은 "Succeed"라는 한 단어인 듯 합니다! 😄

뉴스레터 무료 구독

👉 이 게시글도 읽어보세요