# 맞춤형 추천 자동화 (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 코드를 제공# 견적서 자동화 시스템 (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 코드를 제공.
// 웹 앱으로 배포할 때 필요한 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
};
}<!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>공공데이터를 활용한 웹서비스를 만들거야. google apps script로 코딩할거고, 다른 복잡한 방법은 쓰지 말아줘. 공공데이터 API 키는 ~~이야. 캠핑장 데이터이고 웹서비스에서 캠핑장 데이터를 조회할 수 있어야 하고, 지역이랑 캠핑 유형을 필터링 할 수 있는 기능을 넣어줘. 웹서비스에서 한국어로 보여줘야 하고 이쁘게 디자인해줘