3-2-1.추가 실습안: 업무 자동화

1.추가 실습안: 맞춤형 추천 자동화

# 맞춤형 추천 자동화 (Google Forms → Google Sheets → 이메일 발송) ## 시나리오 배경 나는 온라인 교육 플랫폼을 운영하고 있으며, 고객에게 맞춤형 강의 추천을 제공하고 싶어. 고객은 제공된 Google Forms 링크를 통해 자신의 이름, 이메일, 그리고 관심 있는 분야(예: "데이터 분석", "웹 개발", "UI/UX 디자인")를 선택하여 신청하게 돼. 이 시스템은 폼 응답 데이터를 Google Sheets에 저장하고, 미리 준비된 추천 강의 목록(예시 데이터 포함)을 기반으로 고객이 선택한 분야에 맞는 강의를 찾아 이메일로 추천 강의 제목과 링크를 자동으로 발송하는 기능을 갖추길 원해. **추가 요구사항** - 이메일 디자인: 발송하는 모든 이메일은 HTML 형식으로 간단하게 디자인되어야 해. ## 단계별 요구사항 (초보자용 설명 포함) 1. **Google Forms 자동 생성** - 응답에는 반드시 다음 항목이 포함되어야 함: * 고객 이름 (예: 고객님의 성명) * 고객 이메일 (예: 연락 가능한 이메일) * 관심 있는 분야 선택 (예: “데이터 분석”, “웹 개발”, “UI/UX 디자인”) - 각 항목에 간단한 설명을 추가하여 초보자도 쉽게 이해할 수 있도록 해줘. 2. **Google Sheets 자동 생성 및 데이터 준비** - 위의 Google Forms 응답 데이터를 저장할 Google Sheets를 자동으로 생성하고, - 미리 정의된 추천 강의 목록을 포함한 샘플 데이터를 시트에 추가해줘. - 예시 데이터: - “데이터 분석” → “Python 데이터 분석 입문, SQL 기초” - “웹 개발” → “HTML & CSS 기초, JavaScript 기초” - “UI/UX 디자인” → “Figma 기초, UX 디자인 개론” 3. **이메일 발송 (HTML 디자인 포함)** - 사용자가 폼을 제출하면, Google Sheets에서 해당 관심 분야에 맞는 추천 강의 목록을 조회하여, - 추천 강의 제목과 링크를 포함한 HTML 형식의 이메일을 자동으로 발송할 것. 4. **전체 자동화 구현** - 위의 모든 과정(Forms 생성, Sheets 생성 및 데이터 준비, 데이터 조회, 이메일 발송)이 한 번의 코드 실행으로 자동 진행되도록 구성할 것. - 초보자도 따라할 수 있도록 단계별 구체적인 설명과 주석이 포함된 완전한 Google Apps Script 코드를 생성. ## 요청 위의 기능과 단계별 요구사항을 모두 충족하는, 실행하면 오류 없이 바로 작동하는 Google Apps Script 코드를 제공

2.추가 실습안: 견적서 자동화 시스템

# 견적서 자동화 시스템 (Google Forms → Google Docs + PDF 변환 → 이메일 발송) ## 시나리오 배경 나는 작은 비즈니스를 운영하고 있으며, 고객에게 서비스를 제공하기 위해 견적서를 자동으로 발급하는 시스템이 필요해. 고객은 제공된 Google Forms 링크를 통해 자신의 정보를 입력하고, 원하는 서비스를 선택하여 견적 요청을 할 수 있어. 이 시스템은 폼 응답 데이터를 Google Sheets에 저장하고, 그 데이터를 기반으로 Google Docs에서 견적서를 자동 생성한 후 PDF로 변환하여, 고객에게 이메일로 견적서를 발송하는 기능을 갖추길 원해. **추가 요구사항:** - 이메일 디자인: 발송하는 모든 이메일은 HTML 형식으로 간단하게 디자인되어야 해. - 이메일 본문에는 견적서 요약 내용과 PDF 첨부파일(또는 다운로드 링크)을 포함하도록 해. ## 단계별 요구사항 (초보자용 설명 포함) 1. **Google Forms 자동 생성** - 견적 요청 폼에는 반드시 다음 항목이 포함되어야 함: * 고객이름 (예: 고객님의 성명) * 이메일 주소(**예: 연락 가능한 이메일**) * 연락처 번호 (예: 고객님의 전화번호) * 원하는 서비스 선택 (예: A서비스(10만원), B서비스(20만원), C서비스(30만원)) * 추가 요청사항 (예: 추가 문의 사항 등) - 각 항목에 간단한 설명을 추가하여 초보자도 이해할 수 있도록 해줘. 2. **Google Sheets 자동 생성** - 위의 폼 응답 데이터를 저장할 Google Sheets를 자동으로 생성하고, - 샘플 데이터(예시 행)도 포함시켜 데이터가 어떻게 저장되는지 보여줘. 3. **Google Docs를 통한 견적서 자동 작성** - Google Sheets의 데이터를 바탕으로, Google Docs에서 견적서를 자동으로 생성할 것. - 견적서에는 고객 이름, 선택한 서비스, 해당 서비스의 가격, 그리고 추가 요청사항이 포함되어야 함. - 데이터 불러오기, 문서 작성, 템플릿 사용 등의 각 단계에 대해 구체적인 설명을 포함해줘. 4. **PDF 변환 및 이메일 발송 (HTML 디자인 포함)** - 생성된 Google Docs 문서를 PDF 파일로 변환한 후, 고객에게 자동으로 이메일을 발송할 것. - 이메일 본문은 HTML 형식으로 간단하게 디자인되어야 하며, 견적서의 요약 내용과 PDF 첨부파일(또는 다운로드 링크)을 포함해야 함. 5. **전체 자동화 구현** - 위의 모든 과정(Forms 생성, Sheets 생성, Docs 견적서 작성, PDF 변환, 이메일 발송)이 한 번의 코드 실행으로 자동 진행되도록 구성할 것. - 초보자도 따라할 수 있도록 각 단계별 구체적인 설명과 주석이 포함된 완전한 Google Apps Script 코드를 생성해줘. ## 요청 위의 기능과 단계별 요구사항을 모두 충족하는, 실행하면 오류 없이 바로 작동하는 Google Apps Script 코드를 제공.

추가 사례 #1 Youtube 영상 검색기

소개

구글 앱스 스크립트로 Youtube 영상 검색기 만들어보기
유튜브 영상을 많이 보는데 최근 재미있는 영상이나 볼만한 영상이 무엇이 있는지 빠르게 보기 위해서 만들어보게되었습니다.
(구글 로그인 하면 사용 가능)
(Edge 브라우저 말고 Chrome 브라우저 사용 권장, Edge에서 안열리기도 함)

진행 방법

어떤 도구를 사용했고, 어떻게 활용하셨나요?
Tip: 사용한 프롬프트
1.
유튜브 실시간 조회수 10만이 하루만에 달성한 것을 찾아서 나에게 알려주는게 가능한가?
2.
api 키 입력했고 나의 이메일 주소 입력했는데 이메일이 안보이는데 일단 그냥 index.html을 통해서 즉각 받아보고 싶어 (이메일로 보내주는 것으로 만들어주었기에 이렇게 다시 보내봄)
3.
구글 앱스 스크립트에서 실행하는 index.html (단독으로 실행하는 index.html을 만들어주었기에 이렇게 정확히 구글 앱스 스크립트 코드에서 사용할 것임을 다시 명시함)
4.
새로고침
조건에 맞는 영상이 없습니다.
왜 이렇게 나오지? ( 안되는 것들을 정확히 그대로 복사해서 알려줌)
5.
사용자가 시간, 조회수를 선택할 수 있게끔 해주고
정렬 방식도 사용자가 원하는대로 설정할 수 있게끔 해줘
6.
24시간은 아예 지원을 안하는데 몇시간 몇시간 단위만 지원하는지 정확하게 알아보고 썸네일도 같이 가져와서 보여줄 수 있는지 살펴봐, 그리고 키워드를 입력해서 그 키워드가 있는 것까지도 필터링이 가능하게끔 해줘
7.
index 완벽한 코드를줘 (중간에 생략한 것이 있기에 이렇게 명시)
8.
키워드를 넣었을 때는 1시간 단위로도 검색이 가능한데
키워드를 넣지 않으면 왜 48시간 이후로만 검색이 가능한걸까? (안되는 오류 상황을 명시)
9.
키워드를 넣으면 검색이 되지만 키워드를 넣지 않으면 검색이 여전히 안됨 (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)}&regionCode=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}&regionCode=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만 설정 가능
정렬 기준 조회수, 댓글, 좋아요, 최신 순 정렬 가능
키워드 검색 기능도 구현 - 원하는 키워드 안에서만 검색 (마케팅 트렌드에 사용)

추가 사례 #2 공공데이터 API로 전국 캠핑장 조회 서비스 만들기

소개

"내가 원하는 조건의 캠핑장을 찾을 순 없을까?"
데이터분석가이다보니 데이터를 가지고 어떤 서비스를 만들고 싶다는 니즈가 있었고,
마침 캠핑을 좋아하는지라 전국의 모든 캠핑장 데이터를
제가 원하는 조건으로 필터링을 해서 최적의 캠핑장을 찾고 싶었습니다.

진행 방법

1 챗 GPT로 기획하기

정말 러프하게 GPT한테 요청했습니다. 이쁜 디자인.. 과연 할 수 있을지!
공공데이터를 활용한 웹서비스를 만들거야. google apps script로 코딩할거고, 다른 복잡한 방법은 쓰지 말아줘. 공공데이터 API 키는 ~~이야. 캠핑장 데이터이고 웹서비스에서 캠핑장 데이터를 조회할 수 있어야 하고, 지역이랑 캠핑 유형을 필터링 할 수 있는 기능을 넣어줘. 웹서비스에서 한국어로 보여줘야 하고 이쁘게 디자인해줘
공공데이터 API키를 바로 복사-붙여넣기 했습니다. 공공데이터 포털에 원하는 데이터를 검색하시면 API 신청하기를 거쳐 API 키를 받을 수 있습니다.
아래는 챗 GPT의 기획안 일부입니다.
처음엔 사실.. 기획할 때까지만 해도 캠핑장이 아니라 공방 데이터였는데요. 이후에 캠핑장으로 데이터 변경을 요청했더니 캠핑장으로 변경해서 코드를 짜주었답니다!

2 기획한 내용으로 1차 코드 짜기

기획한 내용으로 코드를 짜달라고 했습니다. 하지만.. 한 번에 잘 될 리가 없죠!
처음으로 만들어본 웹 화면

정말 심플 그자체였습니다;;
2차 웹서비스 화면
제목도 맘대로 지은 듯 했어요..
아무래도 GPT는 코딩은 아닌 것 같다- 싶어서 클로드에게 요청했습니다.

3.클로드에 코딩 맡기기



클로드가 짜준 코드를 Apps script에 돌려보았습니다.

4 결과

디버깅 과정이 1-2회 정도 있었으나, 빠른 시간 내에 웹서비스 하나를 만들게 되었습니다!

물론 중복 필터도 있고.. 완벽한 수준은 아니지만 어느정도 그럴듯한 페이지가 나와서 좋았습니다! 다른 조건들을 더 추가해서 제가 원하는 캠핑장을 찾을 수 있는 서비스로 고도화해보고자 합니다 🎉
배운 점
비개발자도 할 수 있다!
Apps script는 정말 쉽다
기획은 GPT로, 디버깅은 클로드로! (디자인도 클로드..ㅎㅎ)

도움 받은 글

Contact : azureguy@empal.com / azureguy@cau.ac.kr